mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
1 Commits
refactor/u
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2f89062d |
46
.env.example
46
.env.example
@@ -11,21 +11,17 @@ DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# DATABASE_MIN_CONNS=5
|
||||
|
||||
# Server
|
||||
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
|
||||
# "production" by default. Local dev can leave it unset.
|
||||
# APP_ENV gates dev-only auth shortcuts (primarily the 888888 master code).
|
||||
# - Docker self-host: docker-compose.selfhost.yml already pins APP_ENV to
|
||||
# "production" by default, so 888888 is DISABLED — a public instance can't
|
||||
# be logged into with any email + 888888.
|
||||
# - Local dev (make dev): leave APP_ENV unset so 888888 works out of the box.
|
||||
# - Docker self-host on a private network you fully control, or evaluation
|
||||
# without Resend: set APP_ENV=development to re-enable 888888. Do NOT
|
||||
# enable on a publicly reachable instance.
|
||||
# See SELF_HOSTING.md for the full login setup.
|
||||
APP_ENV=
|
||||
# Optional local/testing shortcut. Empty by default, so there is no fixed
|
||||
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
|
||||
# If you need deterministic local automation, set a 6-digit value such as
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
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
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
@@ -49,7 +45,8 @@ MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
|
||||
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and
|
||||
# master code 888888 works (only when APP_ENV != "production"; see above).
|
||||
# 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
|
||||
@@ -63,9 +60,6 @@ GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
CLOUDFRONT_KEY_PAIR_ID=
|
||||
@@ -91,23 +85,6 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# 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)
|
||||
# callers with no forwarding headers and returns 404 to everything else —
|
||||
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
|
||||
# terminating TLS in front of localhost:8080) MUST set this token, since
|
||||
# proxied requests look like loopback at the Go layer; with no token, those
|
||||
# requests are refused with 404. Pass the token as
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → Integrations "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
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
@@ -139,8 +116,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=
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -40,8 +40,6 @@ 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/`), **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
|
||||
|
||||
|
||||
27
.github/workflows/ci.yml
vendored
27
.github/workflows/ci.yml
vendored
@@ -29,17 +29,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- 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'
|
||||
- name: Build, type check, and test
|
||||
run: pnpm exec turbo build typecheck test --filter='!@multica/docs'
|
||||
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,22 +48,8 @@ jobs:
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
env:
|
||||
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
|
||||
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
|
||||
# (which would flip the server binary itself onto the Redis-backed
|
||||
# realtime relay + request stores); the tests talk to this Redis
|
||||
# directly so they run alongside the Postgres-backed suite.
|
||||
REDIS_TEST_URL: redis://localhost:6379/1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -56,12 +56,6 @@ jobs:
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
# Only run on the canonical upstream repo. Forks don't have the
|
||||
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
|
||||
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
|
||||
# tag push fails this job (401 against the upstream tap), which makes
|
||||
# downstream CI go red without affecting the actual artifact pipeline.
|
||||
if: github.repository_owner == 'multica-ai'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -16,33 +16,35 @@ _features
|
||||
.husky
|
||||
.vscode
|
||||
|
||||
/.dockerignore
|
||||
/.goreleaser.yml
|
||||
/AGENTS.md
|
||||
/CLAUDE.md
|
||||
/CLI_AND_DAEMON.md
|
||||
/CLI_INSTALL.md
|
||||
/CONTRIBUTING.md
|
||||
/Dockerfile
|
||||
/Dockerfile.web
|
||||
/HANDOFF_ARCHITECTURE_AUDIT.md
|
||||
/Makefile
|
||||
/README.md
|
||||
/README.zh-CN.md
|
||||
/SELF_HOSTING.md
|
||||
/SELF_HOSTING_ADVANCED.md
|
||||
/SELF_HOSTING_AI.md
|
||||
/docker-compose*.yml
|
||||
/playwright.config.ts
|
||||
/skills-lock.json
|
||||
!.env.example
|
||||
|
||||
/.github/
|
||||
/docker/
|
||||
/docs/
|
||||
/e2e/
|
||||
/server/
|
||||
/apps/desktop/
|
||||
/scripts/
|
||||
.dockerignore
|
||||
.goreleaser.yml
|
||||
AGENTS.md
|
||||
CLAUDE.md
|
||||
CLI_AND_DAEMON.md
|
||||
CLI_INSTALL.md
|
||||
CONTRIBUTING.md
|
||||
Dockerfile
|
||||
Dockerfile.web
|
||||
HANDOFF_ARCHITECTURE_AUDIT.md
|
||||
Makefile
|
||||
README.md
|
||||
README.zh-CN.md
|
||||
SELF_HOSTING.md
|
||||
SELF_HOSTING_ADVANCED.md
|
||||
SELF_HOSTING_AI.md
|
||||
docker-compose*.yml
|
||||
playwright.config.ts
|
||||
scripts
|
||||
skills-lock.json
|
||||
|
||||
.github
|
||||
docker
|
||||
docs
|
||||
e2e
|
||||
server
|
||||
apps/desktop
|
||||
|
||||
*.log
|
||||
*.pid
|
||||
@@ -65,21 +67,5 @@ out
|
||||
build
|
||||
dist-electron
|
||||
|
||||
# Deployment-only trims: tests and lint configs are not used by `next build`.
|
||||
**/__tests__/**
|
||||
**/test/**
|
||||
**/*.test.*
|
||||
**/*.spec.*
|
||||
/packages/eslint-config/
|
||||
/apps/web/components.json
|
||||
/apps/web/eslint.config.mjs
|
||||
/apps/web/vitest.config.ts
|
||||
|
||||
# Root repo metadata not needed in the deployment source.
|
||||
/.env.example
|
||||
/.gitattributes
|
||||
/.gitignore
|
||||
/LICENSE
|
||||
|
||||
*.app
|
||||
*.dmg
|
||||
|
||||
95
CLAUDE.md
95
CLAUDE.md
@@ -2,21 +2,6 @@
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Conventions reference
|
||||
|
||||
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
|
||||
|
||||
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
|
||||
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
|
||||
|
||||
Read that page before:
|
||||
|
||||
- Writing or editing translations (`packages/views/locales/`)
|
||||
- Naming a new route, package, file, DB column, or TS type
|
||||
- Writing Chinese product copy (UI strings, error messages, docs)
|
||||
|
||||
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
|
||||
|
||||
## Project Context
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
@@ -146,38 +131,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
|
||||
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
|
||||
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.
|
||||
|
||||
### Package Boundary Rules
|
||||
|
||||
@@ -234,28 +191,64 @@ Every path in the desktop app falls into exactly one category. Choosing the wron
|
||||
|
||||
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
|
||||
|
||||
### Workspace context
|
||||
### Workspace identity singleton
|
||||
|
||||
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
`setCurrentWorkspace(slug, uuid)` in `@multica/core/platform` is the single source of truth for "which workspace is active right now". Three consumers depend on it:
|
||||
|
||||
1. API client's `X-Workspace-Slug` header.
|
||||
2. Zustand per-workspace storage namespace.
|
||||
3. Chrome gating (`{slug && <AppSidebar />}` on desktop, similar on web).
|
||||
|
||||
Normally set by `WorkspaceRouteLayout` when its route mounts. Critically: **unmount does NOT clear it.** Any code that leaves workspace context (leave workspace, delete workspace, force navigation to overlay) must call `setCurrentWorkspace(null, null)` explicitly — otherwise the realtime `workspace:deleted` handler races the mutation, chrome gating stays truthy while the workspace is gone from cache, and `useWorkspaceId` throws.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
|
||||
Leave / Delete workspace flows must follow this order:
|
||||
|
||||
1. Read destination from cached workspace list.
|
||||
1. Read destination from cached workspace list (no extra fetch).
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)`.
|
||||
3. `navigation.push(destination)` — switch to next workspace or open new-workspace overlay.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
Reversing step 4 with steps 1–3 (mutate first, navigate after) causes a three-way race between the mutation's `onSettled` invalidate, the explicit `navigateAway`, and the realtime handler's `relocateAfterWorkspaceLoss` — all refetching the same `workspaces` query concurrently. One gets cancelled, bubbles as `CancelledError`, and triggers `window.location.assign` → full renderer reload / white screen.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS)
|
||||
### Drag region (macOS window-move)
|
||||
|
||||
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
|
||||
Every full-window desktop view (login, onboarding, new-workspace, invite, no-access, create-workspace modal) — i.e. anything that isn't inside the dashboard shell — needs a top drag strip so users can move the window. The native macOS traffic lights are **kept visible** for every such surface (Linear/Notion/Arc pattern); no `useImmersiveMode` by default.
|
||||
|
||||
**Pattern**: use the shared `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root. It's a 48px transparent row with `-webkit-app-region: drag` — the parent's bg fills through it so the page reads edge-to-edge while the top 48px stays draggable under the traffic lights.
|
||||
|
||||
```tsx
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<DragStrip />
|
||||
<div className="flex flex-1 flex-col px-6 pb-12">
|
||||
{/* page content — interactive elements placed at y ≥ 48 clear the strip;
|
||||
any element at y < 48 needs WebkitAppRegion: "no-drag" */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
Why flex, not absolute: the absolute-strip + `z-index` approach relies on stacking-context hit-testing, which isn't reliable for `-webkit-app-region`. A real flex row with no siblings at that pixel is unambiguous. Web browsers silently ignore `-webkit-app-region`, so shared views render the strip as a plain 48px spacer on web — safe cross-platform.
|
||||
|
||||
**Horizontal clearance**: traffic lights occupy roughly x ∈ [16, 76] on macOS. Interactive UI (Back buttons, menus) should start at x ≥ 80 on desktop-sized viewports. The shared views default to sufficient `lg:px-20` padding; re-examine when laying out anything in the top-left corner.
|
||||
|
||||
Canonical example: `packages/views/platform/drag-strip.tsx`. Used by `onboarding/steps/step-welcome.tsx` (per-column), `onboarding/onboarding-flow.tsx`, `workspace/new-workspace-page.tsx`, `invite/invite-page.tsx`, `workspace/no-access-page.tsx`, `modals/create-workspace.tsx`, and desktop's `pages/login.tsx`.
|
||||
|
||||
**When to use `useImmersiveMode`**: only when a view must place interactive UI in the traffic-light hit-zone (y < 28 AND x < 80). For every current non-dashboard surface, buttons sit at y ≥ 48, so immersive mode is unnecessary. Hook is preserved as an escape hatch but has no callers.
|
||||
|
||||
### UX vs platform chrome
|
||||
|
||||
UX affordances (Back button, Log out button, welcome copy, invite card) belong in `packages/views/` so web and desktop render identical content. Platform chrome (tab system interaction, native-window IPC, `useImmersiveMode`) lives in desktop-only code. The `DragStrip` + `useImmersiveMode` primitives live in `packages/views/platform/` because they're cross-platform safe (web no-op) and need to be callable from shared views that own the page layout — keeping them in desktop-only would force every shared page to leave top-padding decisions to the platform shell, fragmenting the design.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
|
||||
@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
|
||||
### Token Login
|
||||
|
||||
```bash
|
||||
multica login --token <mul_...>
|
||||
multica login --token
|
||||
```
|
||||
|
||||
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
|
||||
Authenticate by pasting a personal access token directly. Useful for headless environments.
|
||||
|
||||
### Check Status
|
||||
|
||||
@@ -140,15 +140,12 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
|-----|---------|-------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
|
||||
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
|
||||
| [GitHub Copilot CLI](https://docs.github.com/en/copilot) | `copilot` | GitHub's coding agent (model routed by your GitHub entitlement) |
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Gemini | `gemini` | Google's coding agent |
|
||||
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
|
||||
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -169,28 +166,11 @@ Daemon behavior is configured via flags or environment variables:
|
||||
| Poll interval | `--poll-interval` | `MULTICA_DAEMON_POLL_INTERVAL` | `3s` |
|
||||
| Heartbeat interval | `--heartbeat-interval` | `MULTICA_DAEMON_HEARTBEAT_INTERVAL` | `15s` |
|
||||
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
|
||||
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
|
||||
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
|
||||
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
|
||||
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
|
||||
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
|
||||
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
|
||||
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
|
||||
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
|
||||
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
|
||||
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
|
||||
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
|
||||
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
|
||||
|
||||
#### Workspace garbage collection
|
||||
|
||||
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
|
||||
|
||||
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
|
||||
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
|
||||
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
|
||||
|
||||
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
|
||||
|
||||
Agent-specific overrides:
|
||||
|
||||
@@ -198,12 +178,8 @@ Agent-specific overrides:
|
||||
|----------|-------------|
|
||||
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
|
||||
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
|
||||
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -216,12 +192,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
@@ -305,12 +275,10 @@ multica workspace members <workspace-id>
|
||||
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`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
|
||||
Available filters: `--status`, `--priority`, `--assignee`, `--project`, `--limit`.
|
||||
|
||||
### Get Issue
|
||||
|
||||
@@ -323,10 +291,9 @@ multica issue get <id> --output json
|
||||
|
||||
```bash
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
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 members --output json` / `multica agent list --output json`.
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -338,12 +305,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
@@ -394,19 +358,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
|
||||
|
||||
@@ -516,12 +478,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
|
||||
|
||||
@@ -140,7 +140,7 @@ multica auth status
|
||||
Expected output should show the authenticated user and server URL.
|
||||
|
||||
**If login fails:**
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
|
||||
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
|
||||
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
|
||||
|
||||
---
|
||||
@@ -166,12 +166,12 @@ Wait 3 seconds, then verify:
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
|
||||
|
||||
**If daemon fails to start:**
|
||||
- Check logs: `multica daemon logs`
|
||||
- If a port conflict occurs, the daemon may already be running under a different profile.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
|
||||
|
||||
---
|
||||
|
||||
@@ -185,12 +185,12 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, tell the user:
|
||||
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -373,8 +373,7 @@ done
|
||||
|
||||
#### 2. Create a test user and token (automated auth)
|
||||
|
||||
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
|
||||
in your env file before starting the backend:
|
||||
In non-production environments the verification code is fixed at `888888`:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "$SERVER/auth/send-code" \
|
||||
@@ -477,9 +476,7 @@ This automatically:
|
||||
3. Starts and manages its own daemon instance
|
||||
4. Connects to the local backend
|
||||
|
||||
Login in the Desktop UI with `dev@localhost` and the generated code from the
|
||||
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
|
||||
the backend, you can use `888888` instead.
|
||||
Login in the Desktop UI with `dev@localhost` and code `888888`.
|
||||
|
||||
If the backend runs on a non-default port (worktree), create
|
||||
`apps/desktop/.env.development.local`:
|
||||
|
||||
@@ -15,7 +15,7 @@ COPY server/ ./server/
|
||||
# Build binaries
|
||||
ARG VERSION=dev
|
||||
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" -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
|
||||
|
||||
|
||||
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
383
HANDOFF_ARCHITECTURE_AUDIT.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Architecture Audit — Workspace & Realtime Cache
|
||||
|
||||
> 基于代码审计整理的 4 个任务。优先级:P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
|
||||
|
||||
---
|
||||
|
||||
## 任务 1 — [P0] 空闲后列表数据陈旧
|
||||
|
||||
**关联 issue**:[#951](https://github.com/multica-ai/multica/issues/951)
|
||||
|
||||
### 问题
|
||||
|
||||
用户登录后静置一段时间,Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
|
||||
|
||||
### 根因
|
||||
|
||||
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
|
||||
|
||||
- `packages/core/query-client.ts:7` — `staleTime: Infinity`,cache 永不主动过期
|
||||
- `packages/core/query-client.ts:9` — `refetchOnWindowFocus: false`,tab 重新获得焦点也不 refetch
|
||||
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
|
||||
|
||||
但 WS 层存在一个**不对称**:
|
||||
|
||||
- **服务端**:`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait,会清理死连接
|
||||
- **客户端**:`packages/core/api/ws-client.ts`(142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
|
||||
|
||||
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS,所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
|
||||
|
||||
1. 浏览器 `readyState` 仍是 `OPEN`
|
||||
2. `onclose` 不触发
|
||||
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
|
||||
4. `packages/core/realtime/use-realtime-sync.ts:462-487` 的 `onReconnect` 全量 invalidate 不跑
|
||||
5. 期间的 WS 事件进黑洞
|
||||
6. cache 保持旧快照
|
||||
|
||||
### 复现
|
||||
|
||||
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
|
||||
|
||||
**方法 A(推荐,最接近真实场景)**:macOS 用 pfctl 丢包
|
||||
|
||||
```bash
|
||||
# 假设后端在 8080
|
||||
sudo pfctl -E
|
||||
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
|
||||
|
||||
# 观察:
|
||||
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
|
||||
# - Network 里 WS 连接仍显示 Pending / 101
|
||||
# 用另一个账号/CLI 创建一个 issue
|
||||
# 回到原客户端: 列表不更新
|
||||
# 登出再登入: 列表恢复完整
|
||||
|
||||
sudo pfctl -d # 解除
|
||||
```
|
||||
|
||||
**方法 B(不动网络)**:临时修改代码,在 `packages/core/api/ws-client.ts:52` 的 `onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
|
||||
|
||||
### 修复方案(三个选项,推荐 C)
|
||||
|
||||
#### 选项 A — 浏览器端心跳探活(治本,改动大)
|
||||
|
||||
在 `ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
|
||||
|
||||
- 优点:从根本上解决半开问题
|
||||
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
|
||||
|
||||
#### 选项 B — Page Visibility API 触发 invalidate(治标,改动小)
|
||||
|
||||
在 `packages/core/platform/core-provider.tsx` 加 `visibilitychange` 监听,tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key)。
|
||||
|
||||
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab)
|
||||
- 缺点:treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
|
||||
|
||||
#### 选项 C — **A + B 组合**(推荐)
|
||||
|
||||
- 短期上 B,立刻止血
|
||||
- 中期上 A,把 cache 新鲜度从"只信 WS"改成"WS 是优化,Visibility 是兜底"
|
||||
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min),作为第三层保险
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 方案 | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
|
||||
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
|
||||
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
|
||||
|
||||
### 验证
|
||||
|
||||
修完之后:
|
||||
|
||||
1. 跑方法 A 复现流程,确认数据不再丢失
|
||||
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
|
||||
|
||||
---
|
||||
|
||||
## 任务 2 — [P1] Workspace 不在 URL 路径中
|
||||
|
||||
**关联 issue**:MUL-723(slug 不在 URL)、MUL-43(切换 workspace 报错)、MUL-509(手机端无法切换)
|
||||
|
||||
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR)。交接时请让执行人以具体症状为准。
|
||||
|
||||
### 问题
|
||||
|
||||
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载,URL 里没有 workspace 信息。所有路径都是 `/issues`、`/issues/:id` 这种 workspace-agnostic 的。
|
||||
|
||||
### 根因
|
||||
|
||||
**数据库和 API 已经支持 slug**:
|
||||
|
||||
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
|
||||
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
|
||||
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
|
||||
|
||||
**但前端路由和导航层没用它**:
|
||||
|
||||
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
|
||||
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
|
||||
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
|
||||
|
||||
**workspace 切换只靠 sidebar UI**(`packages/views/layout/app-sidebar.tsx:284-286`):
|
||||
|
||||
```tsx
|
||||
if (ws.id !== workspace?.id) {
|
||||
push("/issues"); // 硬跳 /issues(workspace-implicit!)
|
||||
switchWorkspace(ws); // 然后改 store
|
||||
}
|
||||
```
|
||||
|
||||
这种设计使得:
|
||||
|
||||
- 手机端因为没 sidebar UI,也没 URL 层切换入口,**完全切不了 workspace**(MUL-509)
|
||||
- 把 `/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue,或找不到报错(MUL-43 系列)
|
||||
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
|
||||
|
||||
### 复现
|
||||
|
||||
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
|
||||
2. **MUL-43**:
|
||||
- 加入两个 workspace A 和 B
|
||||
- 在 A 中打开某个 issue `/issues/abc123`
|
||||
- 切到 B,URL 不变 → 访问失败 / 显示错数据
|
||||
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换(UI 不显示 sidebar 触发器或触发器无法切)
|
||||
|
||||
### 修复方案(三个选项,推荐 A)
|
||||
|
||||
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
|
||||
|
||||
所有路径加上 workspace slug 前缀。例如 `/issues/abc123` → `/ws/my-team/issues/abc123`。
|
||||
|
||||
**要改的地方**:
|
||||
|
||||
1. **Web 路由目录结构**:`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`(~25 个文件)
|
||||
2. **Desktop 路由**:`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
|
||||
3. **Navigation 适配器**:
|
||||
- `apps/web/platform/navigation.tsx` — `push(path)` 内部前置 `/ws/${workspace.slug}`,`pathname` 读取时去掉前缀
|
||||
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
|
||||
4. **Sidebar 切换逻辑**:`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
|
||||
5. **服务端中间件**:`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑,header 继续作为 fallback(迁移期兼容)
|
||||
|
||||
**预计改动**:~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
|
||||
|
||||
**不改也能工作的部分**:
|
||||
- `packages/core/api/client.ts` — 仍旧走 header,不用改
|
||||
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
|
||||
|
||||
**风险**:
|
||||
- 旧的 bookmark URL 失效(如果产品还没正式 ship,问题不大)
|
||||
- E2E 测试需要更新所有 URL 断言
|
||||
|
||||
#### 选项 B — `?ws=slug` query param(折中)
|
||||
|
||||
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件),URL 丑但向后兼容。推荐度低于 A。
|
||||
|
||||
#### 选项 C — 只修症状不动架构
|
||||
|
||||
在 `switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因,技术债越攒越多。**不推荐**。
|
||||
|
||||
### 改动范围(选项 A)
|
||||
|
||||
| 模块 | 文件数 | 备注 |
|
||||
|---|---|---|
|
||||
| Web routes | ~25 | 目录搬迁 |
|
||||
| Desktop routes | 1 | 路径前缀 |
|
||||
| Navigation adapters | 2 | 前缀逻辑 |
|
||||
| Server middleware | 1-2 | slug → ID 解析 |
|
||||
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
|
||||
| E2E tests | 20-30 | URL 断言更新 |
|
||||
|
||||
---
|
||||
|
||||
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
|
||||
|
||||
**关联 issue**:MUL-43(切换报错)、MUL-476(本地缓存未按 workspace 隔离)
|
||||
|
||||
> 同上,这两个编号建议交接时核对症状。
|
||||
|
||||
### 问题
|
||||
|
||||
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`(key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage,切换 workspace 后里面仍是上个 workspace 的路径。
|
||||
|
||||
### 根因
|
||||
|
||||
**`packages/core/navigation/store.ts:15-31`**:
|
||||
|
||||
```typescript
|
||||
export const useNavigationStore = create<NavigationState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastPath: "/issues",
|
||||
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
|
||||
}),
|
||||
{
|
||||
name: "multica_navigation",
|
||||
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global,不是 workspace-aware
|
||||
partialize: (state) => ({ lastPath: state.lastPath }),
|
||||
}
|
||||
)
|
||||
);
|
||||
// ← 没有调 registerForWorkspaceRehydration
|
||||
```
|
||||
|
||||
**对比:其他 store 都是正确的**:
|
||||
|
||||
| Store | 是否 workspace-aware | 是否注册 rehydration |
|
||||
|---|---|---|
|
||||
| useNavigationStore | ❌ | ❌ |
|
||||
| useIssuesScopeStore | ✅ | ✅ |
|
||||
| useIssueDraftStore | ✅ | ✅ |
|
||||
| useRecentIssuesStore | ✅ | ✅ |
|
||||
| useIssueViewStore | ✅ | ✅ |
|
||||
| myIssuesViewStore | ✅ | ✅ |
|
||||
| useChatStore | ✅(手动用 wsKey)| ✅ |
|
||||
|
||||
另外 `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`。
|
||||
|
||||
**现有的 workaround**:`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
|
||||
|
||||
### 复现
|
||||
|
||||
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
|
||||
2. 切到 workspace B
|
||||
3. 观察:如果没有 sidebar 的硬跳 workaround,会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B,导致 404 或错误
|
||||
|
||||
目前因为有硬跳 workaround,症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug(用户期望记住上次位置)。
|
||||
|
||||
### 修复方案(推荐 Option C:组合)
|
||||
|
||||
**三处改动**:
|
||||
|
||||
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
|
||||
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
|
||||
3. `packages/core/platform/storage-cleanup.ts:10-19` 的 `WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
|
||||
|
||||
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285` 的 `push("/issues")` workaround(改完之后不再需要)。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
|
||||
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
|
||||
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
|
||||
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
|
||||
|
||||
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
|
||||
|
||||
---
|
||||
|
||||
## 任务 4 — [P2] Workspace 生命周期副作用散落
|
||||
|
||||
**关联 issue**:MUL-727(创建后闪页)、MUL-728(删除确认)、MUL-820(接受邀请不自动切)
|
||||
|
||||
### 问题
|
||||
|
||||
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug:
|
||||
|
||||
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
|
||||
|
||||
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
|
||||
|
||||
- `packages/core/workspace/mutations.ts:7-21` 的 `useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand,`/issues` 路由开始用新 workspace 渲染
|
||||
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
|
||||
|
||||
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`。
|
||||
|
||||
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx` 的 `onSuccess` 里先 `switchWorkspace` 再 `push`,保证同一个微任务里完成。
|
||||
|
||||
**文件**:`packages/core/workspace/mutations.ts`、`packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
|
||||
|
||||
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
|
||||
|
||||
**核查结果**:`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
|
||||
|
||||
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
|
||||
|
||||
**修复**:在 `handleDeleteWorkspace` 的 `onConfirm` 成功分支里加 `push("/issues")`。
|
||||
|
||||
**文件**:`packages/views/settings/components/workspace-tab.tsx`(加一行)
|
||||
|
||||
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
|
||||
|
||||
**核查结果**:有两条路径:
|
||||
|
||||
- ✅ `/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**:accept → switchWorkspace → push("/issues")
|
||||
- ❌ **Sidebar 下拉里的 "Join" 按钮**(`packages/views/layout/app-sidebar.tsx:203-209, 321-324`)**是错的**:只 invalidate cache,不切也不跳
|
||||
|
||||
**修复(推荐 Option 2)**:Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
|
||||
|
||||
```tsx
|
||||
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
|
||||
{inv.workspace_name}
|
||||
</DropdownMenuItem>
|
||||
```
|
||||
|
||||
**文件**:`packages/views/layout/app-sidebar.tsx`(~10 行)
|
||||
|
||||
### 复现
|
||||
|
||||
| Issue | 步骤 |
|
||||
|---|---|
|
||||
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
|
||||
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面(BUG: 没有自动跳走) |
|
||||
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去(BUG: 不切)|
|
||||
|
||||
### 长期架构建议(可选)
|
||||
|
||||
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bug,hook 抽象作为后续迭代。
|
||||
|
||||
### 改动范围
|
||||
|
||||
| Issue | 文件 | 改动规模 |
|
||||
|---|---|---|
|
||||
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
|
||||
| MUL-728 | workspace-tab.tsx | ~1 行 |
|
||||
| MUL-820 | app-sidebar.tsx | ~10 行 |
|
||||
|
||||
---
|
||||
|
||||
## 总览
|
||||
|
||||
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行;Option C ~1-2 天 | 低 |
|
||||
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中(影响面大、e2e 要改)|
|
||||
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
|
||||
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
|
||||
|
||||
### 建议推进顺序
|
||||
|
||||
1. **立刻做**:任务 1 的 Option B(visibilitychange 触发 invalidate)—— 代码最少、收益最明显,能当天止血
|
||||
2. **同步开始**:任务 3(navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
|
||||
3. **规划立项**:任务 2(URL 化)—— 大改造,需要单独开一个 iteration
|
||||
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR,各自 review
|
||||
|
||||
### 重要澄清
|
||||
|
||||
- **Issue 编号核对**:MUL-43 / MUL-476 的编号需要核对一次,agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
|
||||
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
|
||||
- **MUL-820 实际状态**:`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
|
||||
|
||||
### 所有关键代码位置索引
|
||||
|
||||
```
|
||||
packages/core/query-client.ts:7-10 # staleTime: Infinity
|
||||
packages/core/api/ws-client.ts:1-142 # 客户端 WS,无心跳
|
||||
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
|
||||
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
|
||||
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
|
||||
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
|
||||
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
|
||||
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
|
||||
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
|
||||
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
|
||||
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
|
||||
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
|
||||
|
||||
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
|
||||
server/internal/middleware/workspace.go:41-46 # wsId resolution
|
||||
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
|
||||
```
|
||||
6
Makefile
6
Makefile
@@ -91,7 +91,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
|
||||
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo ""; \
|
||||
echo "Next — install the CLI and connect your machine:"; \
|
||||
echo " brew install multica-ai/tap/multica"; \
|
||||
@@ -130,7 +130,7 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
|
||||
echo " Backend: http://localhost:$${PORT:-8080}"; \
|
||||
echo ""; \
|
||||
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
|
||||
echo " or read the generated code from backend logs when Resend is unset."; \
|
||||
echo " or set APP_ENV=development in .env (private networks only) to enable code 888888."; \
|
||||
echo ""; \
|
||||
echo "Built images locally via docker-compose.selfhost.build.yml."; \
|
||||
echo "Local tags: multica-backend:dev and multica-web:dev."; \
|
||||
@@ -277,7 +277,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
build: ## Build the server, CLI, and migrate binaries into server/bin
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
|
||||
cd server && go build -o bin/server ./cmd/server
|
||||
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
|
||||
cd server && go build -o bin/migrate ./cmd/migrate
|
||||
|
||||
|
||||
38
README.md
38
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)**
|
||||
|
||||
@@ -30,24 +30,12 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
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**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
|
||||
## Why "Multica"?
|
||||
|
||||
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
|
||||
|
||||
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
|
||||
|
||||
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
|
||||
|
||||
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
|
||||
|
||||
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
|
||||
|
||||
## Features
|
||||
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
@@ -110,7 +98,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
|
||||
multica setup # Configure, authenticate, and start the daemon
|
||||
```
|
||||
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `copilot`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`, `kimi`, `kiro-cli`) on your PATH.
|
||||
The daemon runs in the background and auto-detects agent CLIs (`claude`, `codex`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
|
||||
|
||||
### 2. Verify your runtime
|
||||
|
||||
@@ -120,7 +108,7 @@ Open your workspace in the Multica web app. Navigate to **Settings → Runtimes*
|
||||
|
||||
### 3. Create an agent
|
||||
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
Go to **Settings → Agents** and click **New Agent**. Pick the runtime you just connected and choose a provider (Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
|
||||
|
||||
### 4. Assign your first task
|
||||
|
||||
@@ -172,9 +160,9 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ runs on your machine
|
||||
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
|
||||
OpenCode, OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent, Kimi, Kiro CLI)
|
||||
└──────────────┘ (Claude Code, Codex, OpenCode,
|
||||
OpenClaw, Hermes, Gemini,
|
||||
Pi, Cursor Agent)
|
||||
```
|
||||
|
||||
| Layer | Stack |
|
||||
@@ -182,7 +170,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
|
||||
| Frontend | Next.js 16 (App Router) |
|
||||
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| Database | PostgreSQL 17 with pgvector |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
|
||||
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
|
||||
|
||||
## Development
|
||||
|
||||
@@ -197,3 +185,13 @@ make dev
|
||||
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -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) | 简体中文**
|
||||
|
||||
@@ -30,24 +30,12 @@
|
||||
|
||||
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi** 和 **Cursor Agent**。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
|
||||
## 为什么叫 "Multica"?
|
||||
|
||||
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
|
||||
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统,让多个用户能够共享同一台机器,同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
|
||||
|
||||
我们认为,类似的转折点正在再次出现。几十年来,软件团队一直处于一种单线程的工作模式,一个工程师处理一个任务,一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
|
||||
|
||||
在 Multica 中,agents 是一级团队成员。它们会被分配 issue,汇报进展,提出阻塞,并交付代码,就像人类同事一样。任务分配、活动时间线、任务生命周期,以及运行时基础设施,Multica 从第一天起就是围绕这一理念构建的。
|
||||
|
||||
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents,就能发挥出二十人团队的推进速度。
|
||||
|
||||
## 功能特性
|
||||
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
@@ -111,7 +99,7 @@ multica setup # 连接 Multica Cloud,登录,启动 daemon
|
||||
multica setup # 配置、认证、启动 daemon(一条命令搞定)
|
||||
```
|
||||
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`copilot`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`、`kimi`、`kiro-cli`)。
|
||||
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI(`claude`、`codex`、`openclaw`、`opencode`、`hermes`、`gemini`、`pi`、`cursor-agent`)。
|
||||
|
||||
### 2. 确认运行时已连接
|
||||
|
||||
@@ -121,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
### 3. 创建 Agent
|
||||
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime,选择 Provider(Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
|
||||
|
||||
### 4. 分配你的第一个任务
|
||||
|
||||
@@ -154,9 +142,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
│
|
||||
┌──────┴───────┐
|
||||
│ Agent Daemon │ 运行在你的机器上
|
||||
└──────────────┘ (Claude Code、Codex、GitHub Copilot CLI、
|
||||
OpenCode、OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent、Kimi、Kiro CLI)
|
||||
└──────────────┘ (Claude Code、Codex、OpenCode、
|
||||
OpenClaw、Hermes、Gemini、
|
||||
Pi、Cursor Agent)
|
||||
```
|
||||
|
||||
| 层级 | 技术栈 |
|
||||
@@ -164,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
| 前端 | Next.js 16 (App Router) |
|
||||
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
|
||||
| 数据库 | PostgreSQL 17 with pgvector |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、Pi、Cursor Agent、Kimi 或 Kiro CLI |
|
||||
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、Pi 或 Cursor Agent |
|
||||
|
||||
## 开发
|
||||
|
||||
@@ -184,3 +172,13 @@ make start
|
||||
## 开源协议
|
||||
|
||||
[Apache 2.0](LICENSE)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -26,7 +26,7 @@ multica setup self-host
|
||||
|
||||
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
|
||||
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or set `APP_ENV=development` in `.env` to enable the dev master code **`888888`**. See [Step 2 — Log In](#step-2--log-in) for details.
|
||||
|
||||
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
|
||||
>
|
||||
@@ -67,15 +67,15 @@ Once ready:
|
||||
|
||||
### Step 2 — Log In
|
||||
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
|
||||
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), so the dev master code is **disabled by default** for safety on public deployments. Pick one of the following to log in:
|
||||
|
||||
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the 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 container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
|
||||
- **Evaluation / private network:** set `APP_ENV=development` in `.env` and restart the backend. Verification code **`888888`** will then work for any email address.
|
||||
- **Without configuring either:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
|
||||
|
||||
Changes to `ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. 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.
|
||||
> **Warning:** do **not** set `APP_ENV=development` on a publicly reachable instance — anyone who knows an email address can then log in with `888888`.
|
||||
|
||||
### Step 3 — Install CLI & Start Daemon
|
||||
|
||||
@@ -92,15 +92,12 @@ brew install multica-ai/tap/multica
|
||||
You also need at least one AI agent CLI installed:
|
||||
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
|
||||
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
|
||||
- [GitHub Copilot CLI](https://docs.github.com/en/copilot) (`copilot` on PATH)
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
|
||||
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
|
||||
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
|
||||
- Gemini (`gemini` on PATH)
|
||||
- [Pi](https://pi.dev/) (`pi` on PATH)
|
||||
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
|
||||
- Kimi (`kimi` on PATH)
|
||||
- Kiro CLI (`kiro-cli` on PATH)
|
||||
|
||||
### b) One-command setup
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Multica uses email-based magic link authentication via [Resend](https://resend.c
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **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`.
|
||||
> **Note:** The dev master verification code `888888` is gated by `APP_ENV != "production"`. The Docker self-host stack defaults to `APP_ENV=production` (so `888888` is disabled), which protects publicly reachable instances. For local development without email configured, set `APP_ENV=development` in your `.env` to enable `888888` — never do this on a public instance.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
@@ -56,15 +56,13 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
|
||||
|
||||
### File Storage (Optional)
|
||||
|
||||
For file uploads and attachments, configure S3 and (optionally) CloudFront:
|
||||
For file uploads and attachments, configure S3 and CloudFront:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `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 the public URL to path-style |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
|
||||
| `S3_BUCKET` | S3 bucket name |
|
||||
| `S3_REGION` | AWS region (default: `us-west-2`) |
|
||||
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
|
||||
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
|
||||
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
|
||||
|
||||
@@ -81,7 +79,6 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `8080` | Backend server port |
|
||||
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
|
||||
| `FRONTEND_PORT` | `3000` | Frontend port |
|
||||
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
|
||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
|
||||
@@ -105,8 +102,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
|
||||
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
|
||||
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
|
||||
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
|
||||
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
|
||||
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
|
||||
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
|
||||
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
|
||||
@@ -186,47 +181,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
|
||||
@@ -304,67 +268,20 @@ Then restart the stack:
|
||||
docker compose -f docker-compose.selfhost.yml up -d
|
||||
```
|
||||
|
||||
### WebSocket for LAN / Non-localhost Access
|
||||
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
|
||||
|
||||
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
|
||||
|
||||
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
|
||||
|
||||
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
|
||||
|
||||
```bash
|
||||
# In .env
|
||||
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
|
||||
|
||||
# Rebuild the web image so the build-time value is baked in
|
||||
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
|
||||
```
|
||||
|
||||
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
|
||||
|
||||
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image for any other reason, use the same source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
> **Note:** If you need to hard-code a different public API / WebSocket endpoint into the web image, use the source-build override: `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
|
||||
|
||||
## Health Check
|
||||
|
||||
The backend exposes public health endpoints:
|
||||
The backend exposes a health check endpoint:
|
||||
|
||||
```text
|
||||
```
|
||||
GET /health
|
||||
→ {"status":"ok"}
|
||||
|
||||
GET /readyz
|
||||
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
|
||||
|
||||
GET /healthz
|
||||
→ same response as /readyz
|
||||
```
|
||||
|
||||
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
|
||||
dependency-aware readiness probes and external monitoring that should fail when
|
||||
the database is unavailable or migrations are not fully applied. `/healthz` is
|
||||
kept as an alias for operator familiarity.
|
||||
|
||||
## Prometheus Metrics
|
||||
|
||||
The backend can expose Prometheus metrics on a separate management listener:
|
||||
|
||||
```bash
|
||||
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
|
||||
curl http://127.0.0.1:9090/metrics
|
||||
```
|
||||
|
||||
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
|
||||
public API port does not serve `/metrics`; keep it that way for internet-facing
|
||||
deployments. HTTP request metrics start accumulating only after the metrics
|
||||
listener is enabled. Metrics can reveal internal routes, traffic volume,
|
||||
dependency state, and runtime health.
|
||||
|
||||
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
|
||||
metrics listener to an internal interface and protect it with private
|
||||
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
|
||||
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
|
||||
trusted network, for example a host-local mapping such as
|
||||
`127.0.0.1:9090:9090`.
|
||||
Use this for load balancer health checks or monitoring.
|
||||
|
||||
## Upgrading
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ multica setup self-host
|
||||
|
||||
The `multica setup self-host` command will:
|
||||
1. Configure CLI to connect to localhost:8080 / localhost:3000
|
||||
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
|
||||
2. Open a browser for login — use verification code `888888` with any email
|
||||
3. Discover workspaces automatically
|
||||
4. Start the daemon in the background
|
||||
|
||||
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
|
||||
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
|
||||
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
|
||||
- **Daemon issues:** `multica daemon logs`
|
||||
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness
|
||||
- **Health check:** `curl http://localhost:8080/health`
|
||||
|
||||
12
apps/desktop/.env.production
Normal file
12
apps/desktop/.env.production
Normal file
@@ -0,0 +1,12 @@
|
||||
# Production environment for `pnpm package` / `pnpm build`.
|
||||
# electron-vite (Vite under the hood) reads this automatically in
|
||||
# production mode and inlines the values into the renderer bundle via
|
||||
# import.meta.env.VITE_*. These are public URLs, not secrets.
|
||||
|
||||
# Backend API + websocket the desktop app talks to.
|
||||
VITE_API_URL=https://api.multica.ai
|
||||
VITE_WS_URL=wss://api.multica.ai/ws
|
||||
|
||||
# Public web app URL — used to build shareable links like "Copy link to
|
||||
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
|
||||
VITE_APP_URL=https://multica.ai
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 136 KiB After Width: | Height: | Size: 121 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 491 KiB After Width: | Height: | Size: 35 KiB |
@@ -32,47 +32,11 @@ 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 explicitly to the WM_CLASS that Electron emits on
|
||||
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
|
||||
# builds resolves to `productName` (`Multica`). Without an explicit
|
||||
# `StartupWMClass`, electron-builder writes `productName` as the default
|
||||
# — making this declaration redundant with current settings — but
|
||||
# pinning the value here turns a silent future drift (e.g. if anyone
|
||||
# renames productName or sets app.setName at boot) into a visible diff
|
||||
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
|
||||
# GNOME associate the running window with the `.desktop` entry and
|
||||
# therefore render the right icon. The post-build verification step in
|
||||
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
- rpm
|
||||
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
|
||||
rpm:
|
||||
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
|
||||
# binary, whose GNU build-id is identical across every app shipping the same
|
||||
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
|
||||
# would own /usr/lib/.build-id/<hash> paths and collide with any other
|
||||
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
|
||||
fpm:
|
||||
- "--rpm-rpmbuild-define=_build_id_links none"
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
/**
|
||||
* Resolve the running app version. In packaged builds this is the value
|
||||
* `electron-builder` baked into package.json via `extraMetadata.version`
|
||||
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
|
||||
* `app.getVersion()` matches the GitHub Release tag exactly.
|
||||
*
|
||||
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
|
||||
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
|
||||
* the Settings → Updates panel and any other UI surfacing the version
|
||||
* would mislead developers into thinking they're running ancient builds.
|
||||
* Fall back to `git describe --tags --always --dirty` (same source the
|
||||
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
|
||||
* unavailable for whatever reason, we just return the package.json value.
|
||||
*/
|
||||
export function getAppVersion(): string {
|
||||
if (app.isPackaged) {
|
||||
return app.getVersion();
|
||||
}
|
||||
try {
|
||||
const raw = execSync("git describe --tags --always --dirty", {
|
||||
cwd: app.getAppPath(),
|
||||
encoding: "utf-8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (!raw) return app.getVersion();
|
||||
return raw.replace(/^v/, "");
|
||||
} catch {
|
||||
return app.getVersion();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { BrowserWindow, Menu, MenuItem, type WebContents } from "electron";
|
||||
|
||||
// Electron ships with no default right-click menu, so a user selecting text
|
||||
// in the renderer has no way to copy it. Mirror Chrome's minimal clipboard
|
||||
// menu using `roles`, which keeps i18n + accelerator handling native.
|
||||
export function installContextMenu(webContents: WebContents): void {
|
||||
webContents.on("context-menu", (_event, params) => {
|
||||
const { editFlags, selectionText, isEditable } = params;
|
||||
const hasSelection = selectionText.trim().length > 0;
|
||||
|
||||
const menu = new Menu();
|
||||
|
||||
if (isEditable && editFlags.canCut) {
|
||||
menu.append(new MenuItem({ role: "cut" }));
|
||||
}
|
||||
if (hasSelection && editFlags.canCopy) {
|
||||
menu.append(new MenuItem({ role: "copy" }));
|
||||
}
|
||||
if (isEditable && editFlags.canPaste) {
|
||||
menu.append(new MenuItem({ role: "paste" }));
|
||||
}
|
||||
if (isEditable && editFlags.canSelectAll) {
|
||||
if (menu.items.length > 0) {
|
||||
menu.append(new MenuItem({ type: "separator" }));
|
||||
}
|
||||
menu.append(new MenuItem({ role: "selectAll" }));
|
||||
}
|
||||
|
||||
if (menu.items.length === 0) return;
|
||||
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
|
||||
menu.popup({ window });
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, ipcMain, BrowserWindow, shell } from "electron";
|
||||
import { app, ipcMain, BrowserWindow } from "electron";
|
||||
import { execFile } from "child_process";
|
||||
import {
|
||||
readFile,
|
||||
@@ -914,20 +914,6 @@ export function setupDaemonManager(
|
||||
stopLogTail();
|
||||
});
|
||||
|
||||
// Reveal the daemon's log file in the user's default editor / Console
|
||||
// app. Acts as the escape hatch when the in-app log viewer isn't enough
|
||||
// (full history, complex search, copy-to-clipboard at scale).
|
||||
ipcMain.handle("daemon:open-log-file", async () => {
|
||||
const active = await ensureActiveProfile();
|
||||
const logPath = profileLogPath(active.name);
|
||||
if (!existsSync(logPath)) {
|
||||
return { success: false, error: "Log file not found yet" };
|
||||
}
|
||||
// shell.openPath returns "" on success, error string on failure.
|
||||
const error = await shell.openPath(logPath);
|
||||
return error === "" ? { success: true } : { success: false, error };
|
||||
});
|
||||
|
||||
// First-run CLI install kicks off here. Status bar shows "Setting up…"
|
||||
// until the managed binary is on disk (instant on subsequent launches).
|
||||
currentState = "installing_cli";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
|
||||
import { app, BrowserWindow, ipcMain, nativeImage } from "electron";
|
||||
import { homedir } from "os";
|
||||
import { join } from "path";
|
||||
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
@@ -6,30 +6,11 @@ import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
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.).
|
||||
@@ -54,10 +35,6 @@ if (process.platform !== "win32") {
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
};
|
||||
|
||||
// --- Deep link helpers ---------------------------------------------------
|
||||
|
||||
@@ -93,25 +70,7 @@ function handleDeepLink(url: string): void {
|
||||
|
||||
// --- Window creation -----------------------------------------------------
|
||||
|
||||
// Tracks the OS-preferred language as last seen by the running process.
|
||||
// Updated on each window-focus check so we can emit a `locale:system-changed`
|
||||
// event to the renderer when the user changes their OS language without
|
||||
// quitting the app — without restart, app.getPreferredSystemLanguages()
|
||||
// would still report the boot value forever.
|
||||
let lastKnownSystemLocale = "en";
|
||||
|
||||
function getSystemLocale(): string {
|
||||
return app.getPreferredSystemLanguages()[0] ?? "en";
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// Pass the OS-preferred language to the renderer via additionalArguments
|
||||
// instead of a sync IPC call. process.argv is available to the preload
|
||||
// script before the first network request, so the renderer's i18next
|
||||
// instance can initialize with the right locale on the very first paint.
|
||||
const systemLocale = getSystemLocale();
|
||||
lastKnownSystemLocale = systemLocale;
|
||||
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
@@ -121,19 +80,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,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -151,41 +104,11 @@ function createWindow(): void {
|
||||
mainWindow?.show();
|
||||
});
|
||||
|
||||
// Detect OS language changes while the app is running. Electron has no
|
||||
// dedicated event for this on any platform, so we poll on focus regain —
|
||||
// catches the common case where users switch System Settings → Language
|
||||
// and bring the app back. The renderer decides whether to act (it ignores
|
||||
// the signal when the user has an explicit Settings choice).
|
||||
mainWindow.on("focus", () => {
|
||||
const current = getSystemLocale();
|
||||
if (current === lastKnownSystemLocale) return;
|
||||
lastKnownSystemLocale = current;
|
||||
mainWindow?.webContents.send("locale:system-changed", current);
|
||||
});
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler((details) => {
|
||||
openExternalSafely(details.url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
|
||||
installContextMenu(mainWindow.webContents);
|
||||
|
||||
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
|
||||
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
|
||||
} else {
|
||||
@@ -244,25 +167,7 @@ if (!gotTheLock) {
|
||||
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
const viteEnv = import.meta.env as ImportMetaEnv & {
|
||||
readonly VITE_API_URL?: string;
|
||||
readonly VITE_WS_URL?: string;
|
||||
readonly VITE_APP_URL?: string;
|
||||
};
|
||||
|
||||
runtimeConfigResult = await loadRuntimeConfig({
|
||||
isDev: is.dev,
|
||||
// electron-vite exposes VITE_* on import.meta.env for the main process;
|
||||
// keep dev URL overrides on the same source the renderer used before
|
||||
// runtime config moved endpoint resolution into main/preload.
|
||||
env: {
|
||||
apiUrl: viteEnv.VITE_API_URL,
|
||||
wsUrl: viteEnv.VITE_WS_URL,
|
||||
appUrl: viteEnv.VITE_APP_URL,
|
||||
},
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
electronApp.setAppUserModelId(
|
||||
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
|
||||
);
|
||||
@@ -271,7 +176,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);
|
||||
}
|
||||
|
||||
@@ -295,14 +200,7 @@ if (!gotTheLock) {
|
||||
ipcMain.on("app:get-info", (event) => {
|
||||
const p = process.platform;
|
||||
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
ipcMain.on("runtime-config:get", (event) => {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
event.returnValue = { version: app.getVersion(), os };
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
@@ -313,64 +211,6 @@ if (!gotTheLock) {
|
||||
mainWindow?.setWindowButtonVisibility(!immersive);
|
||||
});
|
||||
|
||||
// IPC: show a native OS notification for a new inbox item. The renderer
|
||||
// only fires this when the app is unfocused (it gates on
|
||||
// `document.hasFocus()`), so we don't fight macOS foreground suppression
|
||||
// here. Clicking the banner focuses the main window and routes to the
|
||||
// inbox item via a renderer-side listener.
|
||||
ipcMain.on(
|
||||
"notification:show",
|
||||
(
|
||||
_event,
|
||||
{
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
title,
|
||||
body,
|
||||
}: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
},
|
||||
) => {
|
||||
if (!Notification.isSupported()) return;
|
||||
const notification = new Notification({ title, body });
|
||||
notification.on("click", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
// Ship the full context back — the renderer pins the route to the
|
||||
// source workspace (slug), marks the row read (itemId), and uses
|
||||
// issueKey as the ?issue=<…> selector.
|
||||
mainWindow.webContents.send("inbox:open", {
|
||||
slug,
|
||||
itemId,
|
||||
issueKey,
|
||||
});
|
||||
});
|
||||
notification.show();
|
||||
},
|
||||
);
|
||||
|
||||
// IPC: update the dock / taskbar unread badge. Values above 99 render as
|
||||
// "99+". macOS is the primary target (user-visible dock badge); Linux
|
||||
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
|
||||
// needs a pre-rendered PNG and is deferred — the OS notification + the
|
||||
// in-app inbox sidebar cover the core UX there for now.
|
||||
ipcMain.on("badge:set", (_event, rawCount: number) => {
|
||||
const count = Math.max(0, Math.floor(rawCount));
|
||||
if (process.platform === "darwin") {
|
||||
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
|
||||
app.dock?.setBadge(label);
|
||||
} else {
|
||||
app.setBadgeCount(count);
|
||||
}
|
||||
});
|
||||
|
||||
createWindow();
|
||||
|
||||
setupAutoUpdater(() => mainWindow);
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { mkdtemp, writeFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
|
||||
describe("loadRuntimeConfig", () => {
|
||||
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: true,
|
||||
configPath,
|
||||
env: {
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses cloud defaults when packaged config is absent", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
await expect(
|
||||
loadRuntimeConfig({
|
||||
isDev: false,
|
||||
configPath: join(dir, "missing.json"),
|
||||
env: {},
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a valid packaged desktop.json", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(
|
||||
configPath,
|
||||
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
config: {
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://api.example.com/ws",
|
||||
appUrl: "https://example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when packaged desktop.json is invalid", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
|
||||
const configPath = join(dir, "desktop.json");
|
||||
await writeFile(configPath, "{");
|
||||
|
||||
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.message).toContain(configPath);
|
||||
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import { app } from "electron";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
type RuntimeConfig,
|
||||
type RuntimeConfigEnv,
|
||||
type RuntimeConfigResult,
|
||||
} from "../shared/runtime-config";
|
||||
|
||||
export async function loadRuntimeConfig(options: {
|
||||
isDev: boolean;
|
||||
env: RuntimeConfigEnv;
|
||||
configPath?: string;
|
||||
}): Promise<RuntimeConfigResult> {
|
||||
if (options.isDev) {
|
||||
try {
|
||||
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: { message: errorMessage(err) } };
|
||||
}
|
||||
}
|
||||
|
||||
const configPath = options.configPath ?? desktopConfigPath();
|
||||
try {
|
||||
const raw = await readFile(configPath, "utf-8");
|
||||
return { ok: true, config: parseRuntimeConfig(raw) };
|
||||
} catch (err) {
|
||||
if (isMissingFileError(err)) {
|
||||
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: `Invalid ${configPath}: ${errorMessage(err)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function desktopConfigPath(): string {
|
||||
return join(app.getPath("home"), ".multica", "desktop.json");
|
||||
}
|
||||
|
||||
function isMissingFileError(err: unknown): boolean {
|
||||
return Boolean(
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
(err as NodeJS.ErrnoException).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
export type { RuntimeConfig, RuntimeConfigResult };
|
||||
26
apps/desktop/src/preload/index.d.ts
vendored
26
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,5 +1,4 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -7,12 +6,6 @@ interface DesktopAPI {
|
||||
version: string;
|
||||
os: "macos" | "windows" | "linux" | "unknown";
|
||||
};
|
||||
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
|
||||
systemLocale: string;
|
||||
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -21,24 +14,6 @@ interface DesktopAPI {
|
||||
openExternal: (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. */
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => void;
|
||||
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
|
||||
setUnreadBadge: (count: number) => void;
|
||||
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -75,7 +50,6 @@ interface DaemonAPI {
|
||||
startLogStream: () => void;
|
||||
stopLogStream: () => void;
|
||||
onLogLine: (callback: (line: string) => void) => () => void;
|
||||
openLogFile: () => Promise<{ success: boolean; error?: string }>;
|
||||
}
|
||||
|
||||
interface UpdaterAPI {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Synchronously fetch app metadata from main at preload time so the renderer
|
||||
// can pass it into CoreProvider during the initial render — the alternative
|
||||
@@ -22,53 +21,12 @@ function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" |
|
||||
return { version: "unknown", os };
|
||||
}
|
||||
|
||||
function fetchRuntimeConfig(): RuntimeConfigResult {
|
||||
try {
|
||||
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
|
||||
if (result && typeof result === "object" && "ok" in result) return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: false, error: { message: "Runtime config unavailable" } };
|
||||
}
|
||||
|
||||
const appInfo = fetchAppInfo();
|
||||
const runtimeConfig = fetchRuntimeConfig();
|
||||
|
||||
// Read the OS-preferred locale that main injected via additionalArguments.
|
||||
// Zero IPC, zero blocking — process.argv is populated before preload runs.
|
||||
function fetchSystemLocale(): string {
|
||||
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
|
||||
return arg?.split("=")[1] ?? "en";
|
||||
}
|
||||
|
||||
const systemLocale = fetchSystemLocale();
|
||||
|
||||
const desktopAPI = {
|
||||
/** App version + normalized OS. Read once at preload time so the renderer
|
||||
* can use it synchronously when initializing the API client. */
|
||||
appInfo,
|
||||
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
|
||||
* Used by the renderer's LocaleAdapter as the system-preference signal. */
|
||||
systemLocale,
|
||||
/** Subscribe to OS language changes detected after boot. The renderer
|
||||
* decides whether to act (no-op when the user has an explicit Settings
|
||||
* choice). Returns an unsubscribe function. */
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
|
||||
callback(locale);
|
||||
ipcRenderer.on("locale:system-changed", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("locale:system-changed", handler);
|
||||
};
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -92,50 +50,6 @@ const desktopAPI = {
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
/**
|
||||
* Show a native OS notification for a new inbox item. Fired from the
|
||||
* renderer only when the app is unfocused — in-focus feedback is the
|
||||
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
|
||||
* all round-tripped on click: slug pins routing to the source workspace
|
||||
* (the user may switch workspaces before clicking the banner), itemId
|
||||
* lets the renderer mark the row read, issueKey maps to the inbox URL
|
||||
* param.
|
||||
*/
|
||||
showNotification: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => ipcRenderer.send("notification:show", payload),
|
||||
/**
|
||||
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
|
||||
* above 99 render as "99+" (capping is handled in the main process).
|
||||
*/
|
||||
setUnreadBadge: (count: number) =>
|
||||
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
|
||||
/**
|
||||
* Subscribe to "open this inbox row" requests sent by the main process
|
||||
* when the user clicks an OS notification banner. Returns an unsubscribe
|
||||
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
|
||||
* were passed to `showNotification`.
|
||||
*/
|
||||
onInboxOpen: (
|
||||
callback: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
}) => void,
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { slug: string; itemId: string; issueKey: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("inbox:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("inbox:open", handler);
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
@@ -187,8 +101,6 @@ const daemonAPI = {
|
||||
ipcRenderer.on("daemon:log-line", handler);
|
||||
return () => ipcRenderer.removeListener("daemon:log-line", handler);
|
||||
},
|
||||
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
|
||||
ipcRenderer.invoke("daemon:open-log-file"),
|
||||
};
|
||||
|
||||
const updaterAPI = {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
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 { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useHasOnboarded } from "@multica/core/paths";
|
||||
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";
|
||||
import { Toaster } from "sonner";
|
||||
import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { PageviewTracker } from "./components/pageview-tracker";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
|
||||
function AppContent() {
|
||||
@@ -33,16 +29,11 @@ function AppContent() {
|
||||
// first render.
|
||||
const [bootstrapping, setBootstrapping] = useState(false);
|
||||
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
|
||||
? window.desktopAPI.runtimeConfig.config
|
||||
: null;
|
||||
|
||||
// Tell the main process which backend URL we talk to, so daemon-manager
|
||||
// can pick the matching CLI profile (server_url from ~/.multica config).
|
||||
useEffect(() => {
|
||||
if (!runtimeConfig) return;
|
||||
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
|
||||
}, [runtimeConfig]);
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
@@ -108,68 +99,21 @@ function AppContent() {
|
||||
const wsCount = workspaces.length;
|
||||
const hasOnboarded = useHasOnboarded();
|
||||
|
||||
// Bridge local daemon IPC status into the runtimes cache so this user's
|
||||
// own daemon flips to offline/online sub-second instead of waiting on the
|
||||
// server's 75s sweeper. Resolves wsId from the active tab so workspace
|
||||
// switches automatically rebind the subscription.
|
||||
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
|
||||
const activeWsId = activeWorkspaceSlug
|
||||
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
|
||||
: undefined;
|
||||
useDaemonIPCBridge(activeWsId);
|
||||
|
||||
// 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
|
||||
//
|
||||
// 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.
|
||||
// Onboarding and zero-workspace both resolve to an overlay, but
|
||||
// onboarding wins: a user who hasn't completed it gets the onboarding
|
||||
// overlay regardless of how many workspaces already exist.
|
||||
useEffect(() => {
|
||||
if (!user || !workspaceListFetched) return undefined;
|
||||
if (!user || !workspaceListFetched) return;
|
||||
const { overlay, open } = useWindowOverlayStore.getState();
|
||||
if (overlay) return undefined;
|
||||
if (wsCount > 0) return undefined;
|
||||
if (overlay) return;
|
||||
if (!hasOnboarded) {
|
||||
// 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
|
||||
// missed invites later once they're onboarded.
|
||||
let cancelled = false;
|
||||
void api
|
||||
.listMyInvitations()
|
||||
.then((invites) => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
if (invites.length > 0) {
|
||||
qc.setQueryData(workspaceKeys.myInvitations(), invites);
|
||||
latestOpen({ type: "invitations" });
|
||||
} else {
|
||||
latestOpen({ type: "onboarding" });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
const { overlay: latestOverlay, open: latestOpen } =
|
||||
useWindowOverlayStore.getState();
|
||||
if (latestOverlay) return;
|
||||
latestOpen({ type: "onboarding" });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
open({ type: "onboarding" });
|
||||
return;
|
||||
}
|
||||
open({ type: "new-workspace" });
|
||||
return undefined;
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
|
||||
if (wsCount === 0) {
|
||||
open({ type: "new-workspace" });
|
||||
}
|
||||
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded]);
|
||||
|
||||
// Validate persisted tab state against the current user's workspace list,
|
||||
// and pick an active workspace if none is set. Runs in useLayoutEffect
|
||||
@@ -234,21 +178,9 @@ function AppContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function BlockingRuntimeConfigError({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
|
||||
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
|
||||
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
|
||||
</p>
|
||||
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
|
||||
{message}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Backend the daemon should connect to — same URL the renderer talks to.
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
@@ -272,61 +204,22 @@ async function handleDaemonLogout() {
|
||||
|
||||
export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
() => ({ platform: "desktop", version, os }),
|
||||
[version, os],
|
||||
);
|
||||
// Locale resolution happens once at app boot. Switching language goes
|
||||
// through window.location.reload() to avoid hydration mismatch.
|
||||
const localeAdapter = useMemo(
|
||||
() => createDesktopLocaleAdapter(systemLocale),
|
||||
[systemLocale],
|
||||
);
|
||||
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
|
||||
const resources = useMemo(
|
||||
() => ({ [locale]: RESOURCES[locale] }),
|
||||
[locale],
|
||||
);
|
||||
|
||||
// React to OS-level language changes detected by main on focus regain.
|
||||
// Only act when the user is following the system signal (no explicit
|
||||
// Settings choice) — otherwise their preference wins. Cross-device sync
|
||||
// for the explicit-choice case is handled inside CoreProvider.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
|
||||
if (localeAdapter.getUserChoice()) return;
|
||||
const next = pickLocale({
|
||||
...localeAdapter,
|
||||
getSystemPreferences: () =>
|
||||
nextSystemLocale ? [nextSystemLocale] : [],
|
||||
});
|
||||
if (next === locale) return;
|
||||
localeAdapter.persist(next);
|
||||
window.location.reload();
|
||||
});
|
||||
}, [localeAdapter, locale]);
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
{runtimeConfigResult.ok ? (
|
||||
<CoreProvider
|
||||
apiBaseUrl={runtimeConfigResult.config.apiUrl}
|
||||
wsUrl={runtimeConfigResult.config.wsUrl}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
locale={locale}
|
||||
resources={resources}
|
||||
localeAdapter={localeAdapter}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
) : (
|
||||
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
|
||||
)}
|
||||
<CoreProvider
|
||||
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
|
||||
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
|
||||
onLogout={handleDaemonLogout}
|
||||
identity={identity}
|
||||
>
|
||||
<AppContent />
|
||||
</CoreProvider>
|
||||
<Toaster />
|
||||
<UpdateNotification />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,261 +1,150 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import {
|
||||
Fragment,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Copy as CopyIcon,
|
||||
Search,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Trash2,
|
||||
ChevronDown,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@multica/ui/components/ui/sheet";
|
||||
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
interface DaemonPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
status: DaemonStatus;
|
||||
/** Number of runtimes this local daemon has registered (for the context badge). */
|
||||
runtimeCount: number;
|
||||
}
|
||||
|
||||
const LOG_LEVEL_COLORS: Record<string, string> = {
|
||||
INFO: "text-info",
|
||||
WARN: "text-warning",
|
||||
ERROR: "text-destructive",
|
||||
DEBUG: "text-muted-foreground",
|
||||
};
|
||||
|
||||
function colorizeLogLine(line: string): { level: string; className: string } {
|
||||
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
|
||||
if (line.includes(level)) return { level, className };
|
||||
}
|
||||
return { level: "", className: "text-muted-foreground" };
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-4 py-1">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="truncate text-right text-sm">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ state }: { state: DaemonState }) {
|
||||
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
id: number;
|
||||
line: string;
|
||||
}
|
||||
|
||||
const MAX_LOG_LINES = 500;
|
||||
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
let logIdCounter = 0;
|
||||
|
||||
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
|
||||
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
|
||||
INFO: "border-foreground/15 text-foreground/80",
|
||||
WARN: "border-warning/40 text-warning",
|
||||
ERROR: "border-destructive/40 text-destructive",
|
||||
};
|
||||
|
||||
// What gets rendered in the viewport — a single line or a folded group of
|
||||
// consecutive lines that share the same `message`. The group form is what
|
||||
// turns a wall of `DBG poll: no tasks` into a single placeholder.
|
||||
type DisplayItem =
|
||||
| { kind: "line"; line: ParsedLogLine }
|
||||
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
|
||||
|
||||
export function DaemonPanel({
|
||||
open,
|
||||
onOpenChange,
|
||||
status,
|
||||
runtimeCount,
|
||||
}: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
// Each level chip is an independent toggle. DEBUG is off by default so
|
||||
// poll-loop noise doesn't drown out real events when the panel opens —
|
||||
// users opt in if they want to see it.
|
||||
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
|
||||
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
|
||||
);
|
||||
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
|
||||
|
||||
const idCounterRef = useRef(0);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const logContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// --- Log stream subscription ---
|
||||
// Active only while the modal is open. On open we replay the file's tail
|
||||
// (~200 lines) so users have context for "what just happened"; on close
|
||||
// we tear down the watcher so the main process isn't doing work for a
|
||||
// hidden UI.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
idCounterRef.current = 0;
|
||||
|
||||
window.daemonAPI.startLogStream();
|
||||
const unsub = window.daemonAPI.onLogLine((line) => {
|
||||
setLogs((prev) => {
|
||||
const id = ++idCounterRef.current;
|
||||
const parsed = parseLogLine(line, id);
|
||||
const next =
|
||||
prev.length >= MAX_LOG_LINES
|
||||
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
|
||||
: [...prev, parsed];
|
||||
return next;
|
||||
const next = [...prev, { id: ++logIdCounter, line }];
|
||||
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
window.daemonAPI.stopLogStream();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
// --- Derived: counts per level (for filter chip badges) ---
|
||||
const levelCounts = useMemo(() => {
|
||||
const counts: Record<LogLevel, number> = {
|
||||
DEBUG: 0,
|
||||
INFO: 0,
|
||||
WARN: 0,
|
||||
ERROR: 0,
|
||||
};
|
||||
for (const l of logs) {
|
||||
if (l.level) counts[l.level] += 1;
|
||||
}
|
||||
return counts;
|
||||
}, [logs]);
|
||||
|
||||
// --- Derived: filtered list (level toggle + search) ---
|
||||
// Lines that didn't parse (level = null) always pass — they're typically
|
||||
// panic stack traces / partial writes; never silently drop them.
|
||||
const filtered = useMemo(() => {
|
||||
let result = logs;
|
||||
result = result.filter((l) => {
|
||||
if (!l.level) return true;
|
||||
return enabledLevels.has(l.level);
|
||||
});
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((l) => l.raw.toLowerCase().includes(q));
|
||||
}
|
||||
return result;
|
||||
}, [logs, enabledLevels, search]);
|
||||
|
||||
// --- Derived: collapse runs of consecutive lines that share the same
|
||||
// message into a single group placeholder. The most common case is the
|
||||
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
|
||||
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
|
||||
// doesn't strand groups.
|
||||
const displayed = useMemo<DisplayItem[]>(() => {
|
||||
const out: DisplayItem[] = [];
|
||||
for (const line of filtered) {
|
||||
const last = out[out.length - 1];
|
||||
if (!last) {
|
||||
out.push({ kind: "line", line });
|
||||
continue;
|
||||
}
|
||||
const lastMessage =
|
||||
last.kind === "line" ? last.line.message : last.first.message;
|
||||
if (lastMessage && lastMessage === line.message) {
|
||||
if (last.kind === "line") {
|
||||
out[out.length - 1] = {
|
||||
kind: "group",
|
||||
first: last.line,
|
||||
rest: [line],
|
||||
};
|
||||
} else {
|
||||
last.rest.push(line);
|
||||
}
|
||||
} else {
|
||||
out.push({ kind: "line", line });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}, [filtered]);
|
||||
|
||||
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [displayed, autoScroll]);
|
||||
if (autoScroll && logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, autoScroll]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const handleLogScroll = useCallback(() => {
|
||||
const el = logContainerRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
|
||||
// here. Re-enabling lives in the "Jump to latest" footer button so a
|
||||
// burst of lines doesn't yank a reading user back to the bottom.
|
||||
if (!atBottom && autoScroll) setAutoScroll(false);
|
||||
}, [autoScroll]);
|
||||
|
||||
const handleResume = useCallback(() => {
|
||||
setAutoScroll(true);
|
||||
const el = logContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
setAutoScroll(atBottom);
|
||||
}, []);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const text = filtered.map((l) => l.raw).join("\n");
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(
|
||||
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error("Failed to copy", {
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [filtered]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLogs([]);
|
||||
setExpandedFields(new Set());
|
||||
setExpandedGroups(new Set());
|
||||
}, []);
|
||||
|
||||
const toggleLevel = useCallback((lv: LogLevel) => {
|
||||
setEnabledLevels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(lv)) next.delete(lv);
|
||||
else next.add(lv);
|
||||
return next;
|
||||
});
|
||||
const handleStart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.start();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to start daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleFields = useCallback((id: number) => {
|
||||
setExpandedFields((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to stop daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleGroup = useCallback((id: number) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
setActionLoading(false);
|
||||
if (!result.success) {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex flex-col sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Server className="size-4 shrink-0 text-muted-foreground" />
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
Local daemon logs
|
||||
</DialogTitle>
|
||||
<ContextBadge status={status} runtimeCount={runtimeCount} />
|
||||
</div>
|
||||
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<Server className="size-4" />
|
||||
Local Daemon
|
||||
</SheetTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
@@ -264,412 +153,157 @@ export function DaemonPanel({
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative w-56">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
|
||||
<div className="shrink-0 space-y-4">
|
||||
{/* Status info */}
|
||||
<div className="rounded-lg border p-3 space-y-0.5">
|
||||
<InfoRow
|
||||
label="Status"
|
||||
value={
|
||||
<span className="flex items-center gap-1.5">
|
||||
<StatusDot state={status.state} />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level toggle chips. Each chip is independent — click to
|
||||
show/hide that level. DEBUG starts hidden because the
|
||||
poll-loop heartbeat dominates otherwise. */}
|
||||
<div className="flex items-center gap-1">
|
||||
{LEVELS.map((lv) => (
|
||||
<FilterChip
|
||||
key={lv}
|
||||
active={enabledLevels.has(lv)}
|
||||
onClick={() => toggleLevel(lv)}
|
||||
label={lv}
|
||||
count={levelCounts[lv]}
|
||||
variant={lv}
|
||||
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
|
||||
<InfoRow label="Profile" value={status.profile || "default"} />
|
||||
{status.serverUrl && (
|
||||
<InfoRow
|
||||
label="Server"
|
||||
value={
|
||||
<span className="font-mono text-xs" title={status.serverUrl}>
|
||||
{status.serverUrl}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
{status.agents && status.agents.length > 0 && (
|
||||
<InfoRow label="Agents" value={status.agents.join(", ")} />
|
||||
)}
|
||||
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
|
||||
{status.daemonId && (
|
||||
<InfoRow
|
||||
label="Daemon ID"
|
||||
value={<span className="font-mono text-xs">{status.daemonId}</span>}
|
||||
/>
|
||||
)}
|
||||
{typeof status.workspaceCount === "number" && (
|
||||
<InfoRow label="Workspaces" value={status.workspaceCount} />
|
||||
)}
|
||||
{status.pid && (
|
||||
<InfoRow
|
||||
label="PID"
|
||||
value={<span className="font-mono text-xs">{status.pid}</span>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right-aligned actions */}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleCopy}
|
||||
disabled={filtered.length === 0}
|
||||
>
|
||||
<CopyIcon className="size-3.5 mr-1.5" />
|
||||
Copy
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={handleClear}
|
||||
disabled={logs.length === 0}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Logs viewport */}
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
|
||||
>
|
||||
{displayed.length === 0 ? (
|
||||
<EmptyState
|
||||
hasLogs={logs.length > 0}
|
||||
hasFilter={hasActiveFilter}
|
||||
isRunning={status.state === "running"}
|
||||
/>
|
||||
{/* Actions */}
|
||||
{status.state === "installing_cli" ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
|
||||
Setting up the local runtime… this only happens the first time.
|
||||
</div>
|
||||
) : status.state === "cli_not_found" ? (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
|
||||
<p className="text-sm">
|
||||
Couldn't download the local runtime. Check your network
|
||||
connection and try again.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{displayed.map((item) =>
|
||||
item.kind === "line" ? (
|
||||
<LogLineRow
|
||||
key={item.line.id}
|
||||
line={item.line}
|
||||
expanded={expandedFields.has(item.line.id)}
|
||||
onToggle={() => toggleFields(item.line.id)}
|
||||
search={search}
|
||||
/>
|
||||
) : (
|
||||
<GroupRows
|
||||
key={item.first.id}
|
||||
first={item.first}
|
||||
rest={item.rest}
|
||||
expanded={expandedGroups.has(item.first.id)}
|
||||
onToggle={() => toggleGroup(item.first.id)}
|
||||
expandedFields={expandedFields}
|
||||
onToggleFields={toggleFields}
|
||||
search={search}
|
||||
/>
|
||||
),
|
||||
<div className="flex gap-2">
|
||||
{status.state === "stopped" ? (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Start
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading || isTransitioning}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status bar — count only. The "is the user following" state is
|
||||
communicated implicitly by the presence of the Jump-to-latest
|
||||
button below; an explicit "Paused" word read as "log stream is
|
||||
paused" (it isn't — data keeps flowing into the buffer). */}
|
||||
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
Showing {filtered.length} of {logs.length}
|
||||
{logs.length === MAX_LOG_LINES && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
(buffer full)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!autoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResume}
|
||||
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowDown className="size-3" />
|
||||
Jump to latest
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function ContextBadge({
|
||||
status,
|
||||
runtimeCount,
|
||||
}: {
|
||||
status: DaemonStatus;
|
||||
runtimeCount: number;
|
||||
}) {
|
||||
const isRunning = status.state === "running";
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
{isRunning && runtimeCount > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({
|
||||
active,
|
||||
onClick,
|
||||
label,
|
||||
count,
|
||||
variant,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
count: number;
|
||||
variant?: LogLevel;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
|
||||
active
|
||||
? variant
|
||||
? LEVEL_BADGE_CLASS[variant]
|
||||
: "bg-accent text-accent-foreground"
|
||||
: "border-dashed text-muted-foreground/50",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
active ? "text-current/80" : "text-muted-foreground/40",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelBadge({ level }: { level: LogLevel }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
|
||||
LEVEL_BADGE_CLASS[level],
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LogLineRow({
|
||||
line,
|
||||
expanded,
|
||||
onToggle,
|
||||
search,
|
||||
}: {
|
||||
line: ParsedLogLine;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
search: string;
|
||||
}) {
|
||||
const fieldEntries = Object.entries(line.fields);
|
||||
const hasFields = fieldEntries.length > 0;
|
||||
|
||||
// Unparseable line — render the raw text so nothing is hidden. Common
|
||||
// for panic stack traces and partial writes during log rotation.
|
||||
if (!line.timestamp || !line.level) {
|
||||
return (
|
||||
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
|
||||
{highlight(line.raw, search)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
|
||||
hasFields && "cursor-pointer",
|
||||
)}
|
||||
onClick={hasFields ? onToggle : undefined}
|
||||
>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground/60">
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<LevelBadge level={line.level} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="break-words">{highlight(line.message, search)}</span>
|
||||
{hasFields && !expanded && (
|
||||
<span className="min-w-0 truncate text-muted-foreground/60">
|
||||
{fieldEntries
|
||||
.map(([k, v]) => `${k}=${truncateValue(v)}`)
|
||||
.join(" ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{expanded && hasFields && (
|
||||
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
|
||||
{fieldEntries.map(([k, v]) => (
|
||||
<Fragment key={k}>
|
||||
<span className="text-muted-foreground/70">{k}</span>
|
||||
<span className="break-all text-foreground/85">{v}</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupRows({
|
||||
first,
|
||||
rest,
|
||||
expanded,
|
||||
onToggle,
|
||||
expandedFields,
|
||||
onToggleFields,
|
||||
search,
|
||||
}: {
|
||||
first: ParsedLogLine;
|
||||
rest: ParsedLogLine[];
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
expandedFields: Set<number>;
|
||||
onToggleFields: (id: number) => void;
|
||||
search: string;
|
||||
}) {
|
||||
// Folded: show the first occurrence so the user still sees a sample
|
||||
// (timestamp, level, message), then a click-to-expand placeholder for
|
||||
// the suppressed run. The placeholder uses a dashed border + italics
|
||||
// so the eye reads it as "not a real line".
|
||||
if (!expanded) {
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>
|
||||
{rest.length} more “{truncateValue(first.message, 48)}
|
||||
” — click to expand
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Unfolded: render every line, then a small "collapse" affordance at
|
||||
// the end so the user can put the toothpaste back in the tube.
|
||||
return (
|
||||
<>
|
||||
<LogLineRow
|
||||
line={first}
|
||||
expanded={expandedFields.has(first.id)}
|
||||
onToggle={() => onToggleFields(first.id)}
|
||||
search={search}
|
||||
/>
|
||||
{rest.map((l) => (
|
||||
<LogLineRow
|
||||
key={l.id}
|
||||
line={l}
|
||||
expanded={expandedFields.has(l.id)}
|
||||
onToggle={() => onToggleFields(l.id)}
|
||||
search={search}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
<span>···</span>
|
||||
<span>collapse {rest.length + 1} repeated</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
hasLogs,
|
||||
hasFilter,
|
||||
isRunning,
|
||||
}: {
|
||||
hasLogs: boolean;
|
||||
hasFilter: boolean;
|
||||
isRunning: boolean;
|
||||
}) {
|
||||
let title: string;
|
||||
let subtitle: string;
|
||||
if (hasFilter) {
|
||||
title = "No matching log lines";
|
||||
subtitle = "Try a different search or level toggle.";
|
||||
} else if (!isRunning) {
|
||||
title = "Daemon isn't running";
|
||||
subtitle = "Start the daemon to see logs here.";
|
||||
} else if (!hasLogs) {
|
||||
title = "Waiting for logs…";
|
||||
subtitle = "New entries will appear in real time.";
|
||||
} else {
|
||||
title = "";
|
||||
subtitle = "";
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
|
||||
<p className="text-sm">{title}</p>
|
||||
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Helpers ----------
|
||||
|
||||
function truncateValue(value: string, max = 32): string {
|
||||
return value.length > max ? `${value.slice(0, max)}…` : value;
|
||||
}
|
||||
|
||||
function highlight(text: string, query: string): ReactNode {
|
||||
if (!query) return text;
|
||||
const q = query.toLowerCase();
|
||||
const lower = text.toLowerCase();
|
||||
const idx = lower.indexOf(q);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
|
||||
{/* Logs — fills remaining vertical space down to the sheet bottom */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h3 className="text-sm font-medium">Logs</h3>
|
||||
{!autoScroll && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ChevronDown className="size-3 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
onScroll={handleLogScroll}
|
||||
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground/50 text-center py-8">
|
||||
{status.state === "running"
|
||||
? "Waiting for logs…"
|
||||
: "Start the daemon to see logs"}
|
||||
</p>
|
||||
) : (
|
||||
logs.map((entry) => {
|
||||
const { className } = colorizeLogLine(entry.line);
|
||||
return (
|
||||
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
|
||||
{entry.line}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,22 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS, formatUptime } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* 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 DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [confirmStop, setConfirmStop] = useState(false);
|
||||
|
||||
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(
|
||||
runtimes
|
||||
.filter((r) => r.daemon_id === status.daemonId)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
}, [runtimes, status.daemonId]);
|
||||
|
||||
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(
|
||||
(t) =>
|
||||
localRuntimeIds.has(t.runtime_id) &&
|
||||
(t.status === "running" || t.status === "dispatched"),
|
||||
),
|
||||
[snapshot, localRuntimeIds],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getStatus().then((s) => setStatus(s));
|
||||
@@ -108,10 +36,7 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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 () => {
|
||||
const handleStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
if (!result.success) {
|
||||
@@ -119,214 +44,112 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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();
|
||||
} else {
|
||||
setConfirmStop(true);
|
||||
}
|
||||
}, [affectedTasks.length, performStop]);
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.restart();
|
||||
if (!result.success) {
|
||||
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.",
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRetryInstall = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await window.daemonAPI.retryInstall();
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isTransitioning = status.state === "starting" || status.state === "stopping";
|
||||
const isRunning = status.state === "running";
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isTransitioning =
|
||||
status.state === "starting" || status.state === "stopping";
|
||||
const isInstalling = status.state === "installing_cli";
|
||||
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
|
||||
|
||||
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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],
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setPanelOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
setPanelOpen(true);
|
||||
}
|
||||
}}
|
||||
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium">Local Daemon</h3>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
|
||||
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
|
||||
{isRunning && status.uptime && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
{isRunning && status.agents && status.agents.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex items-center gap-1.5 shrink-0"
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading || status.state === "cli_not_found"}
|
||||
>
|
||||
{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 && (
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
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}
|
||||
variant="ghost"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
Restart
|
||||
</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
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
{isTransitioning && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
onOpenChange={setPanelOpen}
|
||||
status={status}
|
||||
runtimeCount={runtimeCount}
|
||||
/>
|
||||
|
||||
<StopConfirmDialog
|
||||
open={confirmStop}
|
||||
onOpenChange={setConfirmStop}
|
||||
affectedCount={affectedTasks.length}
|
||||
onConfirm={() => {
|
||||
setConfirmStop(false);
|
||||
void performStop();
|
||||
}}
|
||||
/>
|
||||
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- Sub-components ----------
|
||||
|
||||
function StopConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
affectedCount,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
affectedCount: number;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const plural = affectedCount === 1 ? "" : "s";
|
||||
const verb = affectedCount === 1 ? "is" : "are";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm" showCloseButton={false}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
</div>
|
||||
<DialogHeader className="flex-1 gap-1">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
Stop daemon with {affectedCount} active task{plural}?
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs leading-relaxed">
|
||||
{affectedCount} task{plural} {verb} currently running on this
|
||||
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
|
||||
— affected tasks get marked <strong>failed</strong> once the
|
||||
timeout hits. The daemon won't auto-restart.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
Stop daemon
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import type { DaemonPrefs } from "../../../shared/daemon-types";
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
@@ -16,7 +10,7 @@ function SettingRow({
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
children: ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 py-4">
|
||||
@@ -29,44 +23,14 @@ function SettingRow({
|
||||
);
|
||||
}
|
||||
|
||||
// One row inside the diagnostics block. Values that are likely to be
|
||||
// long IDs / URLs render as monospaced + truncated with a tooltip.
|
||||
function DiagnosticsRow({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm",
|
||||
mono && "font-mono text-xs",
|
||||
)}
|
||||
title={typeof value === "string" ? value : undefined}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DaemonSettingsTab() {
|
||||
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
|
||||
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
|
||||
useEffect(() => {
|
||||
window.daemonAPI.getPrefs().then(setPrefs);
|
||||
window.daemonAPI.isCliInstalled().then(setCliInstalled);
|
||||
window.daemonAPI.getStatus().then(setStatus);
|
||||
return window.daemonAPI.onStatusChange(setStatus);
|
||||
}, []);
|
||||
|
||||
const updatePref = useCallback(
|
||||
@@ -134,68 +98,6 @@ export function DaemonSettingsTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Diagnostics — moved out of the logs panel so the panel can focus
|
||||
on logs. These fields matter for support tickets and bug reports,
|
||||
not for everyday use. */}
|
||||
<div className="mt-8">
|
||||
<h3 className="text-sm font-semibold">Diagnostics</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Identification and connection details. Useful when filing a bug
|
||||
report or investigating why a runtime isn't showing up.
|
||||
</p>
|
||||
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
|
||||
<DiagnosticsRow
|
||||
label="State"
|
||||
value={
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Uptime"
|
||||
value={status.uptime ? formatUptime(status.uptime) : "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="PID"
|
||||
value={status.pid ?? "—"}
|
||||
mono={!!status.pid}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Daemon ID"
|
||||
value={status.daemonId ?? "—"}
|
||||
mono={!!status.daemonId}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Profile"
|
||||
value={status.profile || "default"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Server URL"
|
||||
value={status.serverUrl ?? "—"}
|
||||
mono={!!status.serverUrl}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Device name"
|
||||
value={status.deviceName ?? "—"}
|
||||
/>
|
||||
<DiagnosticsRow
|
||||
label="Workspaces"
|
||||
value={
|
||||
typeof status.workspaceCount === "number"
|
||||
? status.workspaceCount
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ 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 { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { useDesktopUnreadBadge } from "@multica/views/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
@@ -98,38 +97,6 @@ function useInternalLinkHandler() {
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge between the renderer and the Electron main process for inbox-level
|
||||
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
|
||||
* current workspace's id for the badge hook.
|
||||
*
|
||||
* Two responsibilities:
|
||||
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
|
||||
* 2. When the user clicks an OS notification, open the notified
|
||||
* workspace's inbox focused on that item. The route uses the `slug`
|
||||
* that the notification was *emitted* with — not the currently active
|
||||
* workspace — so a notification from workspace A always opens A's
|
||||
* inbox even if the user has since switched to workspace B. Marking
|
||||
* the row read is handled by InboxPage's selected-item effect, which
|
||||
* covers both click-to-select and URL-param-select paths.
|
||||
*/
|
||||
function DesktopInboxBridge() {
|
||||
const workspace = useCurrentWorkspace();
|
||||
useDesktopUnreadBadge(workspace?.id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
|
||||
if (!slug) return;
|
||||
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DesktopShell() {
|
||||
useInternalLinkHandler();
|
||||
useActiveTitleSync();
|
||||
@@ -151,7 +118,6 @@ export function DesktopShell() {
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<DesktopInboxBridge />
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
// vi.hoisted shared state — every store mock reads the same object so each
|
||||
// test can mutate it then re-render to drive the tracker.
|
||||
const state = vi.hoisted(() => ({
|
||||
user: null as { id: string } | null,
|
||||
overlay: null as { type: string; invitationId?: string } | null,
|
||||
activeWorkspaceSlug: null as string | null,
|
||||
byWorkspace: {} as Record<
|
||||
string,
|
||||
{ activeTabId: string; tabs: { id: string; path: string }[] }
|
||||
>,
|
||||
capturePageview: vi.fn<(path?: string) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/analytics", () => ({
|
||||
capturePageview: state.capturePageview,
|
||||
}));
|
||||
|
||||
// Auth store — single selector pattern (`s => s.user`).
|
||||
vi.mock("@multica/core/auth", () => {
|
||||
const useAuthStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useAuthStore };
|
||||
});
|
||||
|
||||
// Window overlay store — same shape.
|
||||
vi.mock("@/stores/window-overlay-store", () => {
|
||||
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
|
||||
selector(state);
|
||||
return { useWindowOverlayStore };
|
||||
});
|
||||
|
||||
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
|
||||
// getState() for the seed pass and the helpers the tracker imports
|
||||
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
|
||||
// from the real store inside a mocked module.
|
||||
vi.mock("@/stores/tab-store", () => {
|
||||
const useTabStore = Object.assign(
|
||||
(selector: (s: typeof state) => unknown) => selector(state),
|
||||
{ getState: () => state },
|
||||
);
|
||||
const getActiveTab = (s: typeof state) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.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,
|
||||
});
|
||||
return { useTabStore, getActiveTab, useActiveTabIdentity };
|
||||
});
|
||||
|
||||
import { PageviewTracker } from "./pageview-tracker";
|
||||
|
||||
function reset() {
|
||||
state.user = { id: "u1" };
|
||||
state.overlay = null;
|
||||
state.activeWorkspaceSlug = null;
|
||||
state.byWorkspace = {};
|
||||
state.capturePageview.mockClear();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
});
|
||||
|
||||
describe("PageviewTracker", () => {
|
||||
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
// Initial mount on tA — seeded as observed, no pageview because both
|
||||
// tabs were already in the persisted store before the tracker mounted.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch to tB (already-known tab on its already-known path).
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tB",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Switch back to tA — still no pageview.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tB", path: "/acme/inbox" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tC",
|
||||
tabs: [
|
||||
{ id: "tA", path: "/acme/issues" },
|
||||
{ id: "tC", path: "/acme/agents" },
|
||||
],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
|
||||
});
|
||||
|
||||
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
|
||||
// creates a fresh tab in the destination workspace and makes it active.
|
||||
state.byWorkspace = {
|
||||
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
|
||||
butter: {
|
||||
activeTabId: "tD",
|
||||
tabs: [{ id: "tD", path: "/butter/inbox" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "butter";
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
|
||||
});
|
||||
|
||||
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues/123" }],
|
||||
},
|
||||
};
|
||||
rerender(<PageviewTracker />);
|
||||
|
||||
expect(state.capturePageview).toHaveBeenCalledTimes(1);
|
||||
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
|
||||
});
|
||||
|
||||
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
const { rerender } = render(<PageviewTracker />);
|
||||
state.capturePageview.mockClear();
|
||||
|
||||
// Open onboarding overlay.
|
||||
state.overlay = { type: "onboarding" };
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
|
||||
|
||||
// Close overlay back to the tab — the tab is already observed on
|
||||
// /acme/issues so this is a re-activation, no pageview.
|
||||
state.capturePageview.mockClear();
|
||||
state.overlay = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
|
||||
// Logout fires /login.
|
||||
state.user = null;
|
||||
rerender(<PageviewTracker />);
|
||||
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
|
||||
});
|
||||
|
||||
it("suppresses on initial mount when the active tab was restored from persistence", () => {
|
||||
state.byWorkspace = {
|
||||
acme: {
|
||||
activeTabId: "tA",
|
||||
tabs: [{ id: "tA", path: "/acme/issues" }],
|
||||
},
|
||||
};
|
||||
state.activeWorkspaceSlug = "acme";
|
||||
|
||||
render(<PageviewTracker />);
|
||||
// Restored tab — seeded, treated as a re-activation.
|
||||
expect(state.capturePageview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { capturePageview } from "@multica/core/analytics";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import {
|
||||
getActiveTab,
|
||||
useActiveTabIdentity,
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
* EXCEPT for re-activations of an already-known tab on its already-known
|
||||
* path.
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes.
|
||||
*
|
||||
* Desktop has three layers that can own the visible page:
|
||||
*
|
||||
@@ -23,18 +17,10 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
|
||||
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
|
||||
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
|
||||
*
|
||||
* Tab-switch suppression: re-activating an already-open tab surfaces a
|
||||
* previously-visited path under a `(workspace, tabId)` we have already
|
||||
* seen — the pageview was emitted when the user originally navigated
|
||||
* there, so re-emitting on every switch just inflates PostHog billing
|
||||
* without adding signal (real-data audit: desktop tab switches were
|
||||
* ~50% of all `$pageview` events).
|
||||
*
|
||||
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
|
||||
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
|
||||
* because their key is not in the observed map yet. The map is seeded
|
||||
* from the persisted tab store on first render so tabs restored from a
|
||||
* previous session don't all re-emit on first activation.
|
||||
* The overlay takes precedence over the tab path because it is visually in
|
||||
* front of the tab system; the logged-out state shadows both because the
|
||||
* shell doesn't render at all yet. This keeps the `$pageview` stream aligned
|
||||
* with what the user actually sees.
|
||||
*
|
||||
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
|
||||
* `initAnalytics`) so this component owns the event shape, matching the web
|
||||
@@ -43,75 +29,34 @@ import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overl
|
||||
export function PageviewTracker() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
|
||||
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
|
||||
const activeTabPath = useTabStore((s) => {
|
||||
const slug = s.activeWorkspaceSlug;
|
||||
if (!slug) return null;
|
||||
const group = s.byWorkspace[slug];
|
||||
if (!group) return null;
|
||||
return group.tabs.find((t) => t.id === group.activeTabId)?.path ?? null;
|
||||
});
|
||||
|
||||
// (slug:tabId) → last path observed while that tab was visible. Lets us
|
||||
// tell "re-activating a tab on a path we already saw" (suppress) apart
|
||||
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
|
||||
// synchronously on first render from the persisted tab store so
|
||||
// session-restored tabs don't re-emit on first click.
|
||||
const observedTabsRef = useRef<Map<string, string> | null>(null);
|
||||
if (observedTabsRef.current === null) {
|
||||
const seed = new Map<string, string>();
|
||||
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
|
||||
for (const tab of group.tabs) {
|
||||
seed.set(`${slug}:${tab.id}`, tab.path);
|
||||
}
|
||||
}
|
||||
observedTabsRef.current = seed;
|
||||
}
|
||||
const lastSurfaceRef = useRef<{
|
||||
kind: "login" | "overlay" | "tab" | null;
|
||||
key: string | null;
|
||||
path: string | null;
|
||||
}>({ kind: null, key: null, path: null });
|
||||
const path = resolvePath(user, overlay, activeTabPath);
|
||||
|
||||
useEffect(() => {
|
||||
let kind: "login" | "overlay" | "tab";
|
||||
let path: string;
|
||||
let key: string | null = null;
|
||||
|
||||
if (!user) {
|
||||
kind = "login";
|
||||
path = "/login";
|
||||
} else if (overlay) {
|
||||
kind = "overlay";
|
||||
path = overlayPath(overlay);
|
||||
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
|
||||
kind = "tab";
|
||||
key = `${activeWorkspaceSlug}:${activeTabId}`;
|
||||
path = activeTabPath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
const observed = observedTabsRef.current!;
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
last.key !== key && knownPath !== undefined && knownPath === path;
|
||||
observed.set(key, path);
|
||||
if (isReactivation) {
|
||||
lastSurfaceRef.current = next;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const unchanged =
|
||||
last.kind === kind && last.key === key && last.path === path;
|
||||
if (unchanged) return;
|
||||
|
||||
if (!path) return;
|
||||
capturePageview(path);
|
||||
lastSurfaceRef.current = next;
|
||||
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
|
||||
}, [path]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolvePath(
|
||||
user: unknown,
|
||||
overlay: WindowOverlay | null,
|
||||
activeTabPath: string | null,
|
||||
): string | null {
|
||||
if (!user) return "/login";
|
||||
if (overlay) return overlayPath(overlay);
|
||||
return activeTabPath;
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
@@ -120,7 +65,5 @@ function overlayPath(overlay: WindowOverlay): string {
|
||||
return "/onboarding";
|
||||
case "invite":
|
||||
return `/invite/${overlay.invitationId}`;
|
||||
case "invitations":
|
||||
return "/invitations";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseLogLine } from "./parse-daemon-log";
|
||||
|
||||
// All sample lines below are taken verbatim from real daemon output (Go
|
||||
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
|
||||
// stay aligned with what tint actually writes — not what we assume.
|
||||
|
||||
describe("parseLogLine", () => {
|
||||
it("parses tint's 3-letter INF level", () => {
|
||||
const line =
|
||||
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.timestamp).toBe("17:52:35.587");
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("task completed");
|
||||
expect(r.fields).toEqual({
|
||||
component: "daemon",
|
||||
task: "c45266e5",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses 3-letter DBG / WRN / ERR levels", () => {
|
||||
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
|
||||
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
|
||||
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
|
||||
});
|
||||
|
||||
it("still accepts 4-letter level names (defensive against config changes)", () => {
|
||||
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("regular");
|
||||
});
|
||||
|
||||
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
|
||||
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
|
||||
// We treat the base level as canonical and drop the delta from the UI.
|
||||
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
|
||||
expect(r.level).toBe("INFO");
|
||||
expect(r.message).toBe("unusual delta");
|
||||
});
|
||||
|
||||
it("preserves message text containing colons and special chars", () => {
|
||||
// Real sample: "tool #1: Skill component=daemon task=..."
|
||||
const r = parseLogLine(
|
||||
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("tool #1: Skill");
|
||||
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
|
||||
});
|
||||
|
||||
it("unquotes a double-quoted value containing escaped quotes", () => {
|
||||
// Real sample with escaped quotes inside the agent's emitted text.
|
||||
const line =
|
||||
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent");
|
||||
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
|
||||
expect(r.fields.task).toBe("8791b717");
|
||||
});
|
||||
|
||||
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
|
||||
// Real sample: error="Post \"http://...\": dial tcp ..."
|
||||
const line =
|
||||
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.level).toBe("WARN");
|
||||
expect(r.message).toBe("claim task failed");
|
||||
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
|
||||
expect(r.fields.error).toBe(
|
||||
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
|
||||
const line =
|
||||
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
|
||||
const r = parseLogLine(line, 1);
|
||||
expect(r.message).toBe("agent command");
|
||||
expect(r.fields.exec).toBe("claude");
|
||||
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
|
||||
});
|
||||
|
||||
it("handles message words ending with characters before the field block", () => {
|
||||
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
|
||||
const r = parseLogLine(
|
||||
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
|
||||
1,
|
||||
);
|
||||
expect(r.message).toBe("execenv: prepared env");
|
||||
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
|
||||
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
|
||||
expect(r.fields).toEqual({});
|
||||
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
|
||||
});
|
||||
|
||||
it("falls back to raw rendering for unrecognised level tokens", () => {
|
||||
// If tint ever emits something we don't know, never crash; show raw.
|
||||
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
|
||||
expect(r.timestamp).toBeNull();
|
||||
expect(r.level).toBeNull();
|
||||
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
|
||||
});
|
||||
|
||||
it("attaches an id to every parsed line for stable React keys", () => {
|
||||
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
|
||||
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
|
||||
expect(a.id).toBe(7);
|
||||
expect(b.id).toBe(8);
|
||||
});
|
||||
|
||||
it("returns empty fields object when there are no key=value pairs", () => {
|
||||
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
|
||||
expect(r.message).toBe("a bare message with no fields");
|
||||
expect(r.fields).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,96 +0,0 @@
|
||||
// Pure parser for daemon log lines. The daemon writes via Go's slog with
|
||||
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
|
||||
// has a stable shape:
|
||||
//
|
||||
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
|
||||
//
|
||||
// We split it into structured pieces so the UI can render timestamp,
|
||||
// level, message and structured fields in separate columns and let users
|
||||
// filter / search across them. Anything that doesn't match (panic stack
|
||||
// traces, third-party prints, partial writes during log rotation) falls
|
||||
// back to a raw view — we never drop input.
|
||||
|
||||
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
||||
|
||||
export interface ParsedLogLine {
|
||||
/** Monotonic id assigned at receive time; stable across re-renders. */
|
||||
id: number;
|
||||
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
|
||||
timestamp: string | null;
|
||||
level: LogLevel | null;
|
||||
/** Human-readable message body, with structured fields stripped off. */
|
||||
message: string;
|
||||
/** key/value pairs trailing the message. Empty if there were none. */
|
||||
fields: Record<string, string>;
|
||||
/** The original line, kept for fallback rendering and copy-to-clipboard. */
|
||||
raw: string;
|
||||
}
|
||||
|
||||
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
|
||||
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
|
||||
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
|
||||
// against future config changes) and normalize them to a canonical
|
||||
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
|
||||
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
|
||||
// and complicates the level filter chips.
|
||||
const HEADER_RE =
|
||||
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
|
||||
|
||||
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
|
||||
DEBUG: "DEBUG",
|
||||
DBG: "DEBUG",
|
||||
INFO: "INFO",
|
||||
INF: "INFO",
|
||||
WARN: "WARN",
|
||||
WRN: "WARN",
|
||||
ERROR: "ERROR",
|
||||
ERR: "ERROR",
|
||||
};
|
||||
// Anchored to the END of the remaining string so we peel one field at a
|
||||
// time from the right. `value` is either a double-quoted string (which may
|
||||
// contain escaped chars) or any non-whitespace run.
|
||||
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
||||
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function extractTrailingFields(rest: string): {
|
||||
message: string;
|
||||
fields: Record<string, string>;
|
||||
} {
|
||||
const fields: Record<string, string> = {};
|
||||
let work = rest;
|
||||
while (true) {
|
||||
const match = work.match(TRAILING_FIELD_RE);
|
||||
if (!match || match.index === undefined) break;
|
||||
fields[match[1]!] = unquote(match[2]!);
|
||||
work = work.slice(0, match.index);
|
||||
}
|
||||
return { message: work.trim(), fields };
|
||||
}
|
||||
|
||||
export function parseLogLine(raw: string, id: number): ParsedLogLine {
|
||||
const match = raw.match(HEADER_RE);
|
||||
if (!match) {
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const [, timestamp, level, rest] = match;
|
||||
const normalized = LEVEL_NORMALIZE[level!];
|
||||
if (!normalized) {
|
||||
// Unknown level token — keep raw shape so we don't mis-categorize.
|
||||
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
|
||||
}
|
||||
const { message, fields } = extractTrailingFields(rest!);
|
||||
return {
|
||||
id,
|
||||
timestamp: timestamp!,
|
||||
level: normalized,
|
||||
message,
|
||||
fields,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
@@ -5,13 +5,12 @@ import { Button } from "@multica/ui/components/ui/button";
|
||||
type CheckState =
|
||||
| { status: "idle" }
|
||||
| { status: "checking" }
|
||||
| { status: "up-to-date" }
|
||||
| { status: "up-to-date"; currentVersion: string }
|
||||
| { status: "available"; latestVersion: string }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
export function UpdatesSettingsTab() {
|
||||
const [state, setState] = useState<CheckState>({ status: "idle" });
|
||||
const currentVersion = window.desktopAPI.appInfo.version;
|
||||
|
||||
const handleCheck = useCallback(async () => {
|
||||
setState({ status: "checking" });
|
||||
@@ -23,7 +22,7 @@ export function UpdatesSettingsTab() {
|
||||
setState(
|
||||
result.available
|
||||
? { status: "available", latestVersion: result.latestVersion }
|
||||
: { status: "up-to-date" },
|
||||
: { status: "up-to-date", currentVersion: result.currentVersion },
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -36,15 +35,6 @@ export function UpdatesSettingsTab() {
|
||||
</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">Current version</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
|
||||
v{currentVersion}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
@@ -55,7 +45,7 @@ export function UpdatesSettingsTab() {
|
||||
{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" />
|
||||
You're on the latest version.
|
||||
You're on the latest version (v{state.currentVersion}).
|
||||
</p>
|
||||
)}
|
||||
{state.status === "available" && (
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { InvitationsPage } from "@multica/views/invitations";
|
||||
import { OnboardingFlow } from "@multica/views/onboarding";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
@@ -59,7 +58,6 @@ function WindowOverlayInner() {
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
import { WorkspacePresencePrefetch } from "@multica/views/layout";
|
||||
import { useTabStore } from "@/stores/tab-store";
|
||||
|
||||
/**
|
||||
@@ -83,7 +82,6 @@ export function WorkspaceRouteLayout() {
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
<WorkspacePresencePrefetch />
|
||||
<Outlet />
|
||||
</WorkspaceSlugProvider>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AgentDetailPage as SharedAgentDetailPage } from "@multica/views/agents";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function AgentDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const agent = agents.find((a) => a.id === id) ?? null;
|
||||
|
||||
useDocumentTitle(agent?.name ?? "Agent");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedAgentDetailPage agentId={id} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -2,23 +2,14 @@ import { LoginPage } from "@multica/views/auth";
|
||||
import { DragStrip } from "@multica/views/platform";
|
||||
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
|
||||
function requireRuntimeAppUrl(): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
"Invariant violated: DesktopLoginPage rendered before App accepted runtime config",
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
const WEB_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
export function DesktopLoginPage() {
|
||||
const webUrl = requireRuntimeAppUrl();
|
||||
const handleGoogleLogin = () => {
|
||||
// Open web login page in the default browser with platform=desktop flag.
|
||||
// The web callback will redirect back via multica:// deep link with the token.
|
||||
window.desktopAPI.openExternal(
|
||||
`${webUrl}/login?platform=desktop`,
|
||||
`${WEB_URL}/login?platform=desktop`,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { RuntimeDetailPage as SharedRuntimeDetailPage } from "@multica/views/runtimes";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function RuntimeDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes } = useQuery(runtimeListOptions(wsId));
|
||||
const runtime = runtimes?.find((r) => r.id === id);
|
||||
|
||||
useDocumentTitle(runtime?.name ?? "Runtime");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedRuntimeDetailPage runtimeId={id} />;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { SkillDetailPage as SharedSkillDetailPage } from "@multica/views/skills";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillDetailOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function SkillDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: skill } = useQuery(skillDetailOptions(wsId, id ?? ""));
|
||||
|
||||
useDocumentTitle(skill?.name ?? "Skill");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedSkillDetailPage skillId={id} />;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { runtimeKeys } from "@multica/core/runtimes";
|
||||
import type { AgentRuntime } from "@multica/core/types";
|
||||
|
||||
/**
|
||||
* DesktopAPI exposes a richer DaemonStatus shape than the public AgentRuntime
|
||||
* type — we redeclare the fields we consume here to avoid coupling the bridge
|
||||
* to the desktop preload typings (which live in apps/desktop/src/preload).
|
||||
*/
|
||||
interface DaemonStatusLike {
|
||||
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
|
||||
daemonId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges a local DaemonStatus into an AgentRuntime row. Only the `status`
|
||||
* field is overridden; other fields (name, provider, last_seen_at, etc)
|
||||
* remain server-authoritative. We deliberately ignore intermediate states
|
||||
* (starting / stopping / installing_cli / cli_not_found) so the cache
|
||||
* doesn't flap during boot — if the daemon is in such a state, the runtime
|
||||
* is effectively offline anyway, and the server-side sweeper will mark it
|
||||
* within 75s.
|
||||
*/
|
||||
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
|
||||
if (status.state === "stopped" || status.state === "stopping") {
|
||||
return { ...rt, status: "offline" };
|
||||
}
|
||||
if (status.state === "running") {
|
||||
return {
|
||||
...rt,
|
||||
status: "online",
|
||||
last_seen_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to local daemon status changes via Electron IPC and writes them
|
||||
* into the runtimes Query cache for the active workspace.
|
||||
*
|
||||
* Why: the server-side runtime sweeper takes up to 75s to flip a runtime to
|
||||
* offline (heartbeat timeout 45s + sweep interval 30s). On the desktop app
|
||||
* we know about local daemon state instantly via IPC, so we use it to
|
||||
* pre-populate the cache and give users a sub-second feedback loop. Web and
|
||||
* "looking at someone else's daemon" still go through the server path.
|
||||
*
|
||||
* Same-daemon-multiple-runtimes: a single daemon can back several runtimes
|
||||
* in the same workspace (one per provider). We map across all matches so
|
||||
* every related runtime row sees the same status flip.
|
||||
*/
|
||||
export function useDaemonIPCBridge(wsId: string | undefined): void {
|
||||
const qc = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
if (typeof window === "undefined") return;
|
||||
const daemonAPI = (window as unknown as { daemonAPI?: { onStatusChange?: (cb: (s: DaemonStatusLike) => void) => () => void } }).daemonAPI;
|
||||
if (!daemonAPI?.onStatusChange) return;
|
||||
|
||||
const unsubscribe = daemonAPI.onStatusChange((status) => {
|
||||
if (!status.daemonId) return;
|
||||
qc.setQueryData<AgentRuntime[]>(runtimeKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
return old.map((rt) =>
|
||||
rt.daemon_id === status.daemonId ? mergeDaemonStatus(rt, status) : rt,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [wsId, qc]);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { LocaleAdapter, SupportedLocale } from "@multica/core/i18n";
|
||||
|
||||
const STORAGE_KEY = "multica-locale";
|
||||
|
||||
// Desktop adapter:
|
||||
// - User choice: localStorage (set by Settings switcher).
|
||||
// - System preference: locale main injected via additionalArguments
|
||||
// (read from preload, exposed on window.desktopAPI.systemLocale).
|
||||
// - Persist: localStorage. The Settings switcher additionally PATCHes
|
||||
// /api/me when logged in so user.language follows the user across devices.
|
||||
export function createDesktopLocaleAdapter(systemLocale: string): LocaleAdapter {
|
||||
return {
|
||||
getUserChoice() {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getSystemPreferences() {
|
||||
return systemLocale ? [systemLocale] : [];
|
||||
},
|
||||
persist(locale: SupportedLocale) {
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -15,15 +15,11 @@ import {
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
function requireRuntimeAppUrl(scope: string): string {
|
||||
const runtimeConfig = window.desktopAPI.runtimeConfig;
|
||||
if (!runtimeConfig.ok) {
|
||||
throw new Error(
|
||||
`Invariant violated: ${scope} rendered before App accepted runtime config`,
|
||||
);
|
||||
}
|
||||
return runtimeConfig.config.appUrl;
|
||||
}
|
||||
// Public web app URL — injected at build time via .env.production. In dev
|
||||
// (no VITE_APP_URL set) falls back to the local web dev server so "Copy
|
||||
// link" in a dev build yields a URL that points at the running dev
|
||||
// frontend, not the prod host. Matches the fallback used in pages/login.tsx.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "http://localhost:3000";
|
||||
|
||||
/**
|
||||
* Extract the leading workspace slug from a path, or null if the path isn't
|
||||
@@ -65,13 +61,6 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path === "/invitations") {
|
||||
overlay.open({ type: "invitations" });
|
||||
if (router && router.state.location.pathname !== "/") {
|
||||
router.navigate("/", { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
@@ -120,38 +109,23 @@ export function DesktopNavigationProvider({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("DesktopNavigationProvider");
|
||||
// Primitive-only subscriptions so this component doesn't re-render on
|
||||
// unrelated store updates (e.g. an inactive tab's router tick). We
|
||||
// resolve the active router here only to subscribe once per tab switch.
|
||||
const { tabId: activeTabId } = useActiveTabIdentity();
|
||||
const router = useActiveTabRouter();
|
||||
// Mirror the active tab router's full location (pathname + search) so
|
||||
// shell-level consumers of useNavigation() — ChatWindow in particular —
|
||||
// can read URL search params. Must stay in sync with TabNavigationProvider
|
||||
// below; a partial shape here (just pathname) silently broke focus-mode
|
||||
// anchor resolution on `/inbox?issue=…`.
|
||||
const [location, setLocation] = useState<{ pathname: string; search: string }>(
|
||||
() => ({
|
||||
pathname: router?.state.location.pathname ?? "/",
|
||||
search: router?.state.location.search ?? "",
|
||||
}),
|
||||
const [pathname, setPathname] = useState(
|
||||
router?.state.location.pathname ?? "/",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router) {
|
||||
setLocation({ pathname: "/", search: "" });
|
||||
setPathname("/");
|
||||
return;
|
||||
}
|
||||
setLocation({
|
||||
pathname: router.state.location.pathname,
|
||||
search: router.state.location.search,
|
||||
});
|
||||
setPathname(router.state.location.pathname);
|
||||
return router.subscribe((state) => {
|
||||
setLocation({
|
||||
pathname: state.location.pathname,
|
||||
search: state.location.search,
|
||||
});
|
||||
setPathname(state.location.pathname);
|
||||
});
|
||||
}, [activeTabId, router]);
|
||||
|
||||
@@ -176,8 +150,8 @@ export function DesktopNavigationProvider({
|
||||
back: () => {
|
||||
currentActiveTab()?.router.navigate(-1);
|
||||
},
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
pathname,
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: (path: string, title?: string) => {
|
||||
// Cross-workspace "open in new tab" switches workspace and opens
|
||||
// the path there; same-workspace just adds a tab in the current group.
|
||||
@@ -191,9 +165,9 @@ export function DesktopNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, location],
|
||||
[pathname],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
@@ -216,7 +190,6 @@ export function TabNavigationProvider({
|
||||
router: DataRouter;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const appUrl = requireRuntimeAppUrl("TabNavigationProvider");
|
||||
const [location, setLocation] = useState(router.state.location);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -252,9 +225,9 @@ export function TabNavigationProvider({
|
||||
const tabId = store.openTab(path, title ?? path, icon);
|
||||
if (tabId) store.setActiveTab(tabId);
|
||||
},
|
||||
getShareableUrl: (path: string) => `${appUrl}${path}`,
|
||||
getShareableUrl: (path: string) => `${APP_URL}${path}`,
|
||||
}),
|
||||
[appUrl, router, location],
|
||||
[router, location],
|
||||
);
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -9,12 +9,8 @@ import type { RouteObject } from "react-router-dom";
|
||||
import { IssueDetailPage } from "./pages/issue-detail-page";
|
||||
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 { RuntimeDetailPage } from "./pages/runtime-detail-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";
|
||||
@@ -22,7 +18,6 @@ import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
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";
|
||||
@@ -85,15 +80,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 />,
|
||||
@@ -129,29 +116,9 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <DesktopRuntimesPage />,
|
||||
handle: { title: "Runtimes" },
|
||||
},
|
||||
{
|
||||
path: "runtimes/:id",
|
||||
element: <RuntimeDetailPage />,
|
||||
handle: { title: "Runtime" },
|
||||
},
|
||||
{ path: "skills", element: <SkillsPage />, handle: { title: "Skills" } },
|
||||
{
|
||||
path: "skills/:id",
|
||||
element: <SkillDetailPage />,
|
||||
handle: { title: "Skill" },
|
||||
},
|
||||
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
|
||||
{
|
||||
path: "agents/:id",
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
handle: { title: "Usage" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
@@ -15,7 +15,6 @@ import { create } from "zustand";
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string }
|
||||
| { type: "invitations" }
|
||||
| { type: "onboarding" };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
|
||||
@@ -51,35 +51,3 @@ export function formatUptime(uptime?: string): string {
|
||||
const m = match[2] ? `${match[2]}m` : "";
|
||||
return `${h}${m}`.trim() || uptime;
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing description for the local daemon's current state. Replaces the
|
||||
* raw state label ("Running" / "Stopped") with a sentence that answers
|
||||
* "what does this mean for me?" — i.e. whether tasks can run on this device.
|
||||
*
|
||||
* `runtimeCount` is the number of runtimes the local daemon has registered
|
||||
* (claude / codex / gemini / ... — one per detected CLI). It's only consulted
|
||||
* when state === "running".
|
||||
*/
|
||||
export function daemonStateDescription(state: DaemonState, runtimeCount: number): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
if (runtimeCount === 0) {
|
||||
return "Running, but no runtimes have registered yet.";
|
||||
}
|
||||
if (runtimeCount === 1) {
|
||||
return "Running here · 1 runtime available for tasks.";
|
||||
}
|
||||
return `Running here · ${runtimeCount} runtimes available for tasks.`;
|
||||
case "stopped":
|
||||
return "Not running · this device can't take new tasks.";
|
||||
case "starting":
|
||||
return "Starting up the local daemon…";
|
||||
case "stopping":
|
||||
return "Shutting down the local daemon…";
|
||||
case "installing_cli":
|
||||
return "Setting up the runtime for the first time. Only happens once.";
|
||||
case "cli_not_found":
|
||||
return "Setup failed · couldn't download the runtime. Check your network.";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_RUNTIME_CONFIG,
|
||||
deriveWsUrl,
|
||||
parseRuntimeConfig,
|
||||
runtimeConfigFromDevEnv,
|
||||
} from "./runtime-config";
|
||||
|
||||
describe("runtime config", () => {
|
||||
it("uses cloud defaults without a desktop.json file", () => {
|
||||
expect(DEFAULT_RUNTIME_CONFIG).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
});
|
||||
|
||||
it("derives https/wss compatible URLs from apiUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
wsUrl: "wss://congvc-x99.taila6fa8a.ts.net:18443/ws",
|
||||
appUrl: "https://congvc-x99.taila6fa8a.ts.net:18443",
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
it("accepts explicit appUrl and wsUrl", () => {
|
||||
expect(
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com/",
|
||||
wsUrl: "wss://ws.example.com/socket/",
|
||||
appUrl: "https://app.example.com/",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "wss://ws.example.com/socket",
|
||||
appUrl: "https://app.example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid JSON", () => {
|
||||
expect(() => parseRuntimeConfig("{")).toThrow(/Invalid desktop runtime config JSON/);
|
||||
});
|
||||
|
||||
it("rejects unsupported schema versions", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 2, apiUrl: "https://api.example.com" })),
|
||||
).toThrow(/schemaVersion/);
|
||||
});
|
||||
|
||||
it("rejects non-http api schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(JSON.stringify({ schemaVersion: 1, apiUrl: "file:///tmp/multica" })),
|
||||
).toThrow(/apiUrl must use http or https/);
|
||||
});
|
||||
|
||||
it("rejects non-ws websocket schemes", () => {
|
||||
expect(() =>
|
||||
parseRuntimeConfig(
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.example.com",
|
||||
wsUrl: "https://api.example.com/ws",
|
||||
}),
|
||||
),
|
||||
).toThrow(/wsUrl must use ws or wss/);
|
||||
});
|
||||
|
||||
it("preserves electron-vite dev env precedence", () => {
|
||||
expect(
|
||||
runtimeConfigFromDevEnv({
|
||||
apiUrl: "http://dev-api.example.test:8080/",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws/",
|
||||
appUrl: "http://dev-app.example.test:3000/",
|
||||
}),
|
||||
).toEqual({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://dev-api.example.test:8080",
|
||||
wsUrl: "ws://dev-api.example.test:8080/ws",
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,179 +0,0 @@
|
||||
export interface RuntimeConfig {
|
||||
schemaVersion: 1;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
appUrl: string;
|
||||
}
|
||||
|
||||
export interface RuntimeConfigError {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export type RuntimeConfigResult =
|
||||
| { ok: true; config: RuntimeConfig }
|
||||
| { ok: false; error: RuntimeConfigError };
|
||||
|
||||
export const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "https://api.multica.ai",
|
||||
wsUrl: "wss://api.multica.ai/ws",
|
||||
appUrl: "https://multica.ai",
|
||||
});
|
||||
|
||||
const LOCAL_DEV_RUNTIME_CONFIG: RuntimeConfig = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
apiUrl: "http://localhost:8080",
|
||||
wsUrl: "ws://localhost:8080/ws",
|
||||
appUrl: "http://localhost:3000",
|
||||
});
|
||||
|
||||
export interface RuntimeConfigEnv {
|
||||
apiUrl?: string;
|
||||
wsUrl?: string;
|
||||
appUrl?: string;
|
||||
}
|
||||
|
||||
export function runtimeConfigFromDevEnv(env: RuntimeConfigEnv): RuntimeConfig {
|
||||
const apiUrl = normalizeHttpUrl(
|
||||
env.apiUrl || LOCAL_DEV_RUNTIME_CONFIG.apiUrl,
|
||||
"VITE_API_URL",
|
||||
);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl,
|
||||
wsUrl: env.wsUrl
|
||||
? normalizeWsUrl(env.wsUrl, "VITE_WS_URL")
|
||||
: deriveWsUrl(apiUrl),
|
||||
appUrl: env.appUrl
|
||||
? normalizeHttpUrl(env.appUrl, "VITE_APP_URL")
|
||||
: deriveDevAppUrl(apiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeConfig(raw: string): RuntimeConfig {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
throw new Error(
|
||||
`Invalid desktop runtime config JSON: ${err instanceof Error ? err.message : "parse failed"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid desktop runtime config: expected a JSON object");
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
if (obj.schemaVersion !== 1) {
|
||||
throw new Error("Unsupported desktop runtime config schemaVersion: expected 1");
|
||||
}
|
||||
|
||||
const apiUrl = requiredString(obj.apiUrl, "apiUrl");
|
||||
const appUrl = optionalString(obj.appUrl, "appUrl");
|
||||
const wsUrl = optionalString(obj.wsUrl, "wsUrl");
|
||||
|
||||
const normalizedApiUrl = normalizeHttpUrl(apiUrl, "apiUrl");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
apiUrl: normalizedApiUrl,
|
||||
wsUrl: wsUrl ? normalizeWsUrl(wsUrl, "wsUrl") : deriveWsUrl(normalizedApiUrl),
|
||||
appUrl: appUrl ? normalizeHttpUrl(appUrl, "appUrl") : deriveAppUrl(normalizedApiUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveWsUrl(apiUrl: string): string {
|
||||
const url = new URL(apiUrl);
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
else if (url.protocol === "http:") url.protocol = "ws:";
|
||||
else throw new Error("apiUrl must use http or https");
|
||||
url.pathname = joinPath(url.pathname, "/ws");
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
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`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a non-empty string when set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeHttpUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use http or https`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function normalizeWsUrl(value: string, field: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value.trim());
|
||||
} catch {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must be a valid URL`);
|
||||
}
|
||||
if (url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||
throw new Error(`Invalid desktop runtime config: ${field} must use ws or wss`);
|
||||
}
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return trimTrailingSlash(url.toString());
|
||||
}
|
||||
|
||||
function joinPath(base: string, suffix: string): string {
|
||||
const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
|
||||
return `${normalizedBase}${suffix}`;
|
||||
}
|
||||
|
||||
function trimTrailingSlash(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -1,38 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => values.clear(),
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
key: (index: number) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const localStorageIsUsable =
|
||||
typeof globalThis.localStorage?.getItem === "function" &&
|
||||
typeof globalThis.localStorage?.setItem === "function" &&
|
||||
typeof globalThis.localStorage?.removeItem === "function" &&
|
||||
typeof globalThis.localStorage?.clear === "function";
|
||||
|
||||
if (!localStorageIsUsable) {
|
||||
const storage = createMemoryStorage();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": resolve(__dirname, "src/renderer/src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/*.test.{ts,tsx}", "scripts/**/*.test.mjs"],
|
||||
|
||||
@@ -8,34 +8,22 @@ import {
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const lang = asLang(params.lang);
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX components={{ ...defaultMdxComponents, a: LocaleLink }} />
|
||||
</DocsLocaleProvider>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
@@ -46,15 +34,14 @@ export function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ lang: string; slug: string[] }>;
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates(params.slug),
|
||||
};
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import "../global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { uiTranslations, localeLabels } from "@/lib/translations";
|
||||
import { DocsSettings } from "@/components/docs-settings";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
fallback: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"PingFang SC",
|
||||
"Microsoft YaHei",
|
||||
"Noto Sans CJK SC",
|
||||
"sans-serif",
|
||||
],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
fallback: ["ui-monospace", "SFMono-Regular", "Menlo", "Consolas", "monospace"],
|
||||
});
|
||||
|
||||
// Editorial serif used for headings and showpiece elements. Italic style is
|
||||
// deliberately NOT loaded — italic in CJK is a synthetic slant that breaks
|
||||
// glyph design. Emphasis in docs is carried by brand color + weight, never
|
||||
// font-style. Mirrors apps/web/app/layout.tsx for the upright family.
|
||||
const sourceSerif = Source_Serif_4({
|
||||
subsets: ["latin"],
|
||||
style: ["normal"],
|
||||
variable: "--font-serif",
|
||||
fallback: [
|
||||
"ui-serif",
|
||||
"Iowan Old Style",
|
||||
"Apple Garamond",
|
||||
"Baskerville",
|
||||
"Times New Roman",
|
||||
"serif",
|
||||
],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Multica Docs",
|
||||
default: "Multica Docs",
|
||||
},
|
||||
description:
|
||||
"Documentation for Multica — the open-source managed agents platform.",
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Layout({
|
||||
params,
|
||||
children,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = (i18n.languages as readonly string[]).includes(rawLang)
|
||||
? (rawLang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
const locales = i18n.languages.map((l) => ({
|
||||
locale: l,
|
||||
name: localeLabels[l],
|
||||
}));
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={lang}
|
||||
suppressHydrationWarning
|
||||
className={cn(
|
||||
"antialiased",
|
||||
inter.variable,
|
||||
geistMono.variable,
|
||||
sourceSerif.variable,
|
||||
)}
|
||||
>
|
||||
<body className="font-sans">
|
||||
<RootProvider
|
||||
i18n={{
|
||||
locale: lang,
|
||||
locales,
|
||||
translations: uiTranslations[lang],
|
||||
}}
|
||||
search={{ options: { api: "/docs/api/search" } }}
|
||||
>
|
||||
<DocsLayout
|
||||
tree={source.getPageTree(lang)}
|
||||
// Suppress Fumadocs's default sidebar-footer icons (theme +
|
||||
// language + search). Our custom <DocsSettings> is mounted as
|
||||
// the sidebar footer instead — two labelled buttons, not three
|
||||
// icons.
|
||||
themeSwitch={{ enabled: false }}
|
||||
searchToggle={{ enabled: false }}
|
||||
sidebar={{ footer: <DocsSettings locale={lang} /> }}
|
||||
{...baseOptions}
|
||||
>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { DocsPage, DocsBody } from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
import { DocsHero } from "@/components/hero";
|
||||
import { Byline, NumberedCards, NumberedCard, NumberedSteps, Step } from "@/components/editorial";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { homeCopy } from "@/lib/translations";
|
||||
import { docsAlternates } from "@/lib/site";
|
||||
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
|
||||
|
||||
function asLang(lang: string): Lang {
|
||||
return (i18n.languages as readonly string[]).includes(lang)
|
||||
? (lang as Lang)
|
||||
: (i18n.defaultLanguage as Lang);
|
||||
}
|
||||
|
||||
// A layout's `generateStaticParams` does NOT cascade — every page that
|
||||
// wants SSG must declare its own. Without this, both `/docs/` and
|
||||
// `/docs/zh` (the busiest URLs on the site) render dynamically on every
|
||||
// request.
|
||||
export function generateStaticParams() {
|
||||
return i18n.languages.map((lang) => ({ lang }));
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}) {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = asLang(rawLang);
|
||||
const page = source.getPage([], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
const copy = homeCopy[lang];
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsHero
|
||||
eyebrow={copy.eyebrow}
|
||||
title={
|
||||
<>
|
||||
{copy.titleLead}
|
||||
<em className="font-medium not-italic text-[var(--primary)]">
|
||||
{copy.titleAccent}
|
||||
</em>
|
||||
</>
|
||||
}
|
||||
subtitle={page.data.description}
|
||||
/>
|
||||
<Byline items={[...copy.byline]} />
|
||||
<DocsBody>
|
||||
<DocsLocaleProvider lang={lang}>
|
||||
<MDX
|
||||
components={{
|
||||
...defaultMdxComponents,
|
||||
a: LocaleLink,
|
||||
NumberedCards,
|
||||
NumberedCard,
|
||||
NumberedSteps,
|
||||
Step,
|
||||
}}
|
||||
/>
|
||||
</DocsLocaleProvider>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ lang: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { lang: rawLang } = await params;
|
||||
const lang = asLang(rawLang);
|
||||
const page = source.getPage([], lang);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: docsAlternates([]),
|
||||
};
|
||||
}
|
||||
@@ -1,32 +1,4 @@
|
||||
import { source } from "@/lib/source";
|
||||
import { createFromSource } from "fumadocs-core/search/server";
|
||||
|
||||
// Orama doesn't ship a Chinese tokenizer and its built-in English regex
|
||||
// strips Han characters entirely, so `locale=zh` would either return empty
|
||||
// results or throw. Tokenize CJK input character-by-character and keep
|
||||
// Latin/digit runs whole — gives serviceable recall for Chinese docs while
|
||||
// letting Romanized terms (product names, CLI commands) still match.
|
||||
function tokenizeCJK(raw: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const regex = /[一-鿿㐀-䶿]|[A-Za-z0-9]+/g;
|
||||
const lower = raw.toLowerCase();
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = regex.exec(lower)) !== null) {
|
||||
tokens.push(match[0]);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export const { GET } = createFromSource(source, {
|
||||
localeMap: {
|
||||
zh: {
|
||||
components: {
|
||||
tokenizer: {
|
||||
language: "english",
|
||||
normalizationCache: new Map(),
|
||||
tokenize: tokenizeCJK,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
export const { GET } = createFromSource(source);
|
||||
|
||||
@@ -1,679 +1,3 @@
|
||||
@import "tailwindcss";
|
||||
@import "fumadocs-ui/css/neutral.css";
|
||||
@import "fumadocs-ui/css/preset.css";
|
||||
@import "../../../packages/ui/styles/tokens.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@source "../../../packages/ui/**/*.{ts,tsx}";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Multica Docs — editorial visual identity (v2)
|
||||
*
|
||||
* Docs site is intentionally distinct from the product app: warm-paper
|
||||
* background, editorial serif headings (Source Serif 4), indigo accent,
|
||||
* ruled dividers. Product app keeps its cool-gray dense Linear-style; docs
|
||||
* reads like a literary publication. Same split as Stripe, Cursor, Linear.
|
||||
*
|
||||
* Implementation: docs-scoped token override on top of Multica tokens
|
||||
* (whose @theme inline references read --background / --foreground / etc
|
||||
* at runtime, so re-pointing the vars cascades through fumadocs's full
|
||||
* --color-fd-* bridge below).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--fd-page-width: 1080px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editorial palette — light
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--background: oklch(0.972 0.003 85); /* near-white, faint warm — matches landing #f7f7f5 */
|
||||
--foreground: oklch(0.182 0.012 50); /* warm ink */
|
||||
--muted: oklch(0.955 0.006 85); /* hairline, slightly warmer than bg */
|
||||
--muted-foreground: oklch(0.482 0.012 65); /* warm muted */
|
||||
--card: oklch(0.99 0.002 85); /* paper — near white */
|
||||
--card-foreground: oklch(0.182 0.012 50);
|
||||
--popover: oklch(0.99 0.002 85);
|
||||
--popover-foreground: oklch(0.182 0.012 50);
|
||||
--primary: oklch(0.55 0.16 255); /* Multica brand */
|
||||
--primary-foreground: oklch(0.985 0.008 85);
|
||||
--secondary: oklch(0.945 0.012 85);
|
||||
--secondary-foreground: oklch(0.182 0.012 50);
|
||||
--accent: oklch(0.945 0.022 255); /* brand soft wash */
|
||||
--accent-foreground: oklch(0.46 0.16 255); /* brand ink */
|
||||
--border: oklch(0.91 0.014 85); /* ruled lines */
|
||||
--input: oklch(0.91 0.014 85);
|
||||
--ring: oklch(0.55 0.16 255);
|
||||
--sidebar: oklch(0.99 0.002 85); /* paper — same as card */
|
||||
--sidebar-foreground: oklch(0.182 0.012 50);
|
||||
--sidebar-accent: oklch(0.945 0.006 85); /* subtle cream, hover/active fill */
|
||||
--sidebar-accent-foreground: oklch(0.182 0.012 50);
|
||||
--sidebar-border: oklch(0.91 0.014 85);
|
||||
|
||||
/* Docs-only extras (not bridged to fumadocs slots) */
|
||||
--docs-rule: oklch(0.835 0.018 85); /* heavier rule */
|
||||
--docs-faint: oklch(0.72 0.018 75); /* faintest accent */
|
||||
--docs-code-bg: oklch(0.94 0.018 85); /* warm beige code surface */
|
||||
--docs-code-border: oklch(0.89 0.018 85);
|
||||
--docs-terminal-bg: oklch(0.18 0.012 50); /* terminal warm dark */
|
||||
--docs-terminal-fg: oklch(0.92 0.012 80);
|
||||
--docs-terminal-accent: oklch(0.65 0.16 255);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editorial palette — dark (warm dark, NOT Multica's cool dark)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.18 0.008 50);
|
||||
--foreground: oklch(0.95 0.012 85);
|
||||
--muted: oklch(0.22 0.008 50);
|
||||
--muted-foreground: oklch(0.65 0.012 75);
|
||||
--card: oklch(0.21 0.008 50);
|
||||
--card-foreground: oklch(0.95 0.012 85);
|
||||
--popover: oklch(0.22 0.008 50);
|
||||
--popover-foreground: oklch(0.95 0.012 85);
|
||||
--primary: oklch(0.7 0.15 255); /* Multica brand — dark */
|
||||
--primary-foreground: oklch(0.18 0.008 50);
|
||||
--secondary: oklch(0.24 0.008 50);
|
||||
--secondary-foreground: oklch(0.95 0.012 85);
|
||||
--accent: oklch(0.3 0.05 255); /* brand soft wash — dark */
|
||||
--accent-foreground: oklch(0.78 0.14 255); /* brand ink — dark */
|
||||
--border: oklch(0.28 0.012 50);
|
||||
--input: oklch(0.28 0.012 50);
|
||||
--ring: oklch(0.7 0.15 255);
|
||||
--sidebar: oklch(0.21 0.008 50);
|
||||
--sidebar-foreground: oklch(0.95 0.012 85);
|
||||
--sidebar-accent: oklch(0.26 0.01 50); /* warm neutral, hover/active fill — dark */
|
||||
--sidebar-accent-foreground: oklch(0.95 0.012 85);
|
||||
--sidebar-border: oklch(0.28 0.012 50);
|
||||
|
||||
--docs-rule: oklch(0.36 0.012 50);
|
||||
--docs-faint: oklch(0.42 0.012 50);
|
||||
--docs-code-bg: oklch(0.165 0.008 50);
|
||||
--docs-code-border: oklch(0.26 0.012 50);
|
||||
--docs-terminal-bg: oklch(0.155 0.012 50);
|
||||
--docs-terminal-fg: oklch(0.92 0.012 80);
|
||||
--docs-terminal-accent: oklch(0.78 0.14 255);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Fumadocs slot bridge
|
||||
*
|
||||
* Map fumadocs's --color-fd-* slots to our (now warm) Multica tokens.
|
||||
* @theme inline keeps the var() reference live so the cascade resolves
|
||||
* at runtime — same pattern tokens.css uses.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
@theme inline {
|
||||
--color-fd-background: var(--background);
|
||||
--color-fd-foreground: var(--foreground);
|
||||
--color-fd-muted: var(--muted);
|
||||
--color-fd-muted-foreground: var(--muted-foreground);
|
||||
--color-fd-popover: var(--popover);
|
||||
--color-fd-popover-foreground: var(--popover-foreground);
|
||||
--color-fd-card: var(--card);
|
||||
--color-fd-card-foreground: var(--card-foreground);
|
||||
--color-fd-border: var(--border);
|
||||
--color-fd-primary: var(--primary);
|
||||
--color-fd-primary-foreground: var(--primary-foreground);
|
||||
--color-fd-secondary: var(--secondary);
|
||||
--color-fd-secondary-foreground: var(--secondary-foreground);
|
||||
--color-fd-accent: var(--accent);
|
||||
--color-fd-accent-foreground: var(--accent-foreground);
|
||||
--color-fd-ring: var(--ring);
|
||||
}
|
||||
|
||||
/* Sidebar uses dedicated --sidebar-* tokens so it sits a hair off the main
|
||||
* canvas. Fumadocs renders it as #nd-sidebar (desktop) and
|
||||
* #nd-sidebar-mobile (mobile drawer); both IDs need the override. */
|
||||
#nd-sidebar,
|
||||
#nd-sidebar-mobile {
|
||||
--color-fd-background: var(--sidebar);
|
||||
--color-fd-foreground: var(--sidebar-foreground);
|
||||
--color-fd-muted: var(--sidebar-accent);
|
||||
--color-fd-muted-foreground: var(--sidebar-foreground);
|
||||
--color-fd-accent: var(--sidebar-accent);
|
||||
--color-fd-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-fd-border: var(--sidebar-border);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Editorial typography
|
||||
*
|
||||
* Body keeps Inter for legibility (especially CJK where serif Latin clashes
|
||||
* with sans CJK). Headings switch to Source Serif 4 for the editorial
|
||||
* signature. Italic is intentionally avoided — Chinese italic is a CSS
|
||||
* synthetic slant against upright-designed glyphs and reads as broken.
|
||||
* Emphasis is carried by serif/sans contrast, brand color, and weight.
|
||||
*
|
||||
* Sizing:
|
||||
* - DocsHero h1 (welcome page only): 44px serif, brand-color em accent
|
||||
* - prose h1 (guide / reference pages): 30px serif
|
||||
* - prose h2: 26px serif (no italic)
|
||||
* - prose h3: 13px sans uppercase label
|
||||
* - body: 15.5px (kept from previous build — proven reading size for CN)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
article:has(.prose),
|
||||
.prose {
|
||||
font-size: 0.96875rem; /* 15.5px */
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* DocsTitle h1 (Fumadocs hardcodes text-[1.75em] font-semibold — utility
|
||||
* specificity 0,1,0 beats plain article > h1 0,0,2; !important wins). */
|
||||
article > h1 {
|
||||
font-family: var(--font-serif), ui-serif, serif !important;
|
||||
font-size: 1.875rem !important; /* 30px guide-page heading */
|
||||
font-weight: 400 !important;
|
||||
letter-spacing: -0.018em;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Lead paragraph below DocsTitle */
|
||||
article > p.text-lg {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.125rem; /* 18px serif lede */
|
||||
line-height: 1.55;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Paragraph rhythm */
|
||||
.prose :where(p):not(:where([class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.875rem;
|
||||
color: oklch(from var(--foreground) calc(l + 0.06) c h);
|
||||
}
|
||||
.prose :where(p):not(:where([class~="not-prose"] *)):last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose :where(p) strong {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose :where(ul, ol) {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.875rem; /* 30px */
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
/* Italic is avoided sitewide (Chinese italic = synthetic slant, looks broken).
|
||||
* Force any italicized element to non-italic in prose. Tailwind Typography
|
||||
* defaults blockquote to italic; we also undo it here. Emphasis is carried
|
||||
* by brand color + font-weight in headings, foreground+weight in body. */
|
||||
.prose em,
|
||||
.prose i,
|
||||
.prose cite,
|
||||
.prose blockquote,
|
||||
.prose blockquote p {
|
||||
font-style: normal;
|
||||
}
|
||||
.prose h1 em {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.prose p em,
|
||||
.prose li em {
|
||||
color: var(--foreground);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.625rem; /* 26px */
|
||||
font-weight: 400;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.3;
|
||||
margin-top: 2em;
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--foreground);
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
/* h3 = small uppercase sans label, ruled-bottom — v2 editorial signature */
|
||||
.prose h3 {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.8125rem; /* 13px */
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2.25em;
|
||||
margin-bottom: 0.75em;
|
||||
padding-bottom: 0.25em;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.prose h4 {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-size: 1.0625rem; /* 17px */
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.005em;
|
||||
line-height: 1.4;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.375em;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Description paragraph (fumadocs adds text-lg + muted) */
|
||||
.prose > p:first-of-type:has(+ *) {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Links — Vercel-style hairline underline, reveal brand on hover
|
||||
*
|
||||
* Markdown-heavy prose can put 4+ inline links in a single sentence; a
|
||||
* permanent brand-color underline on every one turns the paragraph into
|
||||
* highlighter spam. The trick isn't "no underline" — it's underlining
|
||||
* in the hairline border color so the line exists but visually recedes.
|
||||
* Hover swaps both text and underline to brand color (no thickness
|
||||
* change) — the link "arrives" as a single color shift.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.prose a:not([data-card]):not(.not-prose) {
|
||||
color: var(--foreground);
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--border);
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 3px;
|
||||
transition: text-decoration-color 150ms, color 150ms;
|
||||
}
|
||||
.prose a:not([data-card]):not(.not-prose):hover {
|
||||
color: var(--primary);
|
||||
text-decoration-color: var(--primary);
|
||||
}
|
||||
|
||||
/* Callout already carries four visual signals (left brand bar, brand-wash
|
||||
* bg, uppercase NOTE label, body). Another decoration over-loads it — so
|
||||
* links inside a callout drop the underline entirely. Color shift on
|
||||
* hover is the full affordance. */
|
||||
.prose div.shadow-md:has(> [role="none"]) a:not([data-card]):not(.not-prose),
|
||||
.prose div.shadow-md:has(> [role="none"]) a:not([data-card]):not(.not-prose):hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Inline code — warm beige chip, accent-color text */
|
||||
.prose :not(pre) > code {
|
||||
background: var(--docs-code-bg);
|
||||
color: var(--accent-foreground);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 0.875em;
|
||||
font-weight: 500;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
}
|
||||
.prose :not(pre) > code::before,
|
||||
.prose :not(pre) > code::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Lists */
|
||||
.prose :where(ul, ol) > li {
|
||||
margin-top: 0.375em;
|
||||
margin-bottom: 0.375em;
|
||||
padding-inline-start: 0.375em;
|
||||
}
|
||||
.prose :where(ul) > li::marker {
|
||||
color: var(--docs-faint);
|
||||
content: "— ";
|
||||
font-family: var(--font-serif), serif;
|
||||
}
|
||||
.prose :where(ol) > li::marker {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* Blockquote — editorial accent rule, serif voice */
|
||||
.prose blockquote {
|
||||
font-family: var(--font-serif), ui-serif, serif;
|
||||
font-weight: 400;
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.55;
|
||||
color: var(--foreground);
|
||||
border-inline-start-width: 2px;
|
||||
border-inline-start-color: var(--primary);
|
||||
padding-inline-start: 1.25em;
|
||||
margin-block: 1.5em;
|
||||
quotes: none;
|
||||
}
|
||||
.prose blockquote p::before,
|
||||
.prose blockquote p::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
/* Tables — hairline below thead only, no outer frame (Stripe / Linear
|
||||
* docs convention). The heavier ink-color top rule v2 used on its API
|
||||
* reference block is intentionally not applied here — that treatment is
|
||||
* "this is a formal declaration"; regular guide tables want quiet. */
|
||||
.prose table {
|
||||
font-size: 0.9375em;
|
||||
border-collapse: collapse;
|
||||
margin-block: 1.5em;
|
||||
}
|
||||
.prose thead {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.prose thead th {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
padding-block: 0.5rem 0.625rem;
|
||||
text-align: start;
|
||||
}
|
||||
.prose tbody tr {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.prose tbody td {
|
||||
padding-block: 0.875rem;
|
||||
}
|
||||
|
||||
/* HR — heavier ruled separator */
|
||||
.prose hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--docs-rule);
|
||||
margin-block: 3em;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Callout — editorial 2px accent bar + soft accent wash
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) {
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 4px 4px 0 !important;
|
||||
background: var(--accent) !important;
|
||||
border: none !important;
|
||||
border-inline-start: 2px solid var(--primary) !important;
|
||||
padding: 0.875rem 1.125rem !important;
|
||||
gap: 0.625rem !important;
|
||||
align-items: flex-start;
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) > [role="none"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) > div:last-child > p {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--primary);
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.prose div.shadow-md:has(> [role="none"]) > div:last-child > div {
|
||||
color: var(--foreground) !important;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Cards — fallback editorial treatment for fumadocs's <Cards>/<Card>
|
||||
* (NumberedCards is the showpiece; this keeps non-showpiece pages on tone)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
.prose [data-card]:not(.peer) {
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
background: var(--card);
|
||||
padding: 1.125rem !important;
|
||||
transition: border-color 150ms, background-color 150ms !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer):hover {
|
||||
border-color: var(--primary) !important;
|
||||
background: var(--card) !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) > div:first-child {
|
||||
box-shadow: none !important;
|
||||
border-radius: 0 !important;
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
color: var(--accent-foreground) !important;
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) > div:first-child svg {
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) h3 {
|
||||
font-family: var(--font-serif), serif !important;
|
||||
font-size: 1.125rem !important;
|
||||
font-weight: 500 !important;
|
||||
font-style: normal !important;
|
||||
letter-spacing: -0.01em;
|
||||
margin-bottom: 0.25rem !important;
|
||||
margin-top: 0 !important;
|
||||
text-transform: none !important;
|
||||
border-bottom: none !important;
|
||||
padding-bottom: 0 !important;
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
.prose [data-card]:not(.peer) p {
|
||||
color: var(--muted-foreground) !important;
|
||||
line-height: 1.6;
|
||||
font-size: 0.9375rem !important;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Sidebar — editorial chrome
|
||||
*
|
||||
* Section headers: small uppercase sans label, ruled bottom border.
|
||||
* Items: muted-foreground at rest, foreground on hover.
|
||||
* Active: solid background fill (mirrors product app's app-sidebar.tsx —
|
||||
* data-active:bg-sidebar-accent / data-active:text-sidebar-accent-foreground).
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#nd-sidebar p,
|
||||
#nd-sidebar-mobile p {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.6875rem; /* 11px */
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
height: auto;
|
||||
display: block;
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.375rem;
|
||||
padding-block: 0 0.375rem;
|
||||
padding-inline-start: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#nd-sidebar p:first-child,
|
||||
#nd-sidebar-mobile p:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
#nd-sidebar a[data-active],
|
||||
#nd-sidebar-mobile a[data-active] {
|
||||
height: auto;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.84375rem; /* 13.5px */
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.005em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#nd-sidebar a[data-active="false"],
|
||||
#nd-sidebar-mobile a[data-active="false"] {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
#nd-sidebar a[data-active="false"]:hover,
|
||||
#nd-sidebar-mobile a[data-active="false"]:hover {
|
||||
background: color-mix(in oklab, var(--sidebar-accent) 70%, transparent);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* Active — solid background fill, no left mark (matches product app) */
|
||||
#nd-sidebar a[data-active="true"],
|
||||
#nd-sidebar-mobile a[data-active="true"] {
|
||||
background: var(--sidebar-accent) !important;
|
||||
color: var(--sidebar-accent-foreground) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sidebar footer — drop the hard top rule. The scroll viewport already
|
||||
* fades content into the footer, so a 1px line on top reads as a
|
||||
* double-weight edge. Fumadocs hardcodes `border-t p-4 pt-2` on its
|
||||
* SidebarFooter div; target that exact class trio inside the sidebar IDs
|
||||
* so we don't touch any other border-t in the app. */
|
||||
#nd-sidebar .border-t.p-4.pt-2,
|
||||
#nd-sidebar-mobile .border-t.p-4.pt-2 {
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Top nav — quiet, ruled bottom
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#nd-nav,
|
||||
#nd-subnav {
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
#nd-nav a,
|
||||
#nd-subnav a {
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted-foreground);
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
#nd-nav a:hover,
|
||||
#nd-subnav a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* TOC (right rail) — quiet sans, brand-color when active
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
#nd-toc a {
|
||||
font-size: 0.84375rem;
|
||||
color: var(--muted-foreground);
|
||||
padding-block: 0.3125rem;
|
||||
letter-spacing: -0.005em;
|
||||
transition: color 150ms;
|
||||
}
|
||||
|
||||
#nd-toc a:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
#nd-toc a[data-active="true"] {
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* TOC heading (Fumadocs renders "On this page" as an h3 / first p) */
|
||||
#nd-toc h3,
|
||||
#nd-toc > p:first-child {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
margin-bottom: 0.625rem;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Code blocks — warm beige (light) / warm dark (dark), NOT pinned
|
||||
*
|
||||
* Removes the previous "always-dark hero black" treatment. Code surface
|
||||
* now follows page theme so it harmonizes with the warm-paper background
|
||||
* in light mode and warm-dark in dark mode. Terminal-style blocks
|
||||
* (handled by the custom <Terminal> component, not here) stay pinned to
|
||||
* the deeper warm dark for the "shell session" feel.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
article figure.shiki {
|
||||
background: var(--docs-code-bg) !important;
|
||||
border: 1px solid var(--docs-code-border) !important;
|
||||
border-radius: 4px !important;
|
||||
box-shadow: none !important;
|
||||
margin-block: 1.25rem !important;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
article figure.shiki pre {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
color: inherit !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
article figure.shiki > div[class*="overflow-auto"] {
|
||||
font-size: 0.84375rem !important;
|
||||
line-height: 1.7;
|
||||
padding: 1rem 1.125rem !important;
|
||||
}
|
||||
|
||||
/* Header bar (filename via ```lang filename="x.ts") */
|
||||
article figure.shiki > div[class*="border-b"] {
|
||||
border-bottom-color: var(--docs-code-border) !important;
|
||||
background: var(--muted) !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
font-family: var(--font-mono), ui-monospace, monospace;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
/* Shiki tokens — pick the palette that matches page theme.
|
||||
* Default (light): use --shiki-light. Override under .dark to --shiki-dark.
|
||||
* Specificity: article figure.shiki code span (0,1,4) beats fumadocs's
|
||||
* default, so no !important needed for the light path. */
|
||||
article figure.shiki code span {
|
||||
color: var(--shiki-light);
|
||||
}
|
||||
|
||||
.dark article figure.shiki code span {
|
||||
color: var(--shiki-dark);
|
||||
}
|
||||
|
||||
/* Copy button on code blocks */
|
||||
article figure.shiki button {
|
||||
color: var(--muted-foreground) !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
article figure.shiki button:hover {
|
||||
color: var(--foreground) !important;
|
||||
background: var(--muted) !important;
|
||||
}
|
||||
|
||||
@@ -1,57 +1,4 @@
|
||||
import type { BaseLayoutProps } from "fumadocs-ui/layouts/shared";
|
||||
import { ArrowUpRight } from "lucide-react";
|
||||
|
||||
// Docs-local stateless Multica mark — matches @multica/ui's MulticaIcon
|
||||
// visually (same 8-pointed-asterisk clip-path), but without useState/
|
||||
// useEffect so it's safe to render from Server Components such as
|
||||
// layout.config.tsx / layout.tsx. Keep in sync with
|
||||
// packages/ui/components/common/multica-icon.tsx if the mark changes.
|
||||
const MULTICA_CLIP = `polygon(
|
||||
45% 62.1%, 45% 100%, 55% 100%, 55% 62.1%,
|
||||
81.8% 88.9%, 88.9% 81.8%, 62.1% 55%, 100% 55%,
|
||||
100% 45%, 62.1% 45%, 88.9% 18.2%, 81.8% 11.1%,
|
||||
55% 37.9%, 55% 0%, 45% 0%, 45% 37.9%,
|
||||
18.2% 11.1%, 11.1% 18.2%, 37.9% 45%, 0% 45%,
|
||||
0% 55%, 37.9% 55%, 11.1% 81.8%, 18.2% 88.9%
|
||||
)`;
|
||||
|
||||
function MulticaMark() {
|
||||
return (
|
||||
<span className="inline-block size-[1em]" aria-hidden="true">
|
||||
<span
|
||||
className="block size-full bg-current"
|
||||
style={{ clipPath: MULTICA_CLIP }}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub mark — inlined SVG (lucide-react dropped the Github icon for brand
|
||||
// trademark reasons). Path matches apps/web/features/landing/components/
|
||||
// shared.tsx GitHubMark.
|
||||
function GitHubMark() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
className="size-[1em]"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 0 0 5.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2 .37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82A7.65 7.65 0 0 1 8 4.84c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// External links shown at the top of the sidebar (and in the top nav on
|
||||
// desktop). Leading icon = brand identity (GitHub mark / Multica asterisk);
|
||||
// trailing ArrowUpRight = "opens externally" glyph, same pattern as
|
||||
// `packages/views/layout/help-launcher.tsx` from PR #1560.
|
||||
const externalLinkText = (label: string) => (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
<ArrowUpRight className="size-3 translate-y-px text-muted-foreground/60" />
|
||||
</span>
|
||||
);
|
||||
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
@@ -61,16 +8,12 @@ export const baseOptions: BaseLayoutProps = {
|
||||
},
|
||||
links: [
|
||||
{
|
||||
icon: <GitHubMark />,
|
||||
text: externalLinkText("GitHub"),
|
||||
text: "GitHub",
|
||||
url: "https://github.com/multica-ai/multica",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
icon: <MulticaMark />,
|
||||
text: externalLinkText("Multica"),
|
||||
text: "Cloud",
|
||||
url: "https://multica.ai",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
30
apps/docs/app/layout.tsx
Normal file
30
apps/docs/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import "./global.css";
|
||||
import { RootProvider } from "fumadocs-ui/provider";
|
||||
import { DocsLayout } from "fumadocs-ui/layouts/docs";
|
||||
import type { ReactNode } from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { baseOptions } from "@/app/layout.config";
|
||||
import { source } from "@/lib/source";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | Multica Docs",
|
||||
default: "Multica Docs",
|
||||
},
|
||||
description:
|
||||
"Documentation for Multica — the open-source managed agents platform.",
|
||||
};
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<RootProvider>
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
</RootProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
37
apps/docs/app/page.tsx
Normal file
37
apps/docs/app/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { source } from "@/lib/source";
|
||||
import {
|
||||
DocsPage,
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsTitle,
|
||||
} from "fumadocs-ui/page";
|
||||
import { notFound } from "next/navigation";
|
||||
import defaultMdxComponents from "fumadocs-ui/mdx";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export default function Page() {
|
||||
const page = source.getPage([]);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
const page = source.getPage([]);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { source } from "@/lib/source";
|
||||
import { i18n } from "@/lib/i18n";
|
||||
import { absoluteDocsUrl } from "@/lib/site";
|
||||
|
||||
/**
|
||||
* Dynamic sitemap — pulls the full page list from Fumadocs' source at build
|
||||
* time. Each logical page emits one entry; all available language variants
|
||||
* are declared as hreflang alternates so Google treats them as the same
|
||||
* article, not as duplicates.
|
||||
*
|
||||
* Served at `/docs/sitemap.xml` (because of basePath). The root
|
||||
* `apps/web/app/robots.ts` references this URL so crawlers discover it.
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
// Group pages by canonical slug so multiple locales collapse to one entry.
|
||||
const bySlug = new Map<string, Map<string, string>>();
|
||||
|
||||
for (const { language, pages } of source.getLanguages()) {
|
||||
for (const page of pages) {
|
||||
const slugKey = page.slugs.join("/");
|
||||
const languages = bySlug.get(slugKey) ?? new Map<string, string>();
|
||||
languages.set(language, page.url);
|
||||
bySlug.set(slugKey, languages);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: MetadataRoute.Sitemap = [];
|
||||
|
||||
for (const languages of bySlug.values()) {
|
||||
// Canonical is the default-language URL when available, otherwise the
|
||||
// first available locale (covers pages still mid-translation).
|
||||
const canonicalRelative =
|
||||
languages.get(i18n.defaultLanguage) ?? languages.values().next().value;
|
||||
if (!canonicalRelative) continue;
|
||||
|
||||
const alternates: Record<string, string> = {};
|
||||
for (const [lang, relative] of languages) {
|
||||
alternates[lang] = absoluteDocsUrl(relative);
|
||||
}
|
||||
alternates["x-default"] = absoluteDocsUrl(canonicalRelative);
|
||||
|
||||
entries.push({
|
||||
url: absoluteDocsUrl(canonicalRelative),
|
||||
alternates: { languages: alternates },
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Multica architecture diagram for §1.2 "How Multica Works".
|
||||
*
|
||||
* Boundary-style layout: one large panel for "Your side" (where all the
|
||||
* interesting stuff happens — code, keys, compute), one smaller panel for
|
||||
* "Multica" (metadata store and coordinator). The asymmetric sizes and the
|
||||
* brand-tinted left panel visually argue Multica's core thesis: AI runs on
|
||||
* your machine, not ours.
|
||||
*
|
||||
* No SVG arrows. Relationships are carried by the layout itself — client
|
||||
* side vs. server side is the universal mental model, readers don't need
|
||||
* arrows to understand it.
|
||||
*/
|
||||
export function ArchitectureDiagram() {
|
||||
return (
|
||||
<div className="not-prose my-8">
|
||||
{/* Desktop: asymmetric two-panel with connector */}
|
||||
<div className="hidden md:grid md:grid-cols-[1.7fr_auto_1fr] md:gap-4 md:items-stretch">
|
||||
<YourSide />
|
||||
<Connector horizontal />
|
||||
<MulticaSide />
|
||||
</div>
|
||||
|
||||
{/* Mobile: stacked */}
|
||||
<div className="md:hidden space-y-4">
|
||||
<YourSide />
|
||||
<Connector horizontal={false} />
|
||||
<MulticaSide />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YourSide() {
|
||||
return (
|
||||
<div className="rounded-lg border border-brand/30 bg-brand/[0.03] p-6 flex flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-brand mb-5">
|
||||
Your side
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-5">
|
||||
{/* Client surfaces */}
|
||||
<div>
|
||||
<SectionLabel>Client</SectionLabel>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Pill>Web app</Pill>
|
||||
<Pill>CLI</Pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Horizontal separator */}
|
||||
<div className="h-px bg-brand/15" />
|
||||
|
||||
{/* Daemon + local tools */}
|
||||
<div>
|
||||
<SectionLabel>Daemon</SectionLabel>
|
||||
<div className="text-xs text-muted-foreground mb-2.5">
|
||||
Polls work from Multica. Invokes local AI coding tools:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Pill>Claude Code</Pill>
|
||||
<Pill>Codex</Pill>
|
||||
<Pill>Cursor</Pill>
|
||||
<Pill>Copilot</Pill>
|
||||
<Pill muted>+ 6 more</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<div className="mt-6 pt-4 border-t border-brand/20 flex items-center justify-center gap-3 text-[13px] font-medium text-brand">
|
||||
<span>Your code.</span>
|
||||
<span className="text-brand/40">·</span>
|
||||
<span>Your keys.</span>
|
||||
<span className="text-brand/40">·</span>
|
||||
<span>Your CPU.</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MulticaSide() {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/25 p-6 flex flex-col">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground mb-5">
|
||||
Multica
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
<SectionLabel>Server</SectionLabel>
|
||||
<div className="text-xs text-muted-foreground mb-4">
|
||||
Cloud or self-hosted
|
||||
</div>
|
||||
|
||||
<div className="text-xs space-y-1.5 text-foreground/80">
|
||||
<div>Workspaces</div>
|
||||
<div>Issues & tasks</div>
|
||||
<div>Agent definitions</div>
|
||||
<div>Realtime (WebSocket)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-border/60 text-[11px] text-muted-foreground text-center uppercase tracking-[0.08em]">
|
||||
No AI execution here.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Connector({ horizontal }: { horizontal: boolean }) {
|
||||
if (horizontal) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center text-muted-foreground/50 text-xl select-none px-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⇄
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="text-center text-muted-foreground/50 text-xl select-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
⇅
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="text-[10px] font-medium uppercase tracking-[0.1em] text-muted-foreground/70 mb-1.5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Pill({
|
||||
children,
|
||||
muted = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
muted?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-md border px-2 py-1 text-[11px] font-medium ${
|
||||
muted
|
||||
? "border-border/50 bg-background/50 text-muted-foreground"
|
||||
: "border-border/70 bg-background text-foreground"
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { i18n } from "@/lib/i18n";
|
||||
import { localeLabels } from "@/lib/translations";
|
||||
|
||||
// Sidebar-footer chrome: a language switch on the left and a theme switch
|
||||
// on the right. Replaces Fumadocs's default icon-only row, which buried
|
||||
// the language option behind a tiny globe. Each control shows the current
|
||||
// value as a label so the affordance is obvious at a glance.
|
||||
|
||||
const BASE_PATH = "/docs";
|
||||
|
||||
function switchLocalePath(pathname: string, target: string): string {
|
||||
// Next strips basePath before the router, so `pathname` starts at `/`
|
||||
// or `/<locale>/...`. Default-locale URLs are prefix-less.
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const first = segments[0];
|
||||
const hasLocalePrefix =
|
||||
first && i18n.languages.some((l) => l === first && l !== i18n.defaultLanguage);
|
||||
|
||||
const rest = hasLocalePrefix ? segments.slice(1) : segments;
|
||||
const prefixed =
|
||||
target === i18n.defaultLanguage ? rest : [target, ...rest];
|
||||
|
||||
return "/" + prefixed.join("/");
|
||||
}
|
||||
|
||||
const THEME_OPTIONS: { value: string; label: string; icon: ReactNode }[] = [
|
||||
{ value: "light", label: "Light", icon: <Sun className="size-4" /> },
|
||||
{ value: "dark", label: "Dark", icon: <Moon className="size-4" /> },
|
||||
{ value: "system", label: "System", icon: <Monitor className="size-4" /> },
|
||||
];
|
||||
|
||||
export function DocsSettings({ locale }: { locale: string }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// Gate theme reads until mount — next-themes is SSR-incompatible and
|
||||
// would otherwise cause a hydration flash of the wrong icon.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const activeTheme = mounted ? (theme ?? "system") : "system";
|
||||
const activeThemeOption =
|
||||
THEME_OPTIONS.find((o) => o.value === activeTheme) ?? THEME_OPTIONS[2]!;
|
||||
|
||||
const handleLocaleChange = (next: string) => {
|
||||
if (next === locale) return;
|
||||
const internal = pathname.startsWith(BASE_PATH)
|
||||
? pathname.slice(BASE_PATH.length) || "/"
|
||||
: pathname;
|
||||
router.push(switchLocalePath(internal, next));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
{/* Language — left pill. Shows current language name. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="font-normal text-muted-foreground"
|
||||
aria-label="Switch language"
|
||||
>
|
||||
{localeLabels[locale as keyof typeof localeLabels] ?? locale}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" side="top" className="min-w-[140px]">
|
||||
{i18n.languages.map((lang) => (
|
||||
<DropdownMenuItem
|
||||
key={lang}
|
||||
onClick={() => handleLocaleChange(lang)}
|
||||
className={cn(lang === locale && "bg-accent")}
|
||||
>
|
||||
{localeLabels[lang as keyof typeof localeLabels]}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Theme — right icon button. Matched height to the sm pill via
|
||||
the icon-sm size token; without this the icon variant defaults
|
||||
to 32px while size="sm" is 28px, misaligning them. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="Switch theme"
|
||||
>
|
||||
{activeThemeOption.icon}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" side="top" className="min-w-[140px]">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
className={cn(
|
||||
"gap-2",
|
||||
opt.value === activeTheme && "bg-accent",
|
||||
)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { useDocsLocale } from "@/components/locale-link";
|
||||
import { prefixLocale } from "@/lib/locale-link";
|
||||
|
||||
/**
|
||||
* Byline — editorial metadata strip with ruled top + bottom borders.
|
||||
*
|
||||
* Sits below DocsHero on showpiece pages (welcome). Carries the small
|
||||
* uppercase metadata: section · updated · read time. Mirrors the v2
|
||||
* editorial pattern of a "by-line" between title and body, separating
|
||||
* the heading hero from the article proper.
|
||||
*/
|
||||
export function Byline({ items }: { items: string[] }) {
|
||||
return (
|
||||
<div className="not-prose mb-9 flex items-center gap-3.5 border-y border-[var(--docs-rule)] py-3.5 text-xs uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{items.map((item, i) => (
|
||||
<span key={i} className="flex items-center gap-3.5">
|
||||
{i > 0 ? (
|
||||
<span className="size-[3px] rounded-full bg-[var(--docs-faint)]" />
|
||||
) : null}
|
||||
<span>{item}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberedCards — three-column ruled-divider grid with No.01/02/03 serif
|
||||
* numbers. Showpiece component; replaces fumadocs's <Cards> on the welcome
|
||||
* page. Top + bottom heavy rules frame the row.
|
||||
*/
|
||||
export function NumberedCards({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="not-prose my-9 grid grid-cols-1 border-y border-[var(--docs-rule)] md:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberedCard — child of NumberedCards. Internally numbered by CSS counter,
|
||||
* but we also accept an explicit `number` prop in case the consumer wants
|
||||
* to override (e.g. start at "03").
|
||||
*/
|
||||
export function NumberedCard({
|
||||
number,
|
||||
title,
|
||||
href,
|
||||
tag,
|
||||
children,
|
||||
}: {
|
||||
number?: string;
|
||||
title: string;
|
||||
href: string;
|
||||
tag?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const lang = useDocsLocale();
|
||||
return (
|
||||
<Link
|
||||
href={prefixLocale(href, lang)}
|
||||
className="group flex flex-col gap-2.5 border-r border-border px-0 py-5 pr-4 no-underline last:border-r-0 md:px-4 md:first:pl-0 md:last:pr-0"
|
||||
>
|
||||
<div className="font-mono text-[0.6875rem] uppercase tracking-[0.08em] text-muted-foreground">
|
||||
{number ? `No. ${number}` : null}
|
||||
</div>
|
||||
<div className="font-[family-name:var(--font-serif)] text-[1.375rem] leading-[1.25] tracking-[-0.015em] text-foreground transition-colors group-hover:text-[var(--primary)]">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[0.84375rem] leading-[1.55] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
{tag ? (
|
||||
<div className="mt-1 font-mono text-[0.625rem] uppercase tracking-[0.06em] text-[var(--primary)]">
|
||||
{tag}
|
||||
</div>
|
||||
) : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* NumberedSteps — large serif step numbers, ruled-row separators.
|
||||
* Use for sequential walkthroughs (install → login → start → assign).
|
||||
*/
|
||||
export function NumberedSteps({ children }: { children: ReactNode }) {
|
||||
return <div className="not-prose my-7 border-t border-border">{children}</div>;
|
||||
}
|
||||
|
||||
export function Step({
|
||||
number,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
number: string;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[3.5rem_1fr] gap-5 border-b border-border py-5">
|
||||
<div className="font-[family-name:var(--font-serif)] text-[2rem] font-normal leading-none tracking-[-0.02em] text-[var(--primary)]">
|
||||
{number}
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 font-[family-name:var(--font-serif)] text-[1.25rem] leading-[1.3] tracking-[-0.01em] text-foreground">
|
||||
{title}
|
||||
</div>
|
||||
<div className="text-[0.9375rem] leading-[1.6] text-muted-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* DocsHero — editorial showpiece header for landing-style pages.
|
||||
*
|
||||
* Escapes prose scope to run its own type scale. Title accepts ReactNode so
|
||||
* callers can pass <em> spans for brand-color emphasis (italic is avoided —
|
||||
* Chinese italic is a synthetic slant and reads as broken).
|
||||
*/
|
||||
export function DocsHero({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="not-prose mb-7 pt-2">
|
||||
{eyebrow ? (
|
||||
<p className="mb-5 text-[0.6875rem] font-semibold uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{eyebrow}
|
||||
</p>
|
||||
) : null}
|
||||
<h1 className="mb-5 font-[family-name:var(--font-serif)] text-[2.25rem] font-normal leading-[1.05] tracking-[-0.025em] text-foreground sm:text-[2.75rem]">
|
||||
{title}
|
||||
</h1>
|
||||
{subtitle ? (
|
||||
<p className="max-w-[36rem] font-[family-name:var(--font-serif)] text-[1.25rem] leading-[1.5] tracking-[-0.005em] text-[oklch(from_var(--foreground)_calc(l+0.06)_c_h)]">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DocsFeatureGrid / DocsFeatureCard — kept for back-compat with any pages
|
||||
* still using the old card grid before the editorial migration. Prefer
|
||||
* <NumberedCards>/<NumberedCard> from editorial.tsx for showpiece pages.
|
||||
*/
|
||||
export function DocsFeatureGrid({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="not-prose my-8 grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocsFeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
href,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="group flex flex-col gap-3 rounded-[4px] border border-border bg-card p-5 no-underline transition-all hover:border-[var(--primary)]"
|
||||
>
|
||||
<div className="flex size-9 items-center justify-center text-[var(--accent-foreground)] [&_svg]:size-[20px]">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-[family-name:var(--font-serif)] text-[1.0625rem] font-medium tracking-[-0.01em] text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
<p className="text-sm leading-[1.55] text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
type AnchorHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { i18n, type Lang } from "@/lib/i18n";
|
||||
import { prefixLocale } from "@/lib/locale-link";
|
||||
|
||||
const DocsLocaleContext = createContext<Lang>(i18n.defaultLanguage as Lang);
|
||||
|
||||
// Wraps the rendered MDX subtree so descendant <LocaleLink>s and any
|
||||
// editorial component using `useDocsLocale()` know which language the page
|
||||
// was rendered in. Mounted at each docs page entry; never elsewhere.
|
||||
export function DocsLocaleProvider({
|
||||
lang,
|
||||
children,
|
||||
}: {
|
||||
lang: Lang;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DocsLocaleContext.Provider value={lang}>
|
||||
{children}
|
||||
</DocsLocaleContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDocsLocale(): Lang {
|
||||
return useContext(DocsLocaleContext);
|
||||
}
|
||||
|
||||
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same
|
||||
// surface shape as the default `a` from `defaultMdxComponents` but routes
|
||||
// internal links through the locale prefixer + next/link so client-side
|
||||
// navigation stays inside the active locale.
|
||||
export function LocaleLink({
|
||||
href,
|
||||
...rest
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { href?: string }) {
|
||||
const lang = useDocsLocale();
|
||||
if (!href) return <a {...rest} />;
|
||||
const final = prefixLocale(href, lang);
|
||||
return <Link href={final} {...rest} />;
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
/**
|
||||
* Client-side Mermaid diagram renderer.
|
||||
*
|
||||
* Dynamic-imports the mermaid package so it's only loaded on pages that
|
||||
* actually use it (~400 KB). Re-renders when the page theme flips.
|
||||
*
|
||||
* Themed to pick up Multica design tokens at runtime via getComputedStyle,
|
||||
* so the diagram tracks both light / dark mode and any future token changes
|
||||
* without a rebuild.
|
||||
*/
|
||||
export function Mermaid({ chart }: { chart: string }) {
|
||||
const reactId = useId();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const [svg, setSvg] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
void import("mermaid").then(({ default: mermaid }) => {
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
// Mermaid's khroma parser only understands legacy color syntax (hex /
|
||||
// rgb / hsl / named). Our tokens are authored in oklch(), which
|
||||
// getComputedStyle preserves verbatim, and a `color-mix(in srgb, ...)`
|
||||
// round-trip still serializes as `color(srgb r g b)` per CSS Color 4.
|
||||
// Rasterize each token through a 1x1 canvas: fillStyle accepts any CSS
|
||||
// <color>, getImageData returns concrete 8-bit sRGB bytes regardless
|
||||
// of the input's color space.
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||
|
||||
const v = (name: string, fallback: string) => {
|
||||
const raw = css.getPropertyValue(name).trim();
|
||||
if (!raw || !ctx) return fallback;
|
||||
// fillStyle silently ignores unparseable input; prime with a known
|
||||
// baseline so a parse failure paints black, not whatever was last set.
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillStyle = raw;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
};
|
||||
|
||||
const brand = v("--brand", "#3b82f6");
|
||||
const brandFg = v("--brand-foreground", "#ffffff");
|
||||
const background = v("--background", "#ffffff");
|
||||
const foreground = v("--foreground", "#111111");
|
||||
const muted = v("--muted", "#f5f5f5");
|
||||
const mutedFg = v("--muted-foreground", "#6b7280");
|
||||
const border = v("--border", "#e5e5e5");
|
||||
const accent = v("--accent", muted);
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: "base",
|
||||
securityLevel: "strict",
|
||||
fontFamily: "inherit",
|
||||
themeVariables: {
|
||||
// Canvas
|
||||
background,
|
||||
mainBkg: background,
|
||||
// Nodes — soft muted fill with full-contrast text and a subtle border
|
||||
primaryColor: muted,
|
||||
primaryTextColor: foreground,
|
||||
primaryBorderColor: border,
|
||||
secondaryColor: accent,
|
||||
secondaryTextColor: foreground,
|
||||
secondaryBorderColor: border,
|
||||
tertiaryColor: background,
|
||||
tertiaryTextColor: foreground,
|
||||
tertiaryBorderColor: border,
|
||||
// Edges + labels
|
||||
lineColor: mutedFg,
|
||||
textColor: foreground,
|
||||
edgeLabelBackground: background,
|
||||
labelBackground: background,
|
||||
// Clusters (subgraph boxes)
|
||||
clusterBkg: accent,
|
||||
clusterBorder: border,
|
||||
titleColor: foreground,
|
||||
// Notes / callouts
|
||||
noteBkgColor: muted,
|
||||
noteTextColor: foreground,
|
||||
noteBorderColor: border,
|
||||
// Brand accent — used for active / start states in state diagrams,
|
||||
// user-decision diamonds in flowcharts, etc.
|
||||
activeTaskBkgColor: brand,
|
||||
activeTaskBorderColor: brand,
|
||||
altBackground: muted,
|
||||
// Sequence / git diagrams (harmless if unused)
|
||||
actorBkg: muted,
|
||||
actorBorder: border,
|
||||
actorTextColor: foreground,
|
||||
actorLineColor: mutedFg,
|
||||
signalColor: foreground,
|
||||
signalTextColor: foreground,
|
||||
// Fine print
|
||||
errorBkgColor: muted,
|
||||
errorTextColor: foreground,
|
||||
},
|
||||
});
|
||||
|
||||
// mermaid requires a DOM-valid id; useId returns ":r0:" which isn't.
|
||||
const domId = `mermaid-${reactId.replace(/:/g, "")}`;
|
||||
|
||||
mermaid
|
||||
.render(domId, chart.trim())
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setSvg(result.svg);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSvg(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, reactId, resolvedTheme]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<pre className="my-4 rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
Mermaid error: {error}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
if (!svg) {
|
||||
return (
|
||||
<div className="my-4 text-sm text-muted-foreground">
|
||||
Rendering diagram…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="my-6 flex justify-center overflow-x-auto rounded-md border border-border/60 bg-muted/20 p-6 [&_.label_foreignObject>div]:!font-[inherit] [&_.nodeLabel]:!font-[inherit] [&_.edgeLabel]:!font-[inherit] [&_text]:!font-[inherit]"
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
title: Create and configure an agent
|
||||
description: The minimum fields to create an agent, plus every optional setting — system instructions, environment variables, visibility, concurrency limit, and archiving.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Creating an [agent](/agents) takes only two things: **a name** and **a choice of [AI coding tool](/providers)**. Everything else is optional — system instructions, model, environment variables, CLI arguments, visibility, concurrency limit — the defaults work fine. Get it running first and tune later; every field can be changed at any time.
|
||||
|
||||
## Create an agent
|
||||
|
||||
Prerequisite: you already have at least one supported [AI coding tool](/providers) installed on your machine (Claude Code, Codex, etc.) and a [daemon](/daemon-runtimes) running. If you're not there yet, start with [Cloud quickstart](/cloud-quickstart) or [Self-host quickstart](/self-host-quickstart).
|
||||
|
||||
Once that's in place, go to the **Agents** page in your workspace and click **+ New**, or use the CLI:
|
||||
|
||||
```bash
|
||||
multica agent create
|
||||
```
|
||||
|
||||
The form has only two required fields: **name** (unique within the workspace) and **runtime** (= pick an AI coding tool). Every other field is covered section by section below.
|
||||
|
||||
## Pick an AI coding tool
|
||||
|
||||
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
|
||||
|
||||
| Tool | Good for |
|
||||
|---|---|
|
||||
| **Claude Code** | Anthropic's official tool, most complete feature set; **best first pick** |
|
||||
| **Codex** | OpenAI, the mainstream alternative |
|
||||
| **Cursor** | Users in the Cursor editor ecosystem |
|
||||
| **Copilot** | Teams leveraging their GitHub account entitlements |
|
||||
| **Gemini** | Users in the Google ecosystem |
|
||||
|
||||
The other six (Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw), along with each tool's full capability matrix (session resume, MCP, skill injection path, model selection), are covered in [AI coding tools comparison](/providers).
|
||||
|
||||
## Writing system instructions
|
||||
|
||||
**System instructions** (`instructions`) are prepended to every task, telling the agent what role it plays and what rules to follow:
|
||||
|
||||
```text
|
||||
You're a frontend code-review agent. When an issue comes in, read the diff first. Focus only on:
|
||||
- Styling issues (tailwind class names, box model)
|
||||
- Accessibility (a11y)
|
||||
Don't change code — leave suggestions in a comment.
|
||||
```
|
||||
|
||||
When left blank (the default), the agent uses the native behavior of its underlying AI coding tool with no extra constraints.
|
||||
|
||||
## Picking a model
|
||||
|
||||
Most AI coding tools support model selection (for example, Claude Code lets you pick between Sonnet and Opus). Leave it blank and the tool's own default is used; pick one explicitly and that's what runs. Each tool's supported models are listed in [AI coding tools comparison](/providers).
|
||||
|
||||
Changing the model **only applies to new tasks**. Already-dispatched tasks continue with the model that was locked in at dispatch time.
|
||||
|
||||
## Custom environment variables (custom_env)
|
||||
|
||||
**Custom environment variables** (`custom_env`) let you inject extra env vars at task execution time — typical uses are API keys or switching the upstream endpoint:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY = sk-...
|
||||
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.** 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.
|
||||
</Callout>
|
||||
|
||||
## Custom CLI arguments (custom_args)
|
||||
|
||||
**Custom CLI arguments** (`custom_args`) is a string array appended one-by-one to the AI coding tool's command line:
|
||||
|
||||
```json
|
||||
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
|
||||
```
|
||||
|
||||
The final command comes out as:
|
||||
|
||||
```bash
|
||||
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
|
||||
```
|
||||
|
||||
Arguments are passed as-is, not through a shell (no injection risk), but whether a given flag is recognized is up to the AI coding tool itself — tools differ substantially here.
|
||||
|
||||
<Callout type="tip">
|
||||
`custom_env` and `custom_args` have no hard caps, but in practice **keep each under 10 entries**. Too many makes the command line long, slows startup, and gets harder to maintain.
|
||||
</Callout>
|
||||
|
||||
## Visibility
|
||||
|
||||
- **Workspace** (`workspace`) — any member of the workspace can assign it
|
||||
- **Private** (`private`) — only workspace owners, admins, or the agent's creator can assign it
|
||||
|
||||
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 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
|
||||
|
||||
**Concurrency limit** (`max_concurrent_tasks`) controls how many tasks this agent can run in parallel at once. The default is **6**. New tasks that hit the cap queue up — they aren't rejected.
|
||||
|
||||
This is only the "agent layer" of a two-tier limit — the daemon itself enforces a broader cap (default 20), and whichever is tighter wins. Details in [Daemon and runtimes → How many tasks can run in parallel](/daemon-runtimes#how-many-tasks-can-run-in-parallel).
|
||||
|
||||
Changing this value **does not cancel tasks already running** — it only applies to the next task about to be picked up.
|
||||
|
||||
## Attaching domain expertise: Skills
|
||||
|
||||
A created agent can have **Skills** attached — **knowledge packs** (`SKILL.md` + supporting files) automatically delivered to the AI coding tool at task execution time. You can create a new skill, import from GitHub or ClawHub, or scan one from an existing skill directory on your machine. See [Skills](/skills).
|
||||
|
||||
## Archive and restore
|
||||
|
||||
Agents you no longer use can be **archived** — they disappear from everyday views, but their historical data (tasks run, comments posted) is fully preserved. **Restore** them anytime to put them back to work.
|
||||
|
||||
<Callout type="warning">
|
||||
**Archiving immediately cancels every unfinished task belonging to the agent** — running, dispatched, and queued tasks are all marked `cancelled` and won't continue. If you have an important task in flight, let it finish before archiving.
|
||||
</Callout>
|
||||
|
||||
Archived agents can't be assigned new tasks.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
|
||||
- [Assigning issues to agents](/assigning-issues) — put your new agent to work
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
title: 创建和配置智能体
|
||||
description: 创建一个智能体的最小字段,以及所有可选配置项——系统指令、环境变量、可见性、并发上限,和归档机制。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
创建一个 [智能体](/agents) 只要两件事:**名字** 和 **选一款 [AI 编程工具](/providers)**。其他全部可选——系统指令、模型、环境变量、命令行参数、可见性、并发上限——默认值都能用,先跑起来再慢慢调,所有字段随时能改。
|
||||
|
||||
## 创建一个智能体
|
||||
|
||||
前置条件:你本机已经装好至少一款受支持的 [AI 编程工具](/providers)(Claude Code、Codex 等),并跑着 [守护进程](/daemon-runtimes)。如果还没走到这一步,先看 [Cloud 快速开始](/cloud-quickstart) 或 [自部署快速开始](/self-host-quickstart)。
|
||||
|
||||
满足之后,在工作区的**智能体**页点 **+ 新建**,或者用命令行:
|
||||
|
||||
```bash
|
||||
multica agent create
|
||||
```
|
||||
|
||||
表单里只有两项必填:**名字**(工作区内唯一)和 **运行时**(= 选一款 AI 编程工具)。其他字段下面一节一节讲。
|
||||
|
||||
## 选一款 AI 编程工具
|
||||
|
||||
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
|
||||
|
||||
| 工具 | 适合 |
|
||||
|---|---|
|
||||
| **Claude Code** | Anthropic 官方,功能最完整;**新手首选** |
|
||||
| **Codex** | OpenAI,主流替代 |
|
||||
| **Cursor** | Cursor 编辑器生态用户 |
|
||||
| **Copilot** | 用 GitHub 账号权益的团队 |
|
||||
| **Gemini** | Google 生态用户 |
|
||||
|
||||
另外 6 款(Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw)以及每款工具的完整能力差别(会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
|
||||
|
||||
## 写系统指令
|
||||
|
||||
**系统指令**(`instructions`)会被拼在每次任务最前面,告诉这个智能体它扮演什么角色、遵守什么规则:
|
||||
|
||||
```text
|
||||
你是一个前端代码审查智能体。拿到 issue 先读 diff,只关注:
|
||||
- 样式问题(tailwind 类名、盒模型)
|
||||
- 可访问性(a11y)
|
||||
不改代码,只在评论里给建议。
|
||||
```
|
||||
|
||||
留空时(默认),智能体用它背后 AI 编程工具的原生行为,没有额外约束。
|
||||
|
||||
## 选模型
|
||||
|
||||
大多数 AI 编程工具支持选模型(例如 Claude Code 能在 Sonnet / Opus 里选)。留空 → 用工具自己的默认;明确选了 → 用选的。每款工具支持的模型见 [AI 编程工具对照](/providers)。
|
||||
|
||||
改模型**只对新任务生效**。已经派发出去的任务继续用派发时固化下来的模型。
|
||||
|
||||
## 自定义环境变量(custom_env)
|
||||
|
||||
**自定义环境变量**(`custom_env`)让你在任务执行时注入额外的 env var——典型用途是 API key 或切换上游 endpoint:
|
||||
|
||||
```
|
||||
ANTHROPIC_API_KEY = sk-...
|
||||
ANTHROPIC_BASE_URL = https://my-proxy.example.com
|
||||
```
|
||||
|
||||
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn,不会报错)。
|
||||
|
||||
<Callout type="warning">
|
||||
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值(API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
|
||||
|
||||
**不要把高价值 secret 放进 `custom_env`**(生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT),定期轮换。
|
||||
</Callout>
|
||||
|
||||
## 自定义命令行参数(custom_args)
|
||||
|
||||
**自定义命令行参数**(`custom_args`)是一串字符串数组,会被逐个追加到 AI 编程工具的命令行尾部:
|
||||
|
||||
```json
|
||||
["--max-turns", "100", "--append-system-prompt", "always respond in Chinese"]
|
||||
```
|
||||
|
||||
拼完会是:
|
||||
|
||||
```bash
|
||||
claude --model <model> --max-turns 100 --append-system-prompt "always respond in Chinese" [...]
|
||||
```
|
||||
|
||||
参数按原样传,不走 shell 解析(没有注入风险),但传什么 flag 能不能被识别看 AI 编程工具本身——不同工具差异很大。
|
||||
|
||||
<Callout type="tip">
|
||||
`custom_env` 和 `custom_args` 没有硬限制,但**实际使用建议控制在 10 条以内**。太多会让命令行变长、启动变慢,也更难维护。
|
||||
</Callout>
|
||||
|
||||
## 可见性
|
||||
|
||||
- **工作区可见**(`workspace`)—— 工作区里任何成员都能分配
|
||||
- **私有**(`private`)—— 只有工作区的 owner、admin,或智能体的创建者能分配
|
||||
|
||||
新建默认 `private`。
|
||||
|
||||
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
|
||||
|
||||
## 并发上限
|
||||
|
||||
**并发上限**(`max_concurrent_tasks`)决定这个智能体同一时间最多同时跑几个任务,默认 **6**。达到上限的新任务留在队列排队,不会被拒绝。
|
||||
|
||||
这只是两层限额里的"智能体层"——守护进程本身还有一层更粗的限额(默认 20),两层中更紧的那层生效。详见 [守护进程与运行时 → 一次能并发跑多少任务](/daemon-runtimes#一次能并发跑多少任务)。
|
||||
|
||||
修改这个值**不会取消已经在跑的任务**——只对下一个要被领走的任务生效。
|
||||
|
||||
## 挂专业知识:Skill
|
||||
|
||||
创建好的智能体可以挂 **Skill**——一种**专业知识包**(`SKILL.md` + 支持文件),任务执行时自动送到对应的 AI 编程工具。可以新建、从 GitHub / ClawHub 导入、或从你本机已有的 skill 目录扫入。详见 [Skills](/skills)。
|
||||
|
||||
## 归档和恢复
|
||||
|
||||
不再用的智能体可以**归档**——它从日常视图里消失,但历史数据(跑过的任务、发过的评论)全部保留。想重新用时**恢复**即可。
|
||||
|
||||
<Callout type="warning">
|
||||
**归档会立刻取消这个智能体所有未结束的任务**——正在跑的、已派发的、还在排队的都会被标为 `cancelled`,不会继续执行。如果有重要任务在跑,先让它完成再归档。
|
||||
</Callout>
|
||||
|
||||
已归档的智能体无法被分配新任务。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Skills](/skills) —— 给智能体挂专业知识包
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
|
||||
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
title: Agents
|
||||
description: "An agent is a first-class member of a Multica workspace — it can be assigned issues, post comments, and be @-mentioned. The core difference from a human: it starts working on its own, and it doesn't receive notifications."
|
||||
---
|
||||
|
||||
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](/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
|
||||
|
||||
Agents use the same "member" surface as humans, and the UI barely distinguishes them:
|
||||
|
||||
- **[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](/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.
|
||||
|
||||
## How it differs from a human
|
||||
|
||||
A few key differences only surface once you actually start using agents:
|
||||
|
||||
- **It starts on its own** — after you assign it an issue or `@` it, Multica dispatches the task to its runtime immediately. Unlike a human, it doesn't wait to see the message and respond. For trigger details, see [Assigning issues to agents](/assigning-issues) and [@-mentioning agents in comments](/mentioning-agents).
|
||||
- **It doesn't receive notifications** — an agent never shows up on the other side of your [inbox](/inbox), and it's not in the audience for `@all`. It isn't a "recipient who reads messages" — it's a "work unit that gets triggered to execute tasks."
|
||||
- **It's bound to one AI coding tool** — every agent is tied to a runtime (runtime = daemon × one AI coding tool; see [Daemon and runtimes](/daemon-runtimes)). If the tool is offline, the agent can't work; new tasks wait until the runtime comes back.
|
||||
- **It can be archived** — archive an agent you don't use anymore and it disappears from everyday views; restore it whenever you want. Archiving cancels any tasks currently running.
|
||||
|
||||
## Who can assign an agent
|
||||
|
||||
When you create an agent, you pick a **visibility** that controls who can assign it to an issue or set it as project lead:
|
||||
|
||||
- **Workspace** — any member of the workspace can assign it
|
||||
- **Private** — only workspace owners, admins, or the agent's creator can assign it
|
||||
|
||||
New agents default to **private**. To make one available to the whole workspace, set visibility to `workspace` at creation time, or change it later in the agent's config. For the full role-permission matrix, see [Members and roles](/members-roles).
|
||||
|
||||
<Callout type="info">
|
||||
**Private means "restricted who can assign," not "hidden from everyone else."** Every member of the workspace sees a private agent's name and description in the agents list — they just can't see its config details (custom environment variables, MCP config, and other sensitive fields are masked). If you need "visible to only one person," that's not currently possible.
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Create and configure an agent](/agents-create) — how to build one
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
title: 智能体
|
||||
description: 智能体(agent)是 Multica 工作区里的一等公民成员——能被分配 issue、发评论、被 @ 点名;和人最大的不同是它自动开工、不收通知。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
智能体(agent)是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
|
||||
|
||||
## 智能体能做什么
|
||||
|
||||
智能体和人用的是同一套"成员"接口,界面上几乎没有区别:
|
||||
|
||||
- **[被分配 issue](/assigning-issues)** —— 作为 assignee,分配后它会自动开工
|
||||
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
|
||||
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
|
||||
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
|
||||
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
|
||||
|
||||
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。
|
||||
|
||||
## 它和人不一样在哪
|
||||
|
||||
几个关键差异在你真正开始用之后才会浮现:
|
||||
|
||||
- **它自动开工**——分配 issue 或 `@` 它之后,Multica 会立刻把任务派给它所在的运行时。不像人那样要等 TA 看到消息再响应。触发方式的细节见 [分配 issue 给智能体](/assigning-issues) 和 [在评论里 @智能体](/mentioning-agents)。
|
||||
- **它不收通知**——智能体永远不会出现在你的 [收件箱](/inbox) 对面;它也不在 `@all` 的接收范围内。它不是"读消息的收信人",而是"被触发执行任务的工作单元"。
|
||||
- **它绑一款 AI 编程工具**——每个智能体关联一个运行时(runtime = 守护进程 × 一款 AI 编程工具,详见 [守护进程与运行时](/daemon-runtimes))。工具不在线,它干不了活,新任务会等到运行时回来。
|
||||
- **它可以被归档**——不用时把它归档起来,会从日常视图里消失;以后想用随时恢复。归档时正在跑的任务会被取消。
|
||||
|
||||
## 谁能把智能体分配出去
|
||||
|
||||
创建智能体时会选一个**可见性**(visibility),决定谁能把它分配给 issue 或设为 project lead:
|
||||
|
||||
- **工作区可见(workspace)** —— 工作区里任何成员都能分配
|
||||
- **私有(private)** —— 只有工作区的 owner、admin,或智能体的创建者能分配
|
||||
|
||||
新建的智能体**默认是私有的**。想让全工作区都能用,在创建时把可见性选为 `workspace`,或之后在配置里改。角色权限完整对照见 [成员与权限](/members-roles)。
|
||||
|
||||
<Callout type="info">
|
||||
**私有 = 限制谁能分配,不是对其他人隐藏**。工作区里所有成员都能在智能体列表里看到私有智能体的名字和描述——只是看不到它的配置细节(自定义环境变量、MCP 配置等敏感字段被打码)。如果你需要"只对一个人可见",目前做不到。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
|
||||
- [Skills](/skills) —— 给智能体挂上专业知识包
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
title: Assign issues to agents
|
||||
description: Hand an issue to an agent and it takes over as the official assignee until the work is done — with full context and the ability to change issue status and fields.
|
||||
---
|
||||
|
||||
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.
|
||||
|
||||
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|
||||
|---|---|---|---|---|---|
|
||||
| **Assign** | Hand an agent ownership | Changes assignee | Issue + all comments | Inherits from issue | ✓ |
|
||||
| [**@-mention**](/mentioning-agents) | Pull it in to take a look | No changes | Issue + trigger comment | Inherits from issue | ✓ |
|
||||
| [**Chat**](/chat) | One-to-one conversation outside any issue | No issue involved | Current conversation history | Fixed medium | ✓ |
|
||||
| [**Autopilots**](/autopilots) | Scheduled or manual automation | Depends on mode | Depends on mode | Set by autopilot | ✗ |
|
||||
|
||||
"Auto retry" refers to retries after infrastructure failures (runtime offline, timeout). Business errors on the agent side (for example, the model reporting an error) are not retried. See [**Tasks**](/tasks) for details.
|
||||
|
||||
## Assign from the UI
|
||||
|
||||
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:
|
||||
|
||||
- **Workspace agents** can be assigned by any member; **private agents** can only be assigned by their owner or a workspace admin.
|
||||
- You can only assign to agents that have **an online runtime** — agents with no one running them show as unavailable in the picker.
|
||||
- When the issue status is **Backlog**, assigning **does not trigger the agent** — Backlog is a parking lot; the agent only gets enqueued once you move the issue to Todo or In Progress.
|
||||
|
||||
## Assign from the CLI
|
||||
|
||||
The command-line equivalent:
|
||||
|
||||
```bash
|
||||
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 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:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --unassign
|
||||
```
|
||||
|
||||
## What happens after assignment
|
||||
|
||||
When a non-Backlog issue is assigned to an agent, Multica immediately does the following in the background:
|
||||
|
||||
1. Enqueues a `queued` `task` with priority inherited from the issue, routed to the runtime where the agent lives.
|
||||
2. The agent's daemon picks up the `task` on its next poll and transitions it to `dispatched`.
|
||||
3. The agent starts working and the `task` moves to `running`; on completion it becomes `completed` or `failed`.
|
||||
4. During execution the agent can change the issue's status, post comments, and edit fields — these actions appear under the agent's identity.
|
||||
|
||||
**If the agent is offline**, the `task` waits in the queue — **it times out and fails after 5 minutes** with reason `runtime_offline`. For retryable sources (assign, @-mention, chat), Multica automatically re-enqueues it. See [**Tasks**](/tasks) for the full retry rules.
|
||||
|
||||
Assigning also auto-subscribes the agent to the issue — but in Multica **agents do not receive inbox notifications** (only members do). This subscription is internal bookkeeping with no user-visible side effect.
|
||||
|
||||
## Reassign or unassign
|
||||
|
||||
When you change the assignee from Agent A to Agent B:
|
||||
|
||||
1. **Everything A has in flight is cancelled** — every `task` in `queued`, `dispatched`, or `running` state is marked `cancelled`.
|
||||
2. **B is enqueued a new `task` immediately** (if the issue is not in Backlog and B has an online runtime).
|
||||
|
||||
<Callout type="warning">
|
||||
**Reassignment cancels every active `task` on this issue — not just the old assignee's.** If another agent is working on this issue because of an @-mention, its `task` is cancelled too. There is currently no UI action to cancel a single agent's `task` in isolation.
|
||||
</Callout>
|
||||
|
||||
Unassigning (`--unassign` or picking "none" in the picker) marks all active `task` entries as `cancelled` and **does not enqueue a new one**. Existing subscriptions are not cleared automatically — the old assignee stays on the subscription list (but still receives no inbox notifications).
|
||||
|
||||
## Why only one active `task` per agent per issue
|
||||
|
||||
**A single agent can have at most one `queued` or `dispatched` `task` on the same issue at any time.** A unique index at the database level plus the claim logic enforces this — it prevents duplicate enqueues and concurrent executions overwriting each other.
|
||||
|
||||
But **different agents can work on the same issue in parallel** — for example, Agent A is the assignee and Agent B is @-mentioned; both `task` entries can coexist, each running on its own runtime. See [**Tasks**](/tasks) for the full serial/concurrent rules.
|
||||
|
||||
## Next
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
@@ -1,82 +0,0 @@
|
||||
---
|
||||
title: 分配 issue 给智能体
|
||||
description: 把 issue 交给智能体,它作为正式负责人一直工作到结束——拿到完整上下文,也能改 issue 状态和字段。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
|
||||
|
||||
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|
||||
|---|---|---|---|---|---|
|
||||
| **分配** | 让智能体正式负责 | 改 assignee | issue + 全部 comments | 继承 issue | ✓ |
|
||||
| [**@ 提及**](/mentioning-agents) | 评论里让它看一眼 | 都不改 | issue + 触发评论 | 继承 issue | ✓ |
|
||||
| [**对话**](/chat) | 独立于 issue 的一对一聊天 | 不涉及 issue | 当前对话历史 | 固定中 | ✓ |
|
||||
| [**Autopilots**](/autopilots) | 定时 / 手动自动化 | 视模式 | 视模式 | autopilot 自定 | ✗ |
|
||||
|
||||
"自动重试"指基础设施故障(运行时离线、超时)导致的重试;智能体侧业务错误(比如模型自己报错)不会自动重试。详见 [**执行任务**](/tasks)。
|
||||
|
||||
## 在界面里分配
|
||||
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员和未归档的智能体。选一个智能体,issue 立刻分给它。
|
||||
|
||||
几条规则:
|
||||
|
||||
- **工作区智能体**任何成员都能分配;**私人智能体**只有它的 owner 或工作区 admin 能分配
|
||||
- 只能分配给**有在线运行时**的智能体——没人在跑的智能体,picker 会提示不可选
|
||||
- Issue 状态是 **Backlog** 时,分配**不会立刻触发**智能体——Backlog 是停泊场,切到 Todo / In Progress 才会真正入队
|
||||
|
||||
## 用 CLI 分配
|
||||
|
||||
等价的命令行操作:
|
||||
|
||||
```bash
|
||||
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 members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
|
||||
|
||||
取消分配:
|
||||
|
||||
```bash
|
||||
multica issue assign MUL-42 --unassign
|
||||
```
|
||||
|
||||
## 分配之后会发生什么
|
||||
|
||||
非 Backlog 的 issue 分配给智能体之后,Multica 会立刻在后台做以下事情:
|
||||
|
||||
1. 入队一个 `queued` 状态的 `task`,优先级继承自 issue,路由到该智能体所在的运行时
|
||||
2. 该智能体的守护进程下次轮询时把 `task` 领走,状态变成 `dispatched`
|
||||
3. 智能体开始执行,`task` 转成 `running`;完成后转成 `completed` / `failed`
|
||||
4. 执行过程中智能体可以改 issue 状态、发评论、改字段——这些动作以智能体的身份出现
|
||||
|
||||
**如果智能体离线**,`task` 会在队列里等——**5 分钟没被领走就超时失败**,失败原因 `runtime_offline`。对可重试的来源(分配、@ 提及、对话),Multica 会自动重新排队;完整重试规则见 [**执行任务**](/tasks)。
|
||||
|
||||
分配还会自动把这个智能体加进 issue 的订阅列表——但 Multica 里**智能体不接收 inbox 通知**(只有成员收)。这个订阅只是内部 bookkeeping,用户侧没有可见的副作用。
|
||||
|
||||
## 换分配人或取消分配
|
||||
|
||||
把 assignee 从 Agent A 换成 Agent B 时:
|
||||
|
||||
1. **A 这边在跑的一切都被取消**——所有 `queued` / `dispatched` / `running` 状态的 `task` 都被标记 `cancelled`
|
||||
2. **B 立刻被入队一个新 `task`**(如果 issue 不是 Backlog 且 B 有在线运行时)
|
||||
|
||||
<Callout type="warning">
|
||||
**换分配人会 cancel 掉这个 issue 上所有活跃的 `task`——不只是旧 assignee 的**。如果另一个智能体因为 @ 提及也正在这个 issue 上干活,它的 `task` 也会被一并取消。目前没有只 cancel 单个智能体 `task` 的 UI 操作。
|
||||
</Callout>
|
||||
|
||||
取消分配(`--unassign` 或 picker 里选"无")把所有活跃 `task` 标记 `cancelled`,**不入队新的**。已有的订阅关系不会自动清除——旧 assignee 仍留在订阅名单里(但同样收不到 inbox 通知)。
|
||||
|
||||
## 为什么同一 issue 同时只能一个活跃 `task`
|
||||
|
||||
**同一个智能体在同一个 issue 上,同时只能有一个 `queued` 或 `dispatched` 的 `task`**。数据库层的 unique index 加上 claim 逻辑保证这一点——避免重复入队、避免并发执行互相覆盖。
|
||||
|
||||
但**不同智能体在同一个 issue 上可以各自独立跑**——比如 Agent A 是 assignee,Agent B 被 @ 提及,两者的 `task` 可以同时存在,各走各的运行时。完整的串行 / 并发规则见 [**执行任务**](/tasks)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
title: Sign-in and signup configuration
|
||||
description: Configure email + verification code sign-in, Google OAuth, signup allowlists, and local test codes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica supports two sign-in methods: **email + verification code** (default) and **Google OAuth** (optional). On successful sign-in, the server issues a JWT cookie with a 30-day lifetime. This page covers how to configure each method, how to restrict who can sign up, and the single biggest trap for self-hosted deployments.
|
||||
|
||||
For the list of environment variables referenced below, see [Environment variables](/environment-variables); for token usage and lifecycle details, see [Authentication and tokens](/auth-tokens).
|
||||
|
||||
## 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. It requires [Resend](https://resend.com/) as the email provider:
|
||||
|
||||
1. Create a Resend account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
|
||||
```
|
||||
|
||||
4. Restart the server
|
||||
|
||||
**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
|
||||
|
||||
<Callout type="warning">
|
||||
**Do not enable a fixed verification code on a publicly reachable instance.**
|
||||
|
||||
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 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
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
This shortcut is ignored when `APP_ENV=production`.
|
||||
</Callout>
|
||||
|
||||
Production deployments should leave `MULTICA_DEV_VERIFICATION_CODE` empty and set `APP_ENV=production`. If you deploy via `make selfhost` / `docker-compose.selfhost.yml`, `APP_ENV` defaults to `production`.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
Optional. Without it, only email + verification code is available; with it, the sign-in page gets a "Sign in with Google" button.
|
||||
|
||||
1. Create an OAuth 2.0 client in the [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Set the **Authorized redirect URIs** to your Multica frontend address plus `/auth/callback`, for example:
|
||||
|
||||
```text
|
||||
https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
3. Once you have the client ID and client secret, set three environment variables:
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
|
||||
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
4. Restart the server.
|
||||
|
||||
**Takes effect at runtime**: the frontend reads these settings at runtime via `/api/config` — after changing them, restart the server and the frontend picks up the new values with no rebuild or redeploy.
|
||||
|
||||
<Callout type="warning">
|
||||
**The redirect URI must match exactly in both the Google Console and `GOOGLE_REDIRECT_URI`** — including protocol (`http` vs `https`), trailing slash, and port. Any mismatch and Google rejects the entire OAuth flow; the error shown to the user is `redirect_uri_mismatch`.
|
||||
</Callout>
|
||||
|
||||
## Restricting who can sign up
|
||||
|
||||
Three environment variables combine by priority:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
Start[New user first sign-in] --> A{Email in<br/>ALLOWED_EMAILS?}
|
||||
A -- Yes --> Allow[Allow signup]
|
||||
A -- No --> B{Domain in<br/>ALLOWED_EMAIL_DOMAINS?}
|
||||
B -- Yes --> Allow
|
||||
B -- No --> C{Any allowlist<br/>non-empty?}
|
||||
C -- Yes --> Block[Reject]
|
||||
C -- No --> D{ALLOW_SIGNUP<br/>= true?}
|
||||
D -- Yes --> Allow
|
||||
D -- No --> Block
|
||||
`} />
|
||||
|
||||
**Existing users can always sign in again** — the signup allowlist only applies to **first-time signup**, not returning users.
|
||||
|
||||
- **`ALLOWED_EMAILS`** (highest priority) — explicit email allowlist, comma-separated. **When non-empty, only listed emails can sign up.**
|
||||
- **`ALLOWED_EMAIL_DOMAINS`** — domain allowlist, comma-separated (for example `company.io,partner.com`).
|
||||
- **`ALLOW_SIGNUP`** — master switch, default `true`. Set `false` to disable signup entirely.
|
||||
|
||||
<Callout type="warning">
|
||||
**The three layers are AND semantics, not OR.** A common wrong intuition is that `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` means "allow company.io plus everyone else." It does **not**. If any layer has a non-empty value, **emails not matching it are rejected outright** — `ALLOW_SIGNUP=true` does not override that.
|
||||
|
||||
To actually "allow everyone," leave all three variables empty (or keep `ALLOW_SIGNUP=true`).
|
||||
</Callout>
|
||||
|
||||
**Typical configurations**:
|
||||
|
||||
| Goal | Configuration |
|
||||
|---|---|
|
||||
| Internal only, employees of `company.io` | `ALLOWED_EMAIL_DOMAINS=company.io` |
|
||||
| Internal + a few external collaborators | `ALLOWED_EMAIL_DOMAINS=company.io` + collaborator addresses added to `ALLOWED_EMAILS` |
|
||||
| Disable self-serve signup entirely, invite-only | `ALLOW_SIGNUP=false` |
|
||||
| Open signup (not recommended for production) | All three empty |
|
||||
|
||||
## Can you still invite people when signup is disabled?
|
||||
|
||||
**Only people who already have a Multica account.** Accepting an invite doesn't check the signup allowlist — if the invitee has signed up already (for example in another workspace), clicking the invite link and signing in lets them accept.
|
||||
|
||||
**But people who have never signed up cannot be rescued by an invite.** Before accepting, they must sign in, and the first step of sign-in (requesting the verification code) passes through the signup allowlist check. If `ALLOW_SIGNUP=false`, or their email isn't in `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS`, they **cannot complete signup**, and therefore cannot accept the invite.
|
||||
|
||||
To invite an external collaborator who hasn't signed up yet: temporarily add their email to `ALLOWED_EMAILS`, wait for them to sign up and accept the invite, then remove the entry.
|
||||
|
||||
For how to create and use invites, see [Members and roles](/members-roles).
|
||||
|
||||
## Next
|
||||
|
||||
- [Environment variables](/environment-variables) — full definitions of every variable used on this page
|
||||
- [Authentication and tokens](/auth-tokens) — JWT / PAT / daemon token categories and usage
|
||||
- [Troubleshooting](/troubleshooting) — verification code not received, OAuth `redirect_uri_mismatch`, signup rejected
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
title: 登录与注册配置
|
||||
description: 配 Email 验证码登录、Google OAuth、注册白名单和本地测试验证码。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google OAuth**(可选)。登录成功后 server 签发一个 30 天有效期的 JWT cookie。这一页讲怎么配、怎么限制谁能注册、以及本地测试验证码怎么安全使用。
|
||||
|
||||
上面用到的环境变量的清单见 [环境变量](/environment-variables);token 怎么用、生命周期细节见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
```bash
|
||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 必须是 Resend 已验证的域名
|
||||
```
|
||||
|
||||
4. 重启 server
|
||||
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
<Callout type="warning">
|
||||
**不要在公网可访问实例上启用固定验证码。**
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
MULTICA_DEV_VERIFICATION_CODE=888888
|
||||
```
|
||||
|
||||
`APP_ENV=production` 时这个快捷码会被忽略。
|
||||
</Callout>
|
||||
|
||||
生产部署应保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,并设置 `APP_ENV=production`。如果你用 `make selfhost` / `docker-compose.selfhost.yml` 自部署,`APP_ENV` 默认就是 `production`。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
可选。不配就只有 Email + 验证码登录;配了后登录页会多出「用 Google 登录」按钮。
|
||||
|
||||
1. 去 [Google Cloud Console](https://console.cloud.google.com/) 创建一个 OAuth 2.0 client
|
||||
2. **授权的回调 URI**(Authorized redirect URIs)填你的 Multica 前端地址加 `/auth/callback`,例如:
|
||||
|
||||
```text
|
||||
https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
3. 拿到 client ID 和 client secret 后设三个环境变量:
|
||||
|
||||
```bash
|
||||
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
|
||||
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
|
||||
```
|
||||
|
||||
4. 重启 server。
|
||||
|
||||
**热生效**:前端通过 `/api/config` 运行时读这些配置——改完只要重启 server,前端不用重建镜像、不用重新部署。
|
||||
|
||||
<Callout type="warning">
|
||||
**回调 URI 在 Google Console 和 `GOOGLE_REDIRECT_URI` 两处必须完全一致**,包括协议(`http` vs `https`)、尾部斜杠、端口。不一致 Google 会拒绝整个 OAuth 流程,用户看到的错误是 `redirect_uri_mismatch`。
|
||||
</Callout>
|
||||
|
||||
## 怎么限制谁能注册
|
||||
|
||||
三层环境变量按优先级组合:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
Start[新用户首次登录] --> A{email 在<br/>ALLOWED_EMAILS 里?}
|
||||
A -- 是 --> Allow[允许注册]
|
||||
A -- 否 --> B{domain 在<br/>ALLOWED_EMAIL_DOMAINS 里?}
|
||||
B -- 是 --> Allow
|
||||
B -- 否 --> C{任一白名单<br/>非空?}
|
||||
C -- 是 --> Block[拒绝]
|
||||
C -- 否 --> D{ALLOW_SIGNUP<br/>= true?}
|
||||
D -- 是 --> Allow
|
||||
D -- 否 --> Block
|
||||
`} />
|
||||
|
||||
**已经登录过的老用户永远可以再次登录**——signup 白名单只对**首次注册**生效,不拦截老用户。
|
||||
|
||||
- **`ALLOWED_EMAILS`**(最高优先级)—— 显式邮箱白名单,逗号分隔。**非空时只有列表里的邮箱能注册**。
|
||||
- **`ALLOWED_EMAIL_DOMAINS`**—— 域名白名单,逗号分隔(例如 `company.io,partner.com`)。
|
||||
- **`ALLOW_SIGNUP`** —— 总开关,默认 `true`。设 `false` 完全关闭注册。
|
||||
|
||||
<Callout type="warning">
|
||||
**三层白名单是 AND 语义,不是 OR。** 很多人第一直觉是「设 `ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` 就是允许 company.io 和其他所有人」——**不是**。任何一层白名单只要设了非空值,**不匹配的邮箱直接拒**,`ALLOW_SIGNUP=true` 挡不住。
|
||||
|
||||
要真的「允许所有人」,所有三个环境变量都留空(或 `ALLOW_SIGNUP=true`)。
|
||||
</Callout>
|
||||
|
||||
**典型配法**:
|
||||
|
||||
| 需求 | 配置 |
|
||||
|---|---|
|
||||
| 公司内网,只允许 `company.io` 员工 | `ALLOWED_EMAIL_DOMAINS=company.io` |
|
||||
| 公司内网 + 几个外部合作者 | `ALLOWED_EMAIL_DOMAINS=company.io` + 合作者个人邮箱加到 `ALLOWED_EMAILS` |
|
||||
| 完全关闭自助注册,只能邀请 | `ALLOW_SIGNUP=false` |
|
||||
| 开放注册(不推荐生产用)| 三个都留空 |
|
||||
|
||||
## 关了注册还能邀请人进来吗
|
||||
|
||||
**只对已经有 Multica 账号的人能**。接受邀请那一步不检查 signup 白名单——如果对方已经注册过(比如在别的工作区),他们点链接登录就能直接接受。
|
||||
|
||||
**但还没注册过的人,邀请救不了他们**。他们接受邀请前必须先登录,登录的第一步(发验证码)会过 signup 白名单检查。如果你 `ALLOW_SIGNUP=false`、或他们的邮箱不在 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 里,他们**没法完成注册**,也就没法接受邀请。
|
||||
|
||||
要邀请一个还没注册的外部协作者:临时把他们的邮箱加到 `ALLOWED_EMAILS`,等他们注册 + 接受邀请之后再把这条移掉。
|
||||
|
||||
邀请的创建和使用见 [成员与权限](/members-roles)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [环境变量](/environment-variables) —— 这一页用到的环境变量完整定义
|
||||
- [认证与令牌](/auth-tokens) —— JWT / PAT / Daemon Token 的分类和使用
|
||||
- [故障排查](/troubleshooting) —— 验证码收不到、OAuth 报 `redirect_uri_mismatch`、注册被拒的常见排查
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: Authentication and tokens
|
||||
description: Multica has three kinds of tokens — one each for the browser, the CLI, and the daemon. When to use which.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica has three kinds of tokens, one for each context: the browser Web UI, the command line and scripts, and the daemon. All three represent the same you, but their scopes and lifetimes differ.
|
||||
|
||||
## The three tokens
|
||||
|
||||
| Token | Format | Where it's used | Lifetime |
|
||||
|---|---|---|---|
|
||||
| **JWT cookie** | `multica_auth` cookie (HttpOnly) | Web browser | 30 days |
|
||||
| **Personal access token (PAT)** | Prefixed with `mul_` | CLI, scripts, direct API calls | No expiry by default; when you create one via the API you can pass `expires_in_days` |
|
||||
| **Daemon token** | Prefixed with `mdt_` | Daemon-to-server communication | Managed by the daemon itself |
|
||||
|
||||
In day-to-day use you'll only touch the first two directly. The **[daemon](/daemon-runtimes) token** is created and refreshed automatically by `multica daemon login` — you don't have to think about it.
|
||||
|
||||
## What each token can hit
|
||||
|
||||
| API route | JWT cookie | PAT | Daemon token |
|
||||
|---|---|---|---|
|
||||
| `/api/user/*` (user-level actions) | ✓ | ✓ | ✗ |
|
||||
| `/api/workspaces/:id/*` (workspace-level) | ✓ | ✓ | ✗ |
|
||||
| `/api/daemon/*` (daemon-only) | ✗ | ✓ | ✓ |
|
||||
| WebSocket `/ws` (real-time push) | ✓ (cookie) | ✓ (authenticates via first message) | ✗ |
|
||||
|
||||
**A PAT can hit almost anything** — it represents "the full you." A daemon token can only do what the daemon needs: fetch tasks and report results.
|
||||
|
||||
**Both can hit `/api/daemon/*`, but their scopes differ.** A PAT represents an **entire user** — once authenticated, it can see every workspace you belong to. A daemon token is pinned to a single workspace at creation time and can only touch resources in that workspace. In production, run your daemon with a daemon token — don't take the shortcut of using a PAT, or you'll be granting far more privilege than the daemon needs.
|
||||
|
||||
## Logging in
|
||||
|
||||
### Email + verification code
|
||||
|
||||
1. Enter your email; the server sends a 6-digit code.
|
||||
2. Enter the code; the server issues a JWT cookie (browser) or exchanges it for a PAT (CLI).
|
||||
|
||||
<Callout type="warning">
|
||||
**Self-hosting operators, take note**: keep `MULTICA_DEV_VERIFICATION_CODE` empty on public deployments. If you enable a fixed local test code, anyone who can request a code can sign in with that value while `APP_ENV` is non-production. See [Self-host auth configuration](/auth-setup).
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
Click **Sign in with Google** and go through the standard OAuth callback. Self-hosting requires `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and the redirect URI to be configured — see [Self-host auth configuration](/auth-setup).
|
||||
|
||||
## Creating, viewing, and revoking a PAT
|
||||
|
||||
**Creating** a PAT can be done two ways:
|
||||
|
||||
- **Web UI**: Settings → Personal Access Tokens → New token
|
||||
- **CLI**: `multica login` creates one automatically if there's no local PAT yet
|
||||
|
||||
<Callout type="warning">
|
||||
**The full PAT is displayed exactly once when it's created.** After you refresh or close the dialog, you won't be able to see it again.
|
||||
|
||||
Multica stores only the hash of the PAT in the database — not even the server can retrieve the original. Copy and save it immediately. If you lose it, your only option is to revoke it and create a new one.
|
||||
</Callout>
|
||||
|
||||
**Viewing** existing PATs (name, creation time, last-used time — **not** the full token) lives under Settings → Personal Access Tokens.
|
||||
|
||||
**Revoking** a PAT: click Revoke in the list. Revocation takes effect immediately — the next request made with that PAT will be rejected with a 401.
|
||||
|
||||
## Logging out only deletes the local token
|
||||
|
||||
When you run `multica auth logout` or click log out in the Web UI:
|
||||
|
||||
- **The local token is cleared** — the CLI removes the PAT from `~/.multica/config.json`; the browser deletes the cookie.
|
||||
- **The PAT is still valid on the server** — if someone obtained your PAT before you logged out (for example, by copying it to another machine), they **can still use it**.
|
||||
|
||||
<Callout type="warning">
|
||||
**If you suspect your PAT has leaked, don't just log out.** Go to Settings → Personal Access Tokens and **revoke** the token. Only revocation invalidates a leaked token immediately.
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [CLI command reference](/cli) — authentication is automatic for every CLI command
|
||||
- [Self-host auth configuration](/auth-setup) — how to configure email, OAuth, and signup allowlists when self-hosting
|
||||
- [Daemon and runtimes](/daemon-runtimes) — where the daemon token comes from
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: 认证与令牌
|
||||
description: Multica 有三种令牌——浏览器、CLI、守护进程各用一种。什么场景用哪种。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica 有三种令牌,对应三种使用场景:浏览器 Web UI、命令行 / 脚本、守护进程(daemon)。三种都代表同一个你,但作用域和有效期不同。
|
||||
|
||||
## 三种令牌
|
||||
|
||||
| 令牌 | 格式 | 用在哪 | 有效期 |
|
||||
|---|---|---|---|
|
||||
| **JWT Cookie** | `multica_auth` cookie(HttpOnly) | Web 浏览器 | 30 天 |
|
||||
| **个人访问令牌(PAT)** | 以 `mul_` 开头 | CLI / 脚本 / 直接调 API | 默认不过期;用 API 创建时可选传 `expires_in_days` |
|
||||
| **守护进程令牌(Daemon Token)** | 以 `mdt_` 开头 | Daemon 内部和 server 通信 | 由 daemon 自己管理 |
|
||||
|
||||
日常使用你只会直接接触前两种。**[守护进程](/daemon-runtimes)令牌**是 `multica daemon login` 自动生成和刷新的,你不用关心。
|
||||
|
||||
## 三种令牌能访问哪些 API
|
||||
|
||||
| API 路由 | JWT Cookie | PAT | Daemon Token |
|
||||
|---|---|---|---|
|
||||
| `/api/user/*`(用户级操作) | ✓ | ✓ | ✗ |
|
||||
| `/api/workspaces/:id/*`(工作区级) | ✓ | ✓ | ✗ |
|
||||
| `/api/daemon/*`(daemon 专用) | ✗ | ✓ | ✓ |
|
||||
| WebSocket `/ws`(实时推送) | ✓(cookie) | ✓(首条消息认证) | ✗ |
|
||||
|
||||
**PAT 几乎什么都能命中**——它代表"完整的你"。Daemon Token 能做的事非常有限,只够 daemon 拉任务和汇报结果。
|
||||
|
||||
**同样是访问 `/api/daemon/*`,两者作用域不同**:PAT 代表**一整个用户**——进来之后能看到你所有的工作区;daemon token 在创建时就绑死一个工作区,只能动这一个工作区的资源。生产部署用 daemon token 跑 daemon,不要图方便用 PAT——权限会被放大。
|
||||
|
||||
## 登录
|
||||
|
||||
### Email + 验证码
|
||||
|
||||
1. 填邮箱,server 发一封带 6 位验证码的邮件
|
||||
2. 输入验证码,server 签发 JWT cookie(浏览器)或交换出 PAT(CLI)
|
||||
|
||||
<Callout type="warning">
|
||||
**自部署运维注意**:公网部署时保持 `MULTICA_DEV_VERIFICATION_CODE` 为空。如果启用固定本地测试验证码,在 `APP_ENV` 非 production 时,任何能请求验证码的人都能用该固定值登录。详见 [自部署的认证配置](/auth-setup)。
|
||||
</Callout>
|
||||
|
||||
### Google OAuth
|
||||
|
||||
点 **Sign in with Google**,走标准 OAuth 回调。自部署时需要配好 `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / redirect URI——详见 [自部署的认证配置](/auth-setup)。
|
||||
|
||||
## 创建、查看、撤销 PAT
|
||||
|
||||
**创建**有两种方式:
|
||||
|
||||
- **Web UI**:Settings → Personal Access Tokens → New token
|
||||
- **CLI**:`multica login` 在本地没有 PAT 时会自动创建一个
|
||||
|
||||
<Callout type="warning">
|
||||
**PAT 创建时完整内容只显示一次。** 刷新页面或关闭对话框之后就看不到了。
|
||||
|
||||
Multica 在数据库里只保存 PAT 的哈希值——服务端也查不回来。创建时**立即复制保存**。丢了只能撤销后重新创建。
|
||||
</Callout>
|
||||
|
||||
**查看**已签发的 PAT 列表(名字、创建时间、最后使用时间,**不含**完整令牌):Settings → Personal Access Tokens。
|
||||
|
||||
**撤销** PAT:在列表里点 Revoke。撤销是立即生效的——被撤销的 PAT 下一次请求就 401。
|
||||
|
||||
## 退出登录只是删本地令牌
|
||||
|
||||
执行 `multica auth logout` 或在 Web UI 点退出时:
|
||||
|
||||
- **本地令牌被清除** —— CLI 从 `~/.multica/config.json` 里删掉 PAT;Web 删 cookie
|
||||
- **服务端的 PAT 仍然有效** —— 如果登出前有人已经拿到过你的 PAT(比如复制到了另一台机器),他们**还能继续用**
|
||||
|
||||
<Callout type="warning">
|
||||
**如果怀疑 PAT 泄露,不要只 logout。** 去 Settings → Personal Access Tokens 把那个 PAT **撤销**。撤销才会让泄露出去的令牌立刻失效。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [CLI 命令速查](/cli) —— 每条 CLI 命令的认证是自动的
|
||||
- [自部署的认证配置](/auth-setup) —— 自部署时怎么配邮件 / OAuth / signup 白名单
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程令牌是从哪来的
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: Autopilots
|
||||
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";
|
||||
|
||||
Autopilots let [agents](/agents) **start work automatically on a schedule** — configure a cron expression and a timezone, and Multica dispatches a [`task`](/tasks) on its own, without you triggering anything. It fits periodic checks, recurring reports, and overnight cleanup jobs — the "standing order" shape of work. Compared to the other three trigger paths ([assigning](/assigning-issues), [@-mention](/mentioning-agents), and [chat](/chat), where you are the one kicking things off), the core difference with Autopilots is that they are **time-driven**.
|
||||
|
||||
## Configure an autopilot
|
||||
|
||||
Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
- **Name** — display name
|
||||
- **Agent** — who the run is dispatched to
|
||||
- **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)
|
||||
|
||||
## 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 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.
|
||||
|
||||
## 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.
|
||||
|
||||
A few examples:
|
||||
|
||||
- `0 9 * * 1-5`, `Asia/Shanghai` — 9 AM Beijing time on weekdays
|
||||
- `*/30 * * * *`, `UTC` — every 30 minutes
|
||||
- `0 3 * * *`, `UTC` — every day at 3 AM UTC
|
||||
|
||||
The Multica server scans for due triggers every **30 seconds** — **the actual fire time can lag by up to 30 seconds**, not down to the second. If the server is restarted across a fire time, it catches up missed triggers on startup (nothing is lost, but they fire right away).
|
||||
|
||||
## Trigger once manually
|
||||
|
||||
To avoid waiting for cron while debugging an autopilot, trigger it manually:
|
||||
|
||||
- UI: click "Run now" on the autopilot detail page
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
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`.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot failures are not auto-retried and do not send inbox notifications.** A failure leaves a `failed` entry in run history — no system-level re-enqueue like assign or @-mention, and no notification to anyone. If the autopilot is periodic, **the next cron fire will trigger a new run**, but the failed work is not automatically re-run.
|
||||
|
||||
If an autopilot is important, design your own monitoring — for example, have the agent post a comment on success, and catch failures by noticing missing comments.
|
||||
</Callout>
|
||||
|
||||
Why no auto-retry: autopilots are already periodic, so adding system-level retries stacks on top of the next scheduled run and creates duplicate executions. Leaving the schedule entirely to cron keeps it clean.
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**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
|
||||
|
||||
- [**Assign issues to agents**](/assigning-issues) — a one-shot hand-off of an issue to an agent
|
||||
- [**@-mention agents in comments**](/mentioning-agents) — pull an agent in to take a look from a comment
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
@@ -1,81 +0,0 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron 和时区,到点 Multica 自己派发 [`task`](/tasks),不需要你每次触发。适合定期巡检、周期性报告、凌晨跑的清理任务这类"standing order"场景。和前三种触发方式([分配](/assigning-issues) / [@ 提及](/mentioning-agents) / [对话](/chat) 都是你主动喊一声)相比,Autopilots 的核心差别是**时间驱动**。
|
||||
|
||||
## 配置一个 Autopilot
|
||||
|
||||
在工作区的 **Autopilot** 页新建一条 autopilot,要定下:
|
||||
|
||||
- **名字** — 显示名
|
||||
- **执行智能体** — 到点派给谁
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
几个例子:
|
||||
|
||||
- `0 9 * * 1-5`,`Asia/Shanghai` —— 工作日北京时间早上 9 点
|
||||
- `*/30 * * * *`,`UTC` —— 每 30 分钟一次
|
||||
- `0 3 * * *`,`UTC` —— 每天 UTC 凌晨 3 点
|
||||
|
||||
Multica 服务器每 **30 秒**扫一次到期的触发器——**触发时刻最多延迟 30 秒**,不是秒级精准。服务器重启时如果恰好错过触发点,启动时会补扫漏掉的触发(不会丢触发,但会立刻补跑)。
|
||||
|
||||
## 手动触发一次
|
||||
|
||||
调试 Autopilot 时不想等 cron,可以手动触发一次:
|
||||
|
||||
- UI:在 Autopilot 详情页点"手动运行"
|
||||
- CLI:
|
||||
|
||||
```bash
|
||||
multica autopilot trigger <autopilot-id>
|
||||
```
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed`)
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
<Callout type="warning">
|
||||
**Autopilot 失败不自动重试,也不发 inbox 通知。** 失败后只在运行历史里留一条 `failed` 记录——不会像分配 / @ 提及那样由系统重新排队,也不会给任何人发通知。如果这条 Autopilot 是周期任务,**下一次 cron 到点会重新触发一次**(新的 run),但这一次失败的工作不会被自动补跑。
|
||||
|
||||
如果 Autopilot 很重要,要自己设计监控——例如让智能体在成功时给自己发个评论,通过缺失评论来发现失败。
|
||||
</Callout>
|
||||
|
||||
不自动重试的理由:Autopilot 本身是周期性的,系统层再加自动重试容易和下一次调度叠加,产生重复执行。调度权完全交给 cron 最干净。
|
||||
|
||||
## 暂不可用的能力
|
||||
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 一次性把 issue 指派给智能体
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 评论里让智能体看一眼
|
||||
- [**对话**](/chat) —— 独立于 issue 的一对一聊天
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title: Chat
|
||||
description: One-to-one conversation with an agent outside any issue — fully sandboxed. The agent cannot see or change issues, and nobody else can see the conversation.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
**Chat is a one-to-one conversation between you and an [agent](/agents)** — stepping outside the [issue](/issues) board. The agent sees no issues and cannot change any issue, and the entire conversation is **fully private** (nobody else in the [workspace](/workspaces), including admins, can see it). It fits discussing an approach with an agent, brainstorming, or asking a question that does not belong to any issue.
|
||||
|
||||
## Why not just @-mention the agent?
|
||||
|
||||
[@-mention](/mentioning-agents) **pulls the agent into** an issue's context — it reads the issue description and every historical comment, and it can change the issue. Chat flips this: **it pulls you out of** the issue — the agent only sees this single conversation, has no awareness of any issue, and has no entry point to modify one.
|
||||
|
||||
Two rules of thumb:
|
||||
|
||||
- You want feedback grounded in the context of a specific issue → [@-mention](/mentioning-agents)
|
||||
- You want to discuss a topic unrelated to any issue (or you do not want anyone else to see the discussion) → Chat
|
||||
|
||||
## Start a conversation
|
||||
|
||||
Open **Chat** from the sidebar, pick an agent, and start a new conversation. The interface feels like any messaging app: you send a message, the agent replies. Each message triggers a run in the background (an enqueued `task`), so replies may take a few seconds.
|
||||
|
||||
## What an agent can and cannot do in chat
|
||||
|
||||
Agents run in a **fully sandboxed** mode inside a conversation.
|
||||
|
||||
**Can do:**
|
||||
|
||||
- Answer the questions in your current message
|
||||
- Use its configured [skills](/skills) and MCP
|
||||
- Read and write files in its own working directory
|
||||
- Call `multica` CLI commands that do not need issue context (for example, querying basic workspace info)
|
||||
|
||||
**Cannot do:**
|
||||
|
||||
- **See any issue** — the prompt the agent receives has no issue IDs, and commands like `multica issue list` return empty
|
||||
- **Change any issue** — without issue context, API calls are blocked by permission checks
|
||||
- **See other conversations** — conversations are fully isolated
|
||||
- **@-mention anyone or any agent** — chat is a private space with no path to notify others
|
||||
|
||||
## How multi-turn context is preserved
|
||||
|
||||
Chat maintains multi-turn context via **provider session resumption** — the agent establishes a provider session on its first reply (for example, a Claude session), and the session ID is stored. On the next message, the task dispatch passes that ID back so the agent **resumes from where it left off** without re-reading history every time.
|
||||
|
||||
If **one turn fails**, Multica looks up the previous task that had established a session ID (whether that task succeeded or failed) and tries to resume — a single failure in the middle does not drop the memory of the whole conversation.
|
||||
|
||||
Note: not every provider actually implements session resumption — see the [**Providers Matrix**](/providers) for support status.
|
||||
|
||||
## Archive a conversation
|
||||
|
||||
Conversations you no longer want to see can be archived — right-click in the conversation list or use the "Archive" button on the detail page. After archiving:
|
||||
|
||||
- The conversation disappears from the active list (you can still find it in the "Archived" view)
|
||||
- Historical messages, session ID, and the working directory are all preserved — nothing is deleted
|
||||
|
||||
<Callout type="warning">
|
||||
**There is no "restore" button after archiving.** There is currently no entry point to move an archived conversation back to active. If you want to continue the thread later, you will need to start a new conversation. To revisit content in an archived conversation, open the "Archived" view and read through the history.
|
||||
</Callout>
|
||||
|
||||
## Next
|
||||
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Assign issues to agents**](/assigning-issues) — bring the topic back onto the issue board
|
||||
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title: 对话
|
||||
description: 和智能体一对一独立聊天——完全沙盒,智能体看不到 issue、改不了 issue,也没人能看到你的对话。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
**对话(Chat)是你和 [智能体](/agents) 的一对一独立沟通**——跳出 [issue](/issues) 看板,智能体看不到任何 issue、也改不了 issue,整段对话**完全私人**([工作区](/workspaces) 里其他人、包括 admin 都看不到)。适合和智能体讨论方案、做 brainstorming、问一个不属于任何 issue 的问题。
|
||||
|
||||
## 为什么不用 @ 智能体就够
|
||||
|
||||
[@ 提及](/mentioning-agents) 把智能体**拉进** issue 的上下文——它会读 issue 的描述和所有历史评论,也能改 issue。对话反过来:**把你拉出** issue——智能体只看得到这一次对话,不知道 issue 存在,也没有修改 issue 的入口。
|
||||
|
||||
两条判据:
|
||||
|
||||
- 要智能体基于某个具体 issue 的上下文给反馈 → [@ 提及](/mentioning-agents)
|
||||
- 要和智能体聊一个不属于任何 issue 的话题(或不想让任何人看到讨论)→ 对话
|
||||
|
||||
## 开始一次对话
|
||||
|
||||
从侧边栏的 **Chat** 入口进,选一个智能体,开一段新对话。界面和普通聊天软件一样:你发消息,智能体回复。每条消息都会在后台触发一次执行(入队一个 `task`),所以回复可能要等几秒。
|
||||
|
||||
## 智能体在对话里能做什么、不能做什么
|
||||
|
||||
智能体在对话里跑在**完全沙盒**下。
|
||||
|
||||
**能做的**:
|
||||
|
||||
- 回答你当前消息里提的问题
|
||||
- 使用自己配置的 [skill](/skills) 和 MCP
|
||||
- 在自己的工作目录里读写文件
|
||||
- 调用不需要 issue 上下文的 `multica` CLI 命令(比如查询工作区基本信息)
|
||||
|
||||
**不能做的**:
|
||||
|
||||
- **看到任何 issue**——智能体收到的提示里没有 issue ID,`multica issue list` 之类命令对它返回空
|
||||
- **改任何 issue**——没有 issue 上下文,API 调用会被权限 check 拦截
|
||||
- **看到别的对话**——对话之间完全隔离
|
||||
- **@ 任何人或智能体**——对话是私人空间,没有通知别人的路径
|
||||
|
||||
## 多轮对话怎么保留上下文
|
||||
|
||||
对话用 **provider 会话恢复**机制维持多轮上下文——智能体第一次回复时建立一个 provider 会话(比如 Claude 的 session),session ID 被存起来;下一条消息派任务时把这个 ID 传回去,智能体**接着上次的状态继续**,不需要每次重新读历史。
|
||||
|
||||
如果**某一轮失败**,Multica 会查找上一轮建立过 session ID 的任务(不论它当时成功还是失败)并尝试 resume——不会因为中间一次出错就丢掉整段对话的记忆。
|
||||
|
||||
注意:并非所有 provider 都真正实现了 session 恢复——支持情况见 [**Providers Matrix**](/providers)。
|
||||
|
||||
## 归档对话
|
||||
|
||||
不想再看到的对话可以归档——在对话列表右键或详情页的"归档"按钮。归档后:
|
||||
|
||||
- 对话从活跃列表隐藏(可以在"已归档"视图里翻到)
|
||||
- 历史消息、session ID、工作目录完整保留,不会被删
|
||||
|
||||
<Callout type="warning">
|
||||
**归档之后没有"恢复"按钮**——目前没有把归档对话重新设回活跃的入口。如果后续还想继续这段对话,只能另起一个新对话。需要翻看归档对话里的内容时,去"已归档"视图读历史消息。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**分配 issue 给智能体**](/assigning-issues) —— 把话题放回 issue 看板上
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
title: CLI command reference
|
||||
description: One-page overview of every top-level Multica CLI command. For full usage, run `multica <command> --help`.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
The Multica CLI mirrors almost everything the Web UI can do (create [issues](/issues), assign [agents](/agents), start the [daemon](/daemon-runtimes), and more). This page lists every top-level command with a one-line description. For the full set of flags and examples, run `multica <command> --help`.
|
||||
|
||||
## Getting authenticated
|
||||
|
||||
Run this the first time you use the CLI to obtain a **personal access token (PAT)**:
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
Your browser opens automatically. After you approve in the web app, the CLI saves the PAT (prefixed with `mul_`) to `~/.multica/config.json`. Every subsequent command authenticates with that PAT.
|
||||
|
||||
<Callout type="tip">
|
||||
For CI or headless environments, skip the browser flow: create a PAT in the web app under **Settings → Personal Access Tokens**, then run `multica login --token <mul_...>` to supply it directly.
|
||||
</Callout>
|
||||
|
||||
For the difference between token types, see [Authentication and tokens](/auth-tokens).
|
||||
|
||||
## Auth and setup
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica login` | Log in and save a PAT |
|
||||
| `multica auth status` | Show current login status, user, and workspace |
|
||||
| `multica auth logout` | Clear the local PAT |
|
||||
| `multica setup cloud` | One-shot setup for Multica Cloud (login + install daemon) |
|
||||
| `multica setup self-host` | One-shot setup for a self-hosted backend |
|
||||
|
||||
## Workspaces and members
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica workspace list` | List every workspace you can access |
|
||||
| `multica workspace get <slug>` | Show details for one 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 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 comment <id> ...` | Nested: view / post comments |
|
||||
| `multica issue subscriber <id> ...` | Nested: subscribe / unsubscribe |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
|
||||
## Agents and skills
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica agent list` | List the workspace's agents |
|
||||
| `multica agent get <slug>` | Show an agent's configuration |
|
||||
| `multica agent create ...` | Create an agent |
|
||||
| `multica agent update <slug> ...` | Update an agent |
|
||||
| `multica agent archive <slug>` | Archive |
|
||||
| `multica agent restore <slug>` | Restore an archived agent |
|
||||
| `multica agent tasks <slug>` | Show an agent's task history |
|
||||
| `multica agent skills ...` | Nested: attach / detach skills |
|
||||
| `multica skill list/get/create/update/delete` | Skill CRUD |
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica autopilot list` | List every autopilot in the workspace |
|
||||
| `multica autopilot get <id>` | Show a single autopilot |
|
||||
| `multica autopilot create ...` | Create an autopilot |
|
||||
| `multica autopilot update <id> ...` | Update |
|
||||
| `multica autopilot delete <id>` | Delete |
|
||||
| `multica autopilot runs <id>` | Show run history |
|
||||
| `multica autopilot trigger <id>` | Trigger a run manually |
|
||||
|
||||
## Daemon and runtimes
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica daemon start` | Start the daemon (background by default; add `--foreground` to run in the foreground) |
|
||||
| `multica daemon stop` | Stop the daemon |
|
||||
| `multica daemon restart` | Restart the daemon |
|
||||
| `multica daemon status` | Check whether the daemon is online and its concurrency |
|
||||
| `multica daemon logs` | View daemon logs |
|
||||
| `multica runtime list` | List runtimes in the current workspace |
|
||||
| `multica runtime usage` | Show resource usage |
|
||||
| `multica runtime activity` | Recent activity log |
|
||||
| `multica runtime update <id> ...` | Update a runtime's configuration |
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica repo checkout <url>` | Clone a repo locally for agents to use |
|
||||
| `multica config` | View or edit local CLI configuration |
|
||||
| `multica version` | Print the CLI version |
|
||||
| `multica update` | Upgrade the CLI to the latest release |
|
||||
| `multica attachment download <id>` | Download an attachment from an issue or comment |
|
||||
|
||||
## Getting full flags
|
||||
|
||||
Every command supports `--help`:
|
||||
|
||||
```bash
|
||||
multica issue create --help
|
||||
multica agent update --help
|
||||
```
|
||||
|
||||
v2 will ship a dedicated detailed reference page for each command.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Authentication and tokens](/auth-tokens) — PAT vs. JWT vs. daemon token
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the `daemon` commands work under the hood
|
||||
- [Creating and configuring agents](/agents-create) — all options for `multica agent create`
|
||||
@@ -1,133 +0,0 @@
|
||||
---
|
||||
title: CLI 命令速查
|
||||
description: Multica CLI 的所有顶级命令一页概览。完整用法查 `multica <命令> --help`。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica CLI 把 Web UI 能做的事几乎全部搬到了命令行上(创建 [issue](/issues)、分配 [智能体](/agents)、启动 [守护进程](/daemon-runtimes) 等等)。这一页把所有顶级命令列出来,每条配一句用途。完整 flag 和示例用 `multica <命令> --help` 查。
|
||||
|
||||
## 认证入口
|
||||
|
||||
第一次用 CLI 时先登录,拿一个**个人访问令牌(Personal Access Token,PAT)**:
|
||||
|
||||
```bash
|
||||
multica login
|
||||
```
|
||||
|
||||
浏览器会自动打开,你在 Web 端同意后,CLI 把 PAT(`mul_` 前缀)保存到 `~/.multica/config.json`。此后所有命令都会自动用这个 PAT 认证。
|
||||
|
||||
<Callout type="tip">
|
||||
CI / 无浏览器环境跳过浏览器流程:先在 Web 端 **Settings → Personal Access Tokens** 创建一个 PAT,然后 `multica login --token <mul_...>` 直接填入。
|
||||
</Callout>
|
||||
|
||||
Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
|
||||
## 认证与初始化
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica login` | 登录并保存 PAT |
|
||||
| `multica auth status` | 查看当前登录状态、用户、工作区 |
|
||||
| `multica auth logout` | 清除本地 PAT |
|
||||
| `multica setup cloud` | Multica Cloud 一键初始化(登录 + 装 daemon) |
|
||||
| `multica setup self-host` | 自部署后端的一键初始化 |
|
||||
|
||||
## 工作区和成员
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica workspace list` | 列出你有权访问的所有工作区 |
|
||||
| `multica workspace get <slug>` | 查看一个工作区的详情 |
|
||||
| `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 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 comment <id> ...` | 嵌套:看 / 发评论 |
|
||||
| `multica issue subscriber <id> ...` | 嵌套:订阅 / 取消订阅 |
|
||||
| `multica project list/get/create/update/delete/status` | Project CRUD |
|
||||
|
||||
## 智能体和 Skill
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica agent list` | 列出工作区的智能体 |
|
||||
| `multica agent get <slug>` | 查看智能体配置 |
|
||||
| `multica agent create ...` | 创建智能体 |
|
||||
| `multica agent update <slug> ...` | 修改智能体 |
|
||||
| `multica agent archive <slug>` | 归档 |
|
||||
| `multica agent restore <slug>` | 恢复归档的智能体 |
|
||||
| `multica agent tasks <slug>` | 查看智能体的任务历史 |
|
||||
| `multica agent skills ...` | 嵌套:挂载 / 卸载 Skill |
|
||||
| `multica skill list/get/create/update/delete` | Skill CRUD |
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica autopilot list` | 列出工作区所有 autopilot |
|
||||
| `multica autopilot get <id>` | 查看单个 autopilot |
|
||||
| `multica autopilot create ...` | 创建 autopilot |
|
||||
| `multica autopilot update <id> ...` | 修改 |
|
||||
| `multica autopilot delete <id>` | 删除 |
|
||||
| `multica autopilot runs <id>` | 查看运行历史 |
|
||||
| `multica autopilot trigger <id>` | 手动触发一次 |
|
||||
|
||||
## 守护进程和运行时
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica daemon start` | 启动 daemon(默认后台;加 `--foreground` 前台跑)|
|
||||
| `multica daemon stop` | 停止 daemon |
|
||||
| `multica daemon restart` | 重启 daemon |
|
||||
| `multica daemon status` | 查看 daemon 是否在线 + 并发情况 |
|
||||
| `multica daemon logs` | 查看 daemon 日志 |
|
||||
| `multica runtime list` | 列出当前工作区的 runtime |
|
||||
| `multica runtime usage` | 查看资源使用情况 |
|
||||
| `multica runtime activity` | 近期活动记录 |
|
||||
| `multica runtime update <id> ...` | 更新 runtime 配置 |
|
||||
|
||||
## 杂项
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica repo checkout <url>` | 把 repo 拉到本地以供智能体使用 |
|
||||
| `multica config` | 查看 / 修改 CLI 本地配置 |
|
||||
| `multica version` | 显示 CLI 版本 |
|
||||
| `multica update` | 升级 CLI 到最新版 |
|
||||
| `multica attachment download <id>` | 下载 issue / 评论的附件 |
|
||||
|
||||
## 查完整 flag
|
||||
|
||||
每条命令都支持 `--help`:
|
||||
|
||||
```bash
|
||||
multica issue create --help
|
||||
multica agent update --help
|
||||
```
|
||||
|
||||
v2 会给每条命令一个独立的详细 reference 页。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [认证与令牌](/auth-tokens) —— PAT / JWT / Daemon Token 的区别
|
||||
- [守护进程与运行时](/daemon-runtimes) —— `daemon` 命令背后的工作机制
|
||||
- [创建和配置智能体](/agents-create) —— `multica agent create` 的完整选项
|
||||
@@ -78,7 +78,7 @@ multica daemon status
|
||||
|
||||
Confirm:
|
||||
1. Status is `running`
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, `kiro`, or `pi`)
|
||||
2. At least one agent is listed (e.g. `claude`, `codex`, `gemini`, `opencode`, `openclaw`, `hermes`, or `pi`)
|
||||
3. At least one workspace is being watched
|
||||
|
||||
If the agents list is empty, install at least one supported AI agent CLI:
|
||||
@@ -88,8 +88,6 @@ If the agents list is empty, install at least one supported AI agent CLI:
|
||||
- OpenCode (`opencode`)
|
||||
- OpenClaw (`openclaw`)
|
||||
- Hermes (`hermes`)
|
||||
- Kimi (`kimi`)
|
||||
- Kiro CLI (`kiro-cli`)
|
||||
|
||||
Then restart the daemon:
|
||||
|
||||
@@ -18,10 +18,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
|
||||
### Token Login
|
||||
|
||||
```bash
|
||||
multica login --token <mul_...>
|
||||
multica login --token
|
||||
```
|
||||
|
||||
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
|
||||
Authenticate by pasting a personal access token directly. Useful for headless environments.
|
||||
|
||||
### Check Status
|
||||
|
||||
@@ -92,10 +92,6 @@ The daemon auto-detects these AI CLIs on your PATH:
|
||||
| OpenCode | `opencode` | Open-source coding agent |
|
||||
| OpenClaw | `openclaw` | Open-source coding agent |
|
||||
| Hermes | `hermes` | Nous Research coding agent |
|
||||
| Kimi | `kimi` | Moonshot coding agent |
|
||||
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
|
||||
| Pi | `pi` | Inflection coding agent |
|
||||
| Cursor Agent | `cursor-agent` | Cursor coding agent |
|
||||
|
||||
You need at least one installed. The daemon registers each detected CLI as an available runtime.
|
||||
|
||||
@@ -138,14 +134,6 @@ Agent-specific overrides:
|
||||
| `MULTICA_HERMES_MODEL` | Override the Hermes model used |
|
||||
| `MULTICA_GEMINI_PATH` | Custom path to the `gemini` binary |
|
||||
| `MULTICA_GEMINI_MODEL` | Override the Gemini model used |
|
||||
| `MULTICA_PI_PATH` | Custom path to the `pi` binary |
|
||||
| `MULTICA_PI_MODEL` | Override the Pi model used |
|
||||
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
|
||||
| `MULTICA_CURSOR_MODEL` | Override the Cursor model used |
|
||||
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
|
||||
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
|
||||
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
|
||||
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
|
||||
|
||||
### Self-Hosted Server
|
||||
|
||||
@@ -213,28 +201,6 @@ multica workspace get <workspace-id> --output json
|
||||
multica workspace members <workspace-id>
|
||||
```
|
||||
|
||||
### Update Workspace
|
||||
|
||||
需要 admin 或 owner 权限。所有字段都是部分更新(PATCH 语义):未传的字段保持不变。
|
||||
|
||||
```bash
|
||||
multica workspace update <workspace-id> --name "Acme Eng"
|
||||
multica workspace update <workspace-id> \
|
||||
--description "Engineering team workspace" \
|
||||
--issue-prefix ENG
|
||||
```
|
||||
|
||||
长文本走 stdin(保留换行/反斜杠):
|
||||
|
||||
```bash
|
||||
cat <<'CTX' | multica workspace update <workspace-id> --context-stdin
|
||||
我们是一支 5 人 AI-native 团队。
|
||||
工作语言:中文 + 英文混合。
|
||||
CTX
|
||||
```
|
||||
|
||||
可编辑字段:`--name`、`--description` / `--description-stdin`、`--context` / `--context-stdin`、`--issue-prefix`。`slug` 创建后只读,不暴露在 CLI。`--description` 与 `--description-stdin`(以及 `context` 同名对)互斥。未传任何字段 flag 时命令拒绝执行,避免空 PATCH 触发无意义的 workspace 更新事件。`--issue-prefix ""` 也会被拒绝:当前后端在 prefix 为空时静默跳过该字段,CLI 在本地拦下避免“看似成功的 no-op”。
|
||||
|
||||
## Issues
|
||||
|
||||
### List Issues
|
||||
@@ -243,31 +209,25 @@ CTX
|
||||
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`, `--project`, `--limit`.
|
||||
|
||||
### 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
|
||||
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
|
||||
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 members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
|
||||
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--project`, `--due-date`.
|
||||
|
||||
### Update Issue
|
||||
|
||||
@@ -279,12 +239,9 @@ multica issue update <id> --title "New title" --priority urgent
|
||||
|
||||
```bash
|
||||
multica issue assign <id> --to "Lambda"
|
||||
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
|
||||
multica issue assign <id> --unassign
|
||||
```
|
||||
|
||||
`--to-id <uuid>`(与 `--to` 互斥)按 UUID 精确分配;适合重名 workspace 下脚本化场景。
|
||||
|
||||
### Change Status
|
||||
|
||||
```bash
|
||||
@@ -314,20 +271,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
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
title: Cloud quickstart
|
||||
description: From sign-up to assigning your first task to an agent in 5 minutes.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
This page walks you end-to-end through Multica Cloud — **sign up → install the [CLI](/cli) → start the [daemon](/daemon-runtimes) → create an [agent](/agents) → assign your first [task](/tasks)**. Takes about 5 minutes.
|
||||
|
||||
One prerequisite: you already have at least one [AI coding tool](/providers) installed locally ([Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), or [Pi](/providers#pi)). The daemon auto-detects them on startup and refuses to start if none are present.
|
||||
|
||||
## 1. Create an account
|
||||
|
||||
Sign up at [multica.ai](https://multica.ai). You can log in with email (6-digit verification code) or Google.
|
||||
|
||||
After sign-up you're automatically placed in a default workspace (generated from your account name). You can rename it later, or create new workspaces.
|
||||
|
||||
## 2. Install the Multica CLI
|
||||
|
||||
**macOS / Linux (Homebrew recommended)**:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
**macOS / Linux (no Homebrew)**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Verify the install:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
## 3. Log in + start the daemon
|
||||
|
||||
A single command handles login and starts the daemon:
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
`multica setup` will:
|
||||
|
||||
1. Configure the CLI to connect to Multica Cloud
|
||||
2. Open your browser for login (same email verification code / Google OAuth as the web)
|
||||
3. Store the generated PAT in `~/.multica/config.json`
|
||||
4. **Start the daemon automatically** — it begins polling for tasks every 3 seconds and sending heartbeats every 15 seconds
|
||||
|
||||
<Callout type="info">
|
||||
**Using the desktop app?** The desktop app **starts the daemon automatically** on launch — no need to run `multica setup` by hand. See [Desktop app](/desktop-app).
|
||||
</Callout>
|
||||
|
||||
Verify the daemon is running:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
`online` means it has registered with the server.
|
||||
|
||||
## 4. Verify the runtime is online
|
||||
|
||||
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 reach the server](/troubleshooting#daemon-cant-reach-the-server).
|
||||
|
||||
## 5. Create an agent
|
||||
|
||||
In the web UI, go to **Settings → Agents** and click **New Agent**:
|
||||
|
||||
- **Name** — the name shown for this agent on boards and in comments. Pick something you like
|
||||
- **Provider** — choose an AI coding tool you have installed locally (the dropdown only lists tools detected by your runtimes)
|
||||
- **Model** (optional) — the model selection inside that tool (a static list or dynamic discovery, depending on the provider)
|
||||
- **Instructions** (optional) — system prompt for this agent
|
||||
|
||||
Once created, the agent shows up in your workspace member list and can be assigned work like a human member.
|
||||
|
||||
## 6. Assign your first task
|
||||
|
||||
Create an issue in the web UI, or from the CLI:
|
||||
|
||||
```bash
|
||||
multica issue create --title "Add an ASCII architecture diagram to the README"
|
||||
```
|
||||
|
||||
Assign the issue to the agent you just created — click its avatar in the web UI, or use the CLI:
|
||||
|
||||
```bash
|
||||
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 members --output json`.
|
||||
|
||||
**What happens next from the daemon**:
|
||||
|
||||
1. It picks up the task within 3 seconds (status goes from `queued` to `dispatched`)
|
||||
2. It invokes the matching AI coding tool to start work (status becomes `running`)
|
||||
3. The AI works locally — it may read your code directory, run commands, edit files
|
||||
4. When done, it reports the result back to Multica (status becomes `completed` or `failed`, depending on whether auto-retry kicks in)
|
||||
|
||||
The web UI updates in **real time** (via WebSocket) — no refresh needed.
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon operates and what runtimes mean
|
||||
- [Tasks](/tasks) — task lifecycle and retry rules
|
||||
- [AI coding tools compared](/providers) — capability differences across the 11 tools
|
||||
- [Desktop app](/desktop-app) — if you'd rather not run the daemon yourself
|
||||
- [Self-host quickstart](/self-host-quickstart) — run your own backend
|
||||
@@ -1,119 +0,0 @@
|
||||
---
|
||||
title: Cloud 快速上手
|
||||
description: 5 分钟从注册到给智能体分配第一个任务。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
这一页带你走一遍 Multica Cloud 的端到端流程——**注册 → 装 [命令行工具](/cli) → 启动 [守护进程](/daemon-runtimes) → 创建 [智能体](/agents) → 分配第一个 [任务](/tasks)**,约 5 分钟完成。
|
||||
|
||||
前置只有一个:你本地已经装了至少一款 [AI 编程工具](/providers)([Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))中的一款。守护进程启动时会自动探测它们,没装任何一个的话守护进程会直接拒绝启动。
|
||||
|
||||
## 1. 注册账号
|
||||
|
||||
到 [multica.ai](https://multica.ai) 注册账号。可以用邮箱(6 位验证码)或 Google 登录。
|
||||
|
||||
注册完成后你会被自动分到一个默认工作区(以你的账号名生成)。之后可以改名字,也可以创建新的工作区。
|
||||
|
||||
## 2. 装 Multica 命令行工具
|
||||
|
||||
**macOS / Linux(推荐走 Homebrew)**:
|
||||
|
||||
```bash
|
||||
brew install multica-ai/tap/multica
|
||||
```
|
||||
|
||||
**macOS / Linux(没有 Homebrew)**:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.sh | bash
|
||||
```
|
||||
|
||||
**Windows(PowerShell)**:
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/multica-ai/multica/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
装完验证一下:
|
||||
|
||||
```bash
|
||||
multica version
|
||||
```
|
||||
|
||||
## 3. 登录 + 启动守护进程
|
||||
|
||||
一条命令完成登录 + 启动守护进程:
|
||||
|
||||
```bash
|
||||
multica setup
|
||||
```
|
||||
|
||||
`multica setup` 会:
|
||||
|
||||
1. 把命令行工具配置成连接 Multica Cloud
|
||||
2. 打开浏览器让你登录(和 Web 登录一样的邮箱验证码 / Google OAuth)
|
||||
3. 把生成的 PAT 存到 `~/.multica/config.json`
|
||||
4. **自动启动守护进程**——开始每 3 秒轮询任务、每 15 秒发心跳
|
||||
|
||||
<Callout type="info">
|
||||
**用的是桌面应用?** 桌面应用启动时**自动拉起守护进程**,不需要手动跑 `multica setup`。见 [桌面应用](/desktop-app)。
|
||||
</Callout>
|
||||
|
||||
验证守护进程在运行:
|
||||
|
||||
```bash
|
||||
multica daemon status
|
||||
```
|
||||
|
||||
看到 `online` 就说明它成功注册到服务器了。
|
||||
|
||||
## 4. 验证 Runtime 在线
|
||||
|
||||
到 Web 界面的 **Settings → Runtimes**,你应该能看到你刚启动的守护进程作为一个或多个活跃 Runtime 列出——每款你本地装好的 AI 编程工具对应一个。
|
||||
|
||||
看到"离线"不要慌,先看 [故障排查 → 守护进程连不上服务器](/troubleshooting#守护进程连不上服务器)。
|
||||
|
||||
## 5. 创建智能体
|
||||
|
||||
到 Web 界面的 **Settings → Agents**,点 **New Agent**:
|
||||
|
||||
- **名字**——智能体在看板上、评论里显示的名字,自己起一个
|
||||
- **Provider**——选一款你本地装好的 AI 编程工具(下拉里只会出现运行时里检测到的那些)
|
||||
- **Model**(可选)——这款工具内部的模型选择(静态列表或动态发现,取决于 provider)
|
||||
- **Instructions**(可选)——给这个智能体的系统提示词
|
||||
|
||||
创建完成后智能体就进入你的工作区成员列表,可以像人类成员一样被分配任务。
|
||||
|
||||
## 6. 分配第一个任务
|
||||
|
||||
在 Web 界面创建一条 issue,或者用命令行:
|
||||
|
||||
```bash
|
||||
multica issue create --title "给 README 加一段 ASCII 架构图"
|
||||
```
|
||||
|
||||
把这条 issue 分配给你刚创建的那个智能体——可以在 Web 上点它的头像,或用命令行:
|
||||
|
||||
```bash
|
||||
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 members --output json`。
|
||||
|
||||
**接下来守护进程会**:
|
||||
|
||||
1. 3 秒内领走这条任务(任务状态从 `queued` 变 `dispatched`)
|
||||
2. 调用对应的 AI 编程工具开始执行(状态变 `running`)
|
||||
3. AI 在本地工作——可能会读你的代码目录、执行命令、编辑文件
|
||||
4. 结束后把结果发回 Multica(状态变 `completed` 或 `failed`,根据是否自动重试)
|
||||
|
||||
Web 界面会**实时**(通过 WebSocket)显示进度——不需要刷新。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程怎么运作、运行时概念
|
||||
- [执行任务](/tasks) —— 任务生命周期、重试规则
|
||||
- [AI 编程工具对照](/providers) —— 11 款工具的能力差异
|
||||
- [桌面应用](/desktop-app) —— 不想自己跑守护进程的话
|
||||
- [Self-Host 快速上手](/self-host-quickstart) —— 在自己服务器上跑一套
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
title: Comments and mentions
|
||||
description: Collaborating under an issue — comments, replies, `@` mentions, reactions, and triggering agents from a comment.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Every [issue](/issues) has a comment thread. Post comments, reply to someone, `@` a [member](/members-roles) or an [agent](/agents), add reactions — the same moves you make in any task manager you've used. The one difference: **mentioning an agent with `@` triggers it to start working.**
|
||||
|
||||
## Posting a comment
|
||||
|
||||
Type into the input at the bottom of the issue detail page and hit **Send**. The comment appears in the thread immediately. Comments support Markdown — headings, lists, code blocks, links, all available.
|
||||
|
||||
## Replying to a comment
|
||||
|
||||
Click **Reply** on the top-right of any comment to open a nested input underneath it. Your reply is displayed as a child of that comment, forming a conversation thread. Replies can have their own replies, nesting as deep as you need.
|
||||
|
||||
The issue list shows only the top-level comment count; opening the issue reveals the full conversation tree.
|
||||
|
||||
## Reactions
|
||||
|
||||
Each comment has a reaction button in the top-right for quick signals (👍, 👀, 🎉) — no need to post a "+1" comment to agree.
|
||||
|
||||
## `@` mentions
|
||||
|
||||
Typing `@` in a comment opens a picker. Choose a member or an agent, and `@` plus the target's slug gets inserted (`@alice` or `@reviewer-bot`). The mentioned party gets a notification in their [inbox](/inbox).
|
||||
|
||||
**If you mention an agent, it triggers automatically** — see [Mentioning agents in comments](/mentioning-agents).
|
||||
|
||||
Mentioning the same person multiple times in one comment still produces **only one** notification.
|
||||
|
||||
### `@all` notifies the entire workspace
|
||||
|
||||
`@all` is a special target: it pushes a notification to every member of the workspace. Both people and agents can use `@all` — which means an agent reporting progress could also `@all`, so remind agents in their instructions to use it sparingly.
|
||||
|
||||
<Callout type="warning">
|
||||
**Use `@all` carefully.** In a larger workspace, a single `@all` generates that many inbox notifications instantly. Reserve it for things everyone genuinely needs to know — not day-to-day updates.
|
||||
</Callout>
|
||||
|
||||
## Editing and deleting a comment
|
||||
|
||||
Only the author of a comment can edit or delete it.
|
||||
|
||||
Deleting a comment also **deletes every reply** under it (including replies to replies). To change content only, use edit instead.
|
||||
|
||||
<Callout type="warning">
|
||||
**Adding an `@` while editing a comment does not trigger the agent.** The trigger fires the moment a comment is **created** — editing to add a new `@`, or changing the target, does not send a new notification or wake the agent. To summon an agent you missed, **post a new comment** that `@`s it.
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
Everything we've covered so far is "the human world" — workspaces, members, issues, projects, comments. If you've used Linear or Jira, none of it should feel unfamiliar.
|
||||
|
||||
But Multica's defining trait hasn't entered the picture yet: **treating agents as first-class members of a workspace**. That's what we turn to next.
|
||||
|
||||
## Next
|
||||
|
||||
- [Agents](/agents) — what they are, and how they differ from people
|
||||
- [Mentioning agents in comments](/mentioning-agents) — use `@` in a comment to start an agent
|
||||
@@ -1,59 +0,0 @@
|
||||
---
|
||||
title: 评论与提及
|
||||
description: 在 issue 下协作——评论、回复、@ 提及、表情反应,以及在评论里触发智能体工作。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
每个 [issue](/issues) 都有一个评论区。你可以在里面发评论、回复别人、用 `@` 点名 [成员](/members-roles) 或 [智能体](/agents)、加表情反应——和你在熟悉的任务管理工具里做的是同一件事。唯一不同的是:**`@` 一个智能体会自动触发它开始工作**。
|
||||
|
||||
## 发评论
|
||||
|
||||
在 issue 详情页底部的输入框里写内容,点**发送**,评论立刻出现在评论流里。评论支持 Markdown——标题、列表、代码块、链接都能用。
|
||||
|
||||
## 回复某条评论
|
||||
|
||||
点任意一条评论右上角的**回复**,会在这条评论下方展开嵌套输入框。你写的回复会显示为这条评论的子项,形成一条对话线。回复之下还能继续回复,层层展开。
|
||||
|
||||
在 issue 列表里看到的只是顶层评论数,点进 issue 里才能看到完整的对话树。
|
||||
|
||||
## 表情反应
|
||||
|
||||
每条评论右上角可以加表情反应(比如 👍、👀、🎉),用来快速表态——不用为了赞同单独发一条"+1"。
|
||||
|
||||
## `@` 提及
|
||||
|
||||
在评论里输入 `@` 会弹出提示,从里面选一个成员或智能体,`@` 后面会填入对方的 slug(比如 `@alice` 或 `@reviewer-bot`)。被提及的人会在自己的 [收件箱](/inbox) 里收到通知。
|
||||
|
||||
**如果你提及的是一个智能体,它会被自动触发开始工作**——详见 [在评论里召唤智能体](/mentioning-agents)。
|
||||
|
||||
同一条评论里 `@` 同一个人多次,对方只会收到**一条**通知。
|
||||
|
||||
### `@all` 会通知整个工作区
|
||||
|
||||
`@all` 是一个特殊目标:它会把通知推送给工作区里的每一个成员。人和智能体都能发 `@all`——这意味着被触发的智能体在汇报进展时也可能 `@all`,需要在智能体的指令里提醒它谨慎使用。
|
||||
|
||||
<Callout type="warning">
|
||||
**谨慎使用 `@all`**。工作区人数较多时,一条 `@all` 的评论会瞬间生成同等数量的收件箱通知。只在确实需要全员知晓的重大事项上使用——不是日常琐事。
|
||||
</Callout>
|
||||
|
||||
## 编辑和删除评论
|
||||
|
||||
只有评论的作者能编辑或删除自己的评论。
|
||||
|
||||
删除一条评论会**一并删除**它下面的所有回复(包括回复的回复)。如果只是想改内容,用编辑功能。
|
||||
|
||||
<Callout type="warning">
|
||||
**编辑评论里加 `@` 不会触发智能体**。触发发生在评论**创建**那一刻——事后修改评论内容加入新的 `@`、或改 `@` 对象,系统不会重新发通知、也不会唤醒智能体。要召唤一个没触发到的智能体,**发一条新的评论** `@` 它。
|
||||
</Callout>
|
||||
|
||||
---
|
||||
|
||||
到这里,我们讲的都是"人的世界"——工作区、成员、issue、project、评论。如果你熟悉 Linear 或 Jira 之类的产品,到目前为止的内容应该没有陌生感。
|
||||
|
||||
但 Multica 的核心特色还没登场:**把智能体作为工作区的一等公民成员**。下一章开始,我们正式认识这个新物种。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [智能体](/agents) —— 它们是什么、和人有什么区别
|
||||
- [在评论里召唤智能体](/mentioning-agents) —— 用 `@` 在评论里触发智能体开工
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
title: Daemon and runtimes
|
||||
description: Agents don't run on Multica's servers — they run on your own machines.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
In Multica, [agents](/agents) do **not** run on our servers — they run on your own machines, driven by a small program called the **daemon** that invokes the [AI coding tools](/providers) installed locally. The Multica server only coordinates: it stores [issues](/issues), queues [tasks](/tasks), and dispatches them to the right **runtime** (runtime = daemon × one AI coding tool).
|
||||
|
||||
This structure is the biggest difference between Multica and Linear / Jira: **your API keys, toolchain, and code directories stay on your machine** — the Multica server never sees any of them. That means "my agent isn't working" is almost always a local problem — the daemon isn't running, an AI tool isn't installed, a key has expired. Check locally first; see [Troubleshooting](/troubleshooting) for a guide.
|
||||
|
||||
## Starting the daemon
|
||||
|
||||
The daemon is part of the Multica CLI. Once you've installed the [Multica CLI](/cli), run on your own machine:
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
On startup it does four things:
|
||||
|
||||
1. Reads the credentials saved when you logged in
|
||||
2. Detects AI coding tools installed on your `PATH` (11 built-in: [Claude Code](/providers#claude-code), [Codex](/providers#codex), [Cursor](/providers#cursor), [Copilot](/providers#copilot), [Gemini](/providers#gemini), [Hermes](/providers#hermes), [Kimi](/providers#kimi), [Kiro CLI](/providers#kiro-cli), [OpenCode](/providers#opencode), [OpenClaw](/providers#openclaw), [Pi](/providers#pi))
|
||||
3. Registers itself with the server, along with a runtime for each detected tool
|
||||
4. Keeps **polling every 3 seconds** for tasks to pick up, and **sends a heartbeat every 15 seconds**
|
||||
|
||||
Common commands:
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica daemon start` | Start (background by default; add `--foreground` to run in the foreground) |
|
||||
| `multica daemon stop` | Stop |
|
||||
| `multica daemon restart` | Restart |
|
||||
| `multica daemon status` | Show status |
|
||||
| `multica daemon logs` | Show logs (add `-f` to follow) |
|
||||
|
||||
Full CLI reference in [CLI commands](/cli).
|
||||
|
||||
**The desktop app ships with a daemon.** If you use the [desktop app](/desktop-app), you don't need to run `multica daemon start` manually — it launches the daemon automatically on startup. See the [Desktop app](/desktop-app) page for which option fits your workflow.
|
||||
|
||||
## Why one machine has multiple runtimes
|
||||
|
||||
A runtime is not a server and not a container — it's the combination of "**daemon × one AI coding tool**". For example: you start the daemon on a MacBook with both Claude Code and Codex installed, and you're a member of two workspaces. Multica then registers 4 runtimes:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
D["Your daemon<br/>MacBook"]
|
||||
D --> R1["Runtime<br/>Workspace A × Claude Code"]
|
||||
D --> R2["Runtime<br/>Workspace A × Codex"]
|
||||
D --> R3["Runtime<br/>Workspace B × Claude Code"]
|
||||
D --> R4["Runtime<br/>Workspace B × Codex"]
|
||||
`} />
|
||||
|
||||
Key points:
|
||||
|
||||
- **One daemon can map to multiple runtimes** — one per combination of installed tool and workspace you belong to
|
||||
- **The same daemon, workspace, and tool produces exactly one runtime** — restarting the daemon never creates duplicate records
|
||||
- The **Runtimes** page in the Multica UI lists these rows
|
||||
|
||||
<Callout type="info">
|
||||
**Cloud runtimes are coming**, currently in a waitlist phase. Once available, you'll be able to execute agent tasks directly on Multica Cloud without running a local daemon. Sign up with your email on the [download page](https://multica.ai/download) to get notified.
|
||||
</Callout>
|
||||
|
||||
## When a runtime is marked offline
|
||||
|
||||
Multica uses heartbeats to decide whether a runtime is online. Three key numbers:
|
||||
|
||||
| Event | Threshold |
|
||||
|---|---|
|
||||
| Daemon heartbeat frequency | Every **15 seconds** |
|
||||
| Marked as missing | No heartbeat for **45 seconds** (3 missed beats) |
|
||||
| Auto-deleted | Missing with no associated agents for over **7 days** |
|
||||
|
||||
Missing is not permanent — as soon as the daemon sends another heartbeat it returns to online, and the runtime record is preserved. Restarting the daemon does not lose runtimes.
|
||||
|
||||
<Callout type="warning">
|
||||
**Tasks running on a missing runtime are marked as failed** (failure reason `runtime_offline`). For retryable sources (issues, chat), Multica automatically requeues them; Autopilot-triggered tasks are not retried automatically. See [Tasks → Which failures retry automatically](/tasks#which-failures-retry-automatically-which-dont).
|
||||
</Callout>
|
||||
|
||||
## How many tasks can run in parallel
|
||||
|
||||
Multica enforces concurrency limits at two layers:
|
||||
|
||||
- **Daemon layer**: **20 concurrent tasks** by default (tunable via env var `MULTICA_DAEMON_MAX_CONCURRENT_TASKS`)
|
||||
- **Agent layer**: **6 concurrent tasks per agent** by default (configured per-agent)
|
||||
|
||||
The tighter of the two wins. If your daemon is already running 20 tasks, new tasks wait even if an agent still has headroom.
|
||||
|
||||
If you see tasks stuck in `queued` without moving to `dispatched`, one of these two limits is usually saturated.
|
||||
|
||||
## What happens to in-flight tasks after a daemon crash
|
||||
|
||||
When the daemon crashes or is force-killed, the tasks it had picked up are left in `dispatched` or `running`. On the next start, the daemon tells the server: "these tasks are no longer mine, please mark them failed." The server flips them to `failed` with reason `runtime_recovery` — for retryable sources, the tasks are automatically requeued.
|
||||
|
||||
Even if this step fails due to a network issue, there's a server-side scan **every 30 seconds** as a backstop: any runtime without a heartbeat for over 45 seconds is marked missing, and its tasks are reclaimed along with it.
|
||||
|
||||
## Troubleshooting agents that aren't working
|
||||
|
||||
When you hit a "my agent isn't working" problem, run this three-step checklist first:
|
||||
|
||||
1. Run `multica daemon status` — confirm the daemon is running and online
|
||||
2. Run `multica daemon logs -f` — check for errors
|
||||
3. Open the **Runtimes** page in the Multica UI — confirm your runtime shows "online"
|
||||
|
||||
More scenarios in [Troubleshooting](/troubleshooting).
|
||||
|
||||
## Next
|
||||
|
||||
- [Tasks](/tasks) — the full lifecycle of a task once the daemon picks it up
|
||||
- [Providers Matrix](/providers) — capability differences across the 11 AI coding tools
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
title: 守护进程与运行时
|
||||
description: 智能体不在 Multica 服务器上运行——它们跑在你自己的机器上。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
import { Mermaid } from "@/components/mermaid";
|
||||
|
||||
在 Multica 里,[智能体](/agents) **不**在我们的服务器上运行——它们跑在你自己的机器上,由一个叫**守护进程**(daemon)的小程序调用本地安装的 [AI 编程工具](/providers)。Multica 服务器只做协调:存 [issue](/issues)、排 [任务](/tasks)、派发给正确的**运行时**(runtime = 守护进程 × 一款 AI 编程工具)。
|
||||
|
||||
这个结构带来 Multica 和 Linear / Jira 最大的差别:**你的 API 密钥、工具链、代码目录都留在本地**,Multica 服务器一个都看不到。"我的智能体不工作"类问题几乎都是本地问题——守护进程没启动、某款 AI 工具没装、密钥过期——请先从本地查起;定位指引见 [故障排查](/troubleshooting)。
|
||||
|
||||
## 启动守护进程
|
||||
|
||||
守护进程是 Multica CLI 的一部分。装好 [Multica CLI](/cli) 后,在自己机器上跑:
|
||||
|
||||
```bash
|
||||
multica daemon start
|
||||
```
|
||||
|
||||
启动后它会做四件事:
|
||||
|
||||
1. 读取你登录时保存的凭证
|
||||
2. 探测本机 `PATH` 上已安装的 AI 编程工具(内置支持 11 款:[Claude Code](/providers#claude-code)、[Codex](/providers#codex)、[Cursor](/providers#cursor)、[Copilot](/providers#copilot)、[Gemini](/providers#gemini)、[Hermes](/providers#hermes)、[Kimi](/providers#kimi)、[Kiro CLI](/providers#kiro-cli)、[OpenCode](/providers#opencode)、[OpenClaw](/providers#openclaw)、[Pi](/providers#pi))
|
||||
3. 向服务器注册自己,以及每款检测到的工具对应的运行时
|
||||
4. 持续**每 3 秒轮询一次**是否有任务要领,**每 15 秒发一次心跳**
|
||||
|
||||
常用命令:
|
||||
|
||||
| 命令 | 作用 |
|
||||
|---|---|
|
||||
| `multica daemon start` | 启动(默认后台,加 `--foreground` 前台运行)|
|
||||
| `multica daemon stop` | 停止 |
|
||||
| `multica daemon restart` | 重启 |
|
||||
| `multica daemon status` | 查看状态 |
|
||||
| `multica daemon logs` | 查看日志(加 `-f` 跟随)|
|
||||
|
||||
完整 CLI 参考见 [CLI 命令速查](/cli)。
|
||||
|
||||
**桌面应用自带守护进程。**用 [桌面应用](/desktop-app) 就不必手动 `multica daemon start`——它启动时会自动拉起守护进程。哪种方式更适合你的工作流,详见 [桌面应用](/desktop-app) 页面。
|
||||
|
||||
## 为什么一台机器会有多个运行时
|
||||
|
||||
运行时不是一个服务器,也不是一个容器——它是「**守护进程 × 一款 AI 编程工具**」的组合。举例:你在一台 MacBook 上启动守护进程,本机装了 Claude Code 和 Codex;你是两个工作区的成员。那么 Multica 会注册 4 个运行时:
|
||||
|
||||
<Mermaid chart={`
|
||||
graph TD
|
||||
D["你的守护进程<br/>MacBook"]
|
||||
D --> R1["运行时<br/>工作区 A × Claude Code"]
|
||||
D --> R2["运行时<br/>工作区 A × Codex"]
|
||||
D --> R3["运行时<br/>工作区 B × Claude Code"]
|
||||
D --> R4["运行时<br/>工作区 B × Codex"]
|
||||
`} />
|
||||
|
||||
关键的点:
|
||||
|
||||
- **一个守护进程可以对应多个运行时**——装了多款工具、加入了多个工作区,每个组合就各一个
|
||||
- **同一个守护进程在同一个工作区同一款工具上只会有一条运行时**——重启守护进程不会产生重复记录
|
||||
- Multica 界面的 **Runtimes** 页面列的就是这些行
|
||||
|
||||
<Callout type="info">
|
||||
**云端运行时即将开放**,目前处于等待名单阶段。上线后,你无需在本地运行守护进程,即可在 Multica Cloud 上直接执行智能体任务。在 [下载页面](https://multica.ai/download) 登记邮箱以获取通知。
|
||||
</Callout>
|
||||
|
||||
## 运行时什么时候被判定为离线
|
||||
|
||||
Multica 用心跳判断运行时是否在线。三个关键数字:
|
||||
|
||||
| 事件 | 阈值 |
|
||||
|---|---|
|
||||
| 守护进程心跳频率 | 每 **15 秒** |
|
||||
| 标记为失联 | 超过 **45 秒** 没心跳(漏了 3 次)|
|
||||
| 自动删除 | 失联且无关联智能体超过 **7 天** |
|
||||
|
||||
失联不是永久的——守护进程只要再次发出心跳就立刻回到在线,运行时记录也会保留。重启守护进程不会丢运行时。
|
||||
|
||||
<Callout type="warning">
|
||||
**失联的运行时上正在跑的执行任务会被标记为失败**(失败原因 `runtime_offline`)。对可重试的来源(issue、chat),Multica 会自动重新排队;Autopilots 触发的任务不自动重试。详见 [执行任务 → 哪些失败会自动重试](/tasks#哪些失败会自动重试哪些不会)。
|
||||
</Callout>
|
||||
|
||||
## 一次能并发跑多少任务
|
||||
|
||||
Multica 对并发有两层限额:
|
||||
|
||||
- **守护进程层**:默认 **20 个执行任务并发**(环境变量 `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` 可调)
|
||||
- **智能体层**:每个智能体默认 **6 个执行任务并发**(智能体配置里改)
|
||||
|
||||
两层中更紧的那层生效。如果你的守护进程已经在跑 20 个任务,即使某个智能体还有余量,新的任务也要等。
|
||||
|
||||
如果你看到执行任务卡在 `queued` 状态不 `dispatched`,通常就是这两层里某一层打满了。
|
||||
|
||||
## 守护进程崩溃后,没跑完的任务会怎样
|
||||
|
||||
守护进程崩溃或被强行结束时,它领走的执行任务会停在 `dispatched` 或 `running` 状态。下次启动时,守护进程会告诉服务器:「这些任务不是我的了,请标记失败。」服务器把它们改成 `failed`,失败原因 `runtime_recovery`——对可重试的来源,任务自动重新排队。
|
||||
|
||||
即使这一步因网络问题没完成,还有**每 30 秒**一次的服务器端扫描作为后备:超过 45 秒没心跳的运行时会被统一标记为失联,上面的任务也一并回收。
|
||||
|
||||
## Agent 不工作怎么排查
|
||||
|
||||
遇到「我的智能体不工作」类问题,先过一遍这三步:
|
||||
|
||||
1. 跑 `multica daemon status`,确认守护进程在运行且在线
|
||||
2. 跑 `multica daemon logs -f`,看是否有错误
|
||||
3. 去 Multica 界面的 **Runtimes** 页面,确认你的运行时显示「在线」
|
||||
|
||||
更多场景见 [Troubleshooting](/troubleshooting)。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [执行任务](/tasks) —— 守护进程领到任务后,它的完整生命周期
|
||||
- [Providers Matrix](/providers) —— 11 款 AI 编程工具的能力差异对照
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
title: Desktop app
|
||||
description: What Multica Desktop is, how it differs from the web app, and when it's worth using.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop is a native desktop app for macOS, Windows, and Linux. For the environment it is configured for, it talks to the same backend as the web app and shows the same data. By default Desktop uses Multica Cloud; self-hosted instances can be configured with a local runtime config file. Desktop also adds a few things the browser can't: **independent tab groups per [workspace](/workspaces)**, **automatic [daemon](/daemon-runtimes) startup**, and **one-click upgrades**.
|
||||
|
||||
## Desktop or web — which to pick
|
||||
|
||||
| | Web | Desktop |
|
||||
|---|---|---|
|
||||
| Access | Open a URL in your browser | Install a native app |
|
||||
| Multiple tabs | Your browser's own tabs (no workspace separation) | **One independent tab group per workspace** |
|
||||
| Daemon | You run `multica daemon start` yourself | **Started automatically** on launch |
|
||||
| Upgrades | Refresh to get the latest | App checks in the background and installs on next launch |
|
||||
| Signed-in data | Identical | Identical |
|
||||
|
||||
**Pick web** for one-off use, working on someone else's machine, or when you'd rather not install anything.
|
||||
**Pick desktop** for daily use, juggling multiple workspaces, or avoiding manual daemon management.
|
||||
|
||||
## Multiple tabs: what happens when you switch workspaces
|
||||
|
||||
Desktop maintains an independent tab group for **every workspace you've joined**. When you switch workspaces, the current workspace's tabs are hidden as a unit and the previous workspace's tabs are restored as you left them — similar to VSCode's multi-workspace behavior or switching workspaces in Slack.
|
||||
|
||||
Example: you open 3 issue tabs in workspace A and switch to workspace B. A's 3 tabs disappear, and B shows whatever you last had open in B. Switch back to A and those 3 tabs come back exactly as they were. **Tabs never leak across workspaces.**
|
||||
|
||||
Logging out **clears every workspace's tab state**, so you don't leak data when a machine is shared between users.
|
||||
|
||||
## How Desktop auto-updates
|
||||
|
||||
On launch, Desktop checks GitHub Releases for a newer version. If one is found:
|
||||
|
||||
1. It downloads the new version silently in the background.
|
||||
2. It tells you "ready — will install on next launch."
|
||||
3. When you quit (or next restart), the app installs the update before closing.
|
||||
4. The next launch runs the new version.
|
||||
|
||||
The whole process **doesn't interrupt what you're working on**.
|
||||
|
||||
<Callout type="warning">
|
||||
**On Windows, ARM64 and x64 are separate update channels** — install the wrong architecture and updates won't be detected. When you download, pick the `.exe` that matches your machine (the ARM build has an `arm64` suffix).
|
||||
</Callout>
|
||||
|
||||
The macOS build is signed and notarized, so you won't see an "unidentified developer" warning on first launch. The Linux build is an `.AppImage` — auto-updates rely on electron-updater, which can be flaky on some distros. **If auto-update doesn't work, download the new version manually and replace the old file.**
|
||||
|
||||
## Do I still need the standalone CLI and daemon?
|
||||
|
||||
**No.** Desktop ships with the same `multica` CLI binary embedded inside it, and it launches its own daemon profile at startup (isolated from any daemon you may be running manually from the terminal).
|
||||
|
||||
If you've already installed the CLI and run `multica daemon start` by hand, Desktop won't take over your daemon — it starts its own with a separate profile. Both register as **different runtimes**, and you'll see two independent runtimes in the UI.
|
||||
|
||||
If you want to run CLI commands in your terminal, Desktop doesn't offer a special path — use the CLI you installed separately, or run the bundled copy at `resources/bin/multica` inside the app's resources directory.
|
||||
|
||||
## Downloading and installing
|
||||
|
||||
Grab the installer for your platform from the [Multica downloads page](https://multica.ai/download):
|
||||
|
||||
| Platform | File |
|
||||
|---|---|
|
||||
| macOS (Intel or Apple Silicon) | `.dmg` |
|
||||
| Windows x64 | `.exe` (standard) |
|
||||
| Windows ARM64 | `.exe` (with `arm64` suffix) |
|
||||
| Linux | `.AppImage` |
|
||||
|
||||
On first launch you'll need to sign in — the same email + verification code flow as the web app. Once you're in, Desktop syncs your workspace list automatically.
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop defaults to Multica Cloud, but can be pointed at a self-hosted instance with a local config file.** There is still no in-app "connect to self-host" picker. Desktop reads `~/.multica/desktop.json` before the renderer starts; if the file is missing, it uses the Cloud defaults.
|
||||
|
||||
Minimal self-host config:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
`apiUrl` is required and must use `http` or `https`. Desktop derives `wsUrl` as `/ws` on the same origin (`wss` for `https`, `ws` for `http`) and derives `appUrl` from the API origin. If your deployment uses different origins, set them explicitly:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
If `desktop.json` exists but is invalid, Desktop fails closed and shows a blocking config error instead of silently falling back to Cloud. For development builds, `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL` still take precedence during `electron-vite dev`. Runtime Desktop self-host configuration was implemented for [issue #1371](https://github.com/multica-ai/multica/issues/1371).
|
||||
</Callout>
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) — the Cloud onboarding flow for Desktop
|
||||
- [Self-Host Quickstart](/self-host-quickstart) — running your own backend and connecting with the CLI or Desktop runtime config
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how the daemon works (Desktop starts it for you, but the behavior is the same)
|
||||
@@ -1,99 +0,0 @@
|
||||
---
|
||||
title: 桌面应用
|
||||
description: Multica Desktop 是什么、和 Web 有什么区别、什么时候值得用。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Multica Desktop 是原生桌面应用——macOS / Windows / Linux 三个平台。对它当前配置的环境来说,它和 Web 版连同一个后端、看到的数据完全一样。Desktop 默认使用 Multica Cloud;自部署实例可以通过本地运行时配置文件接入。它还给了几个 Web 做不到的能力:**[工作区](/workspaces) 独立的多标签页**、**自动启动 [守护进程](/daemon-runtimes)**、**一键升级**。
|
||||
|
||||
## Desktop 和 Web 该用哪个
|
||||
|
||||
| | Web | Desktop |
|
||||
|---|---|---|
|
||||
| 访问方式 | 浏览器打开 URL | 装一个本地应用 |
|
||||
| 多标签页 | 浏览器自己的标签页(不区分工作区)| **每个工作区一组独立标签页** |
|
||||
| 守护进程 | 要你自己跑 `multica daemon start` | 启动时**自动拉起** |
|
||||
| 升级 | 刷新页面就是最新 | 应用自动检查 + 下次启动安装 |
|
||||
| 登录后的数据 | 完全一样 | 完全一样 |
|
||||
|
||||
**选 Web**:临时用、在别人电脑上、不想装应用的场景。
|
||||
**选 Desktop**:每天用 Multica、会同时操作多个工作区、不想自己管守护进程的场景。
|
||||
|
||||
## 多 tab:工作区之间切换怎么表现
|
||||
|
||||
Desktop 为**每个你加入的工作区**独立维护一组标签页。切换工作区时,当前工作区的标签页会被整体隐藏,上次那个工作区的标签页会原样恢复——像 VSCode 的多 workspace 行为或 Slack 的 workspace 切换。
|
||||
|
||||
举例:你在工作区 A 打开了 3 个 issue 标签页,切到工作区 B,A 的那 3 个标签页消失,B 里显示你上次在 B 留下的标签页;切回 A,那 3 个原样回来。**不同工作区的标签页不会互相串到对方**。
|
||||
|
||||
登出会**清空所有工作区的标签页状态**,防止多用户共用同一台机器时的数据泄露。
|
||||
|
||||
## Desktop 怎么自动更新
|
||||
|
||||
Desktop 启动时会去 GitHub Releases 检查新版本。检查到新版本:
|
||||
|
||||
1. 在后台静默下载新版本
|
||||
2. 提示你「准备就绪,下次启动时安装」
|
||||
3. 你点击退出(或下次重启)时,应用关闭前把新版本装好
|
||||
4. 再次打开时就是新版本
|
||||
|
||||
整个过程**不中断你正在做的事**。
|
||||
|
||||
<Callout type="warning">
|
||||
**Windows 的 ARM64 和 x64 是独立的更新通道**——装错架构会识别不到更新。安装时下载对应你机器架构的那个 `.exe`(带 `arm64` 后缀的是 ARM 版)。
|
||||
</Callout>
|
||||
|
||||
macOS 版本已经签名 + 公证,第一次打开不会有"未知开发者"的警告。Linux 版是 `.AppImage`——自动更新机制依赖 electron-updater,在某些发行版可能不稳定,**不工作时手动下载新版本覆盖**。
|
||||
|
||||
## 还要单独装 CLI 和守护进程吗
|
||||
|
||||
**不用**。Desktop 包里**内置了同一个 `multica` CLI 二进制**——Desktop 启动时会自动启动守护进程的独立 profile(和你命令行手动跑的守护进程互不干扰)。
|
||||
|
||||
如果你已经装过 CLI 并手动跑过 `multica daemon start`,Desktop 不会抢占你那个守护进程——它起自己的,用不同的 profile 隔离。两边注册的是**不同的运行时**,在 UI 里能看到两个独立运行时。
|
||||
|
||||
想在终端里跑 CLI 命令,Desktop 不提供特殊方式——照常用系统的 CLI(如果你单独装了),或者用 Desktop 自带的版本(在应用的资源目录里,`resources/bin/multica`)。
|
||||
|
||||
## 怎么下载安装
|
||||
|
||||
去 [多卡下载页](https://multica.ai/download) 拿对应平台的安装包:
|
||||
|
||||
| 平台 | 文件 |
|
||||
|---|---|
|
||||
| macOS(Intel 或 Apple Silicon)| `.dmg` |
|
||||
| Windows x64 | `.exe`(常规)|
|
||||
| Windows ARM64 | `.exe`(带 `arm64` 后缀)|
|
||||
| Linux | `.AppImage` |
|
||||
|
||||
安装后第一次打开需要登录——和 Web 版一样的 email + 验证码流程。登录成功后 Desktop 自动把工作区列表同步下来。
|
||||
|
||||
<Callout type="info">
|
||||
**Desktop 默认连接 Multica Cloud,但可以通过本地配置文件指向自部署实例。** 应用内仍然没有“连接自部署”的切换入口。Desktop 会在 renderer 启动前读取 `~/.multica/desktop.json`;如果这个文件不存在,就使用 Cloud 默认值。
|
||||
|
||||
最小自部署配置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
`apiUrl` 是必填项,必须使用 `http` 或 `https`。Desktop 会自动从它推导 `wsUrl`(同源 `/ws`,`https` 对应 `wss`,`http` 对应 `ws`)和 `appUrl`(API 的同源地址)。如果你的部署使用不同域名,可以显式设置:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"apiUrl": "https://api.your-domain",
|
||||
"wsUrl": "wss://api.your-domain/ws",
|
||||
"appUrl": "https://your-domain"
|
||||
}
|
||||
```
|
||||
|
||||
如果 `desktop.json` 存在但内容无效,Desktop 会 fail closed,显示阻塞式配置错误,而不是悄悄回退到 Cloud。开发构建里,`electron-vite dev` 仍然优先使用 `VITE_API_URL` / `VITE_WS_URL` / `VITE_APP_URL`。Desktop 运行时自部署配置能力对应 [issue #1371](https://github.com/multica-ai/multica/issues/1371)。
|
||||
</Callout>
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Cloud Quickstart](/cloud-quickstart) —— Desktop 版的 Cloud 接入流程
|
||||
- [Self-Host Quickstart](/self-host-quickstart) —— 自部署后端,并通过 CLI 或 Desktop 运行时配置连接
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 守护进程机制(Desktop 自动起它,但行为一样)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user