Compare commits
1 Commits
codex/comm
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b254135116 |
@@ -1,39 +0,0 @@
|
||||
---
|
||||
name: web-design-guidelines
|
||||
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
argument-hint: <file-or-pattern>
|
||||
---
|
||||
|
||||
# Web Interface Guidelines
|
||||
|
||||
Review files for compliance with Web Interface Guidelines.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Fetch the latest guidelines from the source URL below
|
||||
2. Read the specified files (or prompt user for files/pattern)
|
||||
3. Check against all rules in the fetched guidelines
|
||||
4. Output findings in the terse `file:line` format
|
||||
|
||||
## Guidelines Source
|
||||
|
||||
Fetch fresh guidelines before each review:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
When a user provides a file or pattern argument:
|
||||
1. Fetch guidelines from the source URL above
|
||||
2. Read the specified files
|
||||
3. Apply all rules from the fetched guidelines
|
||||
4. Output findings using the format specified in the guidelines
|
||||
|
||||
If no files specified, ask the user which files to review.
|
||||
107
.env.example
@@ -21,39 +21,14 @@ APP_ENV=
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Optional aliases for the local/self-host backend port. If one is set, it
|
||||
# takes precedence over PORT in compose, Makefile, and installer helpers.
|
||||
# BACKEND_PORT=8080
|
||||
# API_PORT=8080
|
||||
# SERVER_PORT=8080
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
# HTTP request metrics start accumulating only when this listener is enabled.
|
||||
# METRICS_ADDR=127.0.0.1:9090
|
||||
JWT_SECRET=change-me-in-production
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when the daemon reaches the API through a different URL.
|
||||
# MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when the app's public URL differs from local frontend.
|
||||
# MULTICA_APP_URL=http://localhost:3000
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers. Leave unset behind a same-origin reverse proxy or for plain
|
||||
# localhost dev — the frontend will compose the URL from
|
||||
# window.origin + webhook_path in that case. Headers are intentionally
|
||||
# not used to derive this value, to avoid Host / X-Forwarded-Host
|
||||
# spoofing when a self-hosted reverse proxy is not hardened.
|
||||
MULTICA_PUBLIC_URL=
|
||||
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
|
||||
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
|
||||
# Empty (the default) means "trust no headers" — the limiter uses
|
||||
# r.RemoteAddr only, which is the safe shape when the backend is
|
||||
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
|
||||
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
|
||||
# announced ranges for cloud deployments.
|
||||
MULTICA_TRUSTED_PROXIES=
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
MULTICA_DAEMON_CONFIG=
|
||||
MULTICA_WORKSPACE_ID=
|
||||
MULTICA_DAEMON_ID=
|
||||
@@ -73,26 +48,11 @@ MULTICA_IMAGE_TAG=latest
|
||||
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email
|
||||
# Two delivery options - only one needs to be configured:
|
||||
#
|
||||
# Option A: Resend (SaaS, recommended for cloud deployments)
|
||||
# Set RESEND_API_KEY to a key from resend.com and verify your sending domain there.
|
||||
# For local/dev use, leave RESEND_API_KEY empty - codes print to stdout. To
|
||||
# accept a fixed local code, also set MULTICA_DEV_VERIFICATION_CODE above
|
||||
# (ignored when APP_ENV=production).
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
|
||||
# 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
|
||||
#
|
||||
# Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
# Takes priority over Resend when SMTP_HOST is set.
|
||||
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
|
||||
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
|
||||
# Google OAuth
|
||||
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
|
||||
@@ -100,9 +60,7 @@ SMTP_TLS_INSECURE=false
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when your OAuth callback URL differs from local frontend.
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
@@ -123,46 +81,15 @@ CLOUDFRONT_DOMAIN=
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
|
||||
# "8760h", "720h30m") or plain integer seconds.
|
||||
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
|
||||
# set a longer value to reduce re-authentication frequency.
|
||||
# Note: longer TTL = longer exposure window if a cookie is leaked.
|
||||
# AUTH_TOKEN_TTL=2592000
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when uploads are served through a different public URL.
|
||||
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
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: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# ==================== Rate limiting (optional Redis) ====================
|
||||
# Per-IP fixed-window rate limiter on the public auth endpoints
|
||||
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
|
||||
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
|
||||
# backend logs "rate limiting disabled: REDIS_URL not configured" at
|
||||
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
|
||||
# the PAT cache, and the daemon-token cache.
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
# Max requests per IP per minute. Defaults are 5 for send-code/google
|
||||
# and 20 for verify-code.
|
||||
# RATE_LIMIT_AUTH=5
|
||||
# RATE_LIMIT_AUTH_VERIFY=20
|
||||
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
|
||||
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
|
||||
# REQUIRED behind a reverse proxy — otherwise every real user shares
|
||||
# the proxy IP and the whole deployment lands in one bucket, turning
|
||||
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
|
||||
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
|
||||
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
|
||||
# governs the autopilot webhook limiter).
|
||||
# RATE_LIMIT_TRUSTED_PROXIES=
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
@@ -174,20 +101,11 @@ CORS_ALLOWED_ORIGINS=
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → GitHub "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
# FRONTEND_ORIGIN=http://localhost:3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
|
||||
# Only set explicitly if frontend and backend are on different domains.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
NEXT_PUBLIC_WS_URL=
|
||||
|
||||
@@ -214,8 +132,5 @@ ALLOWED_EMAILS=
|
||||
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
|
||||
POSTHOG_API_KEY=
|
||||
POSTHOG_HOST=https://us.i.posthog.com
|
||||
# Optional override for the `environment` PostHog event property.
|
||||
# Defaults from APP_ENV and normalizes to production / staging / dev.
|
||||
ANALYTICS_ENVIRONMENT=
|
||||
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
|
||||
ANALYTICS_DISABLED=
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,7 +40,7 @@ Closes #
|
||||
- [ ] 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
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
|
||||
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
|
||||
- [ ] I have considered and documented any risks above
|
||||
- [ ] I will address all reviewer comments before requesting merge
|
||||
|
||||
29
.github/workflows/ci.yml
vendored
@@ -29,18 +29,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Test self-host env derivation
|
||||
run: bash scripts/selfhost-config.test.sh
|
||||
|
||||
- name: Verify reserved-slugs.ts is up to date
|
||||
# Re-runs the generator and fails on any drift from the
|
||||
# checked-in TypeScript output. The Go side embeds the JSON
|
||||
# source directly, so a passing diff here proves both sides
|
||||
# share one source of truth.
|
||||
run: |
|
||||
pnpm generate:reserved-slugs
|
||||
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
|
||||
|
||||
- name: Build, type check, lint, and test
|
||||
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
|
||||
|
||||
@@ -94,20 +82,3 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
# backend job so installer regressions surface independently, and
|
||||
# exercised on macOS too because the installer targets macOS/Homebrew
|
||||
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test shell installers
|
||||
run: bash scripts/install.test.sh
|
||||
|
||||
8
.gitignore
vendored
@@ -23,14 +23,6 @@ dist-electron
|
||||
# Desktop production config is public (backend URL, etc.) — track it so
|
||||
# `pnpm package` produces a release-ready build without extra setup.
|
||||
!apps/desktop/.env.production
|
||||
# Mobile staging config is public (staging API URL) — track it so a fresh
|
||||
# checkout can run `pnpm dev:mobile:staging` / `ios:mobile*:staging` without
|
||||
# the user having to copy `.env.example` first.
|
||||
!apps/mobile/.env.staging
|
||||
# Mobile production config is public (production API URL) — track it so
|
||||
# external users can run `pnpm ios:mobile:device:prod:release` against
|
||||
# multica.ai's production backend without copying templates first.
|
||||
!apps/mobile/.env.production
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
||||
75
CLAUDE.md
@@ -32,14 +32,11 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
|
||||
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app (electron-vite)
|
||||
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
|
||||
- `packages/core/` — Headless business logic (zero react-dom)
|
||||
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
|
||||
- `packages/ui/` — Atomic UI components (zero business logic)
|
||||
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
|
||||
- `packages/tsconfig/` — Shared TypeScript configuration
|
||||
|
||||
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
|
||||
|
||||
### Key Architectural Decisions
|
||||
|
||||
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
|
||||
@@ -55,7 +52,7 @@ What lives where for sharing purposes is documented in *Sharing Principles* belo
|
||||
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
|
||||
|
||||
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
|
||||
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
|
||||
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
|
||||
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
|
||||
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
|
||||
|
||||
@@ -72,17 +69,6 @@ The architecture relies on a strict split between server state and client state.
|
||||
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
|
||||
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
|
||||
|
||||
## Sharing Principles
|
||||
|
||||
The monorepo splits into two share zones:
|
||||
|
||||
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
|
||||
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
|
||||
|
||||
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
|
||||
|
||||
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
@@ -125,16 +111,6 @@ cd server && go test ./internal/handler/ -run TestName
|
||||
# Run a single E2E test (requires backend + frontend running)
|
||||
pnpm exec playwright test e2e/tests/specific-test.spec.ts
|
||||
|
||||
# Mobile (Expo) — two environments only: dev and staging
|
||||
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
|
||||
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
|
||||
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
|
||||
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
|
||||
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
|
||||
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
|
||||
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
|
||||
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
|
||||
|
||||
# Desktop build & package
|
||||
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
|
||||
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
|
||||
@@ -170,27 +146,10 @@ make start-worktree # Start using .env.worktree
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
- 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 **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
|
||||
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
|
||||
- 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.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
|
||||
|
||||
### API Response Compatibility
|
||||
|
||||
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
|
||||
|
||||
When writing code that consumes an API response, follow these rules:
|
||||
|
||||
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
|
||||
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
|
||||
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
|
||||
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
|
||||
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
|
||||
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
|
||||
|
||||
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
|
||||
@@ -203,29 +162,21 @@ Every Go handler in `server/internal/handler/` follows these rules. The conventi
|
||||
|
||||
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
|
||||
|
||||
### Dependency Declaration Rule
|
||||
|
||||
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
|
||||
|
||||
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
|
||||
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
|
||||
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
|
||||
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
|
||||
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
|
||||
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
|
||||
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
|
||||
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
|
||||
|
||||
### The No-Duplication Rule (web + desktop)
|
||||
### The No-Duplication Rule
|
||||
|
||||
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
|
||||
**If the same logic exists in both apps, it must be extracted to a shared package.**
|
||||
|
||||
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
|
||||
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
|
||||
|
||||
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
|
||||
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
|
||||
@@ -233,9 +184,9 @@ This applies to everything between web and desktop: components, hooks, guards, p
|
||||
|
||||
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
|
||||
|
||||
### Cross-Platform Development Rules (web + desktop)
|
||||
### Cross-Platform Development Rules
|
||||
|
||||
When adding a new page or feature for web/desktop:
|
||||
When adding a new page or feature:
|
||||
|
||||
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
|
||||
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
|
||||
@@ -244,18 +195,14 @@ When adding a new page or feature for web/desktop:
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
|
||||
|
||||
### CSS Architecture (web + desktop)
|
||||
### CSS Architecture
|
||||
|
||||
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
|
||||
Both apps share the same CSS foundation from `packages/ui/styles/`.
|
||||
|
||||
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## Mobile-specific Rules
|
||||
|
||||
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
|
||||
|
||||
## Desktop-specific Rules
|
||||
|
||||
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
|
||||
|
||||
@@ -269,37 +269,21 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
|
||||
|
||||
## Workspaces
|
||||
|
||||
### Working with multiple workspaces
|
||||
|
||||
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
|
||||
|
||||
1. `--workspace-id <id>` flag on the command
|
||||
2. `MULTICA_WORKSPACE_ID` environment variable
|
||||
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
|
||||
|
||||
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
|
||||
|
||||
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
|
||||
|
||||
### List Workspaces
|
||||
|
||||
```bash
|
||||
multica workspace list
|
||||
multica workspace list --full-id
|
||||
multica workspace list --output json
|
||||
```
|
||||
|
||||
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
|
||||
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
|
||||
|
||||
### Switch Default Workspace
|
||||
### Watch / Unwatch
|
||||
|
||||
```bash
|
||||
multica workspace switch <workspace-id>
|
||||
multica workspace switch <slug>
|
||||
multica workspace watch <workspace-id>
|
||||
multica workspace unwatch <workspace-id>
|
||||
```
|
||||
|
||||
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
|
||||
|
||||
### Get Details
|
||||
|
||||
```bash
|
||||
@@ -307,12 +291,10 @@ multica workspace get <workspace-id>
|
||||
multica workspace get <workspace-id> --output json
|
||||
```
|
||||
|
||||
Passing no `<workspace-id>` resolves to the current default workspace, so `multica workspace get` doubles as "what workspace am I on?".
|
||||
|
||||
### List Members
|
||||
|
||||
```bash
|
||||
multica workspace member list <workspace-id>
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
## Issues
|
||||
@@ -324,18 +306,10 @@ multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
|
||||
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
|
||||
|
||||
```bash
|
||||
multica issue list --metadata pipeline_status=waiting_review
|
||||
multica issue list --metadata pr_number=482 --metadata is_blocked=true
|
||||
```
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -351,7 +325,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -380,44 +354,9 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
|
||||
### Comments
|
||||
|
||||
```bash
|
||||
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
|
||||
# long-running issues prefer one of the thread-aware reads below to keep
|
||||
# context windows tight.
|
||||
# List comments
|
||||
multica issue comment list <issue-id>
|
||||
|
||||
# Single thread (root + every descendant). Anchor may be the root itself
|
||||
# or any reply inside the thread — the server walks up to the root.
|
||||
multica issue comment list <issue-id> --thread <comment-id>
|
||||
|
||||
# Single thread, capped to the N most recent replies. The thread root is
|
||||
# always included (even with --tail 0), so an agent landing on a long
|
||||
# thread keeps the "what is this about" context without dragging hundreds
|
||||
# of replies into its prompt.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30
|
||||
|
||||
# Scroll older replies inside the same thread. --before / --before-id are
|
||||
# the reply cursor that the previous response emitted on stderr as
|
||||
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--before <ts> --before-id <reply-id>
|
||||
|
||||
# Most recently active threads (root + every descendant), grouped by
|
||||
# thread. Returns N complete conversational arcs, oldest-active first so
|
||||
# the freshest thread sits closest to "now" in an agent prompt.
|
||||
multica issue comment list <issue-id> --recent 20
|
||||
|
||||
# Scroll older threads. Under --recent, --before / --before-id are a
|
||||
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
|
||||
# `Next thread cursor: --before <ts> --before-id <root-id>`.
|
||||
multica issue comment list <issue-id> --recent 20 \
|
||||
--before <ts> --before-id <root-id>
|
||||
|
||||
# Incremental polling. Combines with --thread or --recent; filters out
|
||||
# replies created on or before <ts> from the page (the thread root is
|
||||
# exempt so the agent always gets context).
|
||||
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
|
||||
--since <RFC3339-timestamp>
|
||||
|
||||
# Add a comment
|
||||
multica issue comment add <issue-id> --content "Looks good, merging now"
|
||||
|
||||
@@ -428,56 +367,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
|
||||
multica issue comment delete <comment-id>
|
||||
```
|
||||
|
||||
**`--before` / `--before-id` semantics depend on the paging mode**, by
|
||||
design — same flag, different scope:
|
||||
|
||||
| Mode | What the cursor walks | stderr label |
|
||||
| --- | --- | --- |
|
||||
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
|
||||
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
|
||||
|
||||
Outside those two modes (`--thread` without `--tail`, or no `--thread`
|
||||
and no `--recent`) the cursor flags are rejected so they cannot silently
|
||||
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
|
||||
`X-Multica-Next-Before-Id`) only when an older page actually exists —
|
||||
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
|
||||
replies) intentionally return no cursor so callers stop paginating.
|
||||
|
||||
When `--since` is combined with `--recent` or `--thread --tail`, the
|
||||
server additionally suppresses the cursor once the cursor target itself
|
||||
is older than `since`. Older pages walk strictly older rows, so they
|
||||
cannot satisfy `> since` either — emitting a cursor there would just
|
||||
hand back root-only pages until the caller reaches the start of the
|
||||
thread / issue. Incremental polling stops at the first page whose
|
||||
cursor target falls before the watermark.
|
||||
|
||||
### Metadata
|
||||
|
||||
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
|
||||
|
||||
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
|
||||
|
||||
```bash
|
||||
# List every key on an issue
|
||||
multica issue metadata list <issue-id>
|
||||
|
||||
# Read a single key
|
||||
multica issue metadata get <issue-id> --key pipeline_status
|
||||
|
||||
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
|
||||
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
|
||||
multica issue metadata set <issue-id> --key pr_number --value 482
|
||||
multica issue metadata set <issue-id> --key is_blocked --value true
|
||||
|
||||
# Force a specific type when sniffing would pick the wrong one
|
||||
multica issue metadata set <issue-id> --key code --value 42 --type string
|
||||
|
||||
# Remove a key
|
||||
multica issue metadata delete <issue-id> --key pipeline_status
|
||||
```
|
||||
|
||||
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
|
||||
|
||||
### Subscribers
|
||||
|
||||
```bash
|
||||
@@ -504,19 +393,17 @@ Subscribers receive notifications about issue activity (new comments, status cha
|
||||
```bash
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --full-id
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <short-task-id> --issue <issue-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
|
||||
|
||||
## Projects
|
||||
|
||||
@@ -618,8 +505,6 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
@@ -628,12 +513,9 @@ Autopilots are scheduled/triggered automations that dispatch agent tasks (either
|
||||
|
||||
```bash
|
||||
multica autopilot list
|
||||
multica autopilot list --full-id
|
||||
multica autopilot list --status active --output json
|
||||
```
|
||||
|
||||
Autopilot table IDs are short UUID prefixes; follow-up autopilot commands accept copied prefixes when they are unique in the current workspace. Use `--full-id` to print canonical UUIDs.
|
||||
|
||||
### Get Autopilot Details
|
||||
|
||||
```bash
|
||||
|
||||
@@ -18,7 +18,6 @@ ARG COMMIT=unknown
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
@@ -30,7 +29,6 @@ WORKDIR /app
|
||||
COPY --from=builder /src/server/bin/server .
|
||||
COPY --from=builder /src/server/bin/multica .
|
||||
COPY --from=builder /src/server/bin/migrate .
|
||||
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
@@ -8,8 +8,6 @@ WORKDIR /app
|
||||
# Copy workspace config and all package.json files for dependency resolution
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
# postinstall runs fumadocs-mdx which reads apps/web/source.config.ts
|
||||
COPY apps/web/source.config.ts apps/web/source.config.ts
|
||||
COPY packages/core/package.json packages/core/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/views/package.json packages/views/
|
||||
|
||||
3
Makefile
@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
|
||||
POSTGRES_USER ?= multica
|
||||
POSTGRES_PASSWORD ?= multica
|
||||
POSTGRES_PORT ?= 5432
|
||||
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
|
||||
PORT ?= 8080
|
||||
FRONTEND_PORT ?= 3000
|
||||
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
|
||||
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
|
||||
@@ -21,7 +21,6 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
|
||||
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
|
||||
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
|
||||
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
|
||||
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
|
||||
|
||||
export
|
||||
|
||||
|
||||
25
README.md
@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [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)**
|
||||
|
||||
@@ -32,8 +32,6 @@ Multica turns coding agents into real teammates. Assign issues to an agent like
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
@@ -55,9 +53,7 @@ Like Multics before it, the bet is on multiplexing: a small team shouldn't feel
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Autopilots** — schedule recurring work for agents. Cron triggers, webhooks, or manual runs — each autopilot creates the issue and routes it to an agent automatically, so daily standups, weekly reports, and periodic audits run themselves.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
|
||||
@@ -132,6 +128,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.
|
||||
@@ -143,8 +154,6 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
|
||||
| `multica setup self-host` | Same, but for self-hosted deployments |
|
||||
| `multica workspace list` | List your workspaces (current is marked with `*`) |
|
||||
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
@@ -188,5 +197,3 @@ 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.
|
||||
|
||||
An iOS mobile client lives in [`apps/mobile/`](apps/mobile/) — see its [README](apps/mobile/README.md) for how to build it onto your own iPhone.
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](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) | 简体中文**
|
||||
|
||||
@@ -32,8 +32,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
|
||||
面向更大的团队,Squads(小队)提供稳定的路由层:把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
@@ -55,9 +53,7 @@ Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **Squads(小队)** — 把多个 Agent(以及人类成员)组合成由 leader agent 带队的小队,直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **自动化(Autopilots)** — 为 Agent 安排周期性工作。定时(Cron)、Webhook 或手动触发,自动化会自动创建 Issue 并分配给 Agent——日报、周报、定期巡检都能让它自己跑起来。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
|
||||
@@ -135,6 +131,19 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
@@ -172,8 +181,6 @@ make start
|
||||
|
||||
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
iOS 移动端代码位于 [`apps/mobile/`](apps/mobile/),自己编译装到手机的方法见 [README](apps/mobile/README.md)。
|
||||
|
||||
## 开源协议
|
||||
|
||||
[Modified Apache 2.0 (with commercial restrictions)](LICENSE)
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
230
SELF_HOSTING.md
@@ -135,234 +135,6 @@ multica daemon status
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent — it will pick up the task automatically
|
||||
|
||||
---
|
||||
|
||||
## Kubernetes Deployment (Alternative)
|
||||
|
||||
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the Helm chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
|
||||
|
||||
The chart creates the following resources in the target namespace:
|
||||
|
||||
- `multica-postgres` — `pgvector/pgvector:pg17` backed by a 10Gi PVC
|
||||
- `multica-backend` — Go API/WS server backed by a 5Gi uploads PVC
|
||||
- `multica-frontend` — Next.js standalone server
|
||||
- Two `Ingress` resources: one for the web host, one for the backend host
|
||||
- `multica-config` ConfigMap (rendered from `values.yaml`)
|
||||
|
||||
The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git.
|
||||
|
||||
> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias.
|
||||
|
||||
> **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass.
|
||||
|
||||
### Step 1 — Point hostnames at the cluster
|
||||
|
||||
The chart defaults to `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Pick one of:
|
||||
|
||||
- **`/etc/hosts`** on every machine that needs access (developer laptops + the machine running the daemon):
|
||||
|
||||
```text
|
||||
192.168.1.206 multica.dev.lan api.multica.dev.lan
|
||||
```
|
||||
|
||||
Replace `192.168.1.206` with any node IP where your Ingress controller's Service is reachable.
|
||||
|
||||
- **Local DNS** (Pi-hole, Unbound, etc.): add A records for both hostnames pointing at the cluster Ingress IP.
|
||||
|
||||
To use different hostnames, override the matching values at install time (see [Step 4](#step-4--install-the-chart)) — `ingress.frontend.host`, `ingress.backend.host`, plus `backend.config.appUrl`, `backend.config.frontendOrigin`, `backend.config.localUploadBaseUrl`, and `backend.config.googleRedirectUri`.
|
||||
|
||||
### Step 2 — Create the namespace
|
||||
|
||||
```bash
|
||||
kubectl create namespace multica
|
||||
```
|
||||
|
||||
### Step 3 — Create the `multica-secrets` Secret
|
||||
|
||||
The chart references this Secret by name. Create it once with random values:
|
||||
|
||||
```bash
|
||||
kubectl -n multica create secret generic multica-secrets \
|
||||
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
|
||||
--from-literal=RESEND_API_KEY="" \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET="" \
|
||||
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
|
||||
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
|
||||
```
|
||||
|
||||
Leave optional values empty for now — you can fill them in later (see [Step 5 — Log In](#step-5--log-in)).
|
||||
|
||||
### Step 4 — Install the chart
|
||||
|
||||
```bash
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
To override defaults, copy `deploy/helm/multica/values.yaml`, edit it, and pass it with `-f`:
|
||||
|
||||
```bash
|
||||
cp deploy/helm/multica/values.yaml my-values.yaml
|
||||
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
|
||||
helm install multica deploy/helm/multica -n multica -f my-values.yaml
|
||||
```
|
||||
|
||||
Watch the pods come up:
|
||||
|
||||
```bash
|
||||
kubectl -n multica get pods -w
|
||||
```
|
||||
|
||||
On a cold cluster the backend can sit `Running` but not `Ready` for a few minutes while it waits on PostgreSQL and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once the backend reports `Ready`, migrations have completed and `/healthz` returns OK:
|
||||
|
||||
```bash
|
||||
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
|
||||
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
```
|
||||
|
||||
Then open http://multica.dev.lan in your browser.
|
||||
|
||||
### Step 5 — Log In
|
||||
|
||||
The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.config.appEnv`), and there is no fixed verification code by default. Pick one of the following to log in — the same three options as the Docker setup:
|
||||
|
||||
- **Recommended (production):** patch the Secret with a real Resend key, then restart the backend:
|
||||
|
||||
```bash
|
||||
kubectl -n multica patch secret multica-secrets --type=merge \
|
||||
-p '{"stringData":{"RESEND_API_KEY":"re_xxx"}}'
|
||||
kubectl -n multica rollout restart deploy/multica-backend
|
||||
```
|
||||
|
||||
Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
|
||||
|
||||
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
|
||||
|
||||
```bash
|
||||
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
|
||||
```
|
||||
|
||||
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
|
||||
|
||||
```bash
|
||||
helm upgrade multica deploy/helm/multica -n multica \
|
||||
-f my-values.yaml --set backend.config.appEnv=development
|
||||
kubectl -n multica patch secret multica-secrets --type=merge \
|
||||
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
|
||||
kubectl -n multica rollout restart deploy/multica-backend
|
||||
```
|
||||
|
||||
`ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml`. After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
|
||||
|
||||
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
|
||||
|
||||
### Step 6 — Install CLI & Start Daemon
|
||||
|
||||
The daemon runs on your local machine, not in the cluster. Install the CLI and an AI agent as in [Step 3](#step-3--install-cli--start-daemon) above, then point the CLI at your Ingress hostnames:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url http://api.multica.dev.lan \
|
||||
--app-url http://multica.dev.lan
|
||||
```
|
||||
|
||||
Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entries from [Step 1](#step-1--point-hostnames-at-the-cluster).
|
||||
|
||||
### Updating
|
||||
|
||||
To pull the latest images without changing the chart version:
|
||||
|
||||
```bash
|
||||
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
|
||||
```
|
||||
|
||||
To pin a specific Multica release, set the image tags in your values file:
|
||||
|
||||
```yaml
|
||||
images:
|
||||
backend:
|
||||
tag: v0.2.4
|
||||
frontend:
|
||||
tag: v0.2.4
|
||||
```
|
||||
|
||||
Then upgrade:
|
||||
|
||||
```bash
|
||||
helm upgrade multica deploy/helm/multica -n multica -f my-values.yaml
|
||||
```
|
||||
|
||||
To roll back if an upgrade goes sideways:
|
||||
|
||||
```bash
|
||||
helm -n multica rollback multica
|
||||
```
|
||||
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
|
||||
|
||||
### Tearing down
|
||||
|
||||
```bash
|
||||
# Remove the workloads but keep the PVCs and the Secret
|
||||
helm -n multica uninstall multica
|
||||
|
||||
# Wipe everything, including PostgreSQL data and uploads
|
||||
kubectl delete namespace multica
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Dashboard Rollup (Required)
|
||||
|
||||
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
|
||||
|
||||
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
|
||||
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
|
||||
|
||||
### Option A — External cron / systemd-timer (simplest)
|
||||
|
||||
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/multica-rollup — every 5 minutes
|
||||
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
|
||||
exec -T postgres psql -U multica -d multica \
|
||||
-c "SELECT rollup_task_usage_hourly();" >/dev/null
|
||||
```
|
||||
|
||||
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
|
||||
|
||||
### Option B — Swap Postgres for an image that ships `pg_cron`
|
||||
|
||||
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule(
|
||||
'rollup_task_usage_hourly',
|
||||
'*/5 * * * *',
|
||||
$$SELECT rollup_task_usage_hourly()$$
|
||||
);
|
||||
```
|
||||
|
||||
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
|
||||
|
||||
### Option C — Backfill history first, then schedule
|
||||
|
||||
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
|
||||
|
||||
```bash
|
||||
# Backfills task_usage_hourly from all historical task_usage rows and stamps
|
||||
# the rollup watermark. Idempotent — safe to re-run.
|
||||
docker compose -f docker-compose.selfhost.yml exec backend \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
```
|
||||
|
||||
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
|
||||
|
||||
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
|
||||
|
||||
## Stopping Services
|
||||
|
||||
If you installed via the install script:
|
||||
@@ -403,8 +175,6 @@ docker compose -f docker-compose.selfhost.yml up -d
|
||||
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
|
||||
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
|
||||
|
||||
---
|
||||
|
||||
## Manual Docker Compose Setup
|
||||
|
||||
@@ -25,30 +25,14 @@ These have sensible defaults and only need to be set when tuning a large or cons
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica supports two email backends. `SMTP_HOST` takes priority when set; otherwise `RESEND_API_KEY` is used. With neither configured, verification codes are printed to the server log — copy them from there to log in.
|
||||
|
||||
#### Option A: Resend (recommended for cloud deployments)
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
#### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this option when your deployment cannot reach the public internet or you already have an internal mail relay (e.g. Exchange, Postfix, SendGrid on-prem).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `SMTP_HOST` | SMTP relay hostname (setting this activates SMTP mode) | - |
|
||||
| `SMTP_PORT` | SMTP port | `25` |
|
||||
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
|
||||
| `SMTP_PASSWORD` | SMTP password | - |
|
||||
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
|
||||
|
||||
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
|
||||
|
||||
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
|
||||
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -79,7 +63,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
@@ -166,111 +150,6 @@ The Docker Compose setup runs migrations automatically. If you need to run them
|
||||
cd server && go run ./cmd/migrate up
|
||||
```
|
||||
|
||||
## Usage Dashboard Rollup
|
||||
|
||||
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
|
||||
|
||||
Pick one of the supported paths:
|
||||
|
||||
### Option A — External cron / systemd-timer
|
||||
|
||||
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
|
||||
|
||||
Docker Compose:
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/multica-rollup
|
||||
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
|
||||
exec -T postgres psql -U multica -d multica \
|
||||
-c "SELECT rollup_task_usage_hourly();" >/dev/null
|
||||
```
|
||||
|
||||
Kubernetes (one-off `CronJob`):
|
||||
|
||||
```yaml
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: multica-usage-rollup
|
||||
spec:
|
||||
schedule: "*/5 * * * *"
|
||||
concurrencyPolicy: Forbid
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: OnFailure
|
||||
containers:
|
||||
- name: psql
|
||||
image: postgres:17-alpine
|
||||
command:
|
||||
- psql
|
||||
- "$(DATABASE_URL)"
|
||||
- -c
|
||||
- "SELECT rollup_task_usage_hourly();"
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: multica-secrets
|
||||
key: DATABASE_URL
|
||||
```
|
||||
|
||||
### Option B — Postgres with `pg_cron`
|
||||
|
||||
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
|
||||
|
||||
Then register the job once:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule(
|
||||
'rollup_task_usage_hourly',
|
||||
'*/5 * * * *',
|
||||
$$SELECT rollup_task_usage_hourly()$$
|
||||
);
|
||||
```
|
||||
|
||||
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
|
||||
|
||||
### Option C — Backfill historical data first
|
||||
|
||||
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
|
||||
|
||||
```bash
|
||||
# Docker Compose
|
||||
docker compose -f docker-compose.selfhost.yml exec backend \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
|
||||
# Kubernetes
|
||||
kubectl -n multica exec deploy/multica-backend -- \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
```
|
||||
|
||||
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--sleep-between-slices` | Pause between monthly slices to throttle read pressure on busy databases (e.g. `2s`). Recommended on production DBs with years of history. |
|
||||
| `--months-back N` | Only backfill the last N months. **Requires `--force-partial`** because the watermark still advances past the skipped older buckets — those are permanently abandoned. |
|
||||
| `--dry-run` | Log slices that would be processed without writing anything. |
|
||||
|
||||
After backfill completes, the rollup-state watermark is stamped to `now() - 5 minutes`, so the first scheduled tick after backfill does not redo history.
|
||||
|
||||
### `v0.3.4 → v0.3.5+` upgrade order
|
||||
|
||||
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
|
||||
|
||||
```text
|
||||
ERROR: refusing to drop legacy daily rollups:
|
||||
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
|
||||
task_usage latest event (...) by more than 01:00:00 — backfill is
|
||||
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
|
||||
(and let pg_cron catch up) before re-running migrate
|
||||
```
|
||||
|
||||
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
|
||||
|
||||
## Manual Setup (Without Docker Compose)
|
||||
|
||||
If you prefer to build and run services manually:
|
||||
@@ -307,47 +186,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 491 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 782 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -32,45 +32,6 @@ mac:
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
linux:
|
||||
# Override the Linux executable name to avoid leaking the scoped npm
|
||||
# package name (`@multica/desktop`) into the installed binary, the
|
||||
# `.desktop` file, and the hicolor icon filename. Without this override
|
||||
# electron-builder defaults `executableName` to the package `name`,
|
||||
# which after slash-stripping becomes `@multicadesktop` — producing
|
||||
# `/usr/share/applications/@multicadesktop.desktop`,
|
||||
# `Icon=@multicadesktop`, and
|
||||
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
|
||||
# violates the freedesktop desktop-entry naming guidance, so GNOME /
|
||||
# Ubuntu fail to associate the running window with the `.desktop` entry
|
||||
# and fall back to the theme's default app icon (the Settings gear on
|
||||
# Yaru). Forcing `multica` makes every Linux identity slot agree and
|
||||
# matches `StartupWMClass=Multica` (productName-derived).
|
||||
executableName: multica
|
||||
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
|
||||
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
|
||||
# ASAR's `package.json` — `productName` if present, otherwise `name`.
|
||||
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
|
||||
# directly; it does not. With our source package.json carrying only
|
||||
# `name: "@multica/desktop"`, packaged Electron emitted
|
||||
# `WM_CLASS=@multica/desktop`, which broke association with this entry
|
||||
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
|
||||
# outside this file — `productName: "Multica"` on the source
|
||||
# package.json (so the ASAR carries it) and `app.setName("Multica")`
|
||||
# in the production branch of `src/main/index.ts` (belt-and-braces).
|
||||
# Keep `StartupWMClass: Multica` pinned here so any future drift in
|
||||
# those two anchors shows up as a diff against this declaration.
|
||||
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
|
||||
# window prints `Multica` for both fields.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
|
||||
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
|
||||
# auto-generation silently shipped only the 1024×1024 source in our
|
||||
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
|
||||
# with no usable size and falling back to the theme default. Shipping
|
||||
# the sizes from source removes the toolchain dependency entirely.
|
||||
icon: build/icons
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@": resolve("src/renderer/src"),
|
||||
},
|
||||
dedupe: ["react", "react-dom", "@tanstack/react-query"],
|
||||
dedupe: ["react", "react-dom"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -10,11 +10,10 @@ export default [
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
// Security: every renderer-controlled URL that reaches the OS shell or the
|
||||
// native download system must flow through the safe wrappers in
|
||||
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
|
||||
// direct shell.openExternal / webContents.downloadURL calls cannot silently
|
||||
// regress the protection.
|
||||
// Security: every renderer-controlled URL that reaches the OS shell must
|
||||
// flow through openExternalSafely in src/main/external-url.ts (scheme
|
||||
// allowlist). Enforce it statically so a direct shell.openExternal call
|
||||
// cannot silently regress the protection.
|
||||
{
|
||||
files: ["src/main/**/*.ts"],
|
||||
rules: {
|
||||
@@ -26,12 +25,6 @@ export default [
|
||||
message:
|
||||
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
|
||||
message:
|
||||
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"productName": "Multica",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
@@ -45,21 +44,15 @@
|
||||
"@multica/core": "workspace:*",
|
||||
"@multica/ui": "workspace:*",
|
||||
"@multica/views": "workspace:*",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"lucide-react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "^7.6.0",
|
||||
"shadcn": "^4.1.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zustand": "catalog:"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@multica/eslint-config": "workspace:*",
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@testing-library/jest-dom": "catalog:",
|
||||
@@ -71,8 +64,9 @@
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shell, type BrowserWindow } from "electron";
|
||||
import { shell } from "electron";
|
||||
|
||||
// True when the URL parses and uses http/https — the only schemes we let
|
||||
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
|
||||
@@ -19,19 +19,6 @@ export function openExternalSafely(url: string): Promise<void> | void {
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
|
||||
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
|
||||
// URLs that trigger a native download MUST flow through here; direct calls
|
||||
// to `webContents.downloadURL` elsewhere in the main process are banned by
|
||||
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
|
||||
// Reuses the same http/https allowlist as openExternalSafely.
|
||||
export function downloadURLSafely(win: BrowserWindow, url: string): void {
|
||||
if (getHttpProtocol(url) === null) {
|
||||
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
|
||||
return;
|
||||
}
|
||||
win.webContents.downloadURL(url);
|
||||
}
|
||||
|
||||
function getHttpProtocol(url: string): "http:" | "https:" | null {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
|
||||
@@ -5,33 +5,16 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
|
||||
// direct-launch doesn't register the .desktop entry, so GNOME has no path
|
||||
// from the running window to the hicolor icon and falls back to the
|
||||
// theme default. Consumed in createWindow() (all platforms in dev, Linux
|
||||
// in prod) and the macOS dev dock branch.
|
||||
//
|
||||
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
|
||||
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
|
||||
// Linux native window-icon code path expects a real filesystem path
|
||||
// (unlike Electron's nativeImage loader which transparently reads from
|
||||
// asar), so swap the segment — same pattern as bundledCliPath() in
|
||||
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
|
||||
// is a no-op.
|
||||
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
// by the `is.dev` branch below.
|
||||
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
|
||||
|
||||
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
|
||||
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
|
||||
@@ -123,39 +106,13 @@ function createWindow(): void {
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option.
|
||||
// On macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
// Linux production needs this explicitly because AppImage direct-launch
|
||||
// does not install a .desktop entry, so the WM has no other path to
|
||||
// the bundled icon; without it Ubuntu falls back to the theme default.
|
||||
...(is.dev || process.platform === "linux"
|
||||
? { icon: BUNDLED_ICON_PATH }
|
||||
: {}),
|
||||
// Windows/Linux pick up the window/taskbar icon from this option in
|
||||
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
// Required for the Chromium PDF viewer (PDFium) to activate inside
|
||||
// iframes — used by the attachment preview modal for application/pdf
|
||||
// files. Default is false in Electron; without it <iframe src=*.pdf>
|
||||
// renders blank.
|
||||
//
|
||||
// Security trade-off, accepted intentionally:
|
||||
// 1. This window already runs with `webSecurity: false` + `sandbox: false`,
|
||||
// so `plugins: true` does NOT meaningfully widen the renderer's
|
||||
// attack surface beyond what is already accepted.
|
||||
// 2. The only PDFs that reach an iframe here are signed CloudFront URLs
|
||||
// we ourselves issued (see useDownloadAttachment); user-supplied URLs
|
||||
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
|
||||
// cannot land in this renderer.
|
||||
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
|
||||
// and only handles the `application/pdf` MIME — it does not expose
|
||||
// Flash, Java, or other historical plugin surfaces.
|
||||
//
|
||||
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
|
||||
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
|
||||
// to that view, keeping the main renderer plugin-free.
|
||||
plugins: true,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
@@ -191,69 +148,23 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, mainWindow!.webContents)) {
|
||||
event.preventDefault();
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
|
||||
// that DevTools can't be opened (white screen with no clickable surface),
|
||||
// the only way to recover the actual JS error is to forward it from the
|
||||
// main process to the terminal running `make dev`. Without these, the
|
||||
// user sees only the daemon-manager polling noise (`Render frame was
|
||||
// disposed before WebFrameMain could be accessed`) which is a downstream
|
||||
// symptom, not the cause.
|
||||
//
|
||||
// Gated by `is.dev` to keep production stderr clean — packaged builds
|
||||
// don't have a terminal anyway, and we ship to crash-reporting separately.
|
||||
if (is.dev) {
|
||||
const log = (tag: string, ...args: unknown[]) =>
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
|
||||
// Forward every renderer-side console.* call. The detail object also
|
||||
// carries source URL + line — included so a thrown stack trace from
|
||||
// window.onerror is traceable back to a file.
|
||||
mainWindow.webContents.on("console-message", (details) => {
|
||||
const { level, message, sourceId, lineNumber } = details;
|
||||
log(level, `${message} (${sourceId}:${lineNumber})`);
|
||||
});
|
||||
|
||||
// Fires when the renderer process dies for any reason (OOM, crash,
|
||||
// killed). `details.reason` is the discriminator: "crashed", "oom",
|
||||
// "killed", "abnormal-exit", "launch-failed", etc.
|
||||
mainWindow.webContents.on("render-process-gone", (_event, details) => {
|
||||
log("process-gone", JSON.stringify(details));
|
||||
});
|
||||
|
||||
// Fires when loadURL / loadFile can't reach its target (dev server
|
||||
// not up yet, network blip, file missing). errorCode is a Chromium
|
||||
// net error number; -3 = ABORTED is normal during HMR and skipped.
|
||||
mainWindow.webContents.on(
|
||||
"did-fail-load",
|
||||
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
|
||||
if (errorCode === -3) return;
|
||||
log(
|
||||
"did-fail-load",
|
||||
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Fires when the preload script throws before the renderer can boot.
|
||||
// This is the one error class that NEVER reaches DevTools (preload
|
||||
// runs before any window) — without this listener it's invisible.
|
||||
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
|
||||
});
|
||||
}
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
installNavigationGestures(mainWindow);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
@@ -281,14 +192,6 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
if (is.dev) {
|
||||
app.setName(DEV_APP_NAME);
|
||||
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
|
||||
} else {
|
||||
// Pin the production app name in code. Electron's Linux WM_CLASS is set
|
||||
// from app.getName() when the first BrowserWindow is realized; the
|
||||
// packaged ASAR's package.json `productName` already steers app.getName()
|
||||
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
|
||||
// (declared in electron-builder.yml) survive a regression in
|
||||
// productName / the build pipeline. Must run before requestSingleInstanceLock().
|
||||
app.setName("Multica");
|
||||
}
|
||||
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
@@ -348,7 +251,7 @@ if (!gotTheLock) {
|
||||
// so the Canary dev build is visually distinct from a stock Electron
|
||||
// run. `app.dock` is macOS-only — guard the call.
|
||||
if (is.dev && process.platform === "darwin" && app.dock) {
|
||||
const icon = nativeImage.createFromPath(BUNDLED_ICON_PATH);
|
||||
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
|
||||
if (!icon.isEmpty()) app.dock.setIcon(icon);
|
||||
}
|
||||
|
||||
@@ -365,14 +268,6 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
return;
|
||||
}
|
||||
downloadURLSafely(mainWindow, url);
|
||||
});
|
||||
|
||||
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
|
||||
// preload can attach the values to `desktopAPI.appInfo` before any renderer
|
||||
// code reads them, ensuring the very first HTTP request from the renderer
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
|
||||
|
||||
function makeWc(initialLevel = 0) {
|
||||
let level = initialLevel;
|
||||
return {
|
||||
getZoomLevel: vi.fn(() => level),
|
||||
setZoomLevel: vi.fn((next: number) => {
|
||||
level = next;
|
||||
}),
|
||||
currentLevel: () => level,
|
||||
};
|
||||
}
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleAppShortcut — reload blocking", () => {
|
||||
it("swallows Cmd+R on macOS", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows Ctrl+R on Linux/Windows", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows F5 regardless of modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-keyDown events", () => {
|
||||
const wc = makeWc();
|
||||
expect(
|
||||
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom in", () => {
|
||||
it("zooms in on Cmd+= (unshifted)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Cmd++ (Shift+=)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Ctrl+= on non-mac", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps zoom-in at the upper bound", () => {
|
||||
const wc = makeWc(4.5);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
|
||||
it("zooms out on Cmd+- (unshifted)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Cmd+_ (Shift+-)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Ctrl+- on non-mac", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("undoes a prior Cmd+= so the user can return to 100%", () => {
|
||||
const wc = makeWc(0);
|
||||
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps zoom-out at the lower bound", () => {
|
||||
const wc = makeWc(-3);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(-3);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — reset zoom", () => {
|
||||
it("resets to 0 on Cmd+0", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("resets to 0 on Ctrl+0", () => {
|
||||
const wc = makeWc(-1.5);
|
||||
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores plain 0 without modifier", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
it("does not capture plain letters", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
// Shape of the input subset we read from Electron's `before-input-event`.
|
||||
// Modeled as a structural type so the handler is unit-testable without a
|
||||
// real Electron Input instance.
|
||||
export type ShortcutInput = {
|
||||
type: string;
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
|
||||
|
||||
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
|
||||
// per step). Clamp to a range that keeps the UI legible — values outside
|
||||
// this band turn the workspace into either confetti or a microfiche.
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 4.5;
|
||||
|
||||
/**
|
||||
* Inspect a `before-input-event` key and apply (or block) the matching
|
||||
* window-level shortcut. Returns `true` when the caller should call
|
||||
* `event.preventDefault()` — that both swallows the renderer keydown and
|
||||
* prevents the application menu accelerator from firing, so we don't
|
||||
* double-trigger zoom on macOS where the default menu also binds these
|
||||
* keys.
|
||||
*
|
||||
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
|
||||
* default `Cmd+-` accelerator does not fire reliably across keyboard
|
||||
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
|
||||
// (tabs, drafts, WS connections) with no URL bar to recover from.
|
||||
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!cmdOrCtrl) return false;
|
||||
|
||||
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
|
||||
if (input.key === "=" || input.key === "+") {
|
||||
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
|
||||
if (input.key === "-" || input.key === "_") {
|
||||
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + 0 → reset zoom to 100%.
|
||||
if (input.key === "0") {
|
||||
webContents.setZoomLevel(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { NAVIGATION_GESTURE_CHANNEL } from "../shared/navigation-gestures";
|
||||
import { installNavigationGestures } from "./navigation-gestures";
|
||||
|
||||
function makeWindow() {
|
||||
let swipeHandler:
|
||||
| ((event: unknown, direction: string) => void)
|
||||
| undefined;
|
||||
|
||||
const win = {
|
||||
on: vi.fn(
|
||||
(event: string, handler: (event: unknown, direction: string) => void) => {
|
||||
if (event === "swipe") swipeHandler = handler;
|
||||
return win;
|
||||
},
|
||||
),
|
||||
webContents: {
|
||||
send: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
win: win as unknown as BrowserWindow,
|
||||
send: win.webContents.send,
|
||||
emitSwipe: (direction: string) => swipeHandler?.({}, direction),
|
||||
};
|
||||
}
|
||||
|
||||
describe("installNavigationGestures", () => {
|
||||
it("registers macOS swipe navigation", () => {
|
||||
const { win, send, emitSwipe } = makeWindow();
|
||||
|
||||
installNavigationGestures(win, "darwin");
|
||||
|
||||
emitSwipe("right");
|
||||
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "back");
|
||||
|
||||
emitSwipe("left");
|
||||
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "forward");
|
||||
});
|
||||
|
||||
it("ignores non-horizontal swipe directions", () => {
|
||||
const { win, send, emitSwipe } = makeWindow();
|
||||
|
||||
installNavigationGestures(win, "darwin");
|
||||
emitSwipe("up");
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not register on non-mac platforms", () => {
|
||||
const { win, send, emitSwipe } = makeWindow();
|
||||
|
||||
installNavigationGestures(win, "linux");
|
||||
emitSwipe("right");
|
||||
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { BrowserWindow } from "electron";
|
||||
import {
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
navigationGestureFromSwipe,
|
||||
} from "../shared/navigation-gestures";
|
||||
|
||||
export function installNavigationGestures(
|
||||
win: BrowserWindow,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (platform !== "darwin") return;
|
||||
|
||||
win.on("swipe", (_event, direction) => {
|
||||
const gesture = navigationGestureFromSwipe(direction);
|
||||
if (!gesture) return;
|
||||
win.webContents.send(NAVIGATION_GESTURE_CHANNEL, gesture);
|
||||
});
|
||||
}
|
||||
@@ -69,7 +69,7 @@ describe("loadRuntimeConfig", () => {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://example.com",
|
||||
appUrl: "https://api.example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
// downloaded and ready to install on next quit.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
@@ -29,39 +26,8 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Single-flight guard around checkForUpdates(). With autoDownload=true the
|
||||
// startup, periodic, and manual triggers can all kick off downloads, and
|
||||
// overlapping calls have caused duplicate download warnings in the past
|
||||
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
|
||||
// callers onto the same in-flight promise.
|
||||
let inFlightCheck: Promise<unknown> | null = null;
|
||||
function checkForUpdatesOnce(): Promise<unknown> {
|
||||
if (inFlightCheck) return inFlightCheck;
|
||||
const p = autoUpdater
|
||||
.checkForUpdates()
|
||||
.then((result) => {
|
||||
// checkForUpdates resolves as soon as metadata is fetched; the actual
|
||||
// download (when autoDownload=true) is exposed on result.downloadPromise.
|
||||
// Without a handler a download failure becomes an unhandled rejection
|
||||
// in the main process — Node may terminate it on future versions.
|
||||
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
|
||||
(err) => {
|
||||
console.error("Failed to download update:", err);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightCheck === p) inFlightCheck = null;
|
||||
});
|
||||
inFlightCheck = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
@@ -76,20 +42,15 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
// Retained for IPC back-compat with older renderer bundles. With
|
||||
// autoDownload=true the renderer no longer triggers this path.
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
@@ -100,9 +61,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = (await checkForUpdatesOnce()) as
|
||||
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
|
||||
| null;
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
@@ -126,7 +85,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -134,7 +93,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
|
||||
10
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,6 +1,5 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -20,9 +19,6 @@ interface DesktopAPI {
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Download a file by URL through Electron's native download system.
|
||||
* Shows a native save dialog. On non-desktop platforms this is undefined. */
|
||||
downloadURL: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
/** Show a native OS notification for a new inbox item. */
|
||||
@@ -43,8 +39,6 @@ interface DesktopAPI {
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -87,9 +81,7 @@ interface DaemonAPI {
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
type NavigationGesture,
|
||||
} from "../shared/navigation-gestures";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -94,11 +89,6 @@ const desktopAPI = {
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Download a file by URL through Electron's native download system.
|
||||
* Shows a save dialog and saves to disk. Unlike openExternal, this
|
||||
* avoids browser rendering of HTML files on Linux.
|
||||
* On non-desktop platforms this property is undefined. */
|
||||
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
@@ -146,16 +136,6 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
/** Listen for native macOS back/forward swipe gestures. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, gesture: unknown) => {
|
||||
if (isNavigationGesture(gesture)) callback(gesture);
|
||||
};
|
||||
ipcRenderer.on(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -222,11 +202,8 @@ const updaterAPI = {
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
|
||||
callback(info);
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
|
||||
@@ -3,11 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CoreProvider } from "@multica/core/platform";
|
||||
import { pickLocale } from "@multica/core/i18n";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWelcomeStore } from "@multica/core/onboarding";
|
||||
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
@@ -120,31 +118,25 @@ function AppContent() {
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web layout
|
||||
// hard gate via overlays (desktop has no URL bar, so we open the
|
||||
// onboarding overlay instead of router.replace):
|
||||
// onboarded + has workspace → no overlay, dashboard
|
||||
// un-onboarded (any wsCount):
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// onboarded + no workspace → /workspaces/new overlay
|
||||
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
|
||||
// judgment in callback / login:
|
||||
// un-onboarded:
|
||||
// pending invites on email → /invitations overlay
|
||||
// no invites → /onboarding overlay
|
||||
// already onboarded:
|
||||
// zero workspaces → /workspaces/new overlay
|
||||
// ≥1 workspaces → no overlay, fall through to dashboard
|
||||
//
|
||||
// V3 invariant: `onboarded_at != null` is the only path into the
|
||||
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
|
||||
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
|
||||
// somehow has a workspace but no onboarded mark must be sent back to
|
||||
// /onboarding — we also clear the active workspace so the dashboard
|
||||
// doesn't render under the overlay with stale workspace context.
|
||||
// The "un-onboarded but in workspace" state is now physically impossible
|
||||
// because backend transactions atomically set onboarded_at when a user
|
||||
// joins the `member` table. Anyone with workspaces is by definition
|
||||
// onboarded.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return undefined;
|
||||
if (hasOnboarded && wsCount > 0) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (!hasOnboarded) {
|
||||
// Stale workspace context (if any) would leak X-Workspace-Slug
|
||||
// headers into onboarding-time API calls. Clear it before opening
|
||||
// the overlay.
|
||||
setCurrentWorkspace(null, null);
|
||||
// Look up pending invitations by email. Network blip is non-fatal —
|
||||
// fall through to onboarding so the user isn't stuck on a blank
|
||||
// window. The sidebar's pending-invitations dropdown will surface
|
||||
@@ -266,9 +258,6 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
// Drop any post-onboarding welcome signal so user B logging in next
|
||||
// doesn't inherit user A's pending modal state.
|
||||
useWelcomeStore.getState().reset();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
@@ -11,7 +12,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,13 +32,24 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop-only controls for the daemon embedded in this Electron app. The
|
||||
* shared runtimes page renders this inside the selected local machine header.
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeActions() {
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -37,8 +57,14 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
@@ -50,6 +76,10 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
@@ -78,6 +108,9 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
@@ -86,6 +119,8 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
@@ -101,6 +136,9 @@ export function DaemonRuntimeActions() {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
@@ -124,64 +162,106 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ModalRegistry } from "@multica/views/modals/registry";
|
||||
import { AppSidebar } from "@multica/views/layout";
|
||||
import { SearchCommand, SearchTrigger } from "@multica/views/search";
|
||||
import { ChatFab, ChatWindow } from "@multica/views/chat";
|
||||
import { StarterContentPrompt } from "@multica/views/onboarding";
|
||||
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
@@ -54,20 +55,6 @@ function SidebarTopBar() {
|
||||
);
|
||||
}
|
||||
|
||||
function useNativeNavigationGestures() {
|
||||
const { goBack, goForward } = useTabHistory();
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onNavigationGesture((gesture) => {
|
||||
if (gesture === "back") {
|
||||
goBack();
|
||||
} else {
|
||||
goForward();
|
||||
}
|
||||
});
|
||||
}, [goBack, goForward]);
|
||||
}
|
||||
|
||||
// The main area's top bar doubles as a window drag region. When the sidebar
|
||||
// is not occupying main-flow width — either user-collapsed (offcanvas) or
|
||||
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
|
||||
@@ -146,7 +133,6 @@ function DesktopInboxBridge() {
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
useNativeNavigationGestures();
|
||||
|
||||
// Reactive read of current workspace slug from the platform singleton.
|
||||
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
|
||||
@@ -183,6 +169,7 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
{slug && <StarterContentPrompt />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
@@ -19,28 +19,10 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
*/
|
||||
export function DesktopRuntimesPage() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
// Remember the last known daemonId/deviceName. After the daemon is
|
||||
// stopped, `status.daemonId` goes back to undefined — without this
|
||||
// sticky cache the local row would either disappear or get reclassified
|
||||
// as a remote machine (since `isCurrent` requires a daemonId match),
|
||||
// taking the Start button with it.
|
||||
const [lastIdentity, setLastIdentity] = useState<{
|
||||
daemonId: string | null;
|
||||
deviceName: string | null;
|
||||
}>({ daemonId: null, deviceName: null });
|
||||
|
||||
useEffect(() => {
|
||||
const apply = (s: DaemonStatus) => {
|
||||
setStatus(s);
|
||||
if (s.daemonId) {
|
||||
setLastIdentity({
|
||||
daemonId: s.daemonId,
|
||||
deviceName: s.deviceName ?? null,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.daemonAPI.getStatus().then(apply);
|
||||
return window.daemonAPI.onStatusChange(apply);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const bootstrapping =
|
||||
@@ -50,14 +32,7 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
|
||||
localMachineName={status.deviceName ?? lastIdentity.deviceName}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
// Desktop owns a local machine for the lifetime of the app, even
|
||||
// while the daemon is stopped or hasn't registered yet. The shared
|
||||
// page synthesizes a placeholder local row when no real runtime
|
||||
// matches, so the Start button is always reachable.
|
||||
hasLocalMachine
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("PageviewTracker", () => {
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a foreground tab is added (addTab path)", () => {
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
@@ -128,11 +128,7 @@ describe("PageviewTracker", () => {
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate a foreground new-tab action (e.g. an explicit "Open in new
|
||||
// tab" toolbar button that passes `{ activate: true }`) — tC is
|
||||
// appended AND becomes active. `openInNewTab` defaults to background
|
||||
// (no `setActiveTab`); only the `activate: true` branch produces the
|
||||
// state change this test exercises.
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render, fireEvent, within } from "@testing-library/react";
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
};
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
togglePin: vi.fn<(tabId: string) => void>(),
|
||||
closeTab: vi.fn<(tabId: string) => void>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
moveTab: vi.fn<(from: number, to: number) => void>(),
|
||||
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
togglePin: state.togglePin,
|
||||
closeTab: state.closeTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
moveTab: state.moveTab,
|
||||
addTab: state.addTab,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const useActiveGroup = () =>
|
||||
state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
|
||||
: null;
|
||||
const resolveRouteIcon = () => "ListTodo";
|
||||
return { useTabStore, useActiveGroup, resolveRouteIcon };
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
paths: {
|
||||
workspace: (slug: string) => ({
|
||||
issues: () => `/${slug}/issues`,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
import { TabBar } from "./tab-bar";
|
||||
|
||||
function reset() {
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.togglePin.mockReset();
|
||||
state.closeTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.moveTab.mockReset();
|
||||
state.addTab.mockReset();
|
||||
}
|
||||
|
||||
beforeEach(reset);
|
||||
|
||||
describe("TabBar hover action buttons", () => {
|
||||
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
|
||||
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("clicking the Pin button calls togglePin for the tab", () => {
|
||||
const { getAllByLabelText } = render(<TabBar />);
|
||||
const pinButtons = getAllByLabelText("Pin tab");
|
||||
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tB");
|
||||
});
|
||||
|
||||
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
fireEvent.click(getByLabelText("Unpin tab"));
|
||||
expect(state.togglePin).toHaveBeenCalledWith("tA");
|
||||
});
|
||||
|
||||
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { queryAllByLabelText } = render(<TabBar />);
|
||||
// Only the unpinned tab exposes a Close affordance — pinned tab requires
|
||||
// explicit Unpin first (RFC §3 D3c FINAL).
|
||||
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
|
||||
state.byWorkspace.acme.tabs = [
|
||||
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
|
||||
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
|
||||
];
|
||||
const { getByLabelText } = render(<TabBar />);
|
||||
const pinnedTab = getByLabelText("Issues (pinned)");
|
||||
const unpinnedTab = getByLabelText("Projects");
|
||||
// lucide-react renders the icon name into the class list. The leading
|
||||
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
|
||||
// so we qualify on size to avoid matching the action glyph.
|
||||
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
|
||||
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
|
||||
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
|
||||
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
CircleUser,
|
||||
@@ -9,8 +8,6 @@ import {
|
||||
Settings,
|
||||
X,
|
||||
Plus,
|
||||
Pin,
|
||||
PinOff,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -31,20 +28,8 @@ import {
|
||||
restrictToParentElement,
|
||||
} from "@dnd-kit/modifiers";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@multica/ui/components/ui/context-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
useTabStore,
|
||||
useActiveGroup,
|
||||
resolveRouteIcon,
|
||||
type Tab,
|
||||
} from "@/stores/tab-store";
|
||||
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
|
||||
import { paths } from "@multica/core/paths";
|
||||
|
||||
const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
@@ -57,23 +42,9 @@ const TAB_ICONS: Record<string, LucideIcon> = {
|
||||
Settings,
|
||||
};
|
||||
|
||||
function SortableTabItem({
|
||||
tab,
|
||||
isActive,
|
||||
isOnly,
|
||||
}: {
|
||||
tab: Tab;
|
||||
isActive: boolean;
|
||||
/**
|
||||
* True iff this is the only tab in the workspace. Hiding X on the last
|
||||
* tab matches existing behavior and avoids the surprise of the store's
|
||||
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
|
||||
*/
|
||||
isOnly: boolean;
|
||||
}) {
|
||||
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
|
||||
const setActiveTab = useTabStore((s) => s.setActiveTab);
|
||||
const closeTab = useTabStore((s) => s.closeTab);
|
||||
const togglePin = useTabStore((s) => s.togglePin);
|
||||
|
||||
const {
|
||||
attributes,
|
||||
@@ -84,11 +55,7 @@ function SortableTabItem({
|
||||
isDragging,
|
||||
} = useSortable({ id: tab.id });
|
||||
|
||||
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
|
||||
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
|
||||
// present in the title, and this avoids a hard left accent border that read
|
||||
// as visually heavy in light mode.
|
||||
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
|
||||
const Icon = TAB_ICONS[tab.icon];
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
@@ -107,30 +74,17 @@ function SortableTabItem({
|
||||
closeTab(tab.id);
|
||||
};
|
||||
|
||||
const handleTogglePin = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
togglePin(tab.id);
|
||||
};
|
||||
|
||||
const stopDragOnAction = (e: React.PointerEvent) => {
|
||||
const stopDragOnClose = (e: React.PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
|
||||
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
|
||||
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
|
||||
// reachable via the hover action button below and the right-click menu.
|
||||
const showCloseButton = !tab.pinned && !isOnly;
|
||||
|
||||
const tabButton = (
|
||||
return (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
|
||||
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
|
||||
className={cn(
|
||||
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
|
||||
"select-none cursor-default",
|
||||
@@ -140,7 +94,7 @@ function SortableTabItem({
|
||||
isDragging && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
|
||||
{Icon && <Icon className="size-3.5 shrink-0" />}
|
||||
<span
|
||||
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
|
||||
style={{
|
||||
@@ -150,22 +104,10 @@ function SortableTabItem({
|
||||
>
|
||||
{tab.title}
|
||||
</span>
|
||||
<span
|
||||
onClick={handleTogglePin}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
title={tab.pinned ? "Unpin tab" : "Pin tab"}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
|
||||
</span>
|
||||
{showCloseButton && (
|
||||
{!isOnly && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
onPointerDown={stopDragOnAction}
|
||||
role="button"
|
||||
aria-label="Close tab"
|
||||
onPointerDown={stopDragOnClose}
|
||||
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
|
||||
>
|
||||
<X className="size-2.5" />
|
||||
@@ -173,36 +115,6 @@ function SortableTabItem({
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger render={tabButton} />
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => togglePin(tab.id)}>
|
||||
{tab.pinned ? (
|
||||
<>
|
||||
<PinOff />
|
||||
Unpin tab
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pin />
|
||||
Pin tab
|
||||
</>
|
||||
)}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
disabled={tab.pinned || isOnly}
|
||||
onClick={() => closeTab(tab.id)}
|
||||
>
|
||||
<X />
|
||||
Close tab
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function NewTabButton() {
|
||||
@@ -243,17 +155,12 @@ export function TabBar() {
|
||||
const tabs = group?.tabs ?? [];
|
||||
const activeTabId = group?.activeTabId ?? "";
|
||||
const tabIds = tabs.map((t) => t.id);
|
||||
const pinnedCount = tabs.filter((t) => t.pinned).length;
|
||||
const unpinnedCount = tabs.length - pinnedCount;
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const from = tabs.findIndex((t) => t.id === active.id);
|
||||
const to = tabs.findIndex((t) => t.id === over.id);
|
||||
// The store clamps the destination to within the source tab's zone
|
||||
// (pinned vs unpinned), so this call is safe even when the user tries
|
||||
// to drag across the boundary — the tab will land at the boundary.
|
||||
if (from !== -1 && to !== -1) moveTab(from, to);
|
||||
};
|
||||
|
||||
@@ -266,22 +173,13 @@ export function TabBar() {
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
|
||||
{tabs.map((tab, index) => (
|
||||
<Fragment key={tab.id}>
|
||||
<SortableTabItem
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
{tab.pinned &&
|
||||
index === pinnedCount - 1 &&
|
||||
unpinnedCount > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mx-1 h-4 w-px bg-border"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTabItem
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
isOnly={tabs.length === 1}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { RouterProvider } from "react-router-dom";
|
||||
import { useActiveGroup } from "@/stores/tab-store";
|
||||
import { TabNavigationProvider } from "@/platform/navigation";
|
||||
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
|
||||
import { useTabScrollRestore } from "@/hooks/use-tab-scroll-restore";
|
||||
import type { Tab } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -16,28 +15,6 @@ function TabRouterInner({ tab }: { tab: Tab }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a tab's subtree so its scroll position survives the round trip
|
||||
* through `<Activity mode="hidden">`. Lives inside Activity so the hook's
|
||||
* effects cycle with the tab's visibility — see `useTabScrollRestore` for
|
||||
* the mechanism. `display: contents` keeps the wrapper transparent to
|
||||
* the surrounding flex layout.
|
||||
*/
|
||||
function TabScrollRestoreWrapper({
|
||||
tabPath,
|
||||
children,
|
||||
}: {
|
||||
tabPath: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const ref = useTabScrollRestore(tabPath);
|
||||
return (
|
||||
<div ref={ref} style={{ display: "contents" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the active workspace's tabs using Activity for state preservation.
|
||||
* Only the active tab is visible; hidden tabs keep their DOM and React state.
|
||||
@@ -67,12 +44,10 @@ export function TabContent() {
|
||||
key={tab.id}
|
||||
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
|
||||
>
|
||||
<TabScrollRestoreWrapper tabPath={tab.path}>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tab={tab} />
|
||||
</TabNavigationProvider>
|
||||
</TabScrollRestoreWrapper>
|
||||
<TabNavigationProvider router={tab.router}>
|
||||
<RouterProvider router={tab.router} />
|
||||
<TabRouterInner tab={tab} />
|
||||
</TabNavigationProvider>
|
||||
</Activity>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
// Downloads run silently in the background (main process has
|
||||
// autoDownload=true). The renderer only renders UI once the package is fully
|
||||
// downloaded and waiting for a restart.
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "ready"; version: string };
|
||||
| { 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 cleanup = window.updater.onUpdateDownloaded((info) => {
|
||||
setState({ status: "ready", version: info.version });
|
||||
setDismissed(false);
|
||||
});
|
||||
return cleanup;
|
||||
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) 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">
|
||||
@@ -32,31 +60,78 @@ export function UpdateNotification() {
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<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">
|
||||
v{state.version} will be applied on next launch.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{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={() => setDismissed(true)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
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"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.updater.installUpdate()}
|
||||
className="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
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
@@ -11,7 +10,6 @@ type CheckState =
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const { t } = useT("settings");
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
@@ -31,15 +29,16 @@ export function UpdatesSettingsTab() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{t(($) => $.desktop.updates.title)}</h2>
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t(($) => $.desktop.updates.description)}
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t(($) => $.desktop.updates.current_version)}</p>
|
||||
<p className="text-sm font-medium">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
@@ -48,20 +47,22 @@ export function UpdatesSettingsTab() {
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{t(($) => $.desktop.updates.check_section_title)}</p>
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{t(($) => $.desktop.updates.check_section_description)}
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<Check className="size-3.5 text-success" />
|
||||
{t(($) => $.desktop.updates.up_to_date)}
|
||||
You're on the latest version.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
{t(($) => $.desktop.updates.downloading, { version: state.latestVersion })}
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
@@ -81,10 +82,10 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "checking" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t(($) => $.desktop.updates.checking)}
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
t(($) => $.desktop.updates.check_now)
|
||||
"Check now"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -62,25 +62,18 @@ function WindowOverlayInner() {
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws, issueId) => {
|
||||
onComplete={(ws) => {
|
||||
close();
|
||||
// Runtime-connected onboarding lands on its single guide
|
||||
// issue. Runtime-less exits still land on the issues list.
|
||||
if (ws && issueId) {
|
||||
push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
// Restart the bundled daemon when the user hits Refresh on
|
||||
// Step 3. The daemon's PATH probe runs once at boot, so a
|
||||
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
|
||||
// up until the daemon is bounced.
|
||||
onRuntimeRefresh={async () => {
|
||||
await window.daemonAPI?.restart?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -9,10 +9,8 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
|
||||
@@ -36,15 +34,6 @@ export function WorkspaceRouteLayout() {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAuthLoading = useAuthStore((s) => s.isLoading);
|
||||
// While a WindowOverlay is open (onboarding, accept-invite, new-workspace),
|
||||
// the underlying tab is still mounted in the React tree — so this layout
|
||||
// and its WelcomeAfterOnboarding Modal would render UNDER the overlay.
|
||||
// Because the modal uses a Portal that targets document.body, it ends up
|
||||
// rendered LATER in the DOM and visually outranks the overlay's z-50.
|
||||
// Suppress the modal whenever any overlay is active; the moment the
|
||||
// overlay closes the welcome hook re-evaluates and pops if its store
|
||||
// signal is still set.
|
||||
const overlayActive = useWindowOverlayStore((s) => s.overlay !== null);
|
||||
|
||||
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
|
||||
useEffect(() => {
|
||||
@@ -96,14 +85,6 @@ export function WorkspaceRouteLayout() {
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
{/* Reads the welcome-store transient signal parked by
|
||||
* OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay
|
||||
* (onboarding / accept-invite / new-workspace) is open so the modal
|
||||
* doesn't portal-jump in front of an active pre-workspace flow.
|
||||
* Once the overlay closes the hook re-evaluates and pops the
|
||||
* Modal — unless the store signal has already been consumed, in
|
||||
* which case the hook renders null. */}
|
||||
{!overlayActive && <WelcomeAfterOnboarding />}
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Activity } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { useTabScrollRestore } from "./use-tab-scroll-restore";
|
||||
|
||||
function Harness({ path }: { path: string }) {
|
||||
const ref = useTabScrollRestore(path);
|
||||
return (
|
||||
<div ref={ref} style={{ display: "contents" }}>
|
||||
<div
|
||||
data-tab-scroll-root
|
||||
data-testid="scroller"
|
||||
style={{ height: 100, overflow: "auto" }}
|
||||
>
|
||||
<div style={{ height: 1000 }} />
|
||||
</div>
|
||||
<div
|
||||
data-tab-scroll-root="aside"
|
||||
data-testid="aside"
|
||||
style={{ height: 100, overflow: "auto" }}
|
||||
>
|
||||
<div style={{ height: 1000 }} />
|
||||
</div>
|
||||
<div
|
||||
data-testid="unmarked"
|
||||
style={{ height: 100, overflow: "auto" }}
|
||||
>
|
||||
<div style={{ height: 1000 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App({ visible, path }: { visible: boolean; path: string }) {
|
||||
return (
|
||||
<Activity mode={visible ? "visible" : "hidden"}>
|
||||
<Harness path={path} />
|
||||
</Activity>
|
||||
);
|
||||
}
|
||||
|
||||
function setScroll(el: HTMLElement, top: number) {
|
||||
el.scrollTop = top;
|
||||
fireEvent.scroll(el);
|
||||
}
|
||||
|
||||
describe("useTabScrollRestore", () => {
|
||||
it("restores scroll position when a tab cycles through hidden -> visible", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const scroller = getByTestId("scroller") as HTMLElement;
|
||||
|
||||
setScroll(scroller, 500);
|
||||
expect(scroller.scrollTop).toBe(500);
|
||||
|
||||
// Simulate Activity hiding the subtree: layout would drop the offset.
|
||||
rerender(<App visible={false} path="/acme/issues/1" />);
|
||||
scroller.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={true} path="/acme/issues/1" />);
|
||||
expect(scroller.scrollTop).toBe(500);
|
||||
});
|
||||
|
||||
it("restores multiple named scroll roots independently", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const main = getByTestId("scroller") as HTMLElement;
|
||||
const aside = getByTestId("aside") as HTMLElement;
|
||||
|
||||
setScroll(main, 300);
|
||||
setScroll(aside, 150);
|
||||
|
||||
rerender(<App visible={false} path="/acme/issues/1" />);
|
||||
main.scrollTop = 0;
|
||||
aside.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={true} path="/acme/issues/1" />);
|
||||
expect(main.scrollTop).toBe(300);
|
||||
expect(aside.scrollTop).toBe(150);
|
||||
});
|
||||
|
||||
it("ignores scroll on elements without the data-tab-scroll-root marker", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const unmarked = getByTestId("unmarked") as HTMLElement;
|
||||
|
||||
setScroll(unmarked, 250);
|
||||
|
||||
rerender(<App visible={false} path="/acme/issues/1" />);
|
||||
unmarked.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={true} path="/acme/issues/1" />);
|
||||
expect(unmarked.scrollTop).toBe(0);
|
||||
});
|
||||
|
||||
it("drops saved offsets when the tab path changes (intra-tab navigation)", () => {
|
||||
const { rerender, getByTestId } = render(
|
||||
<App visible={true} path="/acme/issues/1" />,
|
||||
);
|
||||
const scroller = getByTestId("scroller") as HTMLElement;
|
||||
|
||||
setScroll(scroller, 500);
|
||||
|
||||
// Navigating within the tab swaps the active route — same marker key,
|
||||
// different page. We should NOT restore the prior page's offset.
|
||||
rerender(<App visible={true} path="/acme/issues/2" />);
|
||||
scroller.scrollTop = 0;
|
||||
|
||||
rerender(<App visible={false} path="/acme/issues/2" />);
|
||||
rerender(<App visible={true} path="/acme/issues/2" />);
|
||||
expect(scroller.scrollTop).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* Persist a tab's scroll positions across <Activity> visibility transitions.
|
||||
*
|
||||
* Tabs render under `<Activity mode="visible|hidden">`, which keeps React
|
||||
* state but loses DOM scrollTop — the subtree is taken out of layout while
|
||||
* hidden and rejoins with scrollTop=0. This hook records every marked
|
||||
* container's `scrollTop` while the tab is visible (continuously, via a
|
||||
* capture-phase scroll listener) and restores them in a `useLayoutEffect`
|
||||
* the next time the tab becomes visible, before the browser paints.
|
||||
*
|
||||
* Mark scroll containers in views with `data-tab-scroll-root`. The
|
||||
* attribute value is the cache key — defaults to `"main"` for unnamed
|
||||
* roots. Most pages have a single scroll container, so a bare attribute
|
||||
* is enough; named keys are only needed when a page has multiple
|
||||
* independently scrollable regions whose positions must all be restored.
|
||||
*
|
||||
* When the tab's path changes (intra-tab navigation), the saved offsets
|
||||
* are dropped — the new route's container shares the same marker key but
|
||||
* is a different page, and restoring the old offset would land the user
|
||||
* somewhere arbitrary on the new page.
|
||||
*
|
||||
* For virtualized children (Virtuoso, react-virtual, etc.) the single
|
||||
* synchronous `scrollTop = saved` inside useLayoutEffect isn't enough:
|
||||
* the child registers its observers in a passive useEffect that fires
|
||||
* later, so at restore time the container's scrollHeight has collapsed
|
||||
* to clientHeight and the browser clamps our assignment to 0. The
|
||||
* restore loops across rAF frames until the assignment sticks, which
|
||||
* lets virtualization rehydrate before we give up.
|
||||
*/
|
||||
export function useTabScrollRestore(tabPath: string) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const savedRef = useRef<Map<string, number>>(new Map());
|
||||
const prevPathRef = useRef(tabPath);
|
||||
|
||||
if (prevPathRef.current !== tabPath) {
|
||||
savedRef.current.clear();
|
||||
prevPathRef.current = tabPath;
|
||||
}
|
||||
|
||||
// <Activity> cleans up effects on hidden and re-mounts them on visible,
|
||||
// so an empty-deps useLayoutEffect runs exactly on every hidden → visible
|
||||
// transition. Restoring here (before the browser paints) handles the
|
||||
// common case without a flash.
|
||||
//
|
||||
// The synchronous set isn't enough for virtualized lists though
|
||||
// (issue-detail uses Virtuoso with customScrollParent). Virtuoso wires
|
||||
// its scroll/resize observers in a passive useEffect, which fires AFTER
|
||||
// useLayoutEffect — so at the moment we try to restore, the spacer that
|
||||
// gives the container its tall scrollHeight hasn't been re-established
|
||||
// yet. The browser silently clamps `scrollTop = saved` down to 0 because
|
||||
// `scrollHeight === clientHeight` in that window. Retry across rAF
|
||||
// frames until the set sticks (or we time out around the time any sane
|
||||
// child should have laid out, ~500ms).
|
||||
useLayoutEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const els = root.querySelectorAll<HTMLElement>("[data-tab-scroll-root]");
|
||||
const cancellers: Array<() => void> = [];
|
||||
els.forEach((el) => {
|
||||
const key = scrollKey(el);
|
||||
const saved = savedRef.current.get(key);
|
||||
if (saved === undefined) return;
|
||||
el.scrollTop = saved;
|
||||
if (el.scrollTop === saved) return;
|
||||
|
||||
let cancelled = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30; // ~500ms at 60fps
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
el.scrollTop = saved;
|
||||
attempts++;
|
||||
if (el.scrollTop === saved) return;
|
||||
if (attempts >= maxAttempts) return;
|
||||
requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
cancellers.push(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
});
|
||||
return () => cancellers.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const onScroll = (e: Event) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (!target.hasAttribute("data-tab-scroll-root")) return;
|
||||
savedRef.current.set(scrollKey(target), target.scrollTop);
|
||||
};
|
||||
// Scroll events don't bubble, but capture catches them anyway.
|
||||
root.addEventListener("scroll", onScroll, { capture: true, passive: true });
|
||||
return () => root.removeEventListener("scroll", onScroll, true);
|
||||
}, []);
|
||||
|
||||
return containerRef;
|
||||
}
|
||||
|
||||
function scrollKey(el: HTMLElement): string {
|
||||
return el.getAttribute("data-tab-scroll-root") || "main";
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export function AttachmentPreviewRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const filename = searchParams.get("name") ?? undefined;
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { IssueDetail } from "@multica/views/issues/components";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
@@ -14,9 +13,5 @@ export function IssueDetailPage() {
|
||||
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<IssueDetail issueId={id} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
return <IssueDetail issueId={id} />;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemberDetailPage as SharedMemberDetailPage } from "@multica/views/members";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function MemberDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const member = members.find((m) => m.user_id === id) ?? null;
|
||||
|
||||
useDocumentTitle(member?.name ?? "Member");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedMemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Shared in-memory state that the mocked tab store reads / mutates. The test
|
||||
// records every method call so we can assert openInNewTab does NOT activate
|
||||
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
|
||||
type MockRouter = {
|
||||
state: { location: { pathname: string; search: string; hash: string } };
|
||||
navigate: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
type MockTab = {
|
||||
id: string;
|
||||
path: string;
|
||||
pinned: boolean;
|
||||
router: MockRouter;
|
||||
};
|
||||
|
||||
function makeMockRouter(pathname: string, search = "", hash = ""): MockRouter {
|
||||
return {
|
||||
state: { location: { pathname, search, hash } },
|
||||
navigate: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
activeWorkspaceSlug: "acme" as string | null,
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
] as MockTab[],
|
||||
},
|
||||
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
|
||||
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
|
||||
setActiveTab: vi.fn<(tabId: string) => void>(),
|
||||
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const store = {
|
||||
get activeWorkspaceSlug() {
|
||||
return state.activeWorkspaceSlug;
|
||||
},
|
||||
get byWorkspace() {
|
||||
return state.byWorkspace;
|
||||
},
|
||||
openTab: state.openTab,
|
||||
setActiveTab: state.setActiveTab,
|
||||
switchWorkspace: state.switchWorkspace,
|
||||
};
|
||||
const useTabStore = Object.assign(
|
||||
(selector?: (s: typeof store) => unknown) =>
|
||||
selector ? selector(store) : store,
|
||||
{ getState: () => store },
|
||||
);
|
||||
const getActiveTab = () => {
|
||||
const slug = state.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = state.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
|
||||
};
|
||||
const useActiveTabIdentity = () => ({
|
||||
slug: state.activeWorkspaceSlug,
|
||||
tabId: state.activeWorkspaceSlug
|
||||
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
|
||||
: null,
|
||||
});
|
||||
const useActiveTabRouter = () => null;
|
||||
const resolveRouteIcon = () => "File";
|
||||
return {
|
||||
useTabStore,
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
useActiveTabRouter,
|
||||
resolveRouteIcon,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/stores/window-overlay-store", () => ({
|
||||
useWindowOverlayStore: Object.assign(
|
||||
() => null,
|
||||
{ getState: () => ({ overlay: null, open: vi.fn(), close: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/auth", () => ({
|
||||
useAuthStore: Object.assign(
|
||||
() => null,
|
||||
{ getState: () => ({ logout: vi.fn() }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", () => ({
|
||||
isReservedSlug: (s: string) =>
|
||||
["login", "workspaces", "invite", "onboarding", "invitations"].includes(s),
|
||||
}));
|
||||
|
||||
// DesktopNavigationProvider reads window.desktopAPI.runtimeConfig synchronously.
|
||||
beforeEach(() => {
|
||||
state.openTab.mockReset();
|
||||
state.setActiveTab.mockReset();
|
||||
state.switchWorkspace.mockReset();
|
||||
state.openTab.mockImplementation(() => "tNew");
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{
|
||||
id: "tA",
|
||||
path: "/acme/issues",
|
||||
pinned: false,
|
||||
router: makeMockRouter("/acme/issues"),
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Object.defineProperty(window, "desktopAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
runtimeConfig: { ok: true, config: { appUrl: "https://app.example" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
import {
|
||||
DesktopNavigationProvider,
|
||||
TabNavigationProvider,
|
||||
} from "./navigation";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
|
||||
function captureAdapter(onAdapter: (adapter: ReturnType<typeof useNavigation>) => void) {
|
||||
function Probe() {
|
||||
const nav = useNavigation();
|
||||
useEffect(() => {
|
||||
onAdapter(nav);
|
||||
}, [nav]);
|
||||
return null;
|
||||
}
|
||||
return Probe;
|
||||
}
|
||||
|
||||
describe("DesktopNavigationProvider.openInNewTab", () => {
|
||||
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
expect(adapter).not.toBeNull();
|
||||
adapter!.openInNewTab!("/acme/agents", "Agents");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the new tab when opts.activate is true (foreground)", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.openInNewTab!("/acme/agents", "Agents", { activate: true });
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delegates to switchWorkspace for a cross-workspace path", () => {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.openInNewTab!("/butter/inbox");
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopNavigationProvider.push with pinned active tab", () => {
|
||||
function pinActive(pathname: string) {
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: makeMockRouter(pathname),
|
||||
};
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/acme/issues?filter=open");
|
||||
// Pathname unchanged → pinned interception declines and falls through to
|
||||
// the router's own navigate — openTab / setActiveTab must not fire.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
|
||||
pinActive("/acme/issues");
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
adapter!.push("/butter/inbox");
|
||||
// Cross-workspace push runs through tryRouteToOtherWorkspace before
|
||||
// tryRouteToPinnedNewTab, so switchWorkspace wins.
|
||||
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DesktopNavigationProvider.push duplicate path guard", () => {
|
||||
it("does not navigate when the target exactly matches the active tab location", () => {
|
||||
const activeRouter = makeMockRouter("/acme/issues/child");
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: "/acme/issues/child",
|
||||
pinned: false,
|
||||
router: activeRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<DesktopNavigationProvider>
|
||||
<Probe />
|
||||
</DesktopNavigationProvider>,
|
||||
);
|
||||
|
||||
adapter!.push("/acme/issues/child");
|
||||
|
||||
expect(activeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.openInNewTab", () => {
|
||||
function renderTabProvider() {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname: "/acme/issues", search: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return () => adapter!;
|
||||
}
|
||||
|
||||
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
|
||||
const getAdapter = renderTabProvider();
|
||||
getAdapter().openInNewTab!("/acme/agents", "Agents");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("activates the new tab when opts.activate is true (foreground)", () => {
|
||||
const getAdapter = renderTabProvider();
|
||||
getAdapter().openInNewTab!("/acme/agents", "Agents", { activate: true });
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
expect(state.switchWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push duplicate path guard", () => {
|
||||
function renderTabProviderAt(
|
||||
pathname: string,
|
||||
search = "",
|
||||
hash = "",
|
||||
) {
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search, hash } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("does not navigate when the target exactly matches the current full location", () => {
|
||||
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues/child");
|
||||
|
||||
getAdapter().push("/acme/issues/child");
|
||||
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still navigates when only search or hash differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues");
|
||||
|
||||
getAdapter().push("/acme/issues?filter=open#top");
|
||||
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open#top");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TabNavigationProvider.push with pinned active tab", () => {
|
||||
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
|
||||
|
||||
function renderPinnedTabProvider(pathname: string) {
|
||||
// The active tab and the per-tab router must share the same pathname:
|
||||
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
|
||||
// pathname (so query-only pushes routed via React Router still compare
|
||||
// correctly), while the TabNavigationProvider falls back to *its own*
|
||||
// router.navigate when no interception fires. In real desktop usage they
|
||||
// are the same router instance; this helper mirrors that invariant.
|
||||
const fakeRouter = {
|
||||
state: { location: { pathname, search: "", hash: "" } },
|
||||
subscribe: () => () => {},
|
||||
navigate: vi.fn(),
|
||||
} as unknown as ProviderRouter;
|
||||
state.byWorkspace.acme.tabs[0] = {
|
||||
id: "tA",
|
||||
path: pathname,
|
||||
pinned: true,
|
||||
router: fakeRouter as unknown as MockRouter,
|
||||
};
|
||||
|
||||
let adapter: ReturnType<typeof useNavigation> | null = null;
|
||||
const Probe = captureAdapter((a) => {
|
||||
adapter = a;
|
||||
});
|
||||
render(
|
||||
<TabNavigationProvider router={fakeRouter}>
|
||||
<Probe />
|
||||
</TabNavigationProvider>,
|
||||
);
|
||||
return { getAdapter: () => adapter!, fakeRouter };
|
||||
}
|
||||
|
||||
it("redirects push to a new foreground tab when pathname differs", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/projects");
|
||||
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
|
||||
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
|
||||
// Pinned interception short-circuits — the per-tab router must NOT
|
||||
// navigate, otherwise the pinned tab itself would move off its path.
|
||||
expect(fakeRouter.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows in-tab navigation when only search/hash changes", () => {
|
||||
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
|
||||
getAdapter().push("/acme/issues?filter=open");
|
||||
// Same pathname → pinned interception declines, push falls through to
|
||||
// the tab's own router.navigate, and no new tab is opened.
|
||||
expect(state.openTab).not.toHaveBeenCalled();
|
||||
expect(state.setActiveTab).not.toHaveBeenCalled();
|
||||
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
|
||||
});
|
||||
});
|
||||
@@ -89,11 +89,6 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function routerLocationPath(router: DataRouter): string {
|
||||
const { pathname, search, hash } = router.state.location;
|
||||
return `${pathname}${search ?? ""}${hash ?? ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes that change workspace. Returns `true` if the navigation
|
||||
* was delegated to the tab store (caller should NOT proceed).
|
||||
@@ -113,37 +108,6 @@ function tryRouteToOtherWorkspace(path: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intercept pushes originating in a pinned tab and force them into a new
|
||||
* tab. Returns `true` if the navigation was redirected (caller should NOT
|
||||
* proceed). Pathname-only changes (search / hash / same-page state) are
|
||||
* allowed through so pinned filter / drawer / form-state interactions
|
||||
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
|
||||
* D2b (FINAL: same pathname → allowed in pinned tab).
|
||||
*
|
||||
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
|
||||
* if one exists, otherwise creates a new one. The newly-focused tab is
|
||||
* activated foreground — a pinned-tab push is an explicit user action, not
|
||||
* a background cmd+click, so the focus follows.
|
||||
*/
|
||||
function tryRouteToPinnedNewTab(path: string): boolean {
|
||||
const store = useTabStore.getState();
|
||||
const active = getActiveTab(store);
|
||||
if (!active?.pinned) return false;
|
||||
|
||||
// Use the live router pathname rather than `active.path` so query-only
|
||||
// navigations performed via React Router (which only sync pathname back
|
||||
// to the store) still compare correctly.
|
||||
const currentPathname = active.router.state.location.pathname;
|
||||
const newPathname = path.split("?")[0].split("#")[0];
|
||||
if (currentPathname === newPathname) return false;
|
||||
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, path, icon);
|
||||
if (newId) store.setActiveTab(newId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab
|
||||
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
|
||||
@@ -200,9 +164,7 @@ export function DesktopNavigationProvider({
|
||||
}
|
||||
const active = currentActiveTab();
|
||||
if (tryRouteToOverlay(path, active?.router)) return;
|
||||
if (active && routerLocationPath(active.router) === path) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
active?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -216,16 +178,9 @@ export function DesktopNavigationProvider({
|
||||
},
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (
|
||||
path: string,
|
||||
title?: string,
|
||||
opts?: { activate?: boolean },
|
||||
) => {
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there (focus follows the user); same-workspace defaults
|
||||
// to background tab (browser cmd+click semantics). Callers that
|
||||
// represent an explicit "Open in new tab" CTA pass `activate: true`
|
||||
// to bring the new tab to the foreground.
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
@@ -233,10 +188,8 @@ export function DesktopNavigationProvider({
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, title ?? path, icon);
|
||||
if (opts?.activate && newId) {
|
||||
store.setActiveTab(newId);
|
||||
}
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
@@ -277,9 +230,7 @@ export function TabNavigationProvider({
|
||||
() => ({
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
if (routerLocationPath(router) === path) return;
|
||||
if (tryRouteToOtherWorkspace(path)) return;
|
||||
if (tryRouteToPinnedNewTab(path)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
@@ -290,11 +241,7 @@ export function TabNavigationProvider({
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
openInNewTab: (
|
||||
path: string,
|
||||
title?: string,
|
||||
opts?: { activate?: boolean },
|
||||
) => {
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
const slug = extractWorkspaceSlug(path);
|
||||
const store = useTabStore.getState();
|
||||
if (slug && slug !== store.activeWorkspaceSlug) {
|
||||
@@ -302,10 +249,8 @@ export function TabNavigationProvider({
|
||||
return;
|
||||
}
|
||||
const icon = resolveRouteIcon(path);
|
||||
const newId = store.openTab(path, title ?? path, icon);
|
||||
if (opts?.activate && newId) {
|
||||
store.setActiveTab(newId);
|
||||
}
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
}),
|
||||
|
||||
@@ -11,54 +11,21 @@ import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { MemberDetailPage } from "./pages/member-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { DashboardPage } from "@multica/views/dashboard";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { useT } from "@multica/views/i18n";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
import { Download, Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
|
||||
/**
|
||||
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
|
||||
* from i18n. The route element has to be a component (not a literal JSX
|
||||
* value) for `useT` to run.
|
||||
*/
|
||||
function DesktopSettingsRoute() {
|
||||
const { t } = useT("settings");
|
||||
return (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: t(($) => $.desktop.tabs.updates),
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
* The tab system observes document.title via MutationObserver.
|
||||
@@ -116,15 +83,7 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <WorkspaceRouteLayout />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="issues" replace /> },
|
||||
{
|
||||
path: "issues",
|
||||
element: (
|
||||
<ErrorBoundary>
|
||||
<IssuesPage />
|
||||
</ErrorBoundary>
|
||||
),
|
||||
handle: { title: "Issues" },
|
||||
},
|
||||
{ path: "issues", element: <IssuesPage />, handle: { title: "Issues" } },
|
||||
{
|
||||
path: "issues/:id",
|
||||
element: <IssueDetailPage />,
|
||||
@@ -177,31 +136,27 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{
|
||||
path: "members/:id",
|
||||
element: <MemberDetailPage />,
|
||||
handle: { title: "Member" },
|
||||
},
|
||||
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
|
||||
{
|
||||
path: "squads/:id",
|
||||
element: <SquadDetailPageView />,
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "attachments/:id/preview",
|
||||
element: <AttachmentPreviewRoute />,
|
||||
handle: { title: "Attachment" },
|
||||
},
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
handle: { title: "Usage" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: <DesktopSettingsRoute />,
|
||||
element: (
|
||||
<SettingsPage
|
||||
extraAccountTabs={[
|
||||
{
|
||||
value: "daemon",
|
||||
label: "Daemon",
|
||||
icon: Server,
|
||||
content: <DaemonSettingsTab />,
|
||||
},
|
||||
{
|
||||
value: "updates",
|
||||
label: "Updates",
|
||||
icon: Download,
|
||||
content: <UpdatesSettingsTab />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
handle: { title: "Settings" },
|
||||
},
|
||||
],
|
||||
|
||||
@@ -17,7 +17,6 @@ vi.mock("../routes", () => ({
|
||||
import {
|
||||
sanitizeTabPath,
|
||||
migrateV1ToV2,
|
||||
migrateV2ToV3,
|
||||
useTabStore,
|
||||
} from "./tab-store";
|
||||
|
||||
@@ -181,61 +180,6 @@ describe("useTabStore actions", () => {
|
||||
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
|
||||
});
|
||||
|
||||
it("defers disposing the closed tab router until after the store update", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
const closingTab = useTabStore
|
||||
.getState()
|
||||
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
|
||||
const dispose = vi.mocked(closingTab!.router.dispose);
|
||||
|
||||
store.closeTab(closedTabId);
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
|
||||
).toBe(false);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores router-sync updates from a tab after it has been closed", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
|
||||
store.closeTab(closedTabId);
|
||||
const before = useTabStore.getState().byWorkspace.acme;
|
||||
|
||||
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
|
||||
store.updateTabHistory(closedTabId, 1, 2);
|
||||
|
||||
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
|
||||
expect(
|
||||
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replace the tab group for no-op router-sync updates", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
|
||||
const before = useTabStore.getState().byWorkspace.acme;
|
||||
|
||||
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
|
||||
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
|
||||
|
||||
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
@@ -278,155 +222,3 @@ describe("useTabStore actions", () => {
|
||||
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
|
||||
});
|
||||
});
|
||||
|
||||
describe("togglePin", () => {
|
||||
it("flips a tab's pinned state", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
|
||||
|
||||
store.togglePin(tabId);
|
||||
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("moves a newly-pinned tab to the start of the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs[0].id).toBe(agentsId);
|
||||
expect(tabs[0].pinned).toBe(true);
|
||||
expect(tabs[1].pinned).toBe(false);
|
||||
expect(tabs[2].pinned).toBe(false);
|
||||
});
|
||||
|
||||
it("appends a second pinned tab after the first pinned tab", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(agentsId);
|
||||
store.togglePin(projectsId);
|
||||
|
||||
// Both pinned, in the order they were pinned (agents first, projects
|
||||
// second), then the unpinned default tab.
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([
|
||||
agentsId,
|
||||
projectsId,
|
||||
tabs[2].id,
|
||||
]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
|
||||
});
|
||||
|
||||
it("returns an unpinned tab to the start of the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
|
||||
|
||||
// Pin both, then unpin one.
|
||||
store.togglePin(issuesId);
|
||||
store.togglePin(projectsId);
|
||||
store.togglePin(issuesId);
|
||||
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveTab boundary clamp", () => {
|
||||
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag the pinned tab to index 2 (unpinned zone end).
|
||||
store.moveTab(0, 2);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
|
||||
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
|
||||
|
||||
store.togglePin(issuesId); // [issues(pinned), projects, agents]
|
||||
|
||||
// User tries to drag agents (index 2) to index 0 (pinned zone).
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
// Clamped to index 1 — start of the unpinned zone.
|
||||
expect(tabs[0].id).toBe(issuesId);
|
||||
expect(tabs[1].id).toBe(agentsId);
|
||||
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
|
||||
});
|
||||
|
||||
it("reorders freely within the same zone", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
store.addTab("/acme/projects", "Projects", "FolderKanban");
|
||||
store.addTab("/acme/agents", "Agents", "Bot");
|
||||
|
||||
// All unpinned; move agents (2) to position 0.
|
||||
store.moveTab(2, 0);
|
||||
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
|
||||
expect(tabs.map((t) => t.path)).toEqual([
|
||||
"/acme/agents",
|
||||
"/acme/issues",
|
||||
"/acme/projects",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateV2ToV3", () => {
|
||||
it("adds pinned=false to every persisted tab", () => {
|
||||
const v2 = {
|
||||
activeWorkspaceSlug: "acme",
|
||||
byWorkspace: {
|
||||
acme: {
|
||||
activeTabId: "t1",
|
||||
tabs: [
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const v3 = migrateV2ToV3(v2);
|
||||
expect(v3.activeWorkspaceSlug).toBe("acme");
|
||||
expect(v3.byWorkspace.acme.tabs).toEqual([
|
||||
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
|
||||
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles missing byWorkspace gracefully", () => {
|
||||
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
|
||||
expect(v3.byWorkspace).toEqual({});
|
||||
expect(v3.activeWorkspaceSlug).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,14 +20,6 @@ export interface Tab {
|
||||
router: DataRouter;
|
||||
historyIndex: number;
|
||||
historyLength: number;
|
||||
/**
|
||||
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
|
||||
* X close button, and turn any `navigation.push()` originating in them into
|
||||
* an `openInNewTab()` so they stay parked on their original path. Pinning
|
||||
* is invariant-preserving: pinned tabs always come before unpinned tabs in
|
||||
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
|
||||
*/
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabGroup {
|
||||
@@ -86,20 +78,8 @@ interface TabStore {
|
||||
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
|
||||
/** Patch history tracking of a tab. Finds across groups. */
|
||||
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
|
||||
/**
|
||||
* Reorder within the active workspace's group only. Clamped so a tab can
|
||||
* never cross the pinned / unpinned boundary — a drag that would move a
|
||||
* pinned tab into the unpinned zone (or vice versa) is dropped at the
|
||||
* boundary instead. This keeps the "pinned tabs first" invariant without
|
||||
* requiring callers to know about it.
|
||||
*/
|
||||
/** Reorder within the active workspace's group only. */
|
||||
moveTab: (fromIndex: number, toIndex: number) => void;
|
||||
/**
|
||||
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
|
||||
* zone; unpinning moves it to the start of the unpinned zone. Both
|
||||
* preserve the "pinned tabs before unpinned tabs" invariant.
|
||||
*/
|
||||
togglePin: (tabId: string) => void;
|
||||
/**
|
||||
* After the workspace list arrives/changes (login, realtime delete), drop
|
||||
* any tab group whose slug is no longer in `validSlugs`, and repoint
|
||||
@@ -210,17 +190,9 @@ function makeTab(path: string, title: string, icon: string): Tab {
|
||||
router: createTabRouter(path),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the first unpinned tab in a group (== pinned count). */
|
||||
function pinnedBoundary(tabs: Tab[]): number {
|
||||
let i = 0;
|
||||
while (i < tabs.length && tabs[i].pinned) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Default entry point for a workspace — its issues list. */
|
||||
function defaultPathFor(slug: string): string {
|
||||
return `/${slug}/issues`;
|
||||
@@ -378,10 +350,7 @@ export const useTabStore = create<TabStore>()(
|
||||
const { slug, group, index } = hit;
|
||||
|
||||
const closing = group.tabs[index];
|
||||
const disposeClosingRouter = () => {
|
||||
// Let React unmount the tab's RouterProvider before disposing it.
|
||||
window.setTimeout(() => closing.router.dispose(), 0);
|
||||
};
|
||||
closing.router.dispose();
|
||||
|
||||
if (group.tabs.length === 1) {
|
||||
// Last tab in this workspace — reseed a default so the workspace
|
||||
@@ -394,7 +363,6 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: [fresh], activeTabId: fresh.id },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,7 +378,6 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
},
|
||||
|
||||
setActiveTab(tabId) {
|
||||
@@ -435,13 +402,6 @@ export const useTabStore = create<TabStore>()(
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const next: Tab = { ...current, ...patch };
|
||||
if (
|
||||
next.path === current.path &&
|
||||
next.title === current.title &&
|
||||
next.icon === current.icon
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
set({
|
||||
@@ -458,12 +418,6 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
if (
|
||||
current.historyIndex === historyIndex &&
|
||||
current.historyLength === historyLength
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const next: Tab = { ...current, historyIndex, historyLength };
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
@@ -481,63 +435,17 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!activeWorkspaceSlug) return;
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group) return;
|
||||
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
|
||||
|
||||
// Clamp the drop position to within the source tab's group (pinned vs
|
||||
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
|
||||
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
|
||||
const boundary = pinnedBoundary(group.tabs);
|
||||
const source = group.tabs[fromIndex];
|
||||
let clampedTo: number;
|
||||
if (source.pinned) {
|
||||
// boundary is exclusive upper bound for pinned-zone indices.
|
||||
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
|
||||
} else {
|
||||
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
|
||||
}
|
||||
if (clampedTo === fromIndex) return;
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[activeWorkspaceSlug]: {
|
||||
...group,
|
||||
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
|
||||
tabs: arrayMove(group.tabs, fromIndex, toIndex),
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
togglePin(tabId) {
|
||||
const { byWorkspace } = get();
|
||||
const hit = findTabLocation(byWorkspace, tabId);
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const nextTab: Tab = { ...current, pinned: !current.pinned };
|
||||
|
||||
// Remove from current position, then insert at the new zone boundary:
|
||||
// pinning → end of pinned zone (just before first unpinned tab)
|
||||
// unpinning → start of unpinned zone (right after last pinned tab)
|
||||
const withoutCurrent = [
|
||||
...group.tabs.slice(0, index),
|
||||
...group.tabs.slice(index + 1),
|
||||
];
|
||||
const newBoundary = pinnedBoundary(withoutCurrent);
|
||||
const insertAt = newBoundary;
|
||||
const nextTabs = [
|
||||
...withoutCurrent.slice(0, insertAt),
|
||||
nextTab,
|
||||
...withoutCurrent.slice(insertAt),
|
||||
];
|
||||
|
||||
set({
|
||||
byWorkspace: {
|
||||
...byWorkspace,
|
||||
[slug]: { ...group, tabs: nextTabs },
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
validateWorkspaceSlugs(validSlugs) {
|
||||
const { activeWorkspaceSlug, byWorkspace } = get();
|
||||
let changed = false;
|
||||
@@ -571,23 +479,17 @@ export const useTabStore = create<TabStore>()(
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
version: 3,
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 → v2: flat `tabs` array → per-workspace grouping.
|
||||
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
|
||||
// are dropped — they have no workspace to belong to, and the new
|
||||
// model's invariant is "every tab lives in a workspace group".
|
||||
let state = persistedState;
|
||||
if (version < 2 && state && typeof state === "object") {
|
||||
state = migrateV1ToV2(state as Partial<V1Persisted>);
|
||||
if (version < 2 && persistedState && typeof persistedState === "object") {
|
||||
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
|
||||
}
|
||||
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
|
||||
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
|
||||
if (version < 3 && state && typeof state === "object") {
|
||||
state = migrateV2ToV3(state as V2Persisted);
|
||||
}
|
||||
return state as V3Persisted;
|
||||
return persistedState as V2Persisted;
|
||||
},
|
||||
partialize: (state) => ({
|
||||
activeWorkspaceSlug: state.activeWorkspaceSlug,
|
||||
@@ -597,19 +499,15 @@ export const useTabStore = create<TabStore>()(
|
||||
{
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map(
|
||||
({
|
||||
router: _router,
|
||||
historyIndex: _hi,
|
||||
historyLength: _hl,
|
||||
...rest
|
||||
}) => rest,
|
||||
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
|
||||
rest,
|
||||
),
|
||||
},
|
||||
]),
|
||||
),
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<V3Persisted> | undefined;
|
||||
const persisted = persistedState as Partial<V2Persisted> | undefined;
|
||||
if (!persisted?.byWorkspace) return currentState;
|
||||
|
||||
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
|
||||
@@ -636,14 +534,9 @@ export const useTabStore = create<TabStore>()(
|
||||
router: createTabRouter(clean),
|
||||
historyIndex: 0,
|
||||
historyLength: 1,
|
||||
pinned: pTab.pinned === true,
|
||||
});
|
||||
}
|
||||
if (tabs.length === 0) continue;
|
||||
// Enforce the "pinned first" invariant on rehydration in case a
|
||||
// user (or a buggy older write) persisted the pinned tabs out of
|
||||
// order. Stable sort preserves intra-group order.
|
||||
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
|
||||
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
|
||||
? pGroup.activeTabId
|
||||
: tabs[0].id;
|
||||
@@ -694,38 +587,6 @@ interface V2Persisted {
|
||||
byWorkspace: Record<string, V2PersistedGroup>;
|
||||
}
|
||||
|
||||
interface V3PersistedTab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface V3PersistedGroup {
|
||||
tabs: V3PersistedTab[];
|
||||
activeTabId: string;
|
||||
}
|
||||
|
||||
interface V3Persisted {
|
||||
activeWorkspaceSlug: string | null;
|
||||
byWorkspace: Record<string, V3PersistedGroup>;
|
||||
}
|
||||
|
||||
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
|
||||
const byWorkspace: Record<string, V3PersistedGroup> = {};
|
||||
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
|
||||
byWorkspace[slug] = {
|
||||
activeTabId: group.activeTabId,
|
||||
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
|
||||
};
|
||||
}
|
||||
return {
|
||||
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
|
||||
byWorkspace,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
|
||||
const byWorkspace: Record<string, V2PersistedGroup> = {};
|
||||
const oldTabs = v1.tabs ?? [];
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
navigationGestureFromSwipe,
|
||||
} from "./navigation-gestures";
|
||||
|
||||
describe("navigationGestureFromSwipe", () => {
|
||||
it("maps horizontal macOS swipe directions to browser-style history", () => {
|
||||
expect(navigationGestureFromSwipe("right")).toBe("back");
|
||||
expect(navigationGestureFromSwipe("left")).toBe("forward");
|
||||
});
|
||||
|
||||
it("ignores vertical and unknown directions", () => {
|
||||
expect(navigationGestureFromSwipe("up")).toBeNull();
|
||||
expect(navigationGestureFromSwipe("down")).toBeNull();
|
||||
expect(navigationGestureFromSwipe("sideways")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isNavigationGesture", () => {
|
||||
it("accepts only the renderer navigation gestures", () => {
|
||||
expect(isNavigationGesture("back")).toBe(true);
|
||||
expect(isNavigationGesture("forward")).toBe(true);
|
||||
expect(isNavigationGesture("right")).toBe(false);
|
||||
expect(isNavigationGesture(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
export const NAVIGATION_GESTURE_CHANNEL = "navigation:gesture";
|
||||
|
||||
export type NavigationGesture = "back" | "forward";
|
||||
|
||||
export function isNavigationGesture(value: unknown): value is NavigationGesture {
|
||||
return value === "back" || value === "forward";
|
||||
}
|
||||
|
||||
export function navigationGestureFromSwipe(
|
||||
direction: string,
|
||||
): NavigationGesture | null {
|
||||
if (direction === "right") return "back";
|
||||
if (direction === "left") return "forward";
|
||||
return null;
|
||||
}
|
||||
@@ -32,19 +32,6 @@ describe("runtime config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the leading api. label when deriving appUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.multica.ai" }),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives ws for http api URLs", () => {
|
||||
expect(deriveWsUrl("http://localhost:8080")).toBe("ws://localhost:8080/ws");
|
||||
});
|
||||
@@ -109,43 +96,4 @@ describe("runtime config", () => {
|
||||
appUrl: "http://dev-app.example.test:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to local web URL when dev apiUrl is localhost", () => {
|
||||
expect(runtimeConfigFromDevEnv({ apiUrl: "http://localhost:8080" })).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives dev appUrl by stripping the leading api. label", () => {
|
||||
// When the dev renderer is pointed at a remote backend (e.g. a test
|
||||
// environment), copy-link / share URLs must reflect that environment's
|
||||
// public web host, not the api host. Multica's convention exposes the
|
||||
// api at `api.<web-host>`, so stripping the leading label gives the
|
||||
// right web origin without a separate VITE_APP_URL.
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({ apiUrl: "https://api.test.multica.ai" }),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
wsUrl: "wss://api.test.multica.ai/ws",
|
||||
appUrl: "https://test.multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("dev VITE_APP_URL still wins over apiUrl-derived value", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
appUrl: "https://staging.multica.ai",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.test.multica.ai",
|
||||
wsUrl: "wss://api.test.multica.ai/ws",
|
||||
appUrl: "https://staging.multica.ai",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,9 +44,10 @@ export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: env.appUrl
|
||||
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
|
||||
: deriveDevAppUrl(apiUrl),
|
||||
appUrl: normalizeHttpUrl(
|
||||
env.appUrl || LOCAL_DEV_RUNTIME_CONFIG.appUrl,
|
||||
"VITE_APP_URL",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,37 +94,14 @@ export function deriveWsUrl(apiUrl: string): string {
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
// Convention: api hosts are exposed at `api.<web-host>` (api.multica.ai →
|
||||
// multica.ai, api.test.multica.ai → test.multica.ai). Strip the leading
|
||||
// `api.` label so a single `apiUrl` configuration produces the right
|
||||
// shareable web URL. Hosts that don't match the convention (no leading
|
||||
// `api.` label, or short two-label hosts like `api.local`) fall through
|
||||
// untouched — those deployments must set `appUrl` explicitly.
|
||||
export function deriveAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
url.pathname = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
if (url.hostname.startsWith("api.") && url.hostname.split(".").length >= 3) {
|
||||
url.hostname = url.hostname.slice("api.".length);
|
||||
}
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
// Dev variant: when the api host is the local backend (`localhost:8080` /
|
||||
// `127.0.0.1:8080`), the renderer is served from a different port (3000),
|
||||
// so deriving by host alone is wrong. Fall back to the local dev web URL
|
||||
// in that case; for any non-local host (e.g. a remote test environment),
|
||||
// trust the production-style derivation so `apiUrl=https://api.test.x`
|
||||
// yields `appUrl=https://test.x` without a separate VITE_APP_URL.
|
||||
export function deriveDevAppUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
|
||||
return LOCAL_DEV_RUNTIME_CONFIG.appUrl;
|
||||
}
|
||||
return deriveAppUrl(apiUrl);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string`);
|
||||
|
||||
@@ -64,9 +64,9 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
|
||||
|
||||
<Callout type="warning">
|
||||
**Values in `custom_env` are stored in plaintext in Multica's server database.** Agent list/get responses no longer carry env values at all — only an opaque count. Reading values requires a workspace owner or admin to hit the dedicated, audited `GET /api/agents/{id}/env` endpoint (CLI: `multica agent env get <id>`). Agents running tasks can NOT use their host's owner credentials to reveal env on other agents — the endpoint denies agent-actor sessions.
|
||||
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
|
||||
|
||||
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly. Database backups and DB audits remain a meaningful exposure surface.
|
||||
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
|
||||
</Callout>
|
||||
|
||||
## Custom CLI arguments (custom_args)
|
||||
@@ -96,7 +96,7 @@ Arguments are passed as-is, not through a shell (no injection risk), but whether
|
||||
|
||||
New agents default to `private`.
|
||||
|
||||
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't read sensitive config (env values never appear in agent list/get responses; MCP config is masked for non-owners). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
|
||||
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
|
||||
|
||||
## Concurrency limit
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn,不会报错)。
|
||||
|
||||
<Callout type="warning">
|
||||
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** agent list/get 接口不再返回任何 env 值——只返回键的数量。读取真实值需要 workspace owner / admin 调用专用且会审计的 `GET /api/agents/{id}/env`(CLI: `multica agent env get <id>`)。正在执行任务的智能体即便宿主是 owner,也无法借此读取其它智能体的 env——这个接口拒绝 agent-actor 会话。数据库备份、DB 审计里仍然能看到真实值。
|
||||
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值(API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
|
||||
|
||||
**不要把高价值 secret 放进 `custom_env`**(生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT),定期轮换。
|
||||
</Callout>
|
||||
@@ -96,7 +96,7 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
|
||||
|
||||
新建默认 `private`。
|
||||
|
||||
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置(env 值从不会出现在 agent list/get 响应里;MCP 配置对非 owner 会被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
|
||||
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
|
||||
|
||||
## 并发上限
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
|
||||
|
||||
## What an agent can do
|
||||
|
||||
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
|
||||
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
|
||||
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
|
||||
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
|
||||
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
|
||||
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
|
||||
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
|
||||
|
||||
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.
|
||||
@@ -45,5 +45,4 @@ New agents default to **private**. To make one available to the whole workspace,
|
||||
|
||||
- [Create and configure an agent](/agents-create) — how to build one
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [Squads](/squads) — group agents under a leader so the right one picks up the right issue
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 智能体(agent)是 Multica 工作区里的一等公民成员
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
|
||||
## 智能体能做什么
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
- **[被分配 issue](/assigning-issues)** —— 作为 assignee,分配后它会自动开工
|
||||
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
|
||||
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
|
||||
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
@@ -45,5 +45,4 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
|
||||
- [Skills](/skills) —— 给智能体挂上专业知识包
|
||||
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Hand an issue to an agent and it takes over as the official assigne
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths. The same flow also accepts a [squad](/squads) as the assignee — Multica then triggers the squad's **leader agent** instead.
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
|
||||
|
||||
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -18,7 +18,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
|
||||
|
||||
## Assign from the UI
|
||||
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace, all non-archived agents, and every non-archived [squad](/squads). Pick an agent (or squad) and the issue is assigned right away.
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
|
||||
|
||||
A few rules:
|
||||
|
||||
@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace member list --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
|
||||
|
||||
Unassign:
|
||||
|
||||
@@ -78,6 +78,5 @@ But **different agents can work on the same issue in parallel** — for example,
|
||||
## Next
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Squads**](/squads) — assign to a group of agents and let the leader decide who picks it up
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 把 issue 交给智能体,它作为正式负责人一直工作到
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。同样的流程也接受 [小队(squad)](/squads) 作为 assignee——这种情况下 Multica 会触发小队的**队长智能体**。
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
|
||||
|
||||
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -18,7 +18,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 在界面里分配
|
||||
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员、未归档的智能体、以及未归档的 [小队](/squads)。选一个智能体(或小队),issue 立刻分配。
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员和未归档的智能体。选一个智能体,issue 立刻分给它。
|
||||
|
||||
几条规则:
|
||||
|
||||
@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
|
||||
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace member list --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`:UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
@@ -78,6 +78,5 @@ multica issue assign MUL-42 --unassign
|
||||
## 下一步
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**小队**](/squads) —— 把 issue 分给一组智能体,由队长决定谁接手
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
|
||||
@@ -12,11 +12,9 @@ For the list of environment variables referenced below, see [Environment variabl
|
||||
|
||||
## How email + verification code sign-in works
|
||||
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. Two delivery backends are supported — pick whichever fits your deployment:
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
|
||||
|
||||
### Option A: Resend (recommended for cloud / public-internet deployments)
|
||||
|
||||
1. Create a [Resend](https://resend.com/) account and verify your domain
|
||||
1. Create a Resend account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
@@ -27,22 +25,7 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
4. Restart the server
|
||||
|
||||
### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
|
||||
|
||||
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## Fixed local testing codes
|
||||
|
||||
@@ -51,7 +34,7 @@ STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTP
|
||||
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
Local development without any email backend configured (no Resend, no SMTP) should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -12,11 +12,9 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一:
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
|
||||
### Option A:Resend(公网/云端部署推荐)
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
@@ -27,22 +25,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
4. 重启 server
|
||||
|
||||
### Option B:SMTP relay(内网/自部署)
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。
|
||||
|
||||
**两种都不配**:server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
@@ -51,7 +34,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
没配任何邮件后端(Resend 和 SMTP 都没设)的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,15 +16,19 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
@@ -50,109 +54,15 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## Trigger from a webhook
|
||||
|
||||
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
|
||||
on the autopilot detail page; Multica generates a unique URL of the shape:
|
||||
|
||||
```
|
||||
https://<your-multica-host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
POST any JSON to that URL — Multica records a run with `source = webhook`,
|
||||
stores the body as the run's `trigger_payload`, and dispatches the agent
|
||||
exactly the way a schedule trigger would.
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
In **create issue mode**, the inbound payload is appended to the new issue's
|
||||
description so the agent can read it inline. In **run-only mode**, the
|
||||
payload is part of the run context the daemon hands the agent.
|
||||
|
||||
### Payload shape
|
||||
|
||||
You can send your own envelope:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
…or any JSON object/array. Multica normalizes it into an internal envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<inferred>",
|
||||
"eventPayload": <your body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
When you don't provide an `event` field, Multica infers it from common
|
||||
headers and body fields (`X-GitHub-Event` + body `action`,
|
||||
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
|
||||
nothing matches, the event is `webhook.received`.
|
||||
|
||||
When configuring GitHub or similar sources, set the content type to
|
||||
`application/json` — form-encoded webhook payloads are not accepted.
|
||||
|
||||
### URL is a bearer secret
|
||||
|
||||
The generated URL **is** the credential. Anyone with it can fire the
|
||||
autopilot. Treat it like a token:
|
||||
|
||||
- **Don't paste it into public issue threads, screenshots, or chat history.**
|
||||
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
|
||||
old URL stops working immediately.
|
||||
- For sources that require strong source authentication, wait for
|
||||
per-trigger HMAC signature verification; this v1 URL is bearer-only.
|
||||
- Workspace members who can view the autopilot can read its webhook URLs
|
||||
for now — tighter per-role secret visibility is a follow-up.
|
||||
|
||||
### Status-code semantics
|
||||
|
||||
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
|
||||
your provider's webhook-retry machinery doesn't keep hammering the URL:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
— a run was dispatched.
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
— the assignee's runtime is offline; recorded as a `skipped` run.
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
|
||||
|
||||
Non-2xx responses cover real failures:
|
||||
|
||||
- `400` — invalid JSON, scalar body, or empty body.
|
||||
- `404` — unknown token (`{"error":"webhook not found"}`).
|
||||
- `413` — payload exceeded 256 KiB.
|
||||
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
|
||||
|
||||
### Self-hosted: configure your public URL
|
||||
|
||||
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
|
||||
the trigger response includes an absolute `webhook_url` and the UI shows a
|
||||
ready-to-copy URL. Without it, the UI composes the URL from the client's
|
||||
API origin — which is fine for desktop and same-origin web, but not for
|
||||
custom self-hosted reverse proxies. Multica deliberately does not derive
|
||||
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
|
||||
reverse proxy cannot trick the server into minting webhook URLs pointing at
|
||||
an attacker-controlled host.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual` / `webhook`)
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed or skipped)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
@@ -166,11 +76,7 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
|
||||
kind, but no ingress route fires it; the UI shows a Deprecated badge for
|
||||
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
|
||||
signature verification, IP allowlists, and provider-specific event presets
|
||||
are tracked as follow-ups; v1 URLs are bearer-only.
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,15 +16,19 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)或 `webhook`
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
</Callout>
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
@@ -50,105 +54,15 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 通过 Webhook 触发
|
||||
|
||||
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
|
||||
触发器,Multica 会生成一个唯一的 URL:
|
||||
|
||||
```
|
||||
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
|
||||
run,把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
|
||||
完全一致的方式派发给智能体。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
|
||||
直接读到;**直跑模式**下,payload 也会随 run 一并交给 daemon。
|
||||
|
||||
### Payload 形态
|
||||
|
||||
可以发自己的封装:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<推断>",
|
||||
"eventPayload": <你的 body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
不带 `event` 字段时,Multica 会按以下顺序从常见 header 和 body 字段
|
||||
推断:`X-GitHub-Event` + body `action`,`X-Gitlab-Event`、
|
||||
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
|
||||
名退化为 `webhook.received`。
|
||||
|
||||
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
|
||||
表单编码的 webhook payload 在 v1 里不接受。
|
||||
|
||||
### URL 即 bearer secret
|
||||
|
||||
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
|
||||
|
||||
- **不要贴到公开 issue 评论、截图、聊天记录里。**
|
||||
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
|
||||
旧 URL 立即失效。
|
||||
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线;v1 URL 仅
|
||||
bearer。
|
||||
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
|
||||
权限可见性是后续工作。
|
||||
|
||||
### 状态码语义
|
||||
|
||||
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
|
||||
机制反复打:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
—— 已派发一次 run。
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
—— 受派智能体的 runtime 离线,记为 `skipped` run。
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
|
||||
|
||||
非 2xx 是真正的失败:
|
||||
|
||||
- `400` —— 无效 JSON、scalar body、空 body。
|
||||
- `404` —— 未知 token(`{"error":"webhook not found"}`)。
|
||||
- `413` —— 请求体超过 256 KiB。
|
||||
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
|
||||
|
||||
### 自托管:配置公开 URL
|
||||
|
||||
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
|
||||
触发器响应里会带绝对的 `webhook_url`,UI 直接显示可复制的 URL。没设
|
||||
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
|
||||
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
|
||||
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
|
||||
指向攻击者域名的 webhook URL。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual` / `webhook`)
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed`)
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(失败或跳过时)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
@@ -162,10 +76,7 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
|
||||
## 暂不可用的能力
|
||||
|
||||
**API 类型触发器尚未接入。** 触发器 schema 里保留了 `api` 类型但没有
|
||||
入站路由会触发它;UI 会给已有的此类记录打 Deprecated 标签,也不显示
|
||||
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
|
||||
的事件预设是后续工作;v1 URL 仅 bearer。
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -39,26 +39,22 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
|---|---|
|
||||
| `multica workspace list` | List every workspace you can access |
|
||||
| `multica workspace get <slug>` | Show details for one workspace |
|
||||
| `multica workspace member list` | List members of the current workspace |
|
||||
| `multica workspace members` | List members of the current workspace |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
|
||||
|
||||
## Issues and projects
|
||||
|
||||
<Callout type="info">
|
||||
`list` commands (`multica issue list`, `autopilot list`, `project list`, etc.) print short, copy-paste-ready IDs by default — issue keys like `MUL-123` for issues, short UUID prefixes for the rest. The `<id>` argument on the follow-up commands below accepts either the short ID or the full UUID, so the typical flow is `multica issue list` → copy the key → `multica issue get MUL-123`. Pass `--full-id` to a list command when you need the canonical UUID.
|
||||
</Callout>
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica issue list` | List issues (prints copy-paste-ready issue keys) |
|
||||
| `multica issue get <id>` | Show a single issue (accepts an issue key or a UUID) |
|
||||
| `multica issue list` | List issues |
|
||||
| `multica issue get <id>` | Show a single issue |
|
||||
| `multica issue create --title "..."` | Create a new issue |
|
||||
| `multica issue update <id> ...` | Update an issue (status, priority, assignee, etc.) |
|
||||
| `multica issue assign <id> --agent <slug>` | Assign to an agent (triggers a task immediately) |
|
||||
| `multica issue status <id> --set <status>` | Shortcut to change status |
|
||||
| `multica issue search <query>` | Keyword search |
|
||||
| `multica issue runs <id>` | Show agent runs on an issue |
|
||||
| `multica issue rerun <id>` | Re-enqueue a fresh task for the issue's current agent assignee |
|
||||
| `multica issue rerun <id>` | Rerun the most recent agent task |
|
||||
| `multica issue comment <id> ...` | Nested: view / post comments |
|
||||
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
@@ -79,20 +75,6 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica squad list` | List squads in the workspace |
|
||||
| `multica squad get <id>` | Show a single squad |
|
||||
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
|
||||
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
|
||||
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
|
||||
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
|
||||
|
||||
See [Squads](/squads) for the full model.
|
||||
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
@@ -117,6 +99,7 @@ See [Squads](/squads) for the full model.
|
||||
| `multica runtime list` | List runtimes in the current workspace |
|
||||
| `multica runtime usage` | Show resource usage |
|
||||
| `multica runtime activity` | Recent activity log |
|
||||
| `multica runtime ping <id>` | Ping a runtime to check it's online |
|
||||
| `multica runtime update <id> ...` | Update a runtime's configuration |
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
@@ -39,26 +39,22 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
|---|---|
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `multica workspace member list` | 列出当前工作区的成员 |
|
||||
| `multica workspace members` | 列出当前工作区的成员 |
|
||||
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据(admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
|
||||
|
||||
## Issue 和 Project
|
||||
|
||||
<Callout type="info">
|
||||
`list` 类命令(`multica issue list`、`autopilot list`、`project list` 等)表格里默认显示**可直接复制**的短 ID:issue 是 key(如 `MUL-123`),其余资源是 UUID 短前缀。下面表格里的 `<id>` 同时接受短 ID 和完整 UUID,所以典型用法是 `multica issue list` → 复制 key → `multica issue get MUL-123`。需要完整 UUID 时给 `list` 加 `--full-id`。
|
||||
</Callout>
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica issue list` | 列出 issue(默认显示可复制的 issue key) |
|
||||
| `multica issue get <id>` | 查看单条 issue(接受 issue key 或 UUID) |
|
||||
| `multica issue list` | 列出 issue |
|
||||
| `multica issue get <id>` | 查看单条 issue |
|
||||
| `multica issue create --title "..."` | 创建新 issue |
|
||||
| `multica issue update <id> ...` | 修改 issue(状态、优先级、分配人等) |
|
||||
| `multica issue assign <id> --agent <slug>` | 分配给智能体(立即触发任务) |
|
||||
| `multica issue status <id> --set <status>` | 快捷改状态 |
|
||||
| `multica issue search <query>` | 关键字搜索 |
|
||||
| `multica issue runs <id>` | 查看 issue 上智能体跑过的任务 |
|
||||
| `multica issue rerun <id>` | 给该 issue 当前的智能体分配人重新创建一条任务 |
|
||||
| `multica issue rerun <id>` | 重跑最近一次智能体任务 |
|
||||
| `multica issue comment <id> ...` | 嵌套:看 / 发评论 |
|
||||
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
@@ -79,20 +75,6 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | 列出工作区里的小队 |
|
||||
| `multica squad get <id>` | 查看一个小队 |
|
||||
| `multica squad create --name "..." --leader <agent>` | 创建小队(owner / admin)|
|
||||
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
|
||||
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
|
||||
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
|
||||
|
||||
完整模型见 [小队](/squads)。
|
||||
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
@@ -117,6 +99,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica runtime list` | 列出当前工作区的 runtime |
|
||||
| `multica runtime usage` | 查看资源使用情况 |
|
||||
| `multica runtime activity` | 近期活动记录 |
|
||||
| `multica runtime ping <id>` | 立即戳一次 runtime 检查在线 |
|
||||
| `multica runtime update <id> ...` | 更新 runtime 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
@@ -210,7 +210,7 @@ multica workspace get <workspace-id> --output json
|
||||
### List Members
|
||||
|
||||
```bash
|
||||
multica workspace member list <workspace-id>
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
### Update Workspace
|
||||
@@ -244,22 +244,18 @@ multica issue list
|
||||
multica issue list --status in_progress
|
||||
multica issue list --priority urgent --assignee "Agent Name"
|
||||
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue list --full-id
|
||||
multica issue list --limit 20 --output json
|
||||
```
|
||||
|
||||
表格输出默认显示可直接复制到后续命令的 issue `KEY`(例如 `MUL-123`);需要完整 UUID 时使用 `--full-id`。Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. 在重名 workspace 下用 `--assignee-id <uuid>` 可以精确锁定一个成员或 agent。
|
||||
|
||||
### Get Issue
|
||||
|
||||
```bash
|
||||
multica issue get MUL-123
|
||||
multica issue get <uuid>
|
||||
multica issue get <id>
|
||||
multica issue get <id> --output json
|
||||
```
|
||||
|
||||
`<id>` 同时接受 issue key(`multica issue list` 表格里直接显示,例如 `MUL-123`)和完整 UUID(给 `list` 加 `--full-id` 可显示)。同样的规则适用于下面 `update` / `assign` / `status` / `comment` / `subscriber` / `runs` 等接受 `<id>` 的命令。
|
||||
|
||||
### Create Issue
|
||||
|
||||
```bash
|
||||
@@ -267,7 +263,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
|
||||
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
```
|
||||
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace member list --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID(例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -314,20 +310,16 @@ multica issue comment delete <comment-id>
|
||||
```bash
|
||||
# List all execution runs for an issue
|
||||
multica issue runs <issue-id>
|
||||
multica issue runs <issue-id> --full-id
|
||||
multica issue runs <issue-id> --output json
|
||||
|
||||
# View messages for a specific execution run
|
||||
multica issue run-messages <task-id>
|
||||
multica issue run-messages <short-task-id> --issue <issue-id>
|
||||
multica issue run-messages <task-id> --output json
|
||||
|
||||
# Incremental fetch (only messages after a given sequence number)
|
||||
multica issue run-messages <task-id> --since 42 --output json
|
||||
```
|
||||
|
||||
`runs` 的表格输出默认显示 task UUID 短前缀;需要完整 task UUID 时使用 `--full-id`。`run-messages` 可直接接受完整 task UUID;从 `runs` 表格复制短前缀时需要同时传 `--issue <issue-id>`,CLI 只会在该 issue 的 runs 内解析。
|
||||
|
||||
## Projects
|
||||
|
||||
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
|
||||
|
||||
@@ -72,7 +72,7 @@ multica daemon status
|
||||
|
||||
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
|
||||
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
|
||||
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
|
||||
## 5. Create an agent
|
||||
|
||||
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace member list --output json`.
|
||||
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
multica issue assign MUL-1 --to my-agent-name
|
||||
```
|
||||
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace member list --output json`。
|
||||
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥);UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
|
||||
### Comments in code
|
||||
|
||||
@@ -160,7 +160,6 @@ Chinese term reference:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号,外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效。
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
|
||||
### 代码注释
|
||||
|
||||
@@ -160,7 +160,6 @@ Multica 的产品名词分两类:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -35,28 +35,14 @@ These are the core variables you must think about before deploying — some have
|
||||
|
||||
## Email configuration
|
||||
|
||||
Multica supports two delivery backends — [Resend](https://resend.com/) for cloud deployments, or an SMTP relay for internal / on-premise networks. `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
### Resend
|
||||
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | empty | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account; also reused as the `From:` header when SMTP is in use) |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission; **port 465 (SMTPS / implicit TLS) is not supported** |
|
||||
| `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay |
|
||||
| `SMTP_PASSWORD` | empty | SMTP password |
|
||||
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
|
||||
|
||||
**Behavior when neither is set**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
@@ -128,25 +114,6 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
|
||||
|
||||
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
|
||||
|
||||
## Rate limiting (optional Redis)
|
||||
|
||||
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
|
||||
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
|
||||
|
||||
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
|
||||
|
||||
<Callout type="warning">
|
||||
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
|
||||
</Callout>
|
||||
|
||||
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
|
||||
|
||||
## Daemon tuning parameters
|
||||
|
||||
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
|
||||
@@ -174,22 +141,6 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
|
||||
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
|
||||
</Callout>
|
||||
|
||||
## GitHub integration
|
||||
|
||||
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
|
||||
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
|
||||
|
||||
**Behavior when either is unset:**
|
||||
|
||||
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
|
||||
|
||||
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
|
||||
|
||||
## Usage analytics
|
||||
|
||||
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
|
||||
@@ -203,6 +154,5 @@ By default, the server reports to Multica's official PostHog instance. To opt ou
|
||||
## Next
|
||||
|
||||
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
|
||||
- [GitHub integration](/github-integration) — how to set up the GitHub App that backs `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET`
|
||||
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do
|
||||
|
||||
@@ -35,28 +35,14 @@ Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量
|
||||
|
||||
## 怎么配邮件
|
||||
|
||||
Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合公网部署,SMTP relay 适合内网/自部署。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
### Resend
|
||||
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头)|
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | 空 | SMTP relay 主机名。设置后即启用 SMTP 模式并覆盖 Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`;**暂不支持 465(SMTPS / 隐式 TLS)** |
|
||||
| `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay |
|
||||
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
|
||||
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s,整个 SMTP 会话有 30s deadline,避免 relay 黑洞把 auth handler 挂死。
|
||||
|
||||
**两种都不设的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发方便(你从 server 日志里抄验证码);**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
**不设 `RESEND_API_KEY` 时的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
@@ -128,25 +114,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
|
||||
|
||||
## 速率限制(可选 Redis)
|
||||
|
||||
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**(fail-open),后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | 空 | Redis 连接 URL(例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
|
||||
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
|
||||
|
||||
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
|
||||
|
||||
<Callout type="warning">
|
||||
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP,整个部署落到同一个桶里,`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`;Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
|
||||
</Callout>
|
||||
|
||||
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
|
||||
|
||||
## 守护进程的调节参数
|
||||
|
||||
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
|
||||
@@ -174,22 +141,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
**`FRONTEND_ORIGIN` 不设就有两个静默失败**:(1)邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例;(2)WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
|
||||
</Callout>
|
||||
|
||||
## GitHub 集成
|
||||
|
||||
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
## 用量统计
|
||||
|
||||
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
|
||||
@@ -203,6 +154,5 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
## 下一步
|
||||
|
||||
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
|
||||
- [GitHub 集成](/github-integration) —— `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` 背后的 GitHub App 怎么建
|
||||
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
|
||||
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义
|
||||
|
||||
@@ -133,18 +133,6 @@ Alternatively, configure step by step: `multica config set server_url http://loc
|
||||
3. Go to **Settings → Agents** and create a new agent
|
||||
4. Create an issue and assign it to your agent
|
||||
|
||||
## Usage Dashboard Rollup (Required)
|
||||
|
||||
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
|
||||
|
||||
Pick one supported path before relying on the Usage / Runtime dashboard:
|
||||
|
||||
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
|
||||
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
|
||||
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
|
||||
|
||||
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
|
||||
## Stopping Services
|
||||
|
||||
```bash
|
||||
@@ -231,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
|
||||
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
|
||||
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
@@ -349,47 +337,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: GitHub integration
|
||||
description: Connect a GitHub App once, then PRs whose branch, title, or body reference an issue identifier auto-attach to that issue — and merging the PR moves the issue to Done.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect a GitHub account or organization once in **Settings → GitHub**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
|
||||
|
||||
There is no per-issue setup. The whole flow is identifier-driven.
|
||||
|
||||
## What the integration does
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Settings → GitHub** | Workspace admins see the GitHub tab with a master toggle, **Connect GitHub** button, and feature switches (PR sidebar, Co-authored-by, auto-link). After install you bounce back to the GitHub tab. |
|
||||
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
|
||||
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
|
||||
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
|
||||
|
||||
Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
|
||||
|
||||
## How identifiers are matched
|
||||
|
||||
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:
|
||||
|
||||
- Case-insensitive — `mul-123`, `MUL-123`, `Mul-123` all match.
|
||||
- Bounded — a `\b` on the left and a digit anchor on the right keep it from grabbing version numbers like `v1.2-3` or email-style strings.
|
||||
- Workspace-scoped — only matches the workspace's own [issue prefix](/workspaces). `FOO-1` in a workspace whose prefix is `MUL` is ignored, even if the integer matches another issue.
|
||||
- Deduplicated — listing `MUL-1, MUL-1` in the body links the issue once.
|
||||
|
||||
You can reference **multiple issues** in one PR. `Closes MUL-1, MUL-2` links the PR to both, and merging it advances both to `Done`.
|
||||
|
||||
## The auto-merge-to-Done rule
|
||||
|
||||
When a PR's `merged` field flips to `true`, every linked issue is evaluated:
|
||||
|
||||
| Issue current status | Result |
|
||||
|---|---|
|
||||
| `done` | No change (already terminal). |
|
||||
| `cancelled` | **No change** — cancelled means the user explicitly abandoned the work; the integration does not override that signal. |
|
||||
| Anything else (`todo`, `in_progress`, `in_review`, `blocked`, `backlog`) | Moved to `done`. |
|
||||
|
||||
Closing a PR **without** merging it only updates the PR card's state to `Closed`. The linked issues stay where they were — the user is the one who decides what closing-without-merge means.
|
||||
|
||||
<Callout type="info">
|
||||
The action is attributed to the `system` actor on the timeline. Subscribers of the issue receive an inbox notification for the status change, the same way they would if a human had moved it.
|
||||
</Callout>
|
||||
|
||||
## What's not auto-linked
|
||||
|
||||
- **Identifiers in commit messages** — only branch / title / body are scanned. A commit titled `MUL-123: fix login` does not auto-link unless the same string also appears in the PR title or body.
|
||||
- **Identifiers in PR comments** — only the PR's own metadata is scanned; later GitHub comments are ignored.
|
||||
- **PRs in repos the App isn't installed on** — without the App, Multica never receives the webhook.
|
||||
- **Manually linking a PR to an issue** — there is no UI for this yet. If your team's convention puts identifiers in a place Multica isn't reading, add them to the PR title or body.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
In **Settings → GitHub** there is no installation list — you manage existing installations from GitHub directly:
|
||||
|
||||
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
|
||||
- **Disconnect from inside Multica is admin-only** — the Disconnect control on the GitHub tab is hidden for non-admins. It stays available even when the master GitHub switch is off, so admins can still revoke a stale installation after one-click-disabling the feature.
|
||||
|
||||
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
|
||||
|
||||
## Permissions and visibility
|
||||
|
||||
- **Connect / disconnect** require workspace **owner or admin**. Members see the card description but no Connect button.
|
||||
- The **Pull requests** sidebar on an issue is visible to anyone who can read the issue — same permissions as the rest of issue detail.
|
||||
- The GitHub App requests **read-only** access to pull requests and metadata. Multica never pushes commits, comments, or status checks back to GitHub.
|
||||
|
||||
## Self-host setup
|
||||
|
||||
If you're running Multica on Multica Cloud, the integration is already configured — skip this section.
|
||||
|
||||
For self-host, you create one GitHub App, point it at your server, and set two environment variables. The whole flow is below.
|
||||
|
||||
### 1. Create a GitHub App
|
||||
|
||||
Go to one of:
|
||||
|
||||
- Personal account → `https://github.com/settings/apps/new`
|
||||
- Organization → `https://github.com/organizations/<org>/settings/apps/new`
|
||||
|
||||
Fill in:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **GitHub App name** | Anything recognizable, e.g. `Multica` or `Multica (staging)`. |
|
||||
| **Homepage URL** | Your Multica frontend, e.g. `https://multica.example.com`. |
|
||||
| **Callback URL** | Leave blank — Multica doesn't use OAuth user identity. |
|
||||
| **Setup URL** | `https://<api-host>/api/github/setup`. **Check "Redirect on update"**. |
|
||||
| **Webhook → Active** | Enabled. |
|
||||
| **Webhook URL** | `https://<api-host>/api/webhooks/github`. |
|
||||
| **Webhook secret** | Generate a long random string (e.g. `openssl rand -hex 32`). You'll paste the same value into Multica's env in step 2. |
|
||||
| **Permissions → Repository → Pull requests** | **Read-only**. |
|
||||
| **Permissions → Repository → Metadata** | Read-only (mandatory). |
|
||||
| **Subscribe to events** | Tick **Pull request**. |
|
||||
| **Where can this GitHub App be installed?** | Your choice. `Only on this account` is fine for single-org setups. |
|
||||
|
||||
After **Create GitHub App**, note two things from the App's detail page:
|
||||
|
||||
- The **public link** at the top — its tail is the slug. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
|
||||
- The **webhook secret** you just generated (you can't read it back from GitHub later — save it now).
|
||||
|
||||
<Callout type="warning">
|
||||
**Webhook secret ≠ Client secret.** The App settings page has both fields stacked together. The **Webhook secret** is what signs `pull_request` payloads — that's the one Multica needs. The **Client secret** is for OAuth and is not used by this integration. Mixing them up produces a confusing `401 invalid signature` on every webhook delivery.
|
||||
</Callout>
|
||||
|
||||
### 2. Set environment variables
|
||||
|
||||
On the API server:
|
||||
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
```
|
||||
|
||||
Both variables are required. If either is missing:
|
||||
|
||||
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
|
||||
|
||||
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
|
||||
|
||||
Restart the API after setting the env vars.
|
||||
|
||||
### 3. Run migrations
|
||||
|
||||
The integration ships its tables in migration `079_github_integration`. If you're upgrading an older deployment:
|
||||
|
||||
```bash
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
Three tables get created: `github_installation`, `github_pull_request`, `issue_pull_request`. They cascade-delete with their workspace, so removing a workspace cleans them up automatically.
|
||||
|
||||
### 4. Connect from the UI
|
||||
|
||||
In Multica:
|
||||
|
||||
1. Open **Settings → GitHub** as an owner or admin.
|
||||
2. Click **Connect GitHub**. GitHub opens in a new tab.
|
||||
3. Pick the repositories to grant access to and **Install**.
|
||||
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`.
|
||||
|
||||
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
|
||||
|
||||
### 5. Verify with a curl probe
|
||||
|
||||
If GitHub's **Recent Deliveries** page reports `401 invalid signature` after install, the two sides have different secrets. The fastest way to find out which side is wrong is to bypass GitHub:
|
||||
|
||||
```bash
|
||||
SECRET="<the value you put in GITHUB_WEBHOOK_SECRET>"
|
||||
BODY='{"zen":"test"}'
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
||||
curl -i -X POST https://<api-host>/api/webhooks/github \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-H "X-GitHub-Event: ping" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
| HTTP status | Meaning | Fix |
|
||||
|---|---|---|
|
||||
| `200` `{"ok":"pong"}` | Server's loaded secret matches your `$SECRET`. The mismatch is on GitHub. | Edit the App → Webhook secret → **paste the same value** → **Save changes** (clicking out of the field without Save keeps the old secret). Redeliver. |
|
||||
| `401 invalid signature` | Server's loaded secret is **not** what you think it is. | Confirm the env var landed in the running process (e.g. `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" | wc -c`). Re-deploy. |
|
||||
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` is empty in the process. | Set the env var, restart the API. |
|
||||
|
||||
## Limitations
|
||||
|
||||
A few rough edges to be aware of today:
|
||||
|
||||
- **No manual link UI yet** — the only way to link a PR is to have the identifier in its branch, title, or body.
|
||||
- **No CI / check state** — only the PR itself is mirrored. Build status, review comments, and reviewers are not surfaced in Multica.
|
||||
- **No workspace-level config** for the merge → Done rule — it's a fixed default (`merged → done`, unless `cancelled`). Workspace-customizable mappings are a future addition.
|
||||
- **Multi-PR-to-one-issue is conservative on merge** — if two PRs both reference `MUL-123` and the first one merges, the issue is moved to `Done` immediately. A follow-up change to wait for all linked PRs to resolve before advancing is in progress.
|
||||
|
||||
## Next
|
||||
|
||||
- [Issues](/issues) — the issue identifiers (`MUL-123`) referenced from PRs
|
||||
- [Workspaces](/workspaces) — where the workspace-specific issue prefix is set
|
||||
- [Environment variables](/environment-variables) — full env reference, including the GitHub variables above
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: GitHub 集成
|
||||
description: 一次性连接 GitHub App,之后 PR 的分支名、标题或正文里写了 issue 编号(例如 MUL-123),就会自动挂到那个 issue 上——PR 合并时 issue 自动转 Done。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 **Settings → GitHub** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
|
||||
没有 per-issue 的配置,整个流程是「编号驱动」的。
|
||||
|
||||
## 集成做了什么
|
||||
|
||||
| 出现位置 | 行为 |
|
||||
|---|---|
|
||||
| **Settings → GitHub** | 工作区 owner / admin 看到 GitHub 这个 tab,里面有主开关、**Connect GitHub** 按钮,以及功能开关(PR 侧栏、Co-authored-by、auto-link)。点 Connect 会打开 GitHub 的 App 安装页;装好后跳回 GitHub tab。 |
|
||||
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR,含标题、仓库、状态(`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
|
||||
| **Webhook(后台)** | 每次 `pull_request` 事件触发:upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
|
||||
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
|
||||
|
||||
只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
|
||||
|
||||
## 编号是怎么匹配的
|
||||
|
||||
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:
|
||||
|
||||
- 大小写不敏感——`mul-123`、`MUL-123`、`Mul-123` 都能匹配
|
||||
- 有边界——左侧 `\b`、右侧只接数字,避免误抓 `v1.2-3`、email 地址等
|
||||
- 限定到本工作区——只匹配本工作区的 [issue prefix](/workspaces)。前缀是 `MUL` 的工作区里,PR 出现 `FOO-1` 不会匹配,即使数字撞另一个 issue 也不会
|
||||
- 自动去重——`Closes MUL-1, MUL-1` 只关联一次
|
||||
|
||||
一个 PR 里**可以同时引用多个 issue**。比如 `Closes MUL-1, MUL-2`:PR 同时关联两个 issue,合并时两个 issue 都会转 `Done`。
|
||||
|
||||
## Merge 自动转 Done 的规则
|
||||
|
||||
PR 的 `merged` 字段翻成 `true` 时,逐个评估关联的 issue:
|
||||
|
||||
| Issue 当前状态 | 结果 |
|
||||
|---|---|
|
||||
| `done` | 不变(已经是终态)|
|
||||
| `cancelled` | **不变**——cancelled 是用户明确放弃工作的信号,集成不覆盖 |
|
||||
| 其他(`todo` / `in_progress` / `in_review` / `blocked` / `backlog`)| 转成 `done` |
|
||||
|
||||
PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`,issue 状态不变。"关闭但不合并"语义因团队而异,Multica 不替用户做决定。
|
||||
|
||||
<Callout type="info">
|
||||
状态变更的 actor 是 `system`。订阅了该 issue 的成员会收到 inbox 通知,和成员手动改状态时一致。
|
||||
</Callout>
|
||||
|
||||
## 哪些情况不会自动关联
|
||||
|
||||
- **Commit message 里的编号**——只扫 PR 的分支 / 标题 / 正文。一个 commit message 写 `MUL-123: fix login` 不会触发关联,除非同样的字符串也出现在 PR 标题或正文里
|
||||
- **PR 评论里的编号**——只扫 PR 自己的元数据,后续的 GitHub comment 不读
|
||||
- **App 没安装的仓库里的 PR**——没 App,Multica 收不到 webhook
|
||||
- **手动把 PR 关联到 issue**——暂时没有这个 UI。如果你们的约定把编号放到 Multica 不扫的地方,请改放到 PR 标题或正文里
|
||||
|
||||
## 断开连接
|
||||
|
||||
**Settings → GitHub** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
|
||||
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
|
||||
- **Multica 这边的断开是 admin only** —— GitHub tab 上的 Disconnect 控件对非 admin 不显示;主开关关掉时 Disconnect 仍然可用,方便 admin 一键关闭功能后再单独清理已连接的 installation
|
||||
|
||||
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR,但来自这个 installation 的新 webhook 事件不再被接受。
|
||||
|
||||
## 权限和可见性
|
||||
|
||||
- **Connect / Disconnect** 需要工作区 **owner 或 admin**。普通成员能看到卡片描述但看不到 Connect 按钮
|
||||
- **Pull requests** 侧栏对所有能看到该 issue 的成员可见——和 issue 详情页其他部分权限一致
|
||||
- GitHub App 申请的是 PR 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check
|
||||
|
||||
## Self-Host 配置
|
||||
|
||||
如果你在 Multica Cloud 上,集成已经配好——跳过本节。
|
||||
|
||||
Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境变量。完整流程如下。
|
||||
|
||||
### 1. 创建一个 GitHub App
|
||||
|
||||
到下面其中一个页面:
|
||||
|
||||
- 个人账号 → `https://github.com/settings/apps/new`
|
||||
- 组织 → `https://github.com/organizations/<org>/settings/apps/new`
|
||||
|
||||
按下表填写:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| **GitHub App name** | 任何能辨识的名字,例如 `Multica` 或 `Multica (staging)` |
|
||||
| **Homepage URL** | 你的 Multica 前端,例如 `https://multica.example.com` |
|
||||
| **Callback URL** | 留空——本集成不使用 OAuth 用户身份 |
|
||||
| **Setup URL** | `https://<api-host>/api/github/setup`。**勾选 "Redirect on update"** |
|
||||
| **Webhook → Active** | 启用 |
|
||||
| **Webhook URL** | `https://<api-host>/api/webhooks/github` |
|
||||
| **Webhook secret** | 生成一个长随机字符串(例如 `openssl rand -hex 32`)。这个值会同样填到 step 2 的 env 里 |
|
||||
| **Permissions → Repository → Pull requests** | **Read-only** |
|
||||
| **Permissions → Repository → Metadata** | Read-only(必填)|
|
||||
| **Subscribe to events** | 勾选 **Pull request** |
|
||||
| **Where can this GitHub App be installed?** | 自选。单组织部署建议选 `Only on this account` |
|
||||
|
||||
点 **Create GitHub App** 之后,从详情页记下两件事:
|
||||
|
||||
- 顶部 **public link** 的尾部即 slug。`https://github.com/apps/multica-acme` → slug = `multica-acme`
|
||||
- 你刚生成的 **webhook secret**(GitHub 之后不会再让你读取这个值——现在就保存好)
|
||||
|
||||
<Callout type="warning">
|
||||
**Webhook secret ≠ Client secret。** App 设置页里两个字段紧挨着。**Webhook secret** 用于签 `pull_request` payload,这才是 Multica 需要的那个;**Client secret** 是 OAuth 用的,和本集成无关。混淆这两个会得到「每条 webhook 都 `401 invalid signature`」的诡异症状。
|
||||
</Callout>
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
API server 上:
|
||||
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
```
|
||||
|
||||
两个都必填。任何一个缺失:
|
||||
|
||||
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
|
||||
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
### 3. 执行 migration
|
||||
|
||||
集成的表在 migration `079_github_integration` 里。如果是升级既有部署:
|
||||
|
||||
```bash
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
会创建三张表:`github_installation`、`github_pull_request`、`issue_pull_request`。三张表都 cascade 跟随 workspace——删工作区会自动清理。
|
||||
|
||||
### 4. 在 UI 里连接
|
||||
|
||||
到 Multica:
|
||||
|
||||
1. 以 owner 或 admin 身份打开 **Settings → GitHub**
|
||||
2. 点 **Connect GitHub**,GitHub 在新 tab 打开
|
||||
3. 选择要授权的仓库,点 **Install**
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`
|
||||
|
||||
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
|
||||
|
||||
### 5. 用 curl 自检
|
||||
|
||||
如果 GitHub 的 **Recent Deliveries** 里第一次 PR 事件就报 `401 invalid signature`,说明两边的 secret 不一致。绕过 GitHub 直接测 server 是最快的定位方法:
|
||||
|
||||
```bash
|
||||
SECRET="<你填给 GITHUB_WEBHOOK_SECRET 的值>"
|
||||
BODY='{"zen":"test"}'
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
||||
curl -i -X POST https://<api-host>/api/webhooks/github \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-H "X-GitHub-Event: ping" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
| HTTP 状态 | 含义 | 修法 |
|
||||
|---|---|---|
|
||||
| `200` `{"ok":"pong"}` | server 加载的 secret 和你 `$SECRET` 一致——GitHub 那边的 secret 才是错的 | 编辑 App → Webhook secret 字段**粘相同的值** → **必须点 Save changes**(不点 Save 等于没改)→ Redeliver |
|
||||
| `401 invalid signature` | server 加载的 secret **不是**你以为的那个 | 进容器确认 env 实际生效(例如 `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`),重新部署 |
|
||||
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` 在进程里是空的 | 配上 env,重启 API |
|
||||
|
||||
## 已知限制
|
||||
|
||||
目前还没做的几个边界:
|
||||
|
||||
- **手动 link UI 暂未提供**——关联 PR 的唯一方法是把 issue 编号写到 PR 分支 / 标题 / 正文
|
||||
- **不读 CI / check 状态**——只镜像 PR 本身,构建状态、reviewer 评论、reviewer 列表都没接进 Multica
|
||||
- **没有工作区级别的 merge → status 映射配置**——默认固定是 `merged → done`(cancelled 除外)。可配置映射是后续迭代
|
||||
- **同 issue 多 PR 时,merge 行为偏激进**——两个 PR 都引用 `MUL-123` 时,第一个 merge 就把 issue 转 Done。"等所有关联 PR 都解决再推进 issue 状态"的优化已经在做了
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Issues](/issues) —— PR 引用的 issue 编号(`MUL-123`)的来源
|
||||
- [工作区](/workspaces) —— 工作区 issue prefix 的设置位置
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单,包含上面提到的 GitHub 变量
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
title: Install an agent runtime
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
|
||||
This page is the install-side companion to:
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection works
|
||||
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
|
||||
|
||||
<Callout type="info">
|
||||
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
|
||||
</Callout>
|
||||
|
||||
## Before you start
|
||||
|
||||
Two prerequisites apply to **every** tool below:
|
||||
|
||||
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
|
||||
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
|
||||
|
||||
After installing a tool, restart the daemon:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 11 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `claude` |
|
||||
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
|
||||
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
|
||||
| Notes | First-choice recommendation for new users. |
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `codex` |
|
||||
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
|
||||
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `cursor-agent` |
|
||||
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
|
||||
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `copilot` |
|
||||
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
|
||||
| Authentication | Browser-based GitHub login through the CLI. |
|
||||
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `gemini` |
|
||||
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
|
||||
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `opencode` |
|
||||
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
|
||||
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
|
||||
|
||||
### Kiro CLI (Amazon)
|
||||
|
||||
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kiro-cli` |
|
||||
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
|
||||
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
|
||||
|
||||
### Kimi (Moonshot)
|
||||
|
||||
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kimi` |
|
||||
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
|
||||
| Authentication | Moonshot API key, configured per the vendor's docs. |
|
||||
|
||||
### Hermes (Nous Research)
|
||||
|
||||
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `hermes` |
|
||||
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `openclaw` |
|
||||
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
|
||||
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
|
||||
|
||||
### Pi (Inflection AI)
|
||||
|
||||
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `pi` |
|
||||
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
## After installing
|
||||
|
||||
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
|
||||
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
|
||||
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
|
||||
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
|
||||
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
|
||||
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
|
||||
|
||||
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
|
||||
|
||||
## Next
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
|
||||
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
title: 安装一个 Agent 运行时
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
|
||||
这一页是装机的入口,和它配套的是:
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
|
||||
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异(会话续接、MCP、模型选择)
|
||||
|
||||
<Callout type="info">
|
||||
Multica 服务器从不接触你的 API key,也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
|
||||
</Callout>
|
||||
|
||||
## 开始前
|
||||
|
||||
下面每一款工具都有两个共同前提:
|
||||
|
||||
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
|
||||
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
|
||||
|
||||
装完一款工具后,重启守护进程:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
|
||||
|
||||
## 11 款支持的工具
|
||||
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
|
||||
|
||||
### Claude Code(Anthropic)
|
||||
|
||||
集成最完整的一款。会话续接好用,MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `claude` |
|
||||
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
|
||||
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
|
||||
| 备注 | 新用户首选。 |
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `codex` |
|
||||
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
|
||||
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `cursor-agent` |
|
||||
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
|
||||
| 认证 | 在 Cursor 编辑器里登录,CLI 复用同一份会话。 |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型,GitHub 决定你拿到哪个模型。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `copilot` |
|
||||
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
|
||||
| 认证 | CLI 里走 GitHub 浏览器登录。 |
|
||||
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
|
||||
|
||||
### Gemini(Google)
|
||||
|
||||
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `gemini` |
|
||||
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
|
||||
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
|
||||
|
||||
### OpenCode(SST)
|
||||
|
||||
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `opencode` |
|
||||
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
|
||||
| 认证 | 按 OpenCode 的文档配你自己的模型供应商(Anthropic、OpenAI 等)。 |
|
||||
|
||||
### Kiro CLI(Amazon)
|
||||
|
||||
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作;skills 拷到 `.kiro/skills/`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kiro-cli` |
|
||||
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
|
||||
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
|
||||
|
||||
### Kimi(Moonshot)
|
||||
|
||||
ACP 协议 agent,主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kimi` |
|
||||
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
|
||||
| 认证 | Moonshot API key,按厂商文档配置。 |
|
||||
|
||||
### Hermes(Nous Research)
|
||||
|
||||
ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `hermes` |
|
||||
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
开源 CLI agent 编排器。**模型绑在 agent 层**(`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `openclaw` |
|
||||
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
|
||||
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
|
||||
|
||||
### Pi(Inflection AI)
|
||||
|
||||
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `pi` |
|
||||
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
|
||||
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
|
||||
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
|
||||
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
|
||||
|
||||
## 排错
|
||||
|
||||
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
|
||||
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`,绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
|
||||
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
|
||||
|
||||
更宽的症状看[排错指南](/zh/troubleshooting)。
|
||||
|
||||
## 接下来
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
|
||||
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
|
||||
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务
|
||||
@@ -16,10 +16,6 @@ Same as mentioning a member — type `@` to open the picker and select an agent.
|
||||
|
||||
The `@mention` Markdown syntax, the picker, and `@all` semantics are covered in [**Comments**](/comments).
|
||||
|
||||
<Callout type="info">
|
||||
**You can also `@`-mention a [squad](/squads) in a comment.** The same picker surfaces squads alongside members and agents; selecting one inserts `[@SquadName](mention://squad/<uuid>)` and triggers the squad's **leader agent** to coordinate a response — assignee and status stay untouched.
|
||||
</Callout>
|
||||
|
||||
## How it differs from assignment
|
||||
|
||||
Both put the agent to work, but the mechanics are entirely different:
|
||||
@@ -57,7 +53,6 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
|
||||
|
||||
## Next
|
||||
|
||||
- [**Squads**](/squads) — `@`-mention a squad to have the leader route the question to the right member
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics
|
||||
|
||||
@@ -16,10 +16,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
`@mention` 的 Markdown 语法、picker 的用法、`@all` 的语义见 [**评论**](/comments)。
|
||||
|
||||
<Callout type="info">
|
||||
**`@` 也可以指向 [小队(squad)](/squads)。** picker 里小队和成员、智能体并列;选中后会插入 `[@SquadName](mention://squad/<uuid>)`,触发小队的**队长智能体**来协调响应——assignee 和 status 都不会变。
|
||||
</Callout>
|
||||
|
||||
## 和分配的差别
|
||||
|
||||
同样是让智能体工作,但机制完全不同:
|
||||
@@ -57,7 +53,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**小队**](/squads) —— `@` 一个小队,由队长把问题派给合适的成员
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义
|
||||
|
||||
@@ -16,10 +16,8 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"install-agent-runtime",
|
||||
"tasks",
|
||||
"providers",
|
||||
"---Collaborating with agents---",
|
||||
@@ -29,8 +27,6 @@
|
||||
"autopilots",
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"github-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
@@ -39,7 +35,6 @@
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"mobile-app",
|
||||
"---Developers---",
|
||||
"developers"
|
||||
]
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---智能体怎么运行---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
@@ -27,8 +26,6 @@
|
||||
"autopilots",
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"github-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
@@ -37,7 +34,6 @@
|
||||
"cli",
|
||||
"auth-tokens",
|
||||
"desktop-app",
|
||||
"mobile-app",
|
||||
"---开发者---",
|
||||
"developers"
|
||||
]
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
title: Mobile app (iOS)
|
||||
description: How to build the open-source Multica iOS app on your own iPhone — no App Store yet.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica's iOS client is open-source and lives in the [main repo](https://github.com/multica-ai/multica) alongside web, desktop, and backend. It isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. The build takes about 10–20 minutes the first time and ~2 minutes after that, and it talks to the same backend as [multica.ai](https://multica.ai) so your existing account just works.
|
||||
|
||||
<Callout type="info">
|
||||
This page is for **personal use**. App developers should read [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) in the repo — it covers the dev / staging variants and the full script matrix.
|
||||
</Callout>
|
||||
|
||||
## What you need
|
||||
|
||||
- A **Mac** with Xcode installed (free from the App Store).
|
||||
- A free **Apple ID** added under Xcode → Settings → Accounts. A paid Apple Developer Program account is optional and only extends the 7-day signing window to 1 year — see [7-day limit](#7-day-signing-limit) below.
|
||||
- An **iPhone** connected via USB cable, with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/) (Settings → Privacy & Security → Developer Mode).
|
||||
- The Multica source code checked out:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
pnpm install
|
||||
```
|
||||
|
||||
If anything in that list is missing, walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) — it's the canonical setup guide for everything except the repo checkout.
|
||||
|
||||
## Build it
|
||||
|
||||
One command:
|
||||
|
||||
```bash
|
||||
pnpm ios:mobile:device:prod:release
|
||||
```
|
||||
|
||||
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — this team is created silently the first time you sign into Xcode with any Apple ID, so it's there even if you don't remember setting anything up. This is a **Release build**: no Metro dependency, splash → app, exactly like an App Store install.
|
||||
|
||||
The first build downloads CocoaPods + compiles React Native from source — expect 10–20 minutes. Subsequent builds reuse Xcode's cache.
|
||||
|
||||
That's it for the typical path. If signing fails, jump to [Troubleshooting](#troubleshooting).
|
||||
|
||||
## 7-day signing limit
|
||||
|
||||
A free Apple ID signs builds for **7 days**. After that, the app refuses to launch on your iPhone and shows an "untrusted developer" error. Plug back into your Mac and re-run the same command to re-sign — your data stays put because it lives on the backend, not in the app.
|
||||
|
||||
The only way to extend this is an **Apple Developer Program account** ($99/yr from [developer.apple.com](https://developer.apple.com)). Signing is then valid for 1 year between renewals, and you can also distribute to other devices via TestFlight.
|
||||
|
||||
## Updating
|
||||
|
||||
There is no auto-update yet. When the Multica codebase moves forward, pull and rebuild:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
pnpm ios:mobile:device:prod:release
|
||||
```
|
||||
|
||||
Subsequent builds are fast because Xcode caches the native compile.
|
||||
|
||||
## Why no App Store yet
|
||||
|
||||
The iOS app is still moving fast — the team prefers ship-and-iterate over App Store review cycles right now. A TestFlight beta is the most likely next step before a full App Store release. Until then, the self-build path above is the only way to use Multica on iOS.
|
||||
|
||||
If you'd like to be notified when TestFlight opens, watch the [GitHub repo](https://github.com/multica-ai/multica).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"No matching provisioning profiles found"** — Xcode refuses to sign the default bundle id `ai.multica.mobile` with your Apple ID. Rare, but happens if someone has registered that prefix on Apple's developer portal. Pick any reverse-domain you control (`com.yourname.multica` is fine), export it, and re-run:
|
||||
|
||||
```bash
|
||||
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
|
||||
pnpm ios:mobile:device:prod:release
|
||||
```
|
||||
|
||||
The id doesn't have to mean anything — Apple just needs it to be unclaimed by other teams.
|
||||
|
||||
**"Could not launch <app>" / "Untrusted Developer"** — either you've hit the 7-day limit (re-run the build) or you need to manually trust the developer profile on your iPhone: Settings → General → VPN & Device Management → tap your Apple ID → Trust.
|
||||
|
||||
**Build hangs on `Pod install` or compiles forever** — first build is genuinely 10–20 minutes because CocoaPods downloads dependencies and Xcode compiles React Native from source. Subsequent builds are much faster.
|
||||
|
||||
**App can't reach the backend** — confirm `apps/mobile/.env.production` hasn't been modified (it ships with `EXPO_PUBLIC_API_URL=https://api.multica.ai`). If you changed it, restore with `git checkout apps/mobile/.env.production`.
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
title: 移动 App(iOS)
|
||||
description: 在自己的 iPhone 上自助 build 开源版 Multica iOS app —— 暂未上 App Store。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica iOS 客户端开源,跟 web、desktop、后端一起放在[主仓库](https://github.com/multica-ai/multica)里。目前没上 App Store —— 在那之前,想用的人自己从源码 build 一份。首次 build 约 10–20 分钟,之后每次约 2 分钟,连接的是 [multica.ai](https://multica.ai) 同一个后端,所以你现有账号直接能登。
|
||||
|
||||
<Callout type="info">
|
||||
本页是给**个人使用者**看的。如果你是要开发这个 app,请看仓库里的 [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) —— 那里覆盖 dev / staging 变体和完整脚本表。
|
||||
</Callout>
|
||||
|
||||
## 你需要
|
||||
|
||||
- 一台装了 Xcode 的 **Mac**(Xcode 在 App Store 免费下载)。
|
||||
- 一个免费的 **Apple ID**,在 Xcode → Settings → Accounts 里加进去。付费的 Apple Developer Program 账号是可选的 —— 只把 7 天签名期延到 1 年,见下方[7 天签名限制](#7-天签名限制)。
|
||||
- 一台通过 USB 线连接的 **iPhone**,并打开 [Developer Mode](https://docs.expo.dev/guides/ios-developer-mode/)(设置 → 隐私与安全性 → 开发者模式)。
|
||||
- Multica 源码已 clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
pnpm install
|
||||
```
|
||||
|
||||
上面任何一项缺失,先走 Expo 的 [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/)(选 **Development build → iOS Device**)—— 它是除仓库拉取外所有环境准备的官方指引。
|
||||
|
||||
## Build
|
||||
|
||||
一条命令:
|
||||
|
||||
```bash
|
||||
pnpm ios:mobile:device:prod:release
|
||||
```
|
||||
|
||||
Xcode 会用你 Apple ID 自动持有的"Personal Team"来签名 —— 这个 team 是你第一次用任何 Apple ID 登 Xcode 时静默建的,所以即使你不记得"什么时候弄过",它都已经在那里了。这是个 **Release build**:不依赖 Metro,启动屏 → app,跟从 App Store 装的体验一样。
|
||||
|
||||
首次 build 会下载 CocoaPods + 从源码编译 React Native —— 大约 10–20 分钟。之后 build 会快很多,Xcode 缓存了原生编译产物。
|
||||
|
||||
典型路径就这样。签名失败的话见下方[排错](#排错)。
|
||||
|
||||
## 7 天签名限制
|
||||
|
||||
免费 Apple ID 签的 build 只有 **7 天**有效期。过期后 app 在 iPhone 上拒绝启动,提示 "untrusted developer"。插回 Mac 重跑同一条命令重签即可 —— 数据不会丢,因为数据在后端,不在 app 里。
|
||||
|
||||
唯一的延期方式是 **Apple Developer Program 账号**($99/年,在 [developer.apple.com](https://developer.apple.com) 注册)。有了它签名一次有效 1 年(直到续费),还能通过 TestFlight 分发给其他设备。
|
||||
|
||||
## 更新
|
||||
|
||||
暂时没有自动更新。Multica 代码库前进时,你 pull 然后重 build:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
pnpm install
|
||||
pnpm ios:mobile:device:prod:release
|
||||
```
|
||||
|
||||
后续 build 很快,因为 Xcode 缓存了原生编译产物。
|
||||
|
||||
## 为什么还没上 App Store
|
||||
|
||||
iOS app 还在快速迭代 —— 团队目前更倾向于"先发再改",而不是 App Store 审核周期。下一步比较可能是 TestFlight 内测,然后才是正式上架。在那之前,上面的自助 build 是 iOS 上用 Multica 的唯一方式。
|
||||
|
||||
想第一时间知道 TestFlight 开放的话,watch 一下 [GitHub 仓库](https://github.com/multica-ai/multica)。
|
||||
|
||||
## 排错
|
||||
|
||||
**"No matching provisioning profiles found"** —— Xcode 拒绝用你的 Apple ID 签默认的 `ai.multica.mobile`。比较罕见,如果有人在 Apple Developer Portal 抢注了这个前缀就会出现。换一个你控制的反向域名(`com.yourname.multica` 就够),export 后重跑:
|
||||
|
||||
```bash
|
||||
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
|
||||
pnpm ios:mobile:device:prod:release
|
||||
```
|
||||
|
||||
id 本身没意义,Apple 只要求它没被别的 team 抢注就行。
|
||||
|
||||
**"无法启动 <app>" / "未受信任的开发者"** —— 要么过了 7 天有效期(重跑 build),要么需要在 iPhone 上手动信任开发者证书:设置 → 通用 → VPN 与设备管理 → 点你的 Apple ID → 信任。
|
||||
|
||||
**Build 卡在 `Pod install` 或者编译很久不动** —— 首次 build 就是 10–20 分钟,CocoaPods 要下载依赖、Xcode 要从源码编译 React Native。后续会快很多。
|
||||
|
||||
**App 连不上后端** —— 确认 `apps/mobile/.env.production` 没动过(默认值 `EXPO_PUBLIC_API_URL=https://api.multica.ai`)。如果你改过,用 `git checkout apps/mobile/.env.production` 还原。
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Self-host quickstart
|
||||
description: Run Multica on your own server or machine with Docker (or Helm on Kubernetes). Takes about 10 minutes.
|
||||
description: Run Multica on your own server or machine with Docker. Takes about 10 minutes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -18,10 +18,6 @@ Agent **execution** still relies on the [daemon](/daemon-runtimes) you run local
|
||||
|
||||
## 1. Pull the project and start the backend
|
||||
|
||||
<Callout type="info">
|
||||
**Already on Kubernetes?** Skip Docker and use the Helm chart instead — jump to [Kubernetes deployment](#kubernetes-deployment-alternative) below, then come back to [Step 4](#4-first-login--create-a-workspace) for first login.
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
@@ -49,10 +45,6 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
</Callout>
|
||||
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -67,9 +59,7 @@ Before any public deployment, make sure `.env` has `APP_ENV=production` and `MUL
|
||||
|
||||
Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
|
||||
|
||||
Two delivery backends are supported — pick whichever fits your network:
|
||||
|
||||
**Option A — Resend (cloud / public-internet deployments):**
|
||||
To actually send verification emails:
|
||||
|
||||
1. Sign up at [Resend](https://resend.com/) and get an API key
|
||||
2. Verify a sending domain you control
|
||||
@@ -80,183 +70,50 @@ Two delivery backends are supported — pick whichever fits your network:
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Option B — SMTP relay (internal networks / on-premise):**
|
||||
4. Restart: `docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
|
||||
|
||||
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).
|
||||
For more auth configuration (OAuth, signup allowlist), see [Auth setup](/auth-setup).
|
||||
|
||||
## 4. First login + create a workspace
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- Enter your email
|
||||
- Grab the verification code from your configured email backend (Resend or SMTP relay); if neither is configured, copy it from the server container stdout — look for the `[DEV] Verification code` line
|
||||
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
|
||||
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
|
||||
- Log in and create your first workspace
|
||||
|
||||
## 5. Point the CLI at your own server
|
||||
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
|
||||
|
||||
### 5a. Same machine
|
||||
```bash
|
||||
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
|
||||
```
|
||||
|
||||
If the CLI and the server run on the same host, the defaults already work:
|
||||
If you're running everything on one local machine:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
|
||||
|
||||
### 5b. Cross-machine: front with a reverse proxy
|
||||
|
||||
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<your-domain> \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
|
||||
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
|
||||
## 6. Create an agent + assign your first task
|
||||
|
||||
Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-create-an-agent).
|
||||
|
||||
## 7. Schedule the usage rollup (required for the Usage dashboard)
|
||||
|
||||
<Callout type="warning">
|
||||
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` Postgres image **does not include `pg_cron`**, and the backend does not run the rollup in-process either. If nothing schedules `rollup_task_usage_hourly()`, raw `task_usage` rows keep arriving while the dashboard stays at zero forever.
|
||||
</Callout>
|
||||
|
||||
Pick one of the supported options — only one is needed.
|
||||
|
||||
**Option A — External cron / systemd-timer (simplest).** Run the rollup every 5 minutes from any out-of-band scheduler. It's idempotent and watermark-driven, so missed ticks catch up:
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/multica-rollup — every 5 minutes
|
||||
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
|
||||
exec -T postgres psql -U multica -d multica \
|
||||
-c "SELECT rollup_task_usage_hourly();" >/dev/null
|
||||
```
|
||||
|
||||
**Option B — Swap Postgres for an image that ships `pg_cron`.** Replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that has both `pgvector` and `pg_cron` (`supabase/postgres`, or a custom build), set `shared_preload_libraries=pg_cron`, restart, then register the job once:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule(
|
||||
'rollup_task_usage_hourly',
|
||||
'*/5 * * * *',
|
||||
$$SELECT rollup_task_usage_hourly()$$
|
||||
);
|
||||
```
|
||||
|
||||
**Option C — Backfill history first (upgrade path).** If you're upgrading from `v0.3.4 → v0.3.5+` and have existing `task_usage` rows, migration `103` will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run the bundled backfill once, then set up Option A or B:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml exec backend \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
```
|
||||
|
||||
`--sleep-between-slices=2s` throttles read pressure on a busy DB. After it finishes, restart the backend container (migrations run on startup) and the upgrade completes.
|
||||
|
||||
Full reference — including the Kubernetes `CronJob` template and the upgrade order — lives in the repo's [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
|
||||
|
||||
## Kubernetes deployment (alternative)
|
||||
|
||||
If you already run a Kubernetes cluster, the repo also ships a Helm chart at `deploy/helm/multica/`. It's the equivalent of `make selfhost` for k8s — same backend image, frontend image, and `pgvector/pgvector:pg17` Postgres, packaged as Deployments / Services / Ingresses with one `ConfigMap` rendered from `values.yaml`. Authored against k3s + Traefik + `local-path` and should work on any cluster with an Ingress controller and a default `ReadWriteOnce` StorageClass.
|
||||
|
||||
The chart **does not template secret values**. It references a Secret named `multica-secrets` by name, so real JWT / DB / Resend / Google keys never need to live in git or in `values.yaml`. Create the namespace + Secret once with kubectl:
|
||||
|
||||
```bash
|
||||
kubectl create namespace multica
|
||||
|
||||
kubectl -n multica create secret generic multica-secrets \
|
||||
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
|
||||
--from-literal=RESEND_API_KEY="" \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET="" \
|
||||
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
|
||||
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
|
||||
```
|
||||
|
||||
Then install the chart:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
Defaults assume the hostnames `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Add them to `/etc/hosts` (or local DNS) pointing at any node IP where your Ingress is reachable. To use different hostnames, copy `deploy/helm/multica/values.yaml`, edit `ingress.frontend.host` / `ingress.backend.host` and the matching `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri`, then install with `-f my-values.yaml`.
|
||||
|
||||
On a cold cluster the backend can stay `Running` but not `Ready` for a few minutes while it waits on Postgres and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once it's `Ready`:
|
||||
|
||||
```bash
|
||||
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
|
||||
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
```
|
||||
|
||||
Then open `http://multica.dev.lan` and continue at [Step 4 — First login](#4-first-login--create-a-workspace) above. Point the CLI at your Ingress hostnames:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url http://api.multica.dev.lan \
|
||||
--app-url http://multica.dev.lan
|
||||
```
|
||||
|
||||
To pull the latest images without changing the chart, `kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`. To pin a specific Multica release, set `images.backend.tag` / `images.frontend.tag` in your values file and `helm upgrade`. `helm -n multica uninstall multica` removes the workloads but keeps the PVCs and Secret; `kubectl delete namespace multica` wipes everything.
|
||||
|
||||
The full reference — three login modes, the `backend` ExternalName workaround for the build-time-baked `REMOTE_API_URL` in the web image, resource limits, and TLS — lives in the repo's [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative).
|
||||
|
||||
## Common issues
|
||||
|
||||
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
|
||||
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **Verification code not received**: Resend isn't configured → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
|
||||
- **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero)
|
||||
- **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. Run `backfill_task_usage_hourly` first — see [Step 7 → Option C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [GitHub integration](/github-integration) — connect a GitHub App so PRs auto-link to issues and merging closes them
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Self-Host 快速上手
|
||||
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来(也可以在 Kubernetes 上用 Helm)。约 10 分钟。
|
||||
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来。约 10 分钟。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -18,10 +18,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 1. 拉取项目 + 一键启动后端
|
||||
|
||||
<Callout type="info">
|
||||
**已经有 Kubernetes 集群?** 不用走 Docker,直接用 Helm chart——跳到下面的 [Kubernetes 部署(替代方案)](#kubernetes-部署替代方案),装完再回到 [第 4 步](#4-首次登录--创建工作区) 完成登录。
|
||||
</Callout>
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
@@ -48,10 +44,6 @@ make selfhost
|
||||
- **前端**:[http://localhost:3000](http://localhost:3000)
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
|
||||
</Callout>
|
||||
|
||||
## 2. 重要:保持生产安全配置
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -66,9 +58,7 @@ make selfhost
|
||||
|
||||
如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
|
||||
|
||||
支持两种发送通道,按部署环境二选一:
|
||||
|
||||
**Option A — Resend(公网/云端部署):**
|
||||
要真的发验证码邮件:
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 注册并拿一个 API key
|
||||
2. 验证一个你控制的发件域名
|
||||
@@ -79,183 +69,50 @@ make selfhost
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Option B — SMTP relay(内网/自部署):**
|
||||
4. 重启:`docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
|
||||
|
||||
更多 auth 配置(OAuth、注册白名单)以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
|
||||
更多 auth 配置(OAuth、注册白名单)见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 4. 首次登录 + 创建工作区
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
## 5. 连接命令行工具到你自己的 server
|
||||
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**:
|
||||
|
||||
### 5a. 同一台机器
|
||||
```bash
|
||||
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
|
||||
```
|
||||
|
||||
CLI 和 server 在同一台机器上时,默认参数就够用:
|
||||
本地就是一台电脑跑整套的话:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
会自动连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend),引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**。
|
||||
默认连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend)。
|
||||
|
||||
### 5b. 跨机访问:用反向代理把服务挡在前面
|
||||
|
||||
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<你的域名> \
|
||||
--app-url https://<你的域名>
|
||||
```
|
||||
|
||||
最小可用的 Caddyfile,单域名同时挂前后端(带 WebSocket 转发,daemon 和网页端都依赖):
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket 路由——必须在 catch-all 之前
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# 其它请求 → 前端
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket),关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
|
||||
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
|
||||
|
||||
## 6. 创建智能体 + 分配第一个任务
|
||||
|
||||
流程和 Cloud 一样——见 [Cloud 快速上手 → 5-6 步](/cloud-quickstart#5-创建智能体)。
|
||||
|
||||
## 7. 调度用量汇总任务(Usage Dashboard 必需)
|
||||
|
||||
<Callout type="warning">
|
||||
Usage / Runtime 看板读的是派生表 `task_usage_hourly`,需要 `rollup_task_usage_hourly()` 周期性运行才能填充。**默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`**,后端进程内部也不会跑这个 rollup——什么都没调度的话,原始 `task_usage` 行会继续写入,但 dashboard 会一直停在 0,不会报错。
|
||||
</Callout>
|
||||
|
||||
三种支持路径,三选一即可。
|
||||
|
||||
**Option A —— 外部 cron / systemd-timer(最简单)。** 在任意外部调度器上每 5 分钟跑一次 rollup。函数是幂等的、按 watermark 推进,丢一两个 tick 下次能补上:
|
||||
|
||||
```bash
|
||||
# /etc/cron.d/multica-rollup —— 每 5 分钟跑一次
|
||||
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
|
||||
exec -T postgres psql -U multica -d multica \
|
||||
-c "SELECT rollup_task_usage_hourly();" >/dev/null
|
||||
```
|
||||
|
||||
**Option B —— 换成自带 `pg_cron` 的 Postgres 镜像。** 把 `docker-compose.selfhost.yml` 里的 `pgvector/pgvector:pg17` 换成同时带 `pgvector` 和 `pg_cron` 的镜像(比如 `supabase/postgres`,或自己 build 一份),把 `shared_preload_libraries=pg_cron` 配上、重启 Postgres,然后注册一次任务:
|
||||
|
||||
```sql
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron;
|
||||
SELECT cron.schedule(
|
||||
'rollup_task_usage_hourly',
|
||||
'*/5 * * * *',
|
||||
$$SELECT rollup_task_usage_hourly()$$
|
||||
);
|
||||
```
|
||||
|
||||
**Option C —— 先回填历史(升级路径)。** 如果你是从 `v0.3.4` 升级到 `v0.3.5+` 且数据库里已经有 `task_usage` 行,migration `103` 会以 `refusing to drop legacy daily rollups: ...` 报错并中止 `migrate up`,直到 hourly 表被 seed 过。先跑一次内置的 backfill 命令,然后再配 Option A 或 Option B 让新数据持续流进来:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.selfhost.yml exec backend \
|
||||
./backfill_task_usage_hourly --sleep-between-slices=2s
|
||||
```
|
||||
|
||||
`--sleep-between-slices=2s` 用来在繁忙的数据库上限制读压力。回填跑完后重启后端容器(migration 在启动时自动跑),升级就能继续。
|
||||
|
||||
完整参考(含 Kubernetes `CronJob` 模板和升级顺序)见仓库的 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)。
|
||||
|
||||
## Kubernetes 部署(替代方案)
|
||||
|
||||
如果你已经在跑 Kubernetes 集群,仓库里也带了一个 Helm chart,路径 `deploy/helm/multica/`。它就是 k8s 版的 `make selfhost`——一样的 backend 镜像、frontend 镜像、`pgvector/pgvector:pg17` Postgres,封装成 Deployment / Service / Ingress,再加上一个由 `values.yaml` 渲染出来的 `ConfigMap`。这套 chart 是按照 k3s + Traefik + `local-path` 写的,集群里只要有 Ingress controller 和默认的 `ReadWriteOnce` StorageClass 就能跑,其他类型的集群稍微改一改也能用。
|
||||
|
||||
这个 chart **不会模板化任何敏感值**。它通过 name 引用一个叫 `multica-secrets` 的 Secret,所以真实的 JWT / DB / Resend / Google 密钥永远不用进 git,也不用进 `values.yaml`。先用 kubectl 一次性把命名空间和 Secret 建好:
|
||||
|
||||
```bash
|
||||
kubectl create namespace multica
|
||||
|
||||
kubectl -n multica create secret generic multica-secrets \
|
||||
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
|
||||
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
|
||||
--from-literal=RESEND_API_KEY="" \
|
||||
--from-literal=GOOGLE_CLIENT_SECRET="" \
|
||||
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
|
||||
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
|
||||
```
|
||||
|
||||
再装 chart:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/multica-ai/multica.git
|
||||
cd multica
|
||||
helm install multica deploy/helm/multica -n multica
|
||||
```
|
||||
|
||||
默认主机名是 `multica.dev.lan`(web)和 `api.multica.dev.lan`(backend)。把它们加进 `/etc/hosts`(或者本地 DNS),指向任意一个 Ingress 可达的节点 IP 就行。要换主机名,就把 `deploy/helm/multica/values.yaml` 复制一份,改掉 `ingress.frontend.host` / `ingress.backend.host`,再把 `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri` 改成相应的地址,然后 `helm install ... -f my-values.yaml`。
|
||||
|
||||
冷集群上 backend 可能会 `Running` 但 `Not Ready` 持续几分钟,等 Postgres 起来并跑完 migration——startupProbe 会兜住这一段,pod 不会被 liveness 重启。等它 `Ready` 之后:
|
||||
|
||||
```bash
|
||||
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
|
||||
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
```
|
||||
|
||||
然后浏览器打开 `http://multica.dev.lan`,回到上面的 [第 4 步——首次登录](#4-首次登录--创建工作区) 继续。命令行连到你的 Ingress 主机:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url http://api.multica.dev.lan \
|
||||
--app-url http://multica.dev.lan
|
||||
```
|
||||
|
||||
只想拉最新镜像、不动 chart:`kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`。要锁到某个 Multica 版本,就在 values 文件里设 `images.backend.tag` / `images.frontend.tag`,再 `helm upgrade`。`helm -n multica uninstall multica` 只删工作负载,PVC 和 Secret 都保留;`kubectl delete namespace multica` 才会全清。
|
||||
|
||||
完整参考——三种登录方式、为了绕过 web 镜像 build-time 写死的 `REMOTE_API_URL` 而加的 `backend` ExternalName 别名、资源限制、TLS——都在仓库的 [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
|
||||
- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **验证码收不到**:没配 Resend → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
|
||||
- **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-调度用量汇总任务usage-dashboard-必需) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0)
|
||||
- **`migrate up` 报 `refusing to drop legacy daily rollups`**:`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。先跑 `backfill_task_usage_hourly` —— 见 [第 7 步 → Option C](#7-调度用量汇总任务usage-dashboard-必需)
|
||||
|
||||
## 下一步
|
||||
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [GitHub 集成](/github-integration) —— 连一个 GitHub App,让 PR 自动关联 issue、merge 时自动转 Done
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: Squads
|
||||
description: "A squad is a group of agents (and optionally human members) led by one designated leader agent. Assign an issue to a squad and the leader decides who picks it up."
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A squad is a **named group of [agents](/agents) and human [members](/members-roles)**, with one designated **leader agent**. The squad is itself a first-class assignee: pick it from any **Assignee** picker and the leader takes the trigger, reads the issue, then `@`-mentions the squad member best suited to do the work. Squads let you assemble specialists once and dispatch them **by topic instead of by name** — the team grows, the routing stays the same.
|
||||
|
||||
## What a squad is, in mechanics
|
||||
|
||||
- **One leader, many members.** The leader must be an agent; members can be agents or human members. A squad with only the leader is allowed (the leader briefing notes "no other members"), and the same agent can sit in multiple squads.
|
||||
- **Assignable everywhere a person is.** Squads appear in the Assignee picker, the @-mention picker, and the quick-create modal — anywhere you'd pick an agent or member, you can pick a squad.
|
||||
- **Soft-deleted via archive.** Archive a squad and it disappears from pickers and lists; any issue currently assigned to it is **transferred to the leader agent** so the work doesn't go silent. Archived squads can't be assigned to new issues.
|
||||
|
||||
## When to use a squad versus a single agent
|
||||
|
||||
| Pick a squad when… | Pick a single agent when… |
|
||||
|---|---|
|
||||
| You have several specialists and don't know which one fits this issue in advance | The work is well-scoped to one specialty and you know who should do it |
|
||||
| You want one stable assignee (the squad) while the actual responder changes per issue | You want the agent's name on the issue and clear individual accountability |
|
||||
| You want a `@FrontendTeam` style routing target in comments | One-on-one `@agent-name` is enough |
|
||||
|
||||
The squad doesn't add capability — it adds **routing**. The members are still ordinary agents; the leader's only job is to pick the right one.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Who can do it |
|
||||
|---|---|
|
||||
| Create / update / archive a squad | Workspace **owner** or **admin** |
|
||||
| Add or remove members, change roles | Workspace **owner** or **admin** |
|
||||
| Assign an issue to a squad | Any workspace member (same as assigning to an agent) |
|
||||
| `@`-mention a squad in a comment | Any workspace member |
|
||||
| Record a squad-leader evaluation | The squad leader agent only (via CLI) |
|
||||
|
||||
The full role matrix lives in [Members and roles](/members-roles).
|
||||
|
||||
## Create a squad
|
||||
|
||||
In the sidebar, open **Squads → New squad** and fill in:
|
||||
|
||||
- **Name** — e.g. `Frontend Team`, `Bug Triage`. Doesn't need to be unique within the workspace.
|
||||
- **Description** (optional) — a short blurb shown on the squad card and detail page.
|
||||
- **Leader** — pick an existing agent. The leader is added to the squad automatically with role `leader`.
|
||||
|
||||
After creation, open the squad's detail page to:
|
||||
|
||||
- **Add members** — pick agents or human members, optionally give each a short role description (e.g. "owns the migrations", "reviewer of last resort"). The leader uses these roles when deciding who to delegate to.
|
||||
- **Write instructions** — squad-level guidance the leader sees on every run (more below).
|
||||
- **Set an avatar** — picked from the same picker used for agents.
|
||||
|
||||
CLI equivalent:
|
||||
|
||||
```bash
|
||||
multica squad create --name "Frontend Team" --leader frontend-lead-agent
|
||||
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
|
||||
```
|
||||
|
||||
## How a squad-assigned issue runs
|
||||
|
||||
When a non-Backlog issue is assigned to a squad, Multica immediately enqueues a `task` for the **leader agent** (not for every member). The flow then looks like this:
|
||||
|
||||
1. **Leader claims the task.** The agent runtime picks up the task on its next poll, same as any other agent assignment.
|
||||
2. **Leader is briefed.** On claim, Multica appends three sections to the leader's system prompt — see [What the leader sees on every turn](#what-the-leader-sees-on-every-turn) below.
|
||||
3. **Leader posts one delegation comment.** The comment `@`-mentions the chosen member(s) using the exact mention markdown from the roster — that mention triggers a new `task` for each mentioned agent.
|
||||
4. **Leader records its evaluation** via `multica squad activity <issue-id> action --reason "..."`. This writes an entry to the issue's activity timeline so humans can see the leader actually evaluated the trigger.
|
||||
5. **Leader stops.** The leader does not do the implementation itself. When the delegated member posts back, the leader is re-triggered to read the update and either delegate the next step, escalate, or stay silent.
|
||||
|
||||
If the issue is in **Backlog**, the leader is not triggered — Backlog is a parking lot, same rule as for direct agent assignment.
|
||||
|
||||
### What the leader sees on every turn
|
||||
|
||||
On each squad-leader run, three blocks are appended to the leader's instructions:
|
||||
|
||||
- **Squad Operating Protocol** — a hard-coded rule set: read the issue, delegate by `@`-mention, be terse (don't restate the issue body — the assignee can read it), record an evaluation every turn, and **stop after dispatching**. This protocol is system-managed and not editable.
|
||||
- **Squad Roster** — the leader's self-row plus one row per non-archived member. Each row carries the exact mention markdown (`[@Name](mention://agent/<uuid>)` or `[@Name](mention://member/<uuid>)`) the leader should paste — typing a plain `@name` won't trigger anyone.
|
||||
- **Squad Instructions** — your custom guidance for this squad (set on the squad detail page or via `multica squad update --instructions`). Use this for routing rules ("send DB work to Alice, frontend to Bob"), escalation policies, or anything else the leader needs to know that isn't already in the issue.
|
||||
|
||||
## When the leader is re-triggered
|
||||
|
||||
After the first dispatch, the leader is woken up automatically by **most subsequent comments** on the issue. The exact rules:
|
||||
|
||||
| Event | Leader triggered? |
|
||||
|---|---|
|
||||
| A non-member (human reporter, external agent) posts a comment | **Yes** |
|
||||
| A squad member posts a progress update with no `@mention` | **Yes** — the leader re-evaluates whether the next step is needed |
|
||||
| Anyone posts a comment that explicitly `@`-mentions another agent / member / squad / `@all` | **No** — the explicit `@` is the routing signal; the leader gets out of the way |
|
||||
| The leader's own comment (self-trigger) | **No** — guarded to prevent a loop |
|
||||
| A comment containing only an issue cross-reference (`[MUL-123](mention://issue/...)`) | **Yes** — issue references aren't routing |
|
||||
|
||||
Dedup applies on top of these rules: if the leader already has a `queued` or `dispatched` task on this issue, a new trigger won't enqueue a duplicate.
|
||||
|
||||
<Callout type="info">
|
||||
**Why the leader doesn't trigger when a member posts an `@`-mention.** Once a squad member directly `@`s someone, that comment is a deliberate hand-off — having the leader wake up to "observe" the routing would just produce a no-op turn and clutter the timeline. Agent-authored comments are the exception: when an agent posts a result that `@`s another agent, the leader still wakes up so it can coordinate the thread.
|
||||
</Callout>
|
||||
|
||||
## `@`-mention a squad in a comment
|
||||
|
||||
Squads appear in the `@` picker alongside members and agents. Mentioning a squad inserts `[@SquadName](mention://squad/<uuid>)` and triggers the **squad leader** as if you had assigned the issue to the squad — without changing the assignee or the status. Use this when you want the squad to pick someone for a question or sub-task while keeping the current owner.
|
||||
|
||||
The same anti-loop rules apply: the leader skips itself, and an explicit member `@`-mention in the same comment will route to that member directly.
|
||||
|
||||
## Reassign or archive a squad
|
||||
|
||||
**Reassigning an issue away from a squad** behaves like any other assignee change: all of the issue's active tasks (including the leader's) are cancelled, and the new assignee — agent, member, or another squad — is enqueued. There is no separate "remove squad without changing assignee" action; pick a different assignee.
|
||||
|
||||
**Archiving a squad** (`multica squad delete <id>`, or the Archive button on the detail page):
|
||||
|
||||
1. **Transfers issues currently assigned to the squad to the leader agent**, so the work continues against a concrete agent instead of going silent.
|
||||
2. Marks the squad with `archived_at` / `archived_by` — the row is preserved so historical activity entries still resolve, but the squad disappears from lists, pickers, and the @-mention dropdown.
|
||||
3. **Rejects future assignments** to this squad with `cannot assign to an archived squad`.
|
||||
|
||||
There is currently no unarchive command; create a new squad if you need the routing back.
|
||||
|
||||
## Squad operations from the CLI
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica squad list` | List squads in the workspace |
|
||||
| `multica squad get <id>` | Show one squad's name, leader, description, instructions |
|
||||
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
|
||||
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | Update one or more fields |
|
||||
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
|
||||
| `multica squad member list <id>` | List a squad's members |
|
||||
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | Add a member (owner / admin) |
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
|
||||
|
||||
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace member list --output json`, and `multica squad list --output json`.
|
||||
|
||||
## Next
|
||||
|
||||
- [Assign issues to agents](/assigning-issues) — same flow, applies to squad assignees too
|
||||
- [`@`-mention agents in comments](/mentioning-agents) — the `@` picker also surfaces squads
|
||||
- [Agents](/agents) — what an agent is, the building block of every squad
|
||||
- [Members and roles](/members-roles) — the full owner / admin / member permission matrix
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: 小队
|
||||
description: 小队(squad)是一组智能体(可选附带成员),由一名指定的"队长"智能体(leader)领导。把 issue 分配给小队,队长来决定谁接手。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
小队(squad)是一组 [智能体](/agents) 和 [人类成员](/members-roles) 的**命名集合**,其中有一名指定的**队长(leader),必须是智能体**。小队本身是一等可分配对象——在任意 **Assignee** 选择器里直接挑它,触发会落到队长身上:队长读 issue、判断谁最合适,然后用 `@` 提及把活派给那个成员。小队让你把一组专家**一次性编好队**,之后**按主题派活,而不是按名字派活**——队伍扩展,路由不变。
|
||||
|
||||
## 小队的运转机制
|
||||
|
||||
- **一个队长,多名成员。** 队长必须是智能体;成员可以是智能体或人类成员。只有队长一个人的小队也是允许的(队长 briefing 会注明"没有其他成员"),同一个智能体也能加入多个小队。
|
||||
- **任何能选人的地方都能选小队。** Assignee picker、@ 提及 picker、快速创建 modal——只要能选智能体或成员的位置,小队都会出现。
|
||||
- **删除走"归档"软删除。** 归档一个小队后,它会从 picker 和列表里消失;当前分配给它的 issue 会被**自动转给队长智能体**,让工作不至于卡住。归档的小队不能再被分配新 issue。
|
||||
|
||||
## 什么时候用小队,什么时候用单个智能体
|
||||
|
||||
| 用小队的场景 | 用单个智能体的场景 |
|
||||
|---|---|
|
||||
| 有几个专家,但事先不知道这条 issue 该归谁 | 工作范围很明确,明确知道该谁干 |
|
||||
| 想让 assignee(小队)稳定,实际响应人按 issue 变 | 希望 issue 上挂的是这个智能体的名字,责任清晰 |
|
||||
| 想要一个 `@FrontendTeam` 那样的路由目标 | 一对一 `@agent-name` 就够用 |
|
||||
|
||||
小队不增加能力——它增加**路由**。成员还是那些智能体,队长唯一的工作是**挑对人**。
|
||||
|
||||
## 权限
|
||||
|
||||
| 操作 | 谁能做 |
|
||||
|---|---|
|
||||
| 创建 / 更新 / 归档小队 | 工作区 **owner** 或 **admin** |
|
||||
| 增删成员、改成员角色 | 工作区 **owner** 或 **admin** |
|
||||
| 把 issue 分配给小队 | 任何工作区成员(和分配给智能体一样)|
|
||||
| 在评论里 `@` 小队 | 任何工作区成员 |
|
||||
| 记录小队队长的 evaluation | 只有队长智能体本人(通过 CLI)|
|
||||
|
||||
完整角色权限对照见 [成员与权限](/members-roles)。
|
||||
|
||||
## 创建小队
|
||||
|
||||
在侧边栏打开 **Squads → New squad**,填几个字段:
|
||||
|
||||
- **名字(Name)** —— 例如 `Frontend Team`、`Bug Triage`。在工作区里**不要求唯一**。
|
||||
- **描述(Description,可选)** —— 一句话简介,展示在小队卡片和详情页上。
|
||||
- **队长(Leader)** —— 选一个已有的智能体。创建后队长会自动以 `leader` 角色加入小队。
|
||||
|
||||
创建完打开小队详情页可以:
|
||||
|
||||
- **加成员** —— 选智能体或人类成员;可以给每个成员加一句"角色描述"(例如 "owns the migrations"、"reviewer of last resort")。队长派活时会参考这些角色。
|
||||
- **写 instructions** —— 小队级别的指令,队长每次执行都能看到(见下文)。
|
||||
- **设头像** —— 用和智能体一样的头像选择器。
|
||||
|
||||
CLI 等价命令:
|
||||
|
||||
```bash
|
||||
multica squad create --name "Frontend Team" --leader frontend-lead-agent
|
||||
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
|
||||
```
|
||||
|
||||
## 分配给小队的 issue 是怎么跑的
|
||||
|
||||
非 Backlog 状态的 issue 一旦分配给小队,Multica 会立刻给**队长智能体**入队一个 `task`(不是给每个成员都入一个)。整个流程是这样的:
|
||||
|
||||
1. **队长领走 task。** 队长所在的 daemon 在下次轮询时把 task 领走,和普通智能体的分配流程一样。
|
||||
2. **队长拿到 briefing。** 领走的瞬间,Multica 会在队长的系统提示后面追加三段内容——详见下文 [队长每次执行看到的内容](#队长每次执行看到的内容)。
|
||||
3. **队长发一条"派活"评论。** 评论里用 roster 里给好的 mention markdown `@` 选中的成员——这个 `@` 会触发被派的成员入队新 `task`。
|
||||
4. **队长记录 evaluation:** `multica squad activity <issue-id> action --reason "..."`。这一行会写进 issue 的 activity 时间线,方便人类回溯队长确实评估过这一次触发。
|
||||
5. **队长停下。** 派完活,队长**不动手干活**。当被派的成员有回复时,队长会被自动唤醒,决定下一步:继续派活、上抛给人类、还是保持沉默。
|
||||
|
||||
如果 issue 是 **Backlog** 状态,队长不会被触发——Backlog 是停泊场,规则和直接分配给智能体一样。
|
||||
|
||||
### 队长每次执行看到的内容
|
||||
|
||||
每次队长被触发,三段内容会被附加到它的 instructions 上:
|
||||
|
||||
- **Squad Operating Protocol(小队工作规范)** —— 一段硬编码的规则集:读 issue → 用 `@` 派活 → 简洁(**不要**复述 issue 内容,被派的成员自己能读)→ 每次都记 evaluation → **派完就停**。这段是系统管理的,不可编辑。
|
||||
- **Squad Roster(小队花名册)** —— 队长自己一行 + 每个未归档成员一行。每一行带上**确切可用**的 mention markdown(`[@Name](mention://agent/<uuid>)` 或 `[@Name](mention://member/<uuid>)`)让队长直接复制——纯文本 `@name` 是**不会**触发任何人的。
|
||||
- **Squad Instructions(小队自定义指令)** —— 你为这个小队写的私货(在详情页里编辑,或用 `multica squad update --instructions`)。用来写路由规则("DB 相关派给 Alice,前端派给 Bob")、上报策略,或者任何 issue 本身不会有的背景。
|
||||
|
||||
## 队长什么时候会被再次触发
|
||||
|
||||
第一次派活完之后,**大多数后续评论**都会自动唤醒队长。具体规则:
|
||||
|
||||
| 事件 | 触发队长?|
|
||||
|---|---|
|
||||
| 非小队成员(人类 reporter、外部智能体)发评论 | **会** |
|
||||
| 小队成员发"进展更新",**不带任何** `@mention` | **会**——队长重新评估是否需要下一步 |
|
||||
| 任何人发的评论里**显式 `@`** 智能体 / 成员 / 小队 / `@all` | **不会**——显式 `@` 就是路由信号,队长让位 |
|
||||
| 队长自己发的评论 | **不会**——硬编码防自触发 |
|
||||
| 评论里只有 issue 互链 `[MUL-123](mention://issue/...)` | **会**——issue 引用不算路由 |
|
||||
|
||||
以上规则之上还有去重:如果队长在这个 issue 上已经有 `queued` 或 `dispatched` 的 task,新一次触发不会重复入队。
|
||||
|
||||
<Callout type="info">
|
||||
**为什么成员发的 `@` 评论不会唤醒队长。** 小队成员一旦直接 `@` 谁,那条评论就是**有意识的交接**——再让队长唤醒一次"观察"路由,只会产出一次空回合、把时间线搞乱。智能体作者的评论是个例外:当某个智能体发出一条结果还顺手 `@` 了另一个智能体时,队长仍然会被唤醒,以便协调整条线程。
|
||||
</Callout>
|
||||
|
||||
## 在评论里 `@` 一个小队
|
||||
|
||||
小队会出现在 `@` picker 里,和成员、智能体并列。点选小队会插入 `[@SquadName](mention://squad/<uuid>)`,效果等同于把这个 issue 分配给小队触发的**队长**——但**不改 assignee、不改 status**。适合"我想让小队挑个人回答一下/做一小步,但 issue 还归原来的人"这种场景。
|
||||
|
||||
防循环规则同样适用:队长跳过自己;同一条评论里如果还显式 `@` 了某个成员,路由会直接落到那个成员。
|
||||
|
||||
## 重新分配或归档一个小队
|
||||
|
||||
**把分配人从小队改成别的**,行为和换 assignee 完全一致:当前 issue 上所有活跃 task(包括队长的)会被取消,新的 assignee(智能体、成员、或另一个小队)被入队。没有"不改 assignee 只移除小队"的单独操作;要换就选新的 assignee。
|
||||
|
||||
**归档小队**(`multica squad delete <id>`,或详情页的 Archive 按钮):
|
||||
|
||||
1. **当前分配给这个小队的 issue 会被自动转给队长智能体**,让工作落到一个具体智能体上,避免无人接手。
|
||||
2. 在 squad 表上写入 `archived_at` / `archived_by`——记录被保留下来,历史的 activity 还能解析;但从列表、picker、`@` 下拉里它都消失。
|
||||
3. **拒绝后续分配**——`cannot assign to an archived squad`。
|
||||
|
||||
目前没有"反归档"命令;要恢复路由,重新建一个小队即可。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | 列出工作区里的小队 |
|
||||
| `multica squad get <id>` | 查看小队的名字、队长、描述、instructions |
|
||||
| `multica squad create --name "..." --leader <agent>` | 创建小队(owner / admin)|
|
||||
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | 修改一个或多个字段 |
|
||||
| `multica squad delete <id>` | 归档(软删除)——同时把当前分配给小队的 issue 转给队长 |
|
||||
| `multica squad member list <id>` | 列出小队成员 |
|
||||
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 加成员(owner / admin)|
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
|
||||
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace member list --output json`、`multica squad list --output json` 拿。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [分配 issue 给智能体](/assigning-issues) —— 流程相同,对小队 assignee 也适用
|
||||
- [在评论里 `@` 智能体](/mentioning-agents) —— `@` picker 同样能选到小队
|
||||
- [智能体](/agents) —— 小队的"零件"
|
||||
- [成员与权限](/members-roles) —— owner / admin / member 的完整权限对照
|
||||