Compare commits

..

2 Commits

Author SHA1 Message Date
Jiang Bohan
a9753dbd64 docs(email): treat SMTP as an email backend in self-host docs and startup warning
Address review feedback on #2666:

- server: startup warning now fires only when both RESEND_API_KEY and SMTP_HOST
  are empty, since either one is a valid email backend. Otherwise the log
  mis-tells SMTP-only operators that verification codes go to stdout.
- self-host-quickstart (EN/ZH): tell readers to fetch the verification code
  from whichever backend they configured (Resend or SMTP); fall back to
  stdout only when neither is configured.
- auth-setup (EN/ZH): \"without Resend\" → \"without any email backend
  configured\" so the wording stays correct now that SMTP is a first-class
  option.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:52:53 +08:00
Jiang Bohan
3ccf3be7c2 docs(email): clarify 888888 is opt-in via MULTICA_DEV_VERIFICATION_CODE; document SMTP option in self-host docs
The startup log line, .env.example, and SELF_HOSTING_ADVANCED.md still
implied that the dev master code 888888 is auto-active whenever
APP_ENV != "production". That has not been true since the master code
was gated behind MULTICA_DEV_VERIFICATION_CODE — the fixed code is
disabled by default and must be opted in explicitly.

Also extend the docs site with the SMTP relay backend added in #1877:
auth-setup, environment-variables, and self-host-quickstart now cover
both Resend and SMTP options in EN and ZH.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-15 13:39:32 +08:00
1893 changed files with 22461 additions and 275437 deletions

View File

@@ -1,39 +0,0 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.

View File

@@ -21,45 +21,14 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
# override the backend host port.
BACKEND_PORT=8080
# Optional aliases for local/self-host backend port helpers outside compose.
# API_PORT=8080
# SERVER_PORT=8080
FRONTEND_PORT=3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
# Set explicitly only when the app's public URL differs from local frontend.
MULTICA_APP_URL=${FRONTEND_ORIGIN}
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers and to show correct daemon setup commands in the web UI. Leave
# unset behind a same-origin reverse proxy or for plain localhost dev —
# the frontend will compose the URL from window.origin + webhook_path in
# that case. Headers are intentionally not used to derive this value, to
# avoid Host / X-Forwarded-Host spoofing when a self-hosted reverse proxy
# is not hardened.
MULTICA_PUBLIC_URL=
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
# Empty (the default) means "trust no headers" — the limiter uses
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
MULTICA_TRUSTED_PROXIES=
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
MULTICA_DAEMON_ID=
@@ -94,22 +63,11 @@ RESEND_FROM_EMAIL=noreply@multica.ai
# Takes priority over Resend when SMTP_HOST is set.
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
# SMTP_TLS controls the TLS mode:
# - unset / "starttls" (default): plaintext connect, upgrade via STARTTLS.
# - "implicit" (aliases: "smtps", "ssl"): TLS handshake on connect (SMTPS).
# Required by providers that only offer port 465 and do not advertise
# STARTTLS (e.g. Aliyun enterprise mail). Auto-enabled when SMTP_PORT=465
# and SMTP_TLS is unset.
# SMTP_EHLO_NAME is the EHLO/HELO name announced to the relay. Defaults to the
# machine hostname; set a real FQDN when a strict relay (e.g. Google Workspace
# smtp-relay.gmail.com) rejects the default and the connection drops as an EOF.
SMTP_HOST=
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
SMTP_TLS=
SMTP_EHLO_NAME=
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
@@ -117,9 +75,7 @@ SMTP_EHLO_NAME=
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
# Set explicitly only when your OAuth callback URL differs from local frontend.
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
@@ -127,15 +83,6 @@ GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
S3_BUCKET=
S3_REGION=us-west-2
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
# not need direct access to the object store.
AWS_ENDPOINT_URL=
ATTACHMENT_DOWNLOAD_MODE=auto
ATTACHMENT_DOWNLOAD_URL_TTL=30m
CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
@@ -149,46 +96,15 @@ CLOUDFRONT_DOMAIN=
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
CORS_ALLOWED_ORIGINS=
# ==================== Rate limiting (optional Redis) ====================
# Per-IP fixed-window rate limiter on the public auth endpoints
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
# backend logs "rate limiting disabled: REDIS_URL not configured" at
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
# the PAT cache, and the daemon-token cache.
# REDIS_URL=redis://localhost:6379/0
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5
# RATE_LIMIT_AUTH_VERIFY=20
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
# REQUIRED behind a reverse proxy — otherwise every real user shares
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
@@ -200,43 +116,18 @@ CORS_ALLOWED_ORIGINS=
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → GitHub "Connect GitHub")
# 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=
# Optional: GitHub App identity for App-authenticated REST calls. When set,
# the setup callback enriches the installation row with the real account
# login (org / user name) immediately after install. When unset, the row
# is created with the "unknown" placeholder and the next `installation`
# webhook from GitHub overwrites it — set both to skip that interim flash.
# GITHUB_APP_ID is the numeric "App ID" shown on the App's settings page.
# GITHUB_APP_PRIVATE_KEY is the full PEM block (including BEGIN/END lines)
# generated under "Private keys" on that same page; preserve newlines.
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")
# Off until MULTICA_LARK_SECRET_KEY is set — a base64-encoded 32-byte key
# that encrypts each Bot's app secret at rest. Leave empty to disable.
# Generate one with: openssl rand -base64 32
MULTICA_LARK_SECRET_KEY=
# Mainland 飞书 and international Lark are auto-detected per installation
# (at QR scan) and served side by side — LEAVE THESE EMPTY for normal use.
# They are optional deployment-wide overrides that force EVERY installation
# onto one host (a proxy, a mock for tests, or a single-cloud staging
# setup); HTTP drives outbound Open Platform API calls, CALLBACK the inbound
# long-conn bootstrap. NOTE: if you previously ran international Lark by
# setting these to https://open.larksuite.com, the server relabels your
# existing installs to region=lark on first boot after upgrade, so you can
# clear these afterwards. See docs/lark-bot-integration.
MULTICA_LARK_HTTP_BASE_URL=
MULTICA_LARK_CALLBACK_BASE_URL=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
# Only set explicitly if frontend and backend are on different domains.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
@@ -257,14 +148,6 @@ ALLOWED_EMAIL_DOMAINS=
# Optional: Only allow these exact email addresses (comma-separated)
ALLOWED_EMAILS=
# Set to "true" to disable workspace creation for every caller on this
# instance (#3433). Operators usually leave this unset, bootstrap the
# shared workspace, then flip this to "true" and restart so subsequent
# users join only via invitations and the entire deployment is visible to
# the platform admin. The web UI reads this from /api/config at runtime,
# so toggling requires a backend restart but not a frontend rebuild.
DISABLE_WORKSPACE_CREATION=
# ==================== Analytics (PostHog) ====================
# Product analytics events feed the acquisition → activation → expansion funnel.
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server

View File

@@ -40,7 +40,7 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`), **starter-content** (`packages/views/onboarding/utils/starter-content-content-*.ts`), and **relevant docs** (`apps/docs/content/docs/`)
- [ ] If this PR touches Chinese product copy, I checked it against `apps/docs/content/docs/developers/conventions.zh.mdx` (terminology, mixed-rule for `task` / `issue` / `skill`)
- [ ] I have considered and documented any risks above
- [ ] I will address all reviewer comments before requesting merge

View File

@@ -29,9 +29,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
@@ -42,12 +39,7 @@ jobs:
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
# Mobile lives in a parallel mobile-verify workflow (path-filtered
# to apps/mobile/** + packages/core/types/**) so it doesn't add
# ~50s of expo-lint + tsc to every web/desktop PR. Keep this
# filter in sync with the root package.json scripts, which also
# exclude @multica/mobile.
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs' --filter='!@multica/mobile'
run: pnpm exec turbo build typecheck lint test --filter='!@multica/docs'
backend:
runs-on: ubuntu-latest
@@ -99,20 +91,3 @@ jobs:
- name: Test
run: cd server && go test ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
# exercised on macOS too because the installer targets macOS/Homebrew
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
run: bash scripts/install.test.sh

View File

@@ -1,66 +0,0 @@
name: Mobile Verify
# Runs lint + typecheck for apps/mobile only, parallel to the main CI
# workflow. Path-filtered so PRs that don't touch mobile (or anything
# mobile transitively depends on) pay zero cost.
#
# Path scope rationale — mobile transitively depends on:
# - apps/mobile/** — the app itself
# - packages/core/** — mobile imports types AND pure functions
# (agents, markdown, permissions, api/schemas, …),
# not only @multica/core/types
# - package.json / pnpm-lock.yaml — install graph
# - pnpm-workspace.yaml — catalog versions
# - turbo.json — turbo task pipeline
#
# Mobile's vitest suite is intentionally narrow (Node env, pure-function
# tests under apps/mobile/lib/*.test.ts — see apps/mobile/vitest.config.ts).
# RN component-level rendering is not exercised here.
on:
push:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
pull_request:
branches: [main]
paths:
- "apps/mobile/**"
- "packages/core/**"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "turbo.json"
- ".github/workflows/mobile-verify.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
mobile:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Type check, lint, and test
run: pnpm exec turbo typecheck lint test --filter=@multica/mobile

View File

@@ -322,47 +322,6 @@ jobs:
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-web:${{ steps.meta.outputs.version }}
helm-chart:
needs: [verify, docker-backend-merge, docker-web-merge]
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
concurrency:
group: release-helm-chart-${{ github.ref }}
cancel-in-progress: true
env:
CHART_DIR: deploy/helm/multica
OCI_REGISTRY: oci://ghcr.io/${{ github.repository_owner }}/charts
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Helm
uses: azure/setup-helm@v4
- name: Sync chart metadata with release tag
env:
TAG_NAME: ${{ needs.verify.outputs.tag_name }}
run: |
chart_version="${TAG_NAME#v}"
sed -i -E "s/^version:.*/version: ${chart_version}/" "$CHART_DIR/Chart.yaml"
sed -i -E "s/^appVersion:.*/appVersion: \"${TAG_NAME}\"/" "$CHART_DIR/Chart.yaml"
echo "CHART_VERSION=${chart_version}" >> "$GITHUB_ENV"
- name: Lint chart
run: helm lint "$CHART_DIR"
- name: Package chart
run: helm package "$CHART_DIR" --destination .chart-packages
- name: Login to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin
- name: Push chart to GHCR
run: helm push ".chart-packages/multica-${CHART_VERSION}.tgz" "$OCI_REGISTRY"
- name: Verify published chart
run: helm show chart "$OCI_REGISTRY/multica" --version "$CHART_VERSION"
# Build the Desktop installers for Linux and Windows and upload them to
# the GitHub Release that the `release` job above just published. macOS
# Desktop continues to ship via the manual `release-desktop` skill so it

10
.gitignore vendored
View File

@@ -23,14 +23,6 @@ dist-electron
# Desktop production config is public (backend URL, etc.) — track it so
# `pnpm package` produces a release-ready build without extra setup.
!apps/desktop/.env.production
# Mobile staging config is public (staging API URL) — track it so a fresh
# checkout can run `pnpm dev:mobile:staging` / `ios:mobile*:staging` without
# the user having to copy `.env.example` first.
!apps/mobile/.env.staging
# Mobile production config is public (production API URL) — track it so
# external users can run `pnpm ios:mobile:device:prod:release` against
# multica.ai's production backend without copying templates first.
!apps/mobile/.env.production
# test coverage
coverage
@@ -48,8 +40,6 @@ apps/web/test-results/
# context (agent workspace)
.context
.agent_context/
.multica/
# local settings
.claude/

View File

@@ -32,14 +32,11 @@ Multica is an AI-native task management platform — like Linear, but with AI ag
- `server/` — Go backend (Chi router, sqlc for DB, gorilla/websocket for real-time)
- `apps/web/` — Next.js frontend (App Router)
- `apps/desktop/` — Electron desktop app (electron-vite)
- `apps/mobile/` — Expo / React Native iOS app. See `apps/mobile/CLAUDE.md`.
- `packages/core/` — Headless business logic (zero react-dom)
- `packages/core/` — Headless business logic (zero react-dom, all-platform reuse)
- `packages/ui/` — Atomic UI components (zero business logic)
- `packages/views/` — Shared business pages/components (zero next/* imports, zero react-router imports)
- `packages/tsconfig/` — Shared TypeScript configuration
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
### Key Architectural Decisions
**Internal Packages pattern** — all shared packages export raw `.ts`/`.tsx` files (no pre-compilation). The consuming app's bundler compiles them directly. This gives zero-config HMR and instant go-to-definition.
@@ -55,7 +52,7 @@ What lives where for sharing purposes is documented in *Sharing Principles* belo
The architecture relies on a strict split between server state and client state. Mixing them is the most common way to break it.
- **TanStack Query owns all server state.** Issues, users, workspaces, inbox — anything fetched from the API lives in the Query cache. WS events keep it fresh via invalidation; no polling, no `staleTime` workarounds.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so they're shared.
- **Zustand owns all client state.** UI selections, filters, drafts, modal state, navigation history. Stores live in `packages/core/` (never in `packages/views/`) so both apps share them.
- **React Context** is reserved for cross-cutting platform plumbing — `WorkspaceIdProvider`, `NavigationProvider`. Don't reach for it for general state.
- **Auth and workspace stores are the only stores allowed to call `api.*` directly**, because they manage critical state that must exist before queries can run. They're created via factory + injected dependencies, registered by the platform layer.
@@ -72,17 +69,6 @@ The architecture relies on a strict split between server state and client state.
- Selectors must return stable references. Returning a freshly built object or array on every call (e.g. `s => ({ a: s.a, b: s.b })` or `s => s.items.map(...)`) triggers infinite re-renders. Either select primitives separately or use shallow comparison.
- Hooks that need workspace context should accept `wsId` as a parameter, not call `useWorkspaceId()` internally — this lets them work outside the `WorkspaceIdProvider` (e.g. in a sidebar that renders before workspace is loaded).
## Sharing Principles
The monorepo splits into two share zones:
- **Web and desktop** share business logic, components, hooks, stores, and views through `packages/core/`, `packages/ui/`, and `packages/views/`. Existing model — keep using it.
- **Mobile (`apps/mobile/`) is independent.** It shares only **types and pure functions** from `@multica/core/`, with `import type` for types (zero runtime coupling). UI, state, hooks, providers, i18n, React version, build pipeline, release cadence — all mobile-owned.
Mobile is locked to the React version that Expo SDK / React Native ships (which lags React main by 6-12 months). Coupling mobile to the root `catalog:` React would block mobile from upgrading on its own schedule.
See `apps/mobile/CLAUDE.md` for the mobile rules and tech-stack baseline.
## Commands
```bash
@@ -125,16 +111,6 @@ cd server && go test ./internal/handler/ -run TestName
# Run a single E2E test (requires backend + frontend running)
pnpm exec playwright test e2e/tests/specific-test.spec.ts
# Mobile (Expo) — two environments only: dev and staging
pnpm dev:mobile # Metro, dev env (reads apps/mobile/.env.development.local)
pnpm dev:mobile:staging # Metro, staging env (reads apps/mobile/.env.staging)
pnpm ios:mobile # Native build + install dev-client to iOS Simulator, dev env
pnpm ios:mobile:staging # Native build + install dev-client to iOS Simulator, staging env
pnpm ios:mobile:device # Native build + install dev-client to USB iPhone, dev env
pnpm ios:mobile:device:staging # Native build + install dev-client to USB iPhone, staging env
# Daily flow: run `pnpm dev:mobile:staging` (or :dev). Only re-run `ios:mobile*` when
# native code or any expo-*/react-native-* dependency changes (lockfile drift counts).
# Desktop build & package
pnpm --filter @multica/desktop build # Compile TS → JS (reads .env.production)
pnpm --filter @multica/desktop package # Package into .app/.dmg/.exe (current platform only)
@@ -176,7 +152,6 @@ make start-worktree # Start using .env.worktree
- 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.
- When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md` **and** its `references/*-source-map.md` in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
### API Response Compatibility
@@ -204,29 +179,21 @@ Every Go handler in `server/internal/handler/` follows these rules. The conventi
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
### Dependency Declaration Rule
Every workspace (`apps/` and `packages/` directories) must explicitly declare all directly imported external packages in its own `package.json`. Relying on pnpm hoist to resolve undeclared imports (phantom deps) is prohibited — it causes production build failures when pnpm creates peer-dep variants.
- Use `"pkg": "catalog:"` to reference the shared version from `pnpm-workspace.yaml`.
- CI enforces this via `eslint-plugin-import-x/no-extraneous-dependencies`.
- Exception: `apps/mobile/` uses pinned versions (not `catalog:`) for packages tied to its own React/Expo version.
### Package Boundary Rules
These are hard constraints. Violating them breaks the cross-platform architecture:
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **Shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/core/` — zero react-dom, zero localStorage (use StorageAdapter), zero process.env, zero UI libraries. **All shared Zustand stores live here**, even view-related ones (filters, view modes) — stores are pure state, not UI.
- `packages/ui/` — zero `@multica/core` imports (pure UI, no business logic).
- `packages/views/` — zero `next/*` imports, zero `react-router-dom` imports, zero stores. Use `NavigationAdapter` for all routing.
- `apps/web/platform/` — the only place for Next.js APIs (`next/navigation`).
- `apps/desktop/src/renderer/src/platform/` — the only place for react-router-dom navigation wiring.
### The No-Duplication Rule (web + desktop)
### The No-Duplication Rule
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
**If the same logic exists in both apps, it must be extracted to a shared package.**
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
This applies to everything: components, hooks, guards, providers, utility functions. The decision process:
1. Does this code depend on Next.js or Electron APIs? → Keep in the respective app.
2. Does it depend on `react-router-dom` or `next/navigation`? → Keep in app's `platform/` layer.
@@ -234,9 +201,9 @@ This applies to everything between web and desktop: components, hooks, guards, p
When the two apps need different behavior for the same concept (e.g., different loading UI), extract the shared logic into a component with props/slots for the differences. Don't duplicate the logic.
### Cross-Platform Development Rules (web + desktop)
### Cross-Platform Development Rules
When adding a new page or feature for web/desktop:
When adding a new page or feature:
1. **New page component** → add to `packages/views/<domain>/`. Never import from `next/*` or `react-router-dom`.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router. **Exception**: pre-workspace transition flows (create workspace, accept invite) are NOT routes on desktop — they're `WindowOverlay` state. See *Desktop-specific Rules → Route categories*.
@@ -245,18 +212,14 @@ When adding a new page or feature for web/desktop:
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
### CSS Architecture (web + desktop)
### CSS Architecture
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
Both apps share the same CSS foundation from `packages/ui/styles/`.
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
## Mobile-specific Rules
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
## Desktop-specific Rules
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.

View File

@@ -168,7 +168,7 @@ 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` | `0` (no cap; bounded by the watchdogs) |
| 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 |
@@ -269,37 +269,21 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --full-id
multica workspace list --output json
```
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
### Switch Default Workspace
### Watch / Unwatch
```bash
multica workspace switch <workspace-id>
multica workspace switch <slug>
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -307,12 +291,10 @@ multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
Passing no `<workspace-id>` resolves to the current default workspace, so `multica workspace get` doubles as "what workspace am I on?".
### List Members
```bash
multica workspace member list <workspace-id>
multica workspace members <workspace-id>
```
## Issues
@@ -328,14 +310,7 @@ multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
```bash
multica issue list --metadata pipeline_status=waiting_review
multica issue list --metadata pr_number=482 --metadata is_blocked=true
```
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.
### Get Issue
@@ -351,7 +326,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
### Update Issue
@@ -380,44 +355,9 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
### Comments
```bash
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
# long-running issues prefer one of the thread-aware reads below to keep
# context windows tight.
# List comments
multica issue comment list <issue-id>
# Single thread (root + every descendant). Anchor may be the root itself
# or any reply inside the thread — the server walks up to the root.
multica issue comment list <issue-id> --thread <comment-id>
# Single thread, capped to the N most recent replies. The thread root is
# always included (even with --tail 0), so an agent landing on a long
# thread keeps the "what is this about" context without dragging hundreds
# of replies into its prompt.
multica issue comment list <issue-id> --thread <comment-id> --tail 30
# Scroll older replies inside the same thread. --before / --before-id are
# the reply cursor that the previous response emitted on stderr as
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--before <ts> --before-id <reply-id>
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 20
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 20 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
# replies created on or before <ts> from the page (the thread root is
# exempt so the agent always gets context).
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--since <RFC3339-timestamp>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
@@ -428,56 +368,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
**`--before` / `--before-id` semantics depend on the paging mode**, by
design — same flag, different scope:
| Mode | What the cursor walks | stderr label |
| --- | --- | --- |
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
Outside those two modes (`--thread` without `--tail`, or no `--thread`
and no `--recent`) the cursor flags are rejected so they cannot silently
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
`X-Multica-Next-Before-Id`) only when an older page actually exists —
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
replies) intentionally return no cursor so callers stop paginating.
When `--since` is combined with `--recent` or `--thread --tail`, the
server additionally suppresses the cursor once the cursor target itself
is older than `since`. Older pages walk strictly older rows, so they
cannot satisfy `> since` either — emitting a cursor there would just
hand back root-only pages until the caller reaches the start of the
thread / issue. Incremental polling stops at the first page whose
cursor target falls before the watermark.
### Metadata
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
```bash
# List every key on an issue
multica issue metadata list <issue-id>
# Read a single key
multica issue metadata get <issue-id> --key pipeline_status
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
multica issue metadata set <issue-id> --key pr_number --value 482
multica issue metadata set <issue-id> --key is_blocked --value true
# Force a specific type when sniffing would pick the wrong one
multica issue metadata set <issue-id> --key code --value 42 --type string
# Remove a key
multica issue metadata delete <issue-id> --key pipeline_status
```
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
### Subscribers
```bash
@@ -618,8 +508,6 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
@@ -655,7 +543,7 @@ multica autopilot update <id> --description "New prompt"
multica autopilot delete <id>
```
`--mode` accepts `create_issue` (creates a new issue on each run and assigns it to the agent) or `run_only` (enqueues a direct agent task without creating an issue). `--agent` accepts either a name or UUID.
`--mode` currently only accepts `create_issue` (creates a new issue on each run and assigns it to the agent). The server data model also defines `run_only`, but the daemon task path doesn't yet resolve a workspace for runs without an issue, so it's not exposed by the CLI. `--agent` accepts either a name or UUID.
### Manual Trigger
@@ -699,79 +587,3 @@ Most commands support `--output` with two formats:
multica issue list --output json
multica daemon status --output json
```
## Error Messages
The CLI funnels command errors returned to the top-level handler through a
single user-facing translation layer (`server/internal/cli/errors.go`) so that
what you see on the terminal is a short, actionable sentence rather than a raw
Go error, an HTTP status line, or an internal `resolve issue: ...` chain. (A
few commands print their own output or run deliberate fast probes — for example
`setup`'s short `/health` reachability check — and don't go through this
layer.) The underlying detail is still available on demand (see `--debug`).
### What you see
- **Friendly, single-line message.** Transport failures (timeout, DNS,
connection refused, TLS) and HTTP status failures (401/403/404/409/400·422/
429/5xx) are each rendered as one clear sentence with a next step — for
example a timeout suggests checking the network or raising
`MULTICA_HTTP_TIMEOUT`, and a 401 tells you to run `multica login`.
- **Server-provided validation messages are preserved.** For a 400/422 that
carries a message from the server, that message is shown verbatim
(`Invalid request: <server message>`); only when there is none do you get the
generic "check your values / run with --help" hint.
- **No leaked internals by default.** Raw URLs, status lines, JSON bodies, and
the internal verb chain are hidden unless you ask for them.
### Language
Messages default to **English**, matching the rest of the CLI's help output.
If a Chinese locale is detected in `LC_ALL`, `LC_MESSAGES`, or `LANG` (in that
precedence order), messages switch to **Chinese**. No flag is needed; set the
locale as usual:
```bash
LANG=zh_CN.UTF-8 multica issue get MUL-9999 # 错误信息显示为中文
```
### Exit codes
The process exit code is tiered so scripts can branch on the failure class:
| Exit code | Meaning |
| --- | --- |
| `0` | success |
| `1` | generic / unclassified error |
| `2` | network error (timeout, DNS, connection refused, TLS, offline) |
| `3` | authentication / authorization (HTTP 401, 403) |
| `4` | not found (HTTP 404) |
| `5` | validation (HTTP 400, 422) |
```bash
multica issue get MUL-9999
if [ $? -eq 4 ]; then echo "no such issue"; fi
```
### Seeing the full detail (`--debug`)
Pass the global `--debug` flag (or set `MULTICA_DEBUG=1`) to print the complete
original error chain — the internal verb chain, the request method/path/status,
and the raw server body — underneath the friendly message. Use it when you need
to file a bug or understand exactly what the server returned:
```bash
multica issue list --debug
MULTICA_DEBUG=1 multica issue update MUL-1234 --title "x"
```
### Request timeout
API requests use a default timeout of 30 seconds. Override it with
`MULTICA_HTTP_TIMEOUT` when you are on a slow network; it accepts a Go duration
(`45s`, `2m`) or a plain number of seconds (`45`). Command-level deadlines are
always at least this value, so raising it takes effect across all commands.
```bash
MULTICA_HTTP_TIMEOUT=60s multica issue list
```

View File

@@ -15,11 +15,9 @@ COPY server/ ./server/
# Build binaries
ARG VERSION=dev
ARG COMMIT=unknown
ARG DATE=unknown
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
# --- Runtime stage ---
FROM alpine:3.21
@@ -31,7 +29,6 @@ WORKDIR /app
COPY --from=builder /src/server/bin/server .
COPY --from=builder /src/server/bin/multica .
COPY --from=builder /src/server/bin/migrate .
COPY --from=builder /src/server/bin/backfill_task_usage_hourly .
COPY server/migrations/ ./migrations/
COPY docker/entrypoint.sh .
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh

View File

@@ -8,8 +8,6 @@ WORKDIR /app
# Copy workspace config and all package.json files for dependency resolution
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json .npmrc ./
COPY apps/web/package.json apps/web/
# postinstall runs fumadocs-mdx which reads apps/web/source.config.ts
COPY apps/web/source.config.ts apps/web/source.config.ts
COPY packages/core/package.json packages/core/
COPY packages/ui/package.json packages/ui/
COPY packages/views/package.json packages/views/

View File

@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
PORT ?= 8080
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
@@ -21,7 +21,6 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
export
@@ -58,17 +57,12 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
PGPASS=$$(openssl rand -hex 24); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
fi; \
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
@@ -113,17 +107,12 @@ selfhost-build: ## Build backend/web from the current checkout and start the sel
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
PGPASS=$$(openssl rand -hex 24); \
if [ "$$(uname)" = "Darwin" ]; then \
sed -i '' "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i '' "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i '' -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
else \
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$$JWT/" .env; \
sed -i "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$$PGPASS/" .env; \
sed -i -E "s#^(DATABASE_URL=postgres://[^:]+:)[^@]*(@.*)#\1$$PGPASS\2#" .env; \
fi; \
echo "==> Generated random JWT_SECRET and POSTGRES_PASSWORD"; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build

View File

@@ -57,7 +57,6 @@ Multica manages the full agent lifecycle: from task assignment to execution moni
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Autopilots** — schedule recurring work for agents. Cron triggers, webhooks, or manual runs — each autopilot creates the issue and routes it to an agent automatically, so daily standups, weekly reports, and periodic audits run themselves.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
- **Multi-Workspace** — organize work across teams with workspace-level isolation. Each workspace has its own agents, issues, and settings.
@@ -114,7 +113,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`, `agy`) on your PATH.
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.
### 2. Verify your runtime
@@ -124,7 +123,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, Kiro CLI, or Antigravity). 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, 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.
### 4. Assign your first task
@@ -143,8 +142,6 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
| `multica daemon status` | Check daemon status |
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
| `multica setup self-host` | Same, but for self-hosted deployments |
| `multica workspace list` | List your workspaces (current is marked with `*`) |
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
| `multica issue list` | List issues in your workspace |
| `multica issue create` | Create a new issue |
| `multica update` | Update to the latest version |
@@ -188,5 +185,3 @@ make dev
`make dev` auto-detects your environment (main checkout or worktree), creates the env file, installs dependencies, sets up the database, runs migrations, and starts all services.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full development workflow, worktree support, testing, and troubleshooting.
An iOS mobile client lives in [`apps/mobile/`](apps/mobile/) — see its [README](apps/mobile/README.md) for how to build it onto your own iPhone.

View File

@@ -57,7 +57,6 @@ Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **Squads小队** — 把多个 Agent以及人类成员组合成由 leader agent 带队的小队直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **自动化Autopilots** — 为 Agent 安排周期性工作。定时Cron、Webhook 或手动触发,自动化会自动创建 Issue 并分配给 Agent——日报、周报、定期巡检都能让它自己跑起来。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
@@ -172,8 +171,6 @@ make start
完整的开发流程、worktree 支持、测试和问题排查请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
iOS 移动端代码位于 [`apps/mobile/`](apps/mobile/),自己编译装到手机的方法见 [README](apps/mobile/README.md)。
## 开源协议
[Modified Apache 2.0 (with commercial restrictions)](LICENSE)
[Apache 2.0](LICENSE)

View File

@@ -73,7 +73,7 @@ Open http://localhost:3000 in your browser. The Docker self-host stack defaults
- **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`.
Changes to `ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` also take effect after restarting the backend / compose stack. The web UI reads all three from `/api/config` at runtime, so no web rebuild is needed. See [Advanced Configuration → Signup Controls](SELF_HOSTING_ADVANCED.md#signup-controls-optional) for the recommended sequence to lock down workspace creation.
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.
@@ -135,258 +135,6 @@ multica daemon status
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
---
## Kubernetes Deployment (Alternative)
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the released OCI Helm chart at `oci://ghcr.io/multica-ai/charts/multica` or the source chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
The chart creates the following resources in the target namespace:
- `multica-postgres``pgvector/pgvector:pg17` backed by a 10Gi PVC
- `multica-backend` — Go API/WS server. Backed by a 5Gi `ReadWriteOnce` uploads PVC by default; set `backend.uploads.persistence.enabled=false` when you have configured S3 (`backend.config.s3Bucket`) and don't want the chart to declare the PVC at all.
- `multica-frontend` — Next.js standalone server
- Two `Ingress` resources: one for the web host, one for the backend host
- `multica-config` ConfigMap (rendered from `values.yaml`)
The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git.
> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias.
> **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass.
### Step 1 — Point hostnames at the cluster
The chart defaults to `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Pick one of:
- **`/etc/hosts`** on every machine that needs access (developer laptops + the machine running the daemon):
```text
192.168.1.206 multica.dev.lan api.multica.dev.lan
```
Replace `192.168.1.206` with any node IP where your Ingress controller's Service is reachable.
- **Local DNS** (Pi-hole, Unbound, etc.): add A records for both hostnames pointing at the cluster Ingress IP.
To use different hostnames, override the matching values at install time (see [Step 4](#step-4--install-the-chart)) — `ingress.frontend.host`, `ingress.backend.host`, plus `backend.config.appUrl`, `backend.config.frontendOrigin`, `backend.config.localUploadBaseUrl`, and `backend.config.googleRedirectUri`.
### Step 2 — Create the namespace
```bash
kubectl create namespace multica
```
### Step 3 — Create the `multica-secrets` Secret
The chart references this Secret by name. Create it once with random values:
```bash
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
Leave optional values empty for now — you can fill them in later (see [Step 5 — Log In](#step-5--log-in)).
### Step 4 — Install the chart
```bash
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica
```
Released chart versions strip the leading `v` from the Git tag. For example, release tag `v0.3.5` publishes chart version `0.3.5`; the chart defaults the backend and frontend image tags to `v0.3.5`.
To override defaults, export the chart values, edit them, and pass them with `-f`:
```bash
helm show values oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> > my-values.yaml
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
helm install multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
When developing from a checkout, use the local chart path instead:
```bash
helm install multica deploy/helm/multica -n multica
```
Watch the pods come up:
```bash
kubectl -n multica get pods -w
```
On a cold cluster the backend can sit `Running` but not `Ready` for a few minutes while it waits on PostgreSQL and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once the backend reports `Ready`, migrations have completed and `/healthz` returns OK:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
Then open http://multica.dev.lan in your browser.
### Step 5 — Log In
The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.config.appEnv`), and there is no fixed verification code by default. Pick one of the following to log in — the same three options as the Docker setup:
- **Recommended (production):** patch the Secret with a real Resend key, then restart the backend:
```bash
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"RESEND_API_KEY":"re_xxx"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
```bash
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
```
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml --set backend.config.appEnv=development
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
`ALLOW_SIGNUP`, `DISABLE_WORKSPACE_CREATION`, and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml` (as `allowSignup`, `disableWorkspaceCreation`, and `googleClientId`). After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads all three from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 6 — Install CLI & Start Daemon
The daemon runs on your local machine, not in the cluster. Install the CLI and an AI agent as in [Step 3](#step-3--install-cli--start-daemon) above, then point the CLI at your Ingress hostnames:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entries from [Step 1](#step-1--point-hostnames-at-the-cluster).
### Updating
To pull the latest images without changing the chart version when your values still use the mutable `latest` image tag:
```bash
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
```
To upgrade to a specific Multica release, upgrade to the matching chart version. The released chart defaults its app images to the matching Git tag:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
If you need to override the app images independently from the chart version, set the image tags in your values file:
```yaml
images:
backend:
tag: v0.2.4
frontend:
tag: v0.2.4
```
Then run the same upgrade command with `-f my-values.yaml`:
```bash
helm upgrade multica oci://ghcr.io/multica-ai/charts/multica \
--version <chart-version> \
-n multica \
-f my-values.yaml
```
To roll back if an upgrade goes sideways:
```bash
helm -n multica rollback multica
```
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically before applying migration `103`, so a clean upgrade is a single `helm upgrade` + backend rollout. If you are still on a pre-MUL-2957 binary or the auto-hook fails, run the standalone backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations. See [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) for the full recovery flow.
### Tearing down
```bash
# Remove the workloads but keep the PVCs and the Secret
helm -n multica uninstall multica
# Wipe everything, including PostgreSQL data and uploads
kubectl delete namespace multica
```
---
## Usage Dashboard Rollup
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`); a fresh self-host install needs no operator action and the bundled `pgvector/pgvector:pg17` image works without changes — you do **not** need to swap it for an image that ships `pg_cron`, register an external cron job, set up a systemd timer, or run a Kubernetes `CronJob`.
Multiple backend replicas are safe: each replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan, but the unique key `(job_name, scope_kind, scope_id, plan_time)` means only one wins each plan. Inspect steady-state operation:
```sql
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
```
Full reference (audit table semantics, advisory lock 4246, the standalone backfill command, flag descriptions, the `v0.3.4 → v0.3.5+` migration auto-hook) lives in [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
> **Upgrading from `v0.3.4` to `v0.3.5+`?** As of MUL-2957 the `migrate up` command runs an idempotent monthly-slice backfill automatically right before applying migration `103`, so the upgrade completes in a single invocation — no operator step required. If you are still on a pre-MUL-2957 binary or the auto-hook fails for an environmental reason, run `backfill_task_usage_hourly` against the same database and re-run the upgrade. See [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup) for the recovery flow.
### Compatibility paths (existing deployments only)
External schedulers — **`pg_cron` registered on the database, an external cron job, a systemd timer, or a Kubernetes `CronJob`** — that call `SELECT rollup_task_usage_hourly()` directly were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler instead. The SQL function holds advisory lock 4246 internally, so the in-process scheduler and any pre-existing external schedule can coexist without ever double-writing the rollup.
If you already have a `pg_cron` job in production, the safe sequence to retire it is:
1. Confirm the in-process scheduler is healthy on at least one backend replica — recent SUCCESS rows should be landing in `sys_cron_executions` for `rollup_task_usage_hourly`:
```sql
SELECT plan_time, status, runner_id, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
AND status = 'SUCCESS'
ORDER BY plan_time DESC
LIMIT 5;
```
2. Once SUCCESS rows are arriving on schedule, unschedule the redundant `pg_cron` entry:
```sql
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
3. Leave the `pg_cron` extension itself installed unless you are sure no other workload depends on it. The bundled `pgvector/pgvector:pg17` image does **not** ship `pg_cron`, so nothing in Multica's default install needs it; uninstalling `pg_cron` from a custom image that other workloads still use is a separate decision.
External cron / systemd timer / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly can be retired the same way — once `sys_cron_executions` shows steady SUCCESS rows from the in-process scheduler, the external job is redundant and can be removed.
## Stopping Services
If you installed via the install script:
@@ -427,8 +175,6 @@ docker compose -f docker-compose.selfhost.yml up -d
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. As of MUL-2957 `migrate up` runs that backfill automatically right before applying `103`, so the upgrade completes in a single invocation. If you are still on a pre-MUL-2957 binary or the auto-hook fails, run `backfill_task_usage_hourly` manually first, then re-run the upgrade. Full instructions in [Advanced Configuration → Usage Dashboard Rollup](SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
---
## Manual Docker Compose Setup

View File

@@ -44,11 +44,9 @@ Use this option when your deployment cannot reach the public internet or you alr
| `SMTP_PORT` | SMTP port | `25` |
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
| `SMTP_PASSWORD` | SMTP password | - |
| `SMTP_TLS` | TLS mode. `implicit` (aliases `smtps`, `ssl`) forces SMTPS on connect; port `465` auto-enables it. Unset / `starttls` upgrades via STARTTLS | `starttls` |
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
| `SMTP_EHLO_NAME` | EHLO/HELO name announced to the relay. Set a real FQDN when a strict relay (e.g. Google Workspace) rejects the default greeting from a public IP | machine hostname |
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is supported and auto-enables implicit TLS; set `SMTP_TLS=implicit` (aliases `smtps`, `ssl`) to force it on a non-standard port.
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
@@ -69,20 +67,8 @@ Changes take effect after restarting the backend / compose stack. The web UI rea
| `ALLOW_SIGNUP` | Set to `false` to disable new user signups on a private instance |
| `ALLOWED_EMAIL_DOMAINS` | Optional comma-separated allowlist of email domains |
| `ALLOWED_EMAILS` | Optional comma-separated allowlist of exact email addresses |
| `DISABLE_WORKSPACE_CREATION` | Set to `true` to make `POST /api/workspaces` return 403 for every caller — users can only join workspaces they were invited to |
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` and `DISABLE_WORKSPACE_CREATION` from `/api/config` at runtime, so no web rebuild is needed.
#### Locking down workspace creation
`ALLOW_SIGNUP=false` blocks new accounts from being created, but it does **not** block an already-signed-in user from creating another workspace via `POST /api/workspaces`. On a self-hosted instance where every issue/repo/agent must be visible to the platform admin, set `DISABLE_WORKSPACE_CREATION=true` to close that gap. The recommended bootstrap sequence is:
1. Start the instance with `DISABLE_WORKSPACE_CREATION=false` (the default).
2. Sign in as the admin and create the shared workspace.
3. Set `DISABLE_WORKSPACE_CREATION=true` and restart the backend. Optionally set `ALLOW_SIGNUP=false` at the same time if you also want to block new account creation.
4. Going forward, additional users join via invitation only — the "Create workspace" affordance is hidden in the UI and any direct API call returns 403.
> Note: setting `ALLOW_SIGNUP=false` blocks **all** new account creation, including users who already have a pending invitation. If you need invited users to be able to sign up but not create their own workspaces, keep `ALLOW_SIGNUP=true` (optionally combined with `ALLOWED_EMAIL_DOMAINS` / `ALLOWED_EMAILS`) and only flip `DISABLE_WORKSPACE_CREATION=true`.
Changes take effect after restarting the backend / compose stack. The web UI reads `ALLOW_SIGNUP` from `/api/config` at runtime, so no web rebuild is needed.
### File Storage (Optional)
@@ -93,9 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `ATTACHMENT_DOWNLOAD_MODE` | Attachment download behavior: `auto` (default), `cloudfront`, `presign`, or `proxy`. Use `proxy` for private buckets behind Docker/VPC-only endpoints such as `http://rustfs:9000` |
| `ATTACHMENT_DOWNLOAD_URL_TTL` | TTL for CloudFront signed URLs and S3 presigned download URLs (default: `30m`) |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
@@ -115,7 +99,7 @@ The `Secure` flag on session cookies is derived automatically from the scheme of
| `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. Governs **both** the HTTP CORS allowlist **and** the WebSocket `Origin` check. A browser origin that isn't listed here (and isn't `localhost`) has its real-time WebSocket upgrade rejected with `403`, so live updates stop working until a manual refresh. |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error` |
### CLI / Daemon
@@ -182,64 +166,6 @@ The Docker Compose setup runs migrations automatically. If you need to run them
cd server && go run ./cmd/migrate up
```
## Usage Dashboard Rollup
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. As of MUL-2957 the backend runs this rollup **in-process** on every replica via a DB-backed scheduler (`sys_cron_executions`); a fresh self-host install needs no operator action — the bundled `pgvector/pgvector:pg17` image works without changes.
### How the in-process scheduler works
Every backend replica ticks every 30 seconds and tries to claim the current 5-minute UTC plan in `sys_cron_executions`. The unique key `(job_name, scope_kind, scope_id, plan_time)` makes the claim a single-winner contest across all replicas, so multi-instance deployments do not double-write. The handler then calls `SELECT rollup_task_usage_hourly()`; the SQL function holds advisory lock `4246` internally, so a stray `pg_cron` job or manual call can run alongside the scheduler without ever colliding on the rollup itself. Inspect the audit table for steady-state operation:
```sql
SELECT plan_time, status, attempt, runner_id,
error_code, error_msg, started_at, finished_at
FROM sys_cron_executions
WHERE job_name = 'rollup_task_usage_hourly'
ORDER BY plan_time DESC
LIMIT 20;
```
### Compatibility — existing `pg_cron` registrations
If you previously registered the rollup as a `pg_cron` job (`SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', …)`), it is safe to leave it in place: advisory lock 4246 prevents double-writes, and the loser path no-ops cleanly. To drop the redundant entry once the in-process scheduler is up:
```sql
SELECT cron.unschedule('rollup_task_usage_hourly')
FROM cron.job WHERE jobname = 'rollup_task_usage_hourly';
```
External cron / systemd / Kubernetes `CronJob` setups that call `SELECT rollup_task_usage_hourly()` directly are also still valid — they were the only option before MUL-2957 and remain a supported compatibility path. They are no longer the recommended setup; new deployments should rely on the in-process scheduler.
### Standalone backfill command
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was claimed for the first time — most commonly when upgrading from `v0.3.4` to `v0.3.5+` on a database that already has months of usage — you can run `backfill_task_usage_hourly` to seed historical buckets:
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the in-process scheduler uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with the scheduler (advisory lock 4246 serialises them). Flags:
| Flag | Description |
|---|---|
| `--sleep-between-slices` | Pause between monthly slices to throttle read pressure on busy databases (e.g. `2s`). Recommended on production DBs with years of history. |
| `--months-back N` | Only backfill the last N months. **Requires `--force-partial`** because the watermark still advances past the skipped older buckets — those are permanently abandoned. |
| `--dry-run` | Log slices that would be processed without writing anything. |
After backfill completes, the rollup-state watermark is stamped to `now() - 5 minutes`, so the first scheduled tick after backfill does not redo history.
### `v0.3.4 → v0.3.5+` upgrade order
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. As of MUL-2957 the migrate command runs an idempotent monthly-slice backfill (under advisory lock 4246) **automatically** immediately before applying migration `103`, so v0.3.4 → v0.3.5+ upgrades complete in a single `migrate up` invocation — no operator step is required.
If you are upgrading from a binary that pre-dates MUL-2957 (or the auto-hook fails for an environmental reason), recovery is the manual path: run `backfill_task_usage_hourly` against the database, then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and the in-process scheduler picks up new buckets from the first tick.
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:
@@ -293,8 +219,6 @@ multica.example.com {
}
```
> Even on a single domain, set `FRONTEND_ORIGIN` / `CORS_ALLOWED_ORIGINS` to that public origin (e.g. `https://multica.example.com`) on the backend. The backend's default origin allowlist is `localhost` only, so without this it rejects the WebSocket upgrade from the public URL with `403` and real-time updates silently stop working. See [LAN / Non-localhost Access](#lan--non-localhost-access).
**Separate-domain layout** — frontend and backend on different hostnames:
```
@@ -414,8 +338,6 @@ HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js
`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.
**Also required: allowlist the browser origin.** The two options above fix the WebSocket *upgrade proxying*, but a second, independent setting gates the connection: the backend validates the WebSocket `Origin` header against an allowlist that defaults to `localhost` only. When you open Multica from any other origin — a LAN IP **or a public domain behind a reverse proxy** — set `CORS_ALLOWED_ORIGINS` (or `FRONTEND_ORIGIN`) on the backend to that exact origin and restart, exactly as shown under [LAN / Non-localhost Access](#lan--non-localhost-access) above. Otherwise the upgrade is refused with `403`: the backend logs `websocket: request origin not allowed by Upgrader.CheckOrigin` and the browser console loops `disconnected, reconnecting in 3s`, while HTTP requests (and manual page refreshes) keep working because they are same-origin to the page. The single value covers both HTTP CORS and the WebSocket origin check.
> **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`.
## Health Check

View File

@@ -23,7 +23,7 @@ export default defineConfig({
alias: {
"@": resolve("src/renderer/src"),
},
dedupe: ["react", "react-dom", "@tanstack/react-query"],
dedupe: ["react", "react-dom"],
},
},
});

View File

@@ -45,21 +45,15 @@
"@multica/core": "workspace:*",
"@multica/ui": "workspace:*",
"@multica/views": "workspace:*",
"@tanstack/react-query": "catalog:",
"electron-updater": "^6.8.3",
"fix-path": "^5.0.0",
"lucide-react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-router-dom": "^7.6.0",
"shadcn": "^4.1.0",
"sonner": "^2.0.7",
"tw-animate-css": "^1.4.0",
"zustand": "catalog:"
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^2.0.0",
"@multica/eslint-config": "workspace:*",
"@multica/tsconfig": "workspace:*",
"@tailwindcss/vite": "^4",
"@testing-library/jest-dom": "catalog:",
@@ -71,8 +65,9 @@
"electron": "^39.2.6",
"electron-builder": "^26.0.12",
"electron-vite": "^5.0.0",
"globals": "^16.0.0",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"tailwindcss": "^4",
"typescript": "catalog:",
"vitest": "catalog:"

View File

@@ -1,221 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
// Capture every MenuItem the SUT constructs so each test can assert
// on the menu that would appear at popup time without booting an
// actual Electron window. State is created via `vi.hoisted` because
// `vi.mock` factories are hoisted above all top-level statements; a
// plain `const` would be a TDZ ReferenceError when the factory runs.
type CapturedMenuItem = {
label?: string;
role?: string;
type?: string;
click?: () => void;
};
const ctx = vi.hoisted(() => ({
capturedItems: [] as CapturedMenuItem[][],
browserWindowFromWebContents: vi.fn(),
popupSpy: vi.fn(),
clipboardWriteText: vi.fn(),
openExternalSpy: vi.fn().mockResolvedValue(undefined),
preferredLanguagesRef: { current: ["en-US"] as string[] },
}));
vi.mock("electron", () => {
class MockMenu {
items: CapturedMenuItem[] = [];
constructor() {
ctx.capturedItems.push(this.items);
}
append(item: CapturedMenuItem) {
this.items.push(item);
}
popup(opts: unknown) {
ctx.popupSpy(opts);
}
}
class MockMenuItem {
label?: string;
role?: string;
type?: string;
click?: () => void;
constructor(opts: CapturedMenuItem) {
Object.assign(this, opts);
}
}
return {
BrowserWindow: { fromWebContents: ctx.browserWindowFromWebContents },
Menu: MockMenu,
MenuItem: MockMenuItem,
app: {
getPreferredSystemLanguages: () => ctx.preferredLanguagesRef.current,
},
clipboard: { writeText: ctx.clipboardWriteText },
shell: { openExternal: ctx.openExternalSpy },
};
});
import { installContextMenu } from "./context-menu";
type ContextMenuParams = {
selectionText: string;
isEditable: boolean;
linkURL: string;
editFlags: {
canCut: boolean;
canCopy: boolean;
canPaste: boolean;
canSelectAll: boolean;
};
};
type Listener = (event: unknown, params: ContextMenuParams) => void;
// Tiny WebContents stub — we only need the `.on("context-menu", ...)`
// hook the SUT installs and a way to fire it back at our own listener
// list. Everything else (clipboard, link opening, label resolution) is
// mocked above.
function makeWebContents() {
const handlers: Listener[] = [];
return {
on(event: string, fn: Listener) {
if (event === "context-menu") handlers.push(fn);
},
fire(params: ContextMenuParams) {
for (const h of handlers) h({}, params);
},
};
}
const baseEditFlags = {
canCut: false,
canCopy: false,
canPaste: false,
canSelectAll: false,
};
describe("installContextMenu — link items", () => {
beforeEach(() => {
ctx.capturedItems.length = 0;
ctx.popupSpy.mockClear();
ctx.clipboardWriteText.mockClear();
ctx.openExternalSpy.mockClear();
ctx.browserWindowFromWebContents.mockReset();
ctx.preferredLanguagesRef.current = ["en-US"];
});
it("adds 'Open Link in Browser' and 'Copy Link Address' when right-clicking an http(s) link", () => {
// The link case is the one this test file is here to cover —
// before MUL-3083 follow-up, right-clicking an <a> in the
// renderer only surfaced 'copy' (when the user happened to have
// text selected) and gave no way to open the URL externally.
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire({
...baseSelection({ linkURL: "https://multica.ai/welcome" }),
});
const labels = lastMenuLabels();
expect(labels).toContain("Open Link in Browser");
expect(labels).toContain("Copy Link Address");
// The two click handlers must route to the existing
// openExternalSafely allowlist + clipboard.writeText.
invokeByLabel("Open Link in Browser");
expect(ctx.openExternalSpy).toHaveBeenCalledWith("https://multica.ai/welcome");
invokeByLabel("Copy Link Address");
expect(ctx.clipboardWriteText).toHaveBeenCalledWith(
"https://multica.ai/welcome",
);
expect(ctx.popupSpy).toHaveBeenCalledTimes(1);
});
it("does NOT add link items when the cursor is over a non-http(s) URL", () => {
// Only http(s) links are surfaced — we don't promise anything for
// mailto:, javascript:, custom app schemes, etc. Surfacing them
// would shell out via openExternalSafely (which would block the
// call anyway) or write a non-URL string to the clipboard, both
// of which violate user expectations for a "link" item.
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "javascript:alert(1)" }));
const labels = lastMenuLabelsOrEmpty();
expect(labels).not.toContain("Open Link in Browser");
expect(labels).not.toContain("Copy Link Address");
});
it("does NOT add link items when there is no link under the cursor", () => {
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire({
selectionText: "hello",
isEditable: false,
linkURL: "",
editFlags: { ...baseEditFlags, canCopy: true },
});
const labels = lastMenuLabelsOrEmpty();
expect(labels).not.toContain("Open Link in Browser");
// Selection-only context still surfaces copy as before — guards
// against a regression where adding the link branch broke the
// base path.
expect(menuItemRoles()).toContain("copy");
});
it("uses zh-Hans labels when the OS preferred language is Chinese", () => {
// Locale fallback is intentionally permissive: every zh-* variant
// routes to zh-Hans so users on zh-CN / zh-TW / zh-HK still see
// Chinese rather than dropping to English. The renderer ships only
// zh-Hans translations, so this matches the rest of the app.
ctx.preferredLanguagesRef.current = ["zh-CN"];
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
expect(lastMenuLabels()).toContain("在浏览器中打开链接");
expect(lastMenuLabels()).toContain("复制链接地址");
});
it("falls back to English when the OS preferred language is something we don't ship", () => {
ctx.preferredLanguagesRef.current = ["fr-FR"];
const wc = makeWebContents();
installContextMenu(wc as never);
wc.fire(baseSelection({ linkURL: "https://multica.ai" }));
expect(lastMenuLabels()).toContain("Open Link in Browser");
});
});
// --- helpers ---
function baseSelection(over: Partial<ContextMenuParams>): ContextMenuParams {
return {
selectionText: "",
isEditable: false,
linkURL: "",
editFlags: { ...baseEditFlags },
...over,
};
}
function lastMenu(): CapturedMenuItem[] {
const last = ctx.capturedItems[ctx.capturedItems.length - 1];
if (!last) throw new Error("no menu was constructed");
return last;
}
function lastMenuLabelsOrEmpty(): string[] {
const last = ctx.capturedItems[ctx.capturedItems.length - 1] ?? [];
return last.map((i) => i.label ?? "");
}
function lastMenuLabels(): string[] {
return lastMenu().map((i) => i.label ?? "");
}
function menuItemRoles(): string[] {
return lastMenu().map((i) => i.role ?? "");
}
function invokeByLabel(label: string): void {
const item = lastMenu().find((i) => i.label === label);
if (!item) throw new Error(`menu item not found: ${label}`);
item.click?.();
}

View File

@@ -1,38 +1,12 @@
import {
BrowserWindow,
Menu,
MenuItem,
app,
clipboard,
type WebContents,
} from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
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.
//
// Custom (non-role) link items below are NOT auto-localized by Electron —
// roles like "copy" pull labels from the OS, but a custom MenuItem only
// shows the `label` you give it. We translate by OS-preferred language so
// the link items at least track Chinese / Japanese / Korean speakers
// alongside the English default; everything else falls through to English,
// which matches Chrome's behavior on those locales without app-level
// translation files.
export function installContextMenu(webContents: WebContents): void {
webContents.on("context-menu", (_event, params) => {
const { editFlags, selectionText, isEditable, linkURL } = params;
const { editFlags, selectionText, isEditable } = params;
const hasSelection = selectionText.trim().length > 0;
// params.linkURL is the resolved absolute URL of the anchor under the
// cursor; Electron normalizes relative hrefs against the page URL for
// us, so we only need to gate on the http(s) scheme allowlist
// (mirrors openExternalSafely + the renderer's <a> usage). Empty for
// non-link right-clicks; other schemes (mailto:, javascript:, custom
// app schemes) are intentionally not surfaced — opening them via
// shell.openExternal would route through the OS handler and is
// outside what this menu promises.
const linkIsHttpUrl = !!linkURL && isSafeExternalHttpUrl(linkURL);
const labels = pickLabels();
const menu = new Menu();
@@ -52,87 +26,8 @@ export function installContextMenu(webContents: WebContents): void {
menu.append(new MenuItem({ role: "selectAll" }));
}
// Link items — only when the cursor is over an actual http(s) <a>.
// Without these the renderer's <a target="_blank"> gives users no
// standard right-click affordance ("Open in new window", "Copy link
// address"); the default click handler does forward to
// setWindowOpenHandler → openExternalSafely, but discoverability via
// the keyboard / mouse context menu was missing.
if (linkIsHttpUrl) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(
new MenuItem({
label: labels.openLink,
click: () => {
// openExternalSafely re-validates the scheme — defense in
// depth in case Electron ever surfaces a non-http linkURL
// we forgot to filter at this layer.
void openExternalSafely(linkURL);
},
}),
);
menu.append(
new MenuItem({
label: labels.copyLinkAddress,
click: () => {
clipboard.writeText(linkURL);
},
}),
);
}
if (menu.items.length === 0) return;
const window = BrowserWindow.fromWebContents(webContents) ?? undefined;
menu.popup({ window });
});
}
// Labels for the two link-related menu items in the user's OS-preferred
// language, with English as the fallback. Kept inline because the main
// process has no shared i18n loader (the renderer's i18next is per-window
// and not reachable from here), and pulling one in for two strings would
// be more rope than payload. Matches the four locales the renderer ships.
type ContextMenuLabels = {
openLink: string;
copyLinkAddress: string;
};
const labelsByLocale: Record<string, ContextMenuLabels> = {
en: {
openLink: "Open Link in Browser",
copyLinkAddress: "Copy Link Address",
},
"zh-Hans": {
openLink: "在浏览器中打开链接",
copyLinkAddress: "复制链接地址",
},
ja: {
openLink: "ブラウザでリンクを開く",
copyLinkAddress: "リンクのアドレスをコピー",
},
ko: {
openLink: "브라우저에서 링크 열기",
copyLinkAddress: "링크 주소 복사",
},
};
// pickLabels resolves the OS-preferred language to one of the four
// locales we ship copy for. We say "Open Link in Browser" rather than
// "Open Link in New Window" because the link is opened via
// shell.openExternal — it lands in the user's default browser, not in
// another Multica window — so the wording matches what actually
// happens.
function pickLabels(): ContextMenuLabels {
const preferred = app.getPreferredSystemLanguages()[0]?.toLowerCase() ?? "";
if (preferred.startsWith("zh")) {
// All Chinese variants get the Simplified copy — Multica only
// ships zh-Hans, and zh-Hant users falling through to en would be
// worse than reading Simplified Chinese.
return labelsByLocale["zh-Hans"];
}
if (preferred.startsWith("ja")) return labelsByLocale.ja;
if (preferred.startsWith("ko")) return labelsByLocale.ko;
return labelsByLocale.en;
}

View File

@@ -1,56 +0,0 @@
import { describe, expect, it } from "vitest";
import { classifyAuthProbe, isAuthStatusError } from "./daemon-auth-probe";
describe("classifyAuthProbe", () => {
it("treats a 401 as expired login", () => {
expect(classifyAuthProbe({ status: 401 })).toBe("auth_expired");
});
it("treats a missing token as expired login", () => {
expect(classifyAuthProbe({ noToken: true })).toBe("auth_expired");
});
it("treats a 2xx as a valid token (failure is non-auth)", () => {
expect(classifyAuthProbe({ status: 200 })).toBe("ok");
expect(classifyAuthProbe({ status: 204 })).toBe("ok");
});
// The headline guard: a network failure must never be reported as an auth
// problem — the daemon is just as unreachable for non-auth reasons.
it("does NOT classify a network error as expired login", () => {
expect(classifyAuthProbe({ networkError: true })).toBe("unknown");
});
it("leaves 5xx and other statuses inconclusive", () => {
expect(classifyAuthProbe({ status: 500 })).toBe("unknown");
expect(classifyAuthProbe({ status: 503 })).toBe("unknown");
expect(classifyAuthProbe({ status: 403 })).toBe("unknown");
});
it("is inconclusive when nothing is known", () => {
expect(classifyAuthProbe({})).toBe("unknown");
});
});
describe("isAuthStatusError", () => {
it("is true only for a 401-tagged error (session token is dead)", () => {
expect(isAuthStatusError(Object.assign(new Error("x"), { status: 401 }))).toBe(
true,
);
});
// The reviewer's must-fix: transient failures must NOT be treated as auth
// failures (which would log the user out). A 5xx mint, a thrown fetch, a
// file-write error — none carry status 401.
it("is false for transient / non-401 failures", () => {
expect(isAuthStatusError(Object.assign(new Error("x"), { status: 503 }))).toBe(
false,
);
expect(isAuthStatusError(new Error("network down"))).toBe(false);
expect(isAuthStatusError(new Error("EACCES: write failed"))).toBe(false);
expect(isAuthStatusError(undefined)).toBe(false);
expect(isAuthStatusError(null)).toBe(false);
expect(isAuthStatusError("401")).toBe(false);
});
});

View File

@@ -1,58 +0,0 @@
/**
* Pure classification for the daemon auth probe. Kept free of Electron imports
* so it can be unit-tested in jsdom.
*
* When the local daemon fails to reach "running" shortly after a start, the
* main process probes the daemon's token against the backend (GET /api/me) to
* tell "the daemon can't authenticate" apart from "the daemon is slow / the
* network is down / it crashed for another reason". Misclassifying a network
* blip as an auth failure would be worse than the original silent-Starting bug,
* so the rules below are deliberately conservative: only an explicit 401 (or a
* missing credential) is treated as auth-expired.
*/
export interface AuthProbeOutcome {
/** HTTP status code returned by the probe request, if one completed. */
status?: number;
/** The daemon profile has no token at all — there is nothing to validate. */
noToken?: boolean;
/** The probe request threw (timeout, connection refused, DNS, TLS). */
networkError?: boolean;
}
export type AuthProbeResult = "auth_expired" | "ok" | "unknown";
/**
* Whether an error represents a genuine auth rejection (HTTP 401) as opposed to
* a transient failure (5xx, network, local I/O). Used by the re-authenticate
* flow so that only a real 401 — the session token itself is dead — forces a
* full re-login; transient failures keep the user signed in to retry.
*
* `mintPat` attaches the response status to the error it throws, so a 401
* surfaces here as `{ status: 401 }`. Everything else (no status, 5xx, a thrown
* fetch, a file-write error) is treated as non-auth.
*/
export function isAuthStatusError(err: unknown): boolean {
return (
typeof err === "object" &&
err !== null &&
(err as { status?: unknown }).status === 401
);
}
export function classifyAuthProbe(outcome: AuthProbeOutcome): AuthProbeResult {
// No credential to validate → the user must sign in.
if (outcome.noToken) return "auth_expired";
// Couldn't reach the server → this is a network problem, not an auth one.
// Stay "unknown" so the caller keeps showing "starting"/"stopped" instead of
// wrongly prompting for re-login.
if (outcome.networkError) return "unknown";
// The server explicitly rejected the token.
if (outcome.status === 401) return "auth_expired";
// The token is accepted — the daemon is failing for some other reason.
if (outcome.status !== undefined && outcome.status >= 200 && outcome.status < 300) {
return "ok";
}
// 5xx and everything else are inconclusive about the token's validity.
return "unknown";
}

View File

@@ -15,39 +15,16 @@ import {
type StatsListener,
} from "fs";
import { join } from "path";
import { homedir, hostname } from "os";
import { homedir } from "os";
import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
import { daemonStatusAlive } from "../shared/daemon-types";
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
import { decideVersionAction } from "./version-decision";
import {
daemonLifecycleUnreachable,
isDaemonExternallyManaged,
normalizeHostOS,
} from "./daemon-os";
import {
classifyAuthProbe,
isAuthStatusError,
type AuthProbeResult,
} from "./daemon-auth-probe";
const DEFAULT_HEALTH_PORT = 19514;
const POLL_INTERVAL_MS = 5_000;
const PREFS_PATH = join(homedir(), ".multica", "desktop_prefs.json");
const LOG_TAIL_RETRY_MS = 2_000;
const LOG_TAIL_MAX_RETRIES = 5;
// How long a start may sit in "starting" (with no /health) before we probe the
// token to find out whether login expired. The daemon's own startup can legitimately
// take a while (it renews the PAT and lists workspaces before serving /health), so we
// wait past the common case to avoid probing healthy-but-slow starts.
const AUTH_PROBE_GRACE_MS = 10_000;
// `multica daemon start` blocks until the daemon reports ready, polling /health
// for up to its own startup timeout (45s in server/cmd/multica/cmd_daemon.go) to
// cover cold-start agent-version detection. This execFile timeout MUST stay
// above that — otherwise Electron kills the CLI supervisor mid-startup and a
// healthy-but-slow start is misreported as a failure (the detached daemon child
// keeps running, so the UI flashes "stopped" then "running").
const DAEMON_START_EXEC_TIMEOUT_MS = 60_000;
const DEFAULT_PREFS: DaemonPrefs = { autoStart: true, autoStop: false };
@@ -71,15 +48,6 @@ let pendingVersionRestart = false;
let targetApiBaseUrl: string | null = null;
let activeProfile: ActiveProfile | null = null;
// Auth-probe state for the current start attempt. When a start fails to reach
// "running", we probe the daemon's token once (after AUTH_PROBE_GRACE_MS) to
// decide whether the cause is an expired/invalid login. `authExpired` is sticky
// until the next start attempt or a successful /health, so the UI keeps showing
// the re-login prompt instead of flapping back to "starting". See #3512.
let startingSince: number | null = null;
let authProbeDone = false;
let authExpired = false;
// Serialize all writes to any profile config file. Multiple paths
// (syncToken, resolveActiveProfile, clearToken, watch/unwatch handlers)
// may try to write concurrently; chaining them avoids interleaved writes
@@ -166,8 +134,6 @@ function sendStatus(status: DaemonStatus): void {
interface HealthPayload {
status?: string;
pid?: number;
/** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */
os?: string;
uptime?: string;
daemon_id?: string;
device_name?: string;
@@ -195,36 +161,6 @@ async function fetchHealthAtPort(
}
}
/**
* Validates the daemon profile's token against the backend to find out whether
* a stuck start is an auth problem. Hits the same endpoint `multica auth status`
* uses (GET /api/me) with the exact token the daemon loads from config.json, so
* the verdict matches what the daemon itself would get from the server.
*
* Only the HTTP status is inspected (never the body) so a future change to the
* /api/me response shape can't break this — a 401 means the token is rejected,
* a 2xx means it's fine, and a thrown request means the network is the problem,
* not auth. See classifyAuthProbe for the full rule set.
*/
async function probeTokenValidity(profile: string): Promise<AuthProbeResult> {
if (!targetApiBaseUrl) return "unknown";
const cfg = await readProfileConfig(profile);
const token = typeof cfg.token === "string" ? cfg.token : "";
if (!token) return classifyAuthProbe({ noToken: true });
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 4_000);
const res = await fetch(`${targetApiBaseUrl.replace(/\/+$/, "")}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
clearTimeout(timeout);
return classifyAuthProbe({ status: res.status });
} catch {
return classifyAuthProbe({ networkError: true });
}
}
// Desktop owns a dedicated CLI profile named after the target API host, so it
// never reads or writes the user's hand-configured profiles. Profile dir:
// ~/.multica/profiles/desktop-<host>/
@@ -313,57 +249,12 @@ async function fetchHealth(): Promise<DaemonStatus> {
const data = await fetchHealthAtPort(active.port);
if (!data || data.status !== "running") {
// A start that never reaches "running" is the symptom; an expired/invalid
// login is the most common cause and the one with no other signal (the
// daemon exits before it can serve /health, so we can't read the reason
// from it). Probe the token once per attempt, after a grace period, to
// surface a re-login prompt instead of spinning on "starting" forever.
if (
currentState === "starting" &&
!authExpired &&
!authProbeDone &&
startingSince !== null &&
Date.now() - startingSince >= AUTH_PROBE_GRACE_MS
) {
authProbeDone = true;
if ((await probeTokenValidity(active.name)) === "auth_expired") {
authExpired = true;
}
}
// Sticky: once login is known-expired, keep reporting it (even after
// currentState flips away from "starting") until the next start attempt or
// a successful /health clears the flag.
if (authExpired) {
return { state: "auth_expired", profile: active.name };
}
// The daemon binds /health before preflight finishes and self-reports
// "starting" until it's ready. Trust that over our own currentState, so a
// daemon booting on its own — or started via the CLI — surfaces as
// "starting" instead of "stopped".
if (data?.status === "starting") {
return { state: "starting", profile: active.name };
}
return {
state: currentState === "starting" ? "starting" : "stopped",
profile: active.name,
};
}
// A live, authenticated daemon clears any prior auth-failure verdict so the
// re-login prompt disappears once the user reconnects.
authExpired = false;
startingSince = null;
// A running daemon whose OS differs from this host's is one we can't drive
// via the native lifecycle CLI (e.g. Linux-in-WSL2 behind a Windows desktop,
// reachable only over localhost forwarding). Surface it so the UI disables
// the auto-start/auto-stop toggles instead of letting them silently no-op,
// and so before-quit skips a stop that would never land. See #3916.
const externallyManaged = isDaemonExternallyManaged(
data.os,
normalizeHostOS(process.platform),
);
// Safety: if we have a target URL and the daemon on our port reports a
// different server_url, it's not "our" daemon — drop it and re-resolve.
if (
@@ -387,7 +278,6 @@ async function fetchHealth(): Promise<DaemonStatus> {
: 0,
profile: active.name,
serverUrl: data.server_url,
externallyManaged,
};
}
@@ -574,15 +464,6 @@ async function ensureRunningDaemonVersionMatches(): Promise<
> {
const active = await ensureActiveProfile();
const running = await fetchHealthAtPort(active.port);
// Don't try to version-match a daemon we can't restart (e.g. WSL2). Treat it
// as up-to-date — restartDaemon would no-op anyway, and skipping here avoids
// a misleading "restarting daemon" log on every auto-start. #3916.
if (isDaemonExternallyManaged(running?.os, normalizeHostOS(process.platform))) {
pendingVersionRestart = false;
return "ok";
}
const bundled = await getCliBinaryVersion();
const action = decideVersionAction(bundled, running);
@@ -634,13 +515,7 @@ async function mintPat(jwt: string): Promise<string> {
});
if (!res.ok) {
const body = await res.text().catch(() => "");
// Attach the status so callers can tell a genuine auth rejection (401 — the
// session token is dead) apart from a transient failure (5xx, etc.) without
// string-matching the message.
throw Object.assign(
new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`),
{ status: res.status },
);
throw new Error(`mint PAT failed: ${res.status} ${res.statusText} ${body}`);
}
const data = (await res.json()) as { token?: unknown };
if (typeof data.token !== "string" || !data.token.startsWith("mul_")) {
@@ -705,10 +580,7 @@ async function syncToken(
if (userChanged) {
try {
const existing = await fetchHealthAtPort(active.port);
if (daemonStatusAlive(existing?.status)) {
// Restart whether it's "running" or still "starting" — a booting daemon
// already loaded the old token at startup, so it must be restarted to
// pick up the rotated credentials.
if (existing?.status === "running") {
console.log(
"[daemon] user switched — restarting daemon with new credentials",
);
@@ -748,52 +620,6 @@ async function clearToken(): Promise<void> {
await removeProfileUserId(active.name);
}
// Result of a user-initiated daemon re-authentication. The distinction matters:
// only `session_invalid` justifies signing the user out of the whole app; a
// `transient` failure must keep them logged in so they can retry.
export type ReauthResult =
| { ok: true }
| { ok: false; reason: "session_invalid" }
| { ok: false; reason: "transient"; message: string };
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
/**
* Recover the local daemon from the "auth_expired" state. Drops the stale
* cached PAT, mints a fresh one from the current session token, and restarts
* the daemon so it loads the new credential.
*
* Failures are classified rather than collapsed: a 401 from the mint means the
* session token itself is dead (`session_invalid` → the renderer drives a full
* re-login); anything else — mint 5xx, a network blip, a config write error, a
* restart hiccup — is `transient`, leaving the user signed in so they can retry.
* This mirrors the conservative classification the startup probe already uses.
*/
async function reauthenticate(
token: string,
userId: string,
): Promise<ReauthResult> {
try {
await clearToken();
// syncToken mints a fresh PAT because clearToken just removed any cache.
await syncToken(token, userId);
} catch (err) {
if (isAuthStatusError(err)) return { ok: false, reason: "session_invalid" };
return { ok: false, reason: "transient", message: errorMessage(err) };
}
const restart = await restartDaemon();
if (!restart.success) {
return {
ok: false,
reason: "transient",
message: restart.error ?? "failed to restart daemon",
};
}
return { ok: true };
}
async function withGuard<T>(fn: () => Promise<T>): Promise<T | { success: false; error: string }> {
if (operationInProgress) {
return { success: false, error: "Another daemon operation is in progress" };
@@ -825,19 +651,12 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
const active = await ensureActiveProfile();
const existing = await fetchHealthAtPort(active.port);
if (daemonStatusAlive(existing?.status)) {
// A daemon is already up ("running") or booting ("starting") on this port —
// don't spawn a second one (the CLI rejects that as "already running").
// Let polling track it through to "running".
if (existing?.status === "running") {
pollOnce();
return { success: true };
}
currentState = "starting";
// Begin a fresh auth-probe window for this attempt.
startingSince = Date.now();
authProbeDone = false;
authExpired = false;
sendStatus({ state: "starting" });
const args = ["daemon", "start", ...profileArgs(active)];
@@ -846,7 +665,7 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
execFile(
bin,
args,
{ timeout: DAEMON_START_EXEC_TIMEOUT_MS, env: desktopSpawnEnv() },
{ timeout: 20_000, env: desktopSpawnEnv() },
(err) => {
if (err) {
currentState = "stopped";
@@ -864,40 +683,12 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
});
}
/**
* Fresh boundary preflight for stop/restart: read the active profile's CURRENT
* /health and decide whether the daemon runs somewhere the app can't drive
* (WSL2 etc.). Done per call rather than off the poll cache, so a lifecycle op
* never shells out to a CLI that can't reach the daemon's process — even on
* paths that didn't just poll (e.g. restart-on-user-switch in syncToken, which
* calls restartDaemon directly). See #3916.
*/
async function lifecycleBlockedByForeignDaemon(): Promise<boolean> {
const active = await ensureActiveProfile();
return daemonLifecycleUnreachable(
async () => (await fetchHealthAtPort(active.port))?.os,
normalizeHostOS(process.platform),
);
}
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
// Central lifecycle guard: a daemon running in an environment we can't drive
// (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the
// native CLI — it would act on the host process namespace and no-op, while
// still flipping our state to "stopped". Bail as a successful no-op so every
// caller (logout, quit, restart, the Runtime card) is covered in one place
// rather than each remembering to check. Preflighted against live /health so
// it holds even when no poll ran first. #3916.
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
const bin = await resolveCliBinary();
if (!bin) return { success: false, error: "multica CLI is not installed" };
const active = await ensureActiveProfile();
currentState = "stopping";
// An explicit stop is a clean reset — drop any pending auth-failure verdict.
authExpired = false;
startingSince = null;
sendStatus({ state: "stopping" });
const args = ["daemon", "stop", ...profileArgs(active)];
@@ -916,11 +707,6 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
}
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
// Same central, live-preflighted guard as stopDaemon: we can neither stop nor
// start a daemon we don't manage, so don't try (user-switch, reauth,
// first-workspace, and any future restart caller all route through here).
// #3916.
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
const stopResult = await stopDaemon();
if (!stopResult.success) return stopResult;
return startDaemon();
@@ -1078,20 +864,11 @@ export function setupDaemonManager(
ipcMain.handle("daemon:stop", () => withGuard(() => stopDaemon()));
ipcMain.handle("daemon:restart", () => withGuard(() => restartDaemon()));
ipcMain.handle("daemon:get-status", () => fetchHealth());
// The host's OS name, available regardless of daemon state. The Runtimes
// page uses it as a fallback identity for "this machine" when no
// app-managed daemon is reporting a device name (e.g. the daemon runs
// out-of-band in WSL2). See desktop-runtimes-page.tsx.
ipcMain.handle("daemon:get-host-name", () => hostname());
ipcMain.handle(
"daemon:sync-token",
(_event, token: string, userId: string) => syncToken(token, userId),
);
ipcMain.handle("daemon:clear-token", () => clearToken());
ipcMain.handle(
"daemon:reauthenticate",
(_event, token: string, userId: string) => reauthenticate(token, userId),
);
ipcMain.handle("daemon:is-cli-installed", async () => {
const bin = await resolveCliBinary();
return bin !== null;
@@ -1168,8 +945,6 @@ export function setupDaemonManager(
isQuitting = true;
event.preventDefault();
try {
// stopDaemon no-ops for an externally-managed daemon (WSL2 etc.), so
// this is safe and instant in that case — the guard lives there. #3916
await stopDaemon();
} catch {
// Best-effort stop on quit

View File

@@ -1,80 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
daemonLifecycleUnreachable,
isDaemonExternallyManaged,
normalizeHostOS,
} from "./daemon-os";
describe("normalizeHostOS", () => {
it("maps win32 to the GOOS spelling 'windows'", () => {
expect(normalizeHostOS("win32")).toBe("windows");
});
it("passes darwin and linux through unchanged (already GOOS spellings)", () => {
expect(normalizeHostOS("darwin")).toBe("darwin");
expect(normalizeHostOS("linux")).toBe("linux");
});
});
describe("isDaemonExternallyManaged", () => {
it("flags a Linux (WSL2) daemon behind a Windows desktop — the #3916 case", () => {
expect(isDaemonExternallyManaged("linux", normalizeHostOS("win32"))).toBe(
true,
);
});
// These three are the "不误伤" guarantees: a native daemon on each platform
// must keep its auto-start/auto-stop toggles.
it("does NOT flag a native Windows daemon under a Windows desktop", () => {
expect(isDaemonExternallyManaged("windows", normalizeHostOS("win32"))).toBe(
false,
);
});
it("does NOT flag a native macOS daemon under a macOS desktop", () => {
expect(isDaemonExternallyManaged("darwin", normalizeHostOS("darwin"))).toBe(
false,
);
});
it("does NOT flag a native Linux daemon under a Linux desktop", () => {
expect(isDaemonExternallyManaged("linux", normalizeHostOS("linux"))).toBe(
false,
);
});
// Fail safe: an older daemon that predates the `os` field reports nothing.
// Hiding a toggle on a guess would 误伤, so unknown OS = treat as manageable.
it("fails safe to false when the daemon reports no OS", () => {
expect(isDaemonExternallyManaged(undefined, "windows")).toBe(false);
expect(isDaemonExternallyManaged("", "windows")).toBe(false);
});
});
// The stop/restart lifecycle boundary funnels through this. It must read the
// daemon's LIVE OS (not a cached poll value), so a restart on a path that
// didn't just poll — e.g. user-switch — still can't shell out at a WSL2 daemon.
describe("daemonLifecycleUnreachable", () => {
it("consults the live OS reader and blocks a foreign-OS (WSL2) daemon", async () => {
const readDaemonOS = vi.fn().mockResolvedValue("linux");
expect(await daemonLifecycleUnreachable(readDaemonOS, "windows")).toBe(true);
// Proves the decision came from a fresh read, not a stale cache.
expect(readDaemonOS).toHaveBeenCalledTimes(1);
});
it("allows a native daemon whose live OS matches the host", async () => {
expect(
await daemonLifecycleUnreachable(async () => "windows", "windows"),
).toBe(false);
expect(
await daemonLifecycleUnreachable(async () => "darwin", "darwin"),
).toBe(false);
});
it("fails safe to false when the live OS is unknown (older daemon / none running)", async () => {
expect(
await daemonLifecycleUnreachable(async () => undefined, "windows"),
).toBe(false);
});
});

View File

@@ -1,67 +0,0 @@
/**
* Detecting a daemon the desktop app can't manage.
*
* The app reads daemon liveness over HTTP at 127.0.0.1:{port}/health, but it
* starts/stops the daemon by shelling out to the bundled native CLI, which acts
* on the *host* OS process namespace. On Windows with the daemon running inside
* WSL2, /health is reachable via localhost forwarding (so status looks fine) but
* the daemon's process lives in a separate Linux namespace the Windows CLI can't
* touch — so auto-start / auto-stop silently do nothing (#3916).
*
* The reliable, low-false-positive signal is the daemon's own OS (reported as
* `os` on /health, = runtime.GOOS) vs the desktop host OS. They only disagree
* when the daemon runs in a foreign environment we can't drive. This module is
* the single source of truth for that comparison so it stays unit-tested — the
* cost of a false positive is hiding a working toggle from a native user, so the
* logic must fail safe (treat unknown / matching as manageable).
*/
/**
* Normalize a Node `process.platform` value to the daemon's `runtime.GOOS`
* vocabulary so the two are directly comparable. Only `win32` -> `windows`
* actually differs across the platforms we ship (darwin/linux already match);
* any other value passes through unchanged.
*/
export function normalizeHostOS(platform: NodeJS.Platform): string {
return platform === "win32" ? "windows" : platform;
}
/**
* Whether a running daemon is in an environment the desktop app can't control.
*
* Returns true ONLY when the daemon reports a concrete OS that differs from the
* host's. Fails safe to false when:
* - `daemonOS` is missing/empty (older daemon that predates the `os` field, or
* a malformed response) — we can't prove it's foreign, so keep toggles live.
* - the OSes match — a normally-managed native daemon.
*
* Callers must only invoke this for a daemon that is actually running; a stopped
* daemon has no OS to compare and its toggles must stay enabled.
*/
export function isDaemonExternallyManaged(
daemonOS: string | undefined,
hostOS: string,
): boolean {
if (typeof daemonOS !== "string" || daemonOS.length === 0) return false;
return daemonOS !== hostOS;
}
/**
* Boundary preflight for daemon lifecycle ops (stop / restart): resolve the
* daemon's CURRENT OS via `readDaemonOS` and return true when it's running
* somewhere the app can't drive.
*
* `readDaemonOS` is a live `/health` read performed at the call site — never a
* cached poll value. That is the whole point: a stale "manageable" cache would
* let a lifecycle op shell out to a native CLI that can't reach a WSL2 daemon
* (the PID lives in another namespace), which is exactly the bug. Taking the
* reader as a parameter keeps this unit-testable without the electron-coupled
* daemon-manager module, and lets the test prove the live value — not a cache —
* drives the decision. See #3916.
*/
export async function daemonLifecycleUnreachable(
readDaemonOS: () => Promise<string | undefined>,
hostOS: string,
): Promise<boolean> {
return isDaemonExternallyManaged(await readDaemonOS(), hostOS);
}

View File

@@ -1,90 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
type FreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
const dirs: string[] = [];
function tempFile(): string {
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
dirs.push(dir);
return join(dir, "last-client-failure.json");
}
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
const sample: FreezeBreadcrumb = {
kind: "unresponsive",
context: { desktopRoute: { path: "/acme/issues" } },
ts: 1_700_000_000_000,
version: "0.3.1",
};
describe("freeze breadcrumb round-trip", () => {
it("writes then reads back the breadcrumb", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
});
it("read clears the file so a failure reports exactly once", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
expect(existsSync(file)).toBe(false);
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
const file = tempFile();
writeFreezeBreadcrumb(file, sample);
clearFreezeBreadcrumb(file);
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
});
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
// IPC) and lives across app versions — a future write shape or a corrupt file
// must never throw into boot. CLAUDE.md "API Response Compatibility".
describe("freeze breadcrumb defends against malformed input", () => {
it("returns null when no file exists", () => {
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
});
it("returns null on corrupt JSON", () => {
const file = tempFile();
writeFileSync(file, "{ not valid json", "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null when `kind` is missing", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null when `kind` is the wrong type", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("returns null on a JSON null payload", () => {
const file = tempFile();
writeFileSync(file, "null", "utf8");
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
});
it("clearing a non-existent file is a no-op, never throws", () => {
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
});
});

View File

@@ -1,76 +0,0 @@
import { writeFileSync, readFileSync, rmSync } from "node:fs";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
// When the renderer truly hangs or its process dies, it can't send telemetry
// itself — the thread is blocked or gone. The main process (always alive) is
// the only watcher that can react, but during the hang it can't reach the
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
// time a renderer boots, it reads + clears the file and reports the event.
// This survives even a force-quit, which is the whole point.
export type { FreezeBreadcrumb };
/**
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
*
* Known limitation: this is a single slot — last write wins. Multiple failures
* within one session collapse to the last one, so per-session failure counts
* are undercounted. Acceptable for now: telemetry aggregates presence and
* frequency across users, not exhaustive per-session sequences. Upgrade to an
* append/ring buffer if per-session failure chains become a question.
*/
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
try {
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
} catch {
// Disk full / permissions — drop silently.
}
}
/**
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
* (a `responsive` event after `unresponsive`): the breadcrumb was written
* pre-emptively while the thread was stuck, but since it came back, the
* in-thread long-task watchdog already reports it — keeping the breadcrumb
* would double-count it AND mislabel a recovered window as `recovered: false`.
* Best-effort; a stale breadcrumb only costs one duplicate report.
*/
export function clearFreezeBreadcrumb(filePath: string): void {
try {
rmSync(filePath, { force: true });
} catch {
// Nothing to clear / permissions — ignore.
}
}
/**
* Read the breadcrumb and delete it in the same call, so a failure is reported
* exactly once. Returns null when there's no breadcrumb (the normal case) or
* when the file is unreadable / corrupt.
*/
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
let raw: string;
try {
raw = readFileSync(filePath, "utf8");
} catch {
return null;
}
try {
rmSync(filePath, { force: true });
} catch {
// If we can't delete it we'd re-report next launch; acceptable over throwing.
}
try {
const parsed: unknown = JSON.parse(raw);
if (
parsed &&
typeof parsed === "object" &&
typeof (parsed as FreezeBreadcrumb).kind === "string"
) {
return parsed as FreezeBreadcrumb;
}
} catch {
// Corrupt JSON — drop.
}
return null;
}

View File

@@ -1,33 +1,15 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from "electron";
import { app, BrowserWindow, ipcMain, nativeImage, Notification } from "electron";
import { homedir } from "os";
import { join } from "path";
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { setupLocalDirectory } from "./local-directory";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { installNavigationGestures } from "./navigation-gestures";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
sanitizeRendererRouteContext,
type RendererRouteContext,
} from "../shared/renderer-route-context";
import {
createElectronReloadPrompt,
installRendererRecoveryHandlers,
type RendererRecoveryWindow,
} from "./renderer-recovery";
import {
writeFreezeBreadcrumb,
readAndClearFreezeBreadcrumb,
clearFreezeBreadcrumb,
} from "./freeze-breadcrumb";
// Bundled icon used for dock/taskbar branding. macOS/Windows production
// builds let the OS pick up the icon from the .app bundle / .exe resources,
@@ -71,15 +53,7 @@ if (process.platform !== "win32") {
const PROTOCOL = "multica";
// Where the main process parks a freeze/crash breadcrumb until the next
// renderer boot flushes it to telemetry. Lives in userData so it survives a
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
function freezeBreadcrumbPath(): string {
return join(app.getPath("userData"), "last-client-failure.json");
}
let mainWindow: BrowserWindow | null = null;
let latestRendererRouteContext: RendererRouteContext | null = null;
let runtimeConfigResult: RuntimeConfigResult = {
ok: false,
error: { message: "Runtime config has not loaded yet" },
@@ -183,19 +157,10 @@ function createWindow(): void {
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});
const window = mainWindow;
latestRendererRouteContext = null;
window.on("closed", () => {
if (mainWindow === window) {
mainWindow = null;
latestRendererRouteContext = null;
}
});
// Strip Origin header from WebSocket upgrade requests so the server's
// origin whitelist doesn't reject connections from localhost dev origins.
window.webContents.session.webRequest.onBeforeSendHeaders(
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ["wss://*/*", "ws://*/*"] },
(details, callback) => {
delete details.requestHeaders["Origin"];
@@ -203,8 +168,8 @@ function createWindow(): void {
},
);
window.on("ready-to-show", () => {
window.show();
mainWindow.on("ready-to-show", () => {
mainWindow?.show();
});
// Detect OS language changes while the app is running. Electron has no
@@ -212,105 +177,40 @@ function createWindow(): void {
// 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).
window.on("focus", () => {
mainWindow.on("focus", () => {
const current = getSystemLocale();
if (current === lastKnownSystemLocale) return;
lastKnownSystemLocale = current;
window.webContents.send("locale:system-changed", current);
mainWindow?.webContents.send("locale:system-changed", current);
});
window.webContents.setWindowOpenHandler((details) => {
mainWindow.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
return { action: "deny" };
});
// Window-level keyboard shortcuts. Calling preventDefault here prevents
// both the renderer keydown AND the application menu accelerator, so
// anything we own here (reload-block, zoom, tab-close) is the sole handler
// for that combination — no double-fire with the macOS default View menu.
window.webContents.on("before-input-event", (event, input) => {
const result = handleAppShortcut(input, window.webContents);
if (result === "close-tab") {
event.preventDefault();
window.webContents.send("tab:close-active");
} else if (result) {
event.preventDefault();
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
// reloading the page. In a desktop app an accidental reload destroys
// in-memory state (tabs, drafts, WS connections) with no URL bar to
// navigate back. DevTools refresh (via the DevTools UI) still works.
mainWindow.webContents.on("before-input-event", (_event, input) => {
if (input.type !== "keyDown") return;
const cmdOrCtrl =
process.platform === "darwin" ? input.meta : input.control;
if (
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
input.key === "F5"
) {
_event.preventDefault();
}
});
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
// that DevTools can't be opened (white screen with no clickable surface),
// the only way to recover the actual JS error is to forward it from the
// main process to the terminal running `make dev`. Without these, the
// user sees only the daemon-manager polling noise (`Render frame was
// disposed before WebFrameMain could be accessed`) which is a downstream
// symptom, not the cause.
//
// Gated by `is.dev` to keep production stderr clean — packaged builds
// don't have a terminal anyway, and we ship to crash-reporting separately.
if (is.dev) {
const log = (tag: string, ...args: unknown[]) =>
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
// Forward every renderer-side console.* call. The detail object also
// carries source URL + line — included so a thrown stack trace from
// window.onerror is traceable back to a file.
window.webContents.on("console-message", (details) => {
const { level, message, sourceId, lineNumber } = details;
log(level, `${message} (${sourceId}:${lineNumber})`);
});
// Fires when loadURL / loadFile can't reach its target (dev server
// not up yet, network blip, file missing). errorCode is a Chromium
// net error number; -3 = ABORTED is normal during HMR and skipped.
window.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (errorCode === -3) return;
log(
"did-fail-load",
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
);
},
);
}
installRendererRecoveryHandlers(window as unknown as RendererRecoveryWindow, {
isDev: is.dev,
showReloadPrompt: createElectronReloadPrompt((options) =>
dialog.showMessageBox(window, options),
),
getDiagnosticContext: () => ({
windowUrl: window.webContents.getURL(),
...(latestRendererRouteContext
? { desktopRoute: latestRendererRouteContext }
: {}),
}),
// Only persist in production: a true hang/crash can't report itself, so we
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
// is excluded to keep field telemetry clean.
persistBreadcrumb: is.dev
? undefined
: (payload) =>
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
kind: payload.kind,
context: payload.context,
ts: Date.now(),
version: getAppVersion(),
}),
clearBreadcrumb: is.dev
? undefined
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
});
installContextMenu(window.webContents);
installNavigationGestures(window);
installContextMenu(mainWindow.webContents);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
window.loadURL(process.env["ELECTRON_RENDERER_URL"]);
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);
} else {
window.loadFile(join(__dirname, "../renderer/index.html"));
mainWindow.loadFile(join(__dirname, "../renderer/index.html"));
}
}
@@ -417,11 +317,6 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
// Renderer requests window close (e.g. Cmd+W on last tab).
ipcMain.on("window:close", () => {
mainWindow?.close();
});
ipcMain.handle("file:download-url", (_event, url: string) => {
if (!mainWindow) {
console.warn("[download] ignored file:download-url — mainWindow torn down");
@@ -440,14 +335,6 @@ if (!gotTheLock) {
event.returnValue = { version: getAppVersion(), os };
});
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
// session. The renderer flushes it to telemetry on boot (it couldn't be
// reported when it happened — the renderer was hung or gone). Read-and-
// clear so a failure reports exactly once.
ipcMain.on("freeze:get-last", (event) => {
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
});
// 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.
@@ -455,13 +342,6 @@ if (!gotTheLock) {
event.returnValue = runtimeConfigResult;
});
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
if (!mainWindow || event.sender !== mainWindow.webContents) return;
const sanitized = sanitizeRendererRouteContext(context);
if (!sanitized) return;
latestRendererRouteContext = sanitized;
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
// modals (e.g. create-workspace) can place UI in the top-left corner
// without fighting the native window controls' hit-test.
@@ -532,7 +412,6 @@ if (!gotTheLock) {
setupAutoUpdater(() => mainWindow);
setupDaemonManager(() => mainWindow);
setupLocalDirectory(() => mainWindow);
// macOS: deep link arrives via open-url event
app.on("open-url", (_event, url) => {

View File

@@ -1,186 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
function makeWc(initialLevel = 0) {
let level = initialLevel;
return {
getZoomLevel: vi.fn(() => level),
setZoomLevel: vi.fn((next: number) => {
level = next;
}),
currentLevel: () => level,
};
}
function key(
k: string,
mods: Partial<Pick<ShortcutInput, "control" | "meta" | "shift">> = {},
): ShortcutInput {
return {
type: "keyDown",
key: k,
control: false,
meta: false,
shift: false,
...mods,
};
}
describe("handleAppShortcut — reload blocking", () => {
it("swallows Cmd+R on macOS", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("swallows Ctrl+R on Linux/Windows", () => {
const wc = makeWc();
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
});
it("swallows F5 regardless of modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
});
it("ignores non-keyDown events", () => {
const wc = makeWc();
expect(
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
).toBe(false);
});
});
describe("handleAppShortcut — zoom in", () => {
it("zooms in on Cmd+= (unshifted)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Cmd++ (Shift+=)", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms in on Ctrl+= on non-mac", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(0);
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("clamps zoom-in at the upper bound", () => {
const wc = makeWc(4.5);
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(4.5);
});
});
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
it("zooms out on Cmd+- (unshifted)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Cmd+_ (Shift+-)", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("zooms out on Ctrl+- on non-mac", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
expect(wc.currentLevel()).toBe(0.5);
});
it("undoes a prior Cmd+= so the user can return to 100%", () => {
const wc = makeWc(0);
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0.5);
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
expect(wc.currentLevel()).toBe(0);
});
it("clamps zoom-out at the lower bound", () => {
const wc = makeWc(-3);
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(-3);
});
it("does nothing without Cmd/Ctrl", () => {
const wc = makeWc(1);
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — reset zoom", () => {
it("resets to 0 on Cmd+0", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("resets to 0 on Ctrl+0", () => {
const wc = makeWc(-1.5);
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
expect(wc.currentLevel()).toBe(0);
});
it("ignores plain 0 without modifier", () => {
const wc = makeWc(2);
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — unrelated keys pass through", () => {
it("does not capture plain letters", () => {
const wc = makeWc();
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
});
});
describe("handleAppShortcut — close tab (Cmd/Ctrl+W)", () => {
it('returns "close-tab" on Cmd+W (macOS)', () => {
const wc = makeWc();
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe("close-tab");
});
it('returns "close-tab" on Cmd+W uppercase', () => {
const wc = makeWc();
expect(handleAppShortcut(key("W", { meta: true }), wc, "darwin")).toBe("close-tab");
});
it('returns "close-tab" on Ctrl+W (Linux/Windows)', () => {
const wc = makeWc();
expect(handleAppShortcut(key("w", { control: true }), wc, "linux")).toBe("close-tab");
expect(handleAppShortcut(key("w", { control: true }), wc, "win32")).toBe("close-tab");
});
it("does not trigger without Cmd/Ctrl modifier", () => {
const wc = makeWc();
expect(handleAppShortcut(key("w"), wc, "darwin")).toBe(false);
});
it("does not trigger on Cmd+Shift+W (reserved for close-window)", () => {
const wc = makeWc();
expect(handleAppShortcut(key("W", { meta: true, shift: true }), wc, "darwin")).toBe(false);
});
it("does not trigger on Ctrl+Shift+W (reserved for close-window)", () => {
const wc = makeWc();
expect(handleAppShortcut(key("W", { control: true, shift: true }), wc, "linux")).toBe(false);
});
});

View File

@@ -1,90 +0,0 @@
import type { WebContents } from "electron";
// Shape of the input subset we read from Electron's `before-input-event`.
// Modeled as a structural type so the handler is unit-testable without a
// real Electron Input instance.
export type ShortcutInput = {
type: string;
key: string;
control: boolean;
meta: boolean;
shift: boolean;
};
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
// per step). Clamp to a range that keeps the UI legible — values outside
// this band turn the workspace into either confetti or a microfiche.
const ZOOM_STEP = 0.5;
const ZOOM_MIN = -3;
const ZOOM_MAX = 4.5;
/**
* Inspect a `before-input-event` key and apply (or block) the matching
* window-level shortcut. Returns `true` when the caller should call
* `event.preventDefault()` — that both swallows the renderer keydown and
* prevents the application menu accelerator from firing, so we don't
* double-trigger zoom on macOS where the default menu also binds these
* keys.
*
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
* default `Cmd+-` accelerator does not fire reliably across keyboard
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*/
/**
* Result of handleAppShortcut:
* - `false`: not handled, let Electron continue
* - `true`: handled (preventDefault), no further action
* - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer
*/
export type ShortcutResult = boolean | "close-tab";
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
): ShortcutResult {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
// (tabs, drafts, WS connections) with no URL bar to recover from.
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
return true;
}
if (!cmdOrCtrl) return false;
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
if (input.key === "=" || input.key === "+") {
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
if (input.key === "-" || input.key === "_") {
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
webContents.setZoomLevel(next);
return true;
}
// Cmd/Ctrl + 0 → reset zoom to 100%.
if (input.key === "0") {
webContents.setZoomLevel(0);
return true;
}
// Cmd/Ctrl + W → close active tab (or window if last tab).
// Cmd/Ctrl + Shift + W is reserved for "close window" — do not intercept.
// Return a signal so the caller can send IPC to the renderer.
if (input.key.toLowerCase() === "w" && !input.shift) {
return "close-tab";
}
return false;
}

View File

@@ -1,93 +0,0 @@
import { ipcMain, dialog, BrowserWindow } from "electron";
import { access, stat } from "fs/promises";
import { constants as fsConstants } from "fs";
import { basename, isAbsolute } from "path";
export interface PickDirectoryResult {
ok: boolean;
path?: string;
basename?: string;
/** Set when ok=false. "cancelled" = user dismissed; otherwise an error blurb. */
reason?: "cancelled" | "no_window" | "error";
error?: string;
}
export interface ValidateLocalDirectoryResult {
ok: boolean;
/** When ok=false, identifies which check failed so the renderer can render a
* specific message without parsing free-form text. */
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}
async function validateLocalDirectory(
path: string,
): Promise<ValidateLocalDirectoryResult> {
if (!path || !isAbsolute(path)) {
return { ok: false, reason: "not_absolute" };
}
try {
const st = await stat(path);
if (!st.isDirectory()) return { ok: false, reason: "not_a_directory" };
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { ok: false, reason: "not_found" };
return { ok: false, reason: "error", error: errorMessage(err) };
}
try {
await access(path, fsConstants.R_OK);
} catch {
return { ok: false, reason: "not_readable" };
}
try {
await access(path, fsConstants.W_OK);
} catch {
return { ok: false, reason: "not_writable" };
}
return { ok: true };
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export function setupLocalDirectory(
windowGetter: () => BrowserWindow | null,
): void {
ipcMain.handle(
"local-directory:pick",
async (_event, defaultPath?: string): Promise<PickDirectoryResult> => {
const win = windowGetter();
if (!win) return { ok: false, reason: "no_window" };
try {
const result = await dialog.showOpenDialog(win, {
// Multiple-selection is intentionally disabled — a project_resource
// points at a single directory, and the create flow expects one
// path per click. Multi-add would have to be a separate UX.
properties: ["openDirectory", "createDirectory"],
...(defaultPath ? { defaultPath } : {}),
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, reason: "cancelled" };
}
const picked = result.filePaths[0];
if (!picked) return { ok: false, reason: "cancelled" };
return { ok: true, path: picked, basename: basename(picked) };
} catch (err) {
return { ok: false, reason: "error", error: errorMessage(err) };
}
},
);
ipcMain.handle(
"local-directory:validate",
(_event, path: string): Promise<ValidateLocalDirectoryResult> =>
validateLocalDirectory(path),
);
}

View File

@@ -1,60 +0,0 @@
import type { BrowserWindow } from "electron";
import { describe, expect, it, vi } from "vitest";
import { NAVIGATION_GESTURE_CHANNEL } from "../shared/navigation-gestures";
import { installNavigationGestures } from "./navigation-gestures";
function makeWindow() {
let swipeHandler:
| ((event: unknown, direction: string) => void)
| undefined;
const win = {
on: vi.fn(
(event: string, handler: (event: unknown, direction: string) => void) => {
if (event === "swipe") swipeHandler = handler;
return win;
},
),
webContents: {
send: vi.fn(),
},
};
return {
win: win as unknown as BrowserWindow,
send: win.webContents.send,
emitSwipe: (direction: string) => swipeHandler?.({}, direction),
};
}
describe("installNavigationGestures", () => {
it("registers macOS swipe navigation", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "darwin");
emitSwipe("right");
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "back");
emitSwipe("left");
expect(send).toHaveBeenCalledWith(NAVIGATION_GESTURE_CHANNEL, "forward");
});
it("ignores non-horizontal swipe directions", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "darwin");
emitSwipe("up");
expect(send).not.toHaveBeenCalled();
});
it("does not register on non-mac platforms", () => {
const { win, send, emitSwipe } = makeWindow();
installNavigationGestures(win, "linux");
emitSwipe("right");
expect(send).not.toHaveBeenCalled();
});
});

View File

@@ -1,18 +0,0 @@
import type { BrowserWindow } from "electron";
import {
NAVIGATION_GESTURE_CHANNEL,
navigationGestureFromSwipe,
} from "../shared/navigation-gestures";
export function installNavigationGestures(
win: BrowserWindow,
platform: NodeJS.Platform = process.platform,
): void {
if (platform !== "darwin") return;
win.on("swipe", (_event, direction) => {
const gesture = navigationGestureFromSwipe(direction);
if (!gesture) return;
win.webContents.send(NAVIGATION_GESTURE_CHANNEL, gesture);
});
}

View File

@@ -1,271 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery";
type Handler = (...args: unknown[]) => void;
function makeWindow() {
const windowHandlers = new Map<string, Handler>();
const webContentsHandlers = new Map<string, Handler>();
const reload = vi.fn();
return {
window: {
on: vi.fn((event: string, handler: Handler) => windowHandlers.set(event, handler)),
isDestroyed: vi.fn(() => false),
webContents: {
on: vi.fn((event: string, handler: Handler) => webContentsHandlers.set(event, handler)),
reload,
},
},
windowHandlers,
webContentsHandlers,
reload,
};
}
describe("installRendererRecoveryHandlers", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
it("registers production reload prompts for renderer death and preload failure without auto reloading", async () => {
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
expect(fixture.webContentsHandlers.has("render-process-gone")).toBe(true);
expect(fixture.webContentsHandlers.has("preload-error")).toBe(true);
expect(fixture.windowHandlers.has("unresponsive")).toBe(true);
expect(fixture.windowHandlers.has("responsive")).toBe(true);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
fixture.webContentsHandlers.get("preload-error")?.({}, "/preload.js", new Error("boom"));
expect(fixture.reload).not.toHaveBeenCalled();
await Promise.resolve();
expect(showReloadPrompt).toHaveBeenCalledTimes(2);
expect(fixture.reload).toHaveBeenCalledTimes(2);
});
it("does not prompt when the renderer exits cleanly", async () => {
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, { isDev: false, showReloadPrompt });
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
await Promise.resolve();
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
it("cancels an unresponsive prompt when the window becomes responsive again", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
fixture.windowHandlers.get("responsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
it("prompts for sustained unresponsive windows and only reloads after user confirmation", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
const desktopRoute = {
surface: "tab",
path: "/acme/issues/MUL-3239",
workspaceSlug: "acme",
tabId: "tab-1",
reportedAt: "2026-06-15T00:00:00.000Z",
};
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
getDiagnosticContext: () => ({
windowUrl:
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
desktopRoute,
}),
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({
kind: "unresponsive",
context: {
windowUrl:
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
desktopRoute,
},
});
expect(fixture.reload).not.toHaveBeenCalled();
});
it("keeps prompting when diagnostic context collection fails", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt,
getDiagnosticContext: () => {
throw new Error("diagnostics unavailable");
},
unresponsivePromptDelayMs: 100,
});
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
});
it("keeps dev diagnostics non-prompting", async () => {
const fixture = makeWindow();
const showReloadPrompt = vi.fn(async () => "reload" as const);
installRendererRecoveryHandlers(fixture.window, { isDev: true, showReloadPrompt, log: vi.fn() });
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
await Promise.resolve();
expect(showReloadPrompt).not.toHaveBeenCalled();
expect(fixture.reload).not.toHaveBeenCalled();
});
it("shows actionable recovery guidance before diagnostic details", async () => {
let detail = "";
const showMessageBox = vi.fn(
async (options: { title: string; message: string; detail: string }) => {
detail = options.detail;
return { response: 1 };
},
);
const showReloadPrompt = createElectronReloadPrompt(showMessageBox);
await showReloadPrompt({ kind: "unresponsive", context: {} });
expect(showMessageBox).toHaveBeenCalledWith(
expect.objectContaining({
title: "Multica needs to reload",
message: "The desktop window has been stuck for a few seconds.",
detail: expect.stringContaining(
"Click Reload to refresh this window and keep using Multica.",
),
}),
);
expect(detail).toContain("what you were doing right before this message appeared");
expect(detail).toContain("Activity Monitor sample");
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
});
});
describe("freeze/crash breadcrumb state machine", () => {
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
function install(fixture: ReturnType<typeof makeWindow>) {
const persistBreadcrumb = vi.fn();
const clearBreadcrumb = vi.fn();
installRendererRecoveryHandlers(fixture.window, {
isDev: false,
showReloadPrompt: vi.fn(async () => "dismiss" as const),
persistBreadcrumb,
clearBreadcrumb,
unresponsivePromptDelayMs: 100,
});
return { persistBreadcrumb, clearBreadcrumb };
}
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(persistBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ kind: "unresponsive" }),
);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
fixture.windowHandlers.get("responsive")?.();
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
});
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
fixture.windowHandlers.get("responsive")?.();
await vi.advanceTimersByTimeAsync(100);
expect(persistBreadcrumb).not.toHaveBeenCalled();
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
vi.useFakeTimers();
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.windowHandlers.get("unresponsive")?.();
await vi.advanceTimersByTimeAsync(100);
// No "responsive" ever fires — the breadcrumb must survive uncleared.
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
const fixture = makeWindow();
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
expect(persistBreadcrumb).toHaveBeenCalledWith(
expect.objectContaining({ kind: "render-process-gone" }),
);
expect(clearBreadcrumb).not.toHaveBeenCalled();
});
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
const fixture = makeWindow();
const { persistBreadcrumb } = install(fixture);
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
expect(persistBreadcrumb).not.toHaveBeenCalled();
});
});

View File

@@ -1,206 +0,0 @@
export type RendererRecoveryWindow = {
isDestroyed: () => boolean;
on: (event: "unresponsive" | "responsive", handler: () => void) => unknown;
webContents: {
on: (event: string, handler: (...args: any[]) => void) => unknown;
reload: () => void;
};
};
type ReloadPromptPayload = {
kind: "render-process-gone" | "preload-error" | "unresponsive";
context: Record<string, unknown>;
};
type ReloadPromptResult = "reload" | "dismiss";
type RendererRecoveryOptions = {
isDev: boolean;
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
getDiagnosticContext?: () => Record<string, unknown>;
/**
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
* true hang or process death itself (blocked / gone), so the main process
* writes it here and the next renderer boot flushes it to telemetry. Omit
* in dev to keep field telemetry clean.
*/
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
/**
* Delete a previously-persisted unresponsive breadcrumb. Called when the
* renderer recovers (`responsive` after `unresponsive`): the window came
* back, so the in-thread watchdog reports the freeze and the breadcrumb
* would only double-count it. Crash breadcrumbs are never cleared — a dead
* process never recovers.
*/
clearBreadcrumb?: () => void;
log?: (tag: string, ...args: unknown[]) => void;
unresponsivePromptDelayMs?: number;
};
export function installRendererRecoveryHandlers(
window: RendererRecoveryWindow,
{
isDev,
showReloadPrompt,
getDiagnosticContext,
persistBreadcrumb,
clearBreadcrumb,
log = defaultDevLog,
unresponsivePromptDelayMs = 1500,
}: RendererRecoveryOptions,
) {
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
// True once a breadcrumb has been written for the current hang. A later
// `responsive` clears it; only a hang that never returns survives to report.
let unresponsiveBreadcrumbWritten = false;
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
...readDiagnosticContext(getDiagnosticContext),
...context,
});
const maybePromptReload = (payload: ReloadPromptPayload) => {
if (isDev) return;
void showReloadPrompt(payload).then((result) => {
if (result === "reload" && !window.isDestroyed()) {
window.webContents.reload();
}
});
};
window.webContents.on("render-process-gone", (_event, details) => {
if (isDev) log("process-gone", JSON.stringify(details));
if (!isRecoverableRendererExit(details)) return;
const payload: ReloadPromptPayload = {
kind: "render-process-gone",
context: mergeDiagnosticContext({ details }),
};
persistBreadcrumb?.(payload);
maybePromptReload(payload);
});
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
// failure of the preload script itself, and the breadcrumb-flush path depends
// on that same preload exposing `getLastFreeze` — if preload is broken, the
// next boot couldn't read it back anyway. We only prompt for reload here.
window.webContents.on("preload-error", (_event, preloadPath, error) => {
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
maybePromptReload({
kind: "preload-error",
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
});
});
window.on("unresponsive", () => {
if (isDev || unresponsivePromptTimer) return;
unresponsivePromptTimer = setTimeout(() => {
unresponsivePromptTimer = null;
const payload: ReloadPromptPayload = {
kind: "unresponsive",
context: mergeDiagnosticContext({}),
};
persistBreadcrumb?.(payload);
unresponsiveBreadcrumbWritten = true;
maybePromptReload(payload);
}, unresponsivePromptDelayMs);
});
window.on("responsive", () => {
if (unresponsivePromptTimer) {
clearTimeout(unresponsivePromptTimer);
unresponsivePromptTimer = null;
}
// The window came back: drop any breadcrumb written during this hang so it
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
if (unresponsiveBreadcrumbWritten) {
clearBreadcrumb?.();
unresponsiveBreadcrumbWritten = false;
}
});
}
export function createElectronReloadPrompt(
showMessageBox: (options: {
type: "warning";
buttons: string[];
defaultId: number;
cancelId: number;
title: string;
message: string;
detail: string;
}) => Promise<{ response: number }>,
) {
return async (payload: ReloadPromptPayload): Promise<ReloadPromptResult> => {
const result = await showMessageBox({
type: "warning",
buttons: ["Reload", "Dismiss"],
defaultId: 0,
cancelId: 1,
title: "Multica needs to reload",
message: rendererRecoveryMessage(payload.kind),
detail: rendererRecoveryDetail(payload),
});
return result.response === 0 ? "reload" : "dismiss";
};
}
function isRecoverableRendererExit(details: unknown) {
if (!details || typeof details !== "object") return false;
const reason = (details as { reason?: unknown }).reason;
return (
reason === "crashed" ||
reason === "oom" ||
reason === "abnormal-exit" ||
reason === "launch-failed" ||
reason === "integrity-failure"
);
}
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
switch (kind) {
case "render-process-gone":
return "The desktop window stopped unexpectedly.";
case "preload-error":
return "The desktop window could not finish starting.";
case "unresponsive":
return "The desktop window has been stuck for a few seconds.";
}
}
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
const guidance = [
"Click Reload to refresh this window and keep using Multica.",
"If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.",
];
if (payload.kind === "unresponsive") {
guidance.push(
"For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.",
);
}
return [
...guidance,
"",
"Diagnostic details:",
`kind: ${payload.kind}`,
`context: ${JSON.stringify(payload.context)}`,
].join("\n");
}
function defaultDevLog(tag: string, ...args: unknown[]) {
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
}
function readDiagnosticContext(
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
) {
if (!getDiagnosticContext) return {};
try {
return getDiagnosticContext();
} catch {
return {};
}
}
function formatError(error: unknown) {
return error instanceof Error ? (error.stack ?? error.message) : String(error);
}

View File

@@ -1,170 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BrowserWindow, WebContents } from "electron";
type Handler = (...args: unknown[]) => void;
const ctx = vi.hoisted(() => ({
handlers: new Map<string, Handler[]>(),
ipcHandle: vi.fn(),
checkForUpdates: vi.fn(async () => ({
updateInfo: { version: "0.3.18" },
isUpdateAvailable: false,
})),
downloadUpdate: vi.fn(),
quitAndInstall: vi.fn(),
getVersion: vi.fn(() => "0.3.17"),
}));
vi.mock("electron-updater", () => {
const autoUpdater = {
autoDownload: false,
autoInstallOnAppQuit: false,
channel: undefined as string | undefined,
on: vi.fn((event: string, handler: Handler) => {
const handlers = ctx.handlers.get(event) ?? [];
handlers.push(handler);
ctx.handlers.set(event, handlers);
return autoUpdater;
}),
checkForUpdates: ctx.checkForUpdates,
downloadUpdate: ctx.downloadUpdate,
quitAndInstall: ctx.quitAndInstall,
};
return { autoUpdater };
});
vi.mock("electron", () => ({
app: {
getVersion: ctx.getVersion,
},
BrowserWindow: class BrowserWindow {},
ipcMain: {
handle: ctx.ipcHandle,
},
}));
import { setupAutoUpdater } from "./updater";
function emitUpdater(event: string, ...args: unknown[]) {
for (const handler of ctx.handlers.get(event) ?? []) {
handler(...args);
}
}
function makeWindow() {
const send = vi.fn();
return {
win: {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
send,
},
} as unknown as BrowserWindow,
send,
};
}
function makeDestroyedWindow() {
return {
isDestroyed: () => true,
get webContents(): WebContents {
throw new TypeError("Object has been destroyed");
},
} as unknown as BrowserWindow;
}
function makeWindowWithDestroyedWebContents() {
const send = vi.fn(() => {
throw new TypeError("Object has been destroyed");
});
return {
win: {
isDestroyed: () => false,
webContents: {
isDestroyed: () => true,
send,
},
} as unknown as BrowserWindow,
send,
};
}
function makeWindowWithThrowingSend(error: Error) {
const send = vi.fn(() => {
throw error;
});
return {
win: {
isDestroyed: () => false,
webContents: {
isDestroyed: () => false,
send,
},
} as unknown as BrowserWindow,
send,
};
}
describe("setupAutoUpdater", () => {
beforeEach(() => {
vi.useFakeTimers();
ctx.handlers.clear();
ctx.ipcHandle.mockClear();
ctx.checkForUpdates.mockClear();
ctx.downloadUpdate.mockClear();
ctx.quitAndInstall.mockClear();
ctx.getVersion.mockClear();
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
});
it("forwards update progress to a live renderer", () => {
const { win, send } = makeWindow();
setupAutoUpdater(() => win);
emitUpdater("download-progress", { percent: 42 });
expect(send).toHaveBeenCalledWith("updater:download-progress", {
percent: 42,
});
});
it("skips update progress when the BrowserWindow has already been destroyed", () => {
setupAutoUpdater(() => makeDestroyedWindow());
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
});
it("skips update progress when the BrowserWindow webContents has already been destroyed", () => {
const { win, send } = makeWindowWithDestroyedWebContents();
setupAutoUpdater(() => win);
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
expect(send).not.toHaveBeenCalled();
});
it("skips update progress when webContents.send loses a destroy race", () => {
const { win, send } = makeWindowWithThrowingSend(
new TypeError("Object has been destroyed"),
);
setupAutoUpdater(() => win);
expect(() => emitUpdater("download-progress", { percent: 42 })).not.toThrow();
expect(send).toHaveBeenCalledWith("updater:download-progress", {
percent: 42,
});
});
it("rethrows non-destroy errors from webContents.send", () => {
const { win } = makeWindowWithThrowingSend(new Error("boom"));
setupAutoUpdater(() => win);
expect(() => emitUpdater("download-progress", { percent: 42 })).toThrow(
"boom",
);
});
});

View File

@@ -1,5 +1,5 @@
import { autoUpdater, type UpdateDownloadedEvent } from "electron-updater";
import { app, type BrowserWindow, ipcMain } from "electron";
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
import { app, BrowserWindow, ipcMain } from "electron";
// Silent background updates: electron-updater downloads on its own as soon
// as `update-available` fires; we only surface UI when the package is fully
@@ -29,32 +29,6 @@ export type ManualUpdateCheckResult =
}
| { ok: false; error: string };
type RendererChannel =
| "updater:update-available"
| "updater:download-progress"
| "updater:update-downloaded";
function isDestroyedObjectError(err: unknown): boolean {
return err instanceof Error && err.message.includes("Object has been destroyed");
}
function sendToLiveRenderer(
win: BrowserWindow | null,
channel: RendererChannel,
payload: unknown,
): void {
if (!win || win.isDestroyed()) return;
try {
const { webContents } = win;
if (webContents.isDestroyed()) return;
webContents.send(channel, payload);
} catch (err) {
if (isDestroyedObjectError(err)) return;
throw err;
}
}
// Single-flight guard around checkForUpdates(). With autoDownload=true the
// startup, periodic, and manual triggers can all kick off downloads, and
// overlapping calls have caused duplicate download warnings in the past
@@ -88,20 +62,23 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.on("update-available", (info) => {
// Forwarded for renderer-side state tracking only; the notification UI
// does not render an "available" affordance with autoDownload=true.
sendToLiveRenderer(getMainWindow(), "updater:update-available", {
const win = getMainWindow();
win?.webContents.send("updater:update-available", {
version: info.version,
releaseNotes: info.releaseNotes,
});
});
autoUpdater.on("download-progress", (progress) => {
sendToLiveRenderer(getMainWindow(), "updater:download-progress", {
const win = getMainWindow();
win?.webContents.send("updater:download-progress", {
percent: progress.percent,
});
});
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
sendToLiveRenderer(getMainWindow(), "updater:update-downloaded", {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded", {
version: info.version,
releaseNotes: info.releaseNotes,
});

View File

@@ -1,8 +1,5 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -16,9 +13,6 @@ interface DesktopAPI {
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig: RuntimeConfigResult;
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
getLastFreeze: () => FreezeBreadcrumb | null;
/** 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. */
@@ -48,52 +42,10 @@ interface DesktopAPI {
issueKey: string;
}) => void,
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Report the renderer's memory-router path for recovery diagnostics. */
setRendererRouteContext: (context: RendererRouteContextInput) => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (
defaultPath?: string,
) => Promise<{
ok: boolean;
path?: string;
basename?: string;
reason?: "cancelled" | "no_window" | "error";
error?: string;
}>;
/** Validate that a path is an existing readable+writable directory.
* Mirrors the daemon's runtime check so the user sees errors before submit. */
validateLocalDirectory: (
path: string,
) => Promise<{
ok: boolean;
reason?:
| "not_absolute"
| "not_found"
| "not_a_directory"
| "not_readable"
| "not_writable"
| "error";
error?: string;
}>;
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
* Returns an unsubscribe function. */
onCloseActiveTab: (callback: () => void) => () => void;
/** Ask the main process to close the window. */
closeWindow: () => void;
}
interface DaemonStatus {
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
@@ -109,25 +61,15 @@ interface DaemonPrefs {
autoStop: boolean;
}
type DaemonReauthResult =
| { ok: true }
| { ok: false; reason: "session_invalid" }
| { ok: false; reason: "transient"; message: string };
interface DaemonAPI {
start: () => Promise<{ success: boolean; error?: string }>;
stop: () => Promise<{ success: boolean; error?: string }>;
restart: () => Promise<{ success: boolean; error?: string }>;
getStatus: () => Promise<DaemonStatus>;
getHostName: () => Promise<string>;
onStatusChange: (callback: (status: DaemonStatus) => void) => () => void;
setTargetApiUrl: (url: string) => Promise<void>;
syncToken: (token: string, userId: string) => Promise<void>;
clearToken: () => Promise<void>;
reauthenticate: (
token: string,
userId: string,
) => Promise<DaemonReauthResult>;
isCliInstalled: () => Promise<boolean>;
getPrefs: () => Promise<DaemonPrefs>;
setPrefs: (prefs: Partial<DaemonPrefs>) => Promise<DaemonPrefs>;

View File

@@ -1,16 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
import {
RENDERER_ROUTE_CONTEXT_CHANNEL,
type RendererRouteContextInput,
} from "../shared/renderer-route-context";
import {
isNavigationGesture,
NAVIGATION_GESTURE_CHANNEL,
type NavigationGesture,
} from "../shared/navigation-gestures";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -79,16 +69,6 @@ const desktopAPI = {
},
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig,
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
* the renderer can flush it to telemetry on boot. Returns null when there's
* nothing pending (the normal case). */
getLastFreeze: (): FreezeBreadcrumb | null => {
try {
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
} catch {
return null;
}
},
/** Listen for auth token delivered via deep link */
onAuthToken: (callback: (token: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
@@ -161,48 +141,10 @@ const desktopAPI = {
ipcRenderer.removeListener("inbox:open", handler);
};
},
/** Listen for native macOS back/forward swipe gestures. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => {
const handler = (_event: Electron.IpcRendererEvent, gesture: unknown) => {
if (isNavigationGesture(gesture)) callback(gesture);
};
ipcRenderer.on(NAVIGATION_GESTURE_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Report the renderer's memory-router path for recovery diagnostics. */
setRendererRouteContext: (context: RendererRouteContextInput) =>
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),
/** Validate that a path is an existing readable+writable directory. */
validateLocalDirectory: (path: string) =>
ipcRenderer.invoke("local-directory:validate", path),
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
* The renderer should close the active tab; if it was the last tab,
* call `closeWindow()` to dismiss the window. Returns an unsubscribe fn. */
onCloseActiveTab: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("tab:close-active", handler);
return () => {
ipcRenderer.removeListener("tab:close-active", handler);
};
},
/** Ask the main process to close the window (used after closing the last tab). */
closeWindow: () => ipcRenderer.send("window:close"),
};
interface DaemonStatus {
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
pid?: number;
uptime?: string;
daemonId?: string;
@@ -213,11 +155,6 @@ interface DaemonStatus {
serverUrl?: string;
}
type DaemonReauthResult =
| { ok: true }
| { ok: false; reason: "session_invalid" }
| { ok: false; reason: "transient"; message: string };
const daemonAPI = {
start: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:start"),
@@ -227,8 +164,6 @@ const daemonAPI = {
ipcRenderer.invoke("daemon:restart"),
getStatus: (): Promise<DaemonStatus> =>
ipcRenderer.invoke("daemon:get-status"),
getHostName: (): Promise<string> =>
ipcRenderer.invoke("daemon:get-host-name"),
onStatusChange: (callback: (status: DaemonStatus) => void) => {
const handler = (_: unknown, status: DaemonStatus) => callback(status);
ipcRenderer.on("daemon:status", handler);
@@ -240,11 +175,6 @@ const daemonAPI = {
ipcRenderer.invoke("daemon:sync-token", token, userId),
clearToken: (): Promise<void> =>
ipcRenderer.invoke("daemon:clear-token"),
reauthenticate: (
token: string,
userId: string,
): Promise<DaemonReauthResult> =>
ipcRenderer.invoke("daemon:reauthenticate", token, userId),
isCliInstalled: (): Promise<boolean> =>
ipcRenderer.invoke("daemon:is-cli-installed"),
getPrefs: (): Promise<{ autoStart: boolean; autoStop: boolean }> =>

View File

@@ -1,13 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { pickLocale, type SupportedLocale } from "@multica/core/i18n";
import { pickLocale } from "@multica/core/i18n";
import { useAuthStore } from "@multica/core/auth";
import { useWelcomeStore } from "@multica/core/onboarding";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { setCurrentWorkspace } from "@multica/core/platform";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -19,58 +17,13 @@ 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 { captureEvent } from "@multica/core/analytics";
import { RESOURCES } from "@multica/views/locales";
// BCP-47 region tags for the <html lang> attribute, mirroring
// apps/web/app/layout.tsx HTML_LANG. index.html ships a static lang="en";
// we sync it to the resolved locale at boot so screen readers announce the
// right language AND the Japanese-scoped CJK font override in globals.css
// (`html[lang|="ja"]`) can take effect.
const HTML_LANG: Record<SupportedLocale, string> = {
en: "en",
"zh-Hans": "zh-CN",
ko: "ko-KR",
ja: "ja-JP",
};
/**
* Cmd/Ctrl+W: close the active tab. When the last real tab is closed
* (or no tabs/workspace exist — e.g. login page), close the window.
*
* Mounted at the App root so every renderer state — including login,
* loading, onboarding, and runtime-config errors — has a working Cmd+W
* handler. Without this, states outside the tab shell would swallow the
* shortcut and do nothing.
*/
function useCmdWCloseTab() {
useEffect(() => {
return window.desktopAPI.onCloseActiveTab(() => {
const store = useTabStore.getState();
const { activeWorkspaceSlug, byWorkspace } = store;
if (!activeWorkspaceSlug) {
// No workspace — nothing to close, dismiss the window.
window.desktopAPI.closeWindow();
return;
}
const group = byWorkspace[activeWorkspaceSlug];
if (!group || group.tabs.length <= 1) {
// Last tab (or no tabs) — close the window.
window.desktopAPI.closeWindow();
return;
}
// Multiple tabs — close the active one.
store.closeActiveTab();
});
}, []);
}
function AppContent() {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
const qc = useQueryClient();
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
// setQueryData sequentially. loginWithToken sets user+isLoading=false
// as soon as getMe resolves, which would cause DesktopShell to mount
@@ -165,31 +118,25 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web layout
// hard gate via overlays (desktop has no URL bar, so we open the
// onboarding overlay instead of router.replace):
// onboarded + has workspace → no overlay, dashboard
// un-onboarded (any wsCount):
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// onboarded + no workspace → /workspaces/new overlay
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
//
// V3 invariant: `onboarded_at != null` is the only path into the
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
// somehow has a workspace but no onboarded mark must be sent back to
// /onboarding — we also clear the active workspace so the dashboard
// doesn't render under the overlay with stale workspace context.
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (hasOnboarded && wsCount > 0) return undefined;
if (wsCount > 0) return undefined;
if (!hasOnboarded) {
// Stale workspace context (if any) would leak X-Workspace-Slug
// headers into onboarding-time API calls. Clear it before opening
// the overlay.
setCurrentWorkspace(null, null);
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
@@ -224,7 +171,6 @@ function AppContent() {
return undefined;
}, [user, workspaceListFetched, wsCount, workspaces, hasOnboarded, qc]);
// Validate persisted tab state against the current user's workspace list,
// and pick an active workspace if none is set. Runs in useLayoutEffect
// (synchronously after render, before paint) rather than the render
@@ -312,9 +258,6 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
// Drop any post-onboarding welcome signal so user B logging in next
// doesn't inherit user A's pending modal state.
useWelcomeStore.getState().reset();
try {
await window.daemonAPI.clearToken();
} catch {
@@ -331,28 +274,6 @@ export default function App() {
const { version, os } = window.desktopAPI.appInfo;
const systemLocale = window.desktopAPI.systemLocale;
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
useCmdWCloseTab();
// Flush a freeze/crash breadcrumb the main process parked from a previous
// session. A true hang or process death can't report itself when it happens
// (the renderer is blocked or gone), so the main process persists it and we
// emit it here on the next boot. The in-thread, recoverable freeze tier is
// handled separately by the shared watchdog in CoreProvider.
useEffect(() => {
const last = window.desktopAPI.getLastFreeze();
if (!last) return;
const crashed = last.kind === "render-process-gone";
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
// Spread context FIRST so our explicit fields below always win — a
// future context key (e.g. its own `source`) must not silently override.
...last.context,
source: crashed ? "render-process-gone" : "main-unresponsive",
recovered: false,
breadcrumb_ts: last.ts,
crashed_version: last.version,
});
}, []);
// Stable identity reference so downstream effects (WS reconnect) don't
// tear down on every parent render.
const identity = useMemo(
@@ -371,15 +292,6 @@ export default function App() {
[locale],
);
// Keep <html lang> in sync with the resolved locale (index.html hardcodes
// "en"). Drives the lang-scoped Japanese CJK font override and a11y.
// useLayoutEffect (not useEffect) so lang is committed before the first
// paint — otherwise Japanese users would see one frame of Kanji rendered
// with the Chinese-first fallback stack before the override kicks in.
useLayoutEffect(() => {
document.documentElement.lang = HTML_LANG[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

View File

@@ -16,7 +16,6 @@ import {
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -195,12 +194,15 @@ export function DaemonPanel({
const handleCopy = useCallback(async () => {
const text = filtered.map((l) => l.raw).join("\n");
if (await copyText(text)) {
try {
await navigator.clipboard.writeText(text);
toast.success(
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
);
} else {
toast.error("Failed to copy");
} catch (err) {
toast.error("Failed to copy", {
description: err instanceof Error ? err.message : String(err),
});
}
}, [filtered]);

View File

@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import type { DaemonStatus } from "../../../shared/daemon-types";
// The component only needs these to render; stub them so the test focuses on
// the externally-managed branching, not data fetching.
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [] }),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
vi.mock("@multica/core/runtimes", () => ({
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
}));
vi.mock("@multica/core/agents", () => ({
agentTaskSnapshotOptions: () => ({ queryKey: ["snapshot"] }),
}));
vi.mock("./daemon-panel", () => ({ DaemonPanel: () => null }));
vi.mock("../platform/daemon-reauth", () => ({
reauthenticateDaemon: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
import { DaemonRuntimeActions } from "./daemon-runtime-card";
function stubDaemonAPI(status: DaemonStatus) {
Object.defineProperty(window, "daemonAPI", {
configurable: true,
value: {
getStatus: vi.fn().mockResolvedValue(status),
onStatusChange: vi.fn(() => () => {}),
},
});
}
describe("DaemonRuntimeActions — externally managed daemon (#3916)", () => {
it("hides Stop/Restart and shows the managed-outside hint for a daemon the app can't control", async () => {
stubDaemonAPI({ state: "running", daemonId: "d1", externallyManaged: true });
render(<DaemonRuntimeActions />);
// View logs still renders, confirming the running branch mounted.
expect(await screen.findByText("View logs")).toBeInTheDocument();
expect(screen.getByText("Managed outside the app")).toBeInTheDocument();
expect(screen.queryByText("Restart")).not.toBeInTheDocument();
expect(screen.queryByText("Stop")).not.toBeInTheDocument();
});
it("shows Stop/Restart for a normally-managed running daemon (no 误伤)", async () => {
stubDaemonAPI({
state: "running",
daemonId: "d1",
externallyManaged: false,
});
render(<DaemonRuntimeActions />);
expect(await screen.findByText("Restart")).toBeInTheDocument();
expect(screen.getByText("Stop")).toBeInTheDocument();
expect(
screen.queryByText("Managed outside the app"),
).not.toBeInTheDocument();
});
});

View File

@@ -4,16 +4,23 @@ import {
Play,
Square,
RotateCw,
Server,
Activity,
ScrollText,
LogIn,
Info,
} 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,
@@ -24,15 +31,25 @@ import {
} from "@multica/ui/components/ui/dialog";
import { toast } from "sonner";
import { DaemonPanel } from "./daemon-panel";
import { reauthenticateDaemon } from "../platform/daemon-reauth";
import type { DaemonStatus } from "../../../shared/daemon-types";
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
daemonStateDescription,
formatUptime,
} from "../../../shared/daemon-types";
/**
* Desktop-only controls for the daemon embedded in this Electron app. The
* shared runtimes page renders this inside the selected local machine header.
* Header card on the desktop Runtimes page that surfaces the daemon embedded
* in this Electron app. The same daemon process registers N runtimes with the
* server (one per detected CLI), which appear in the runtime list below — so
* this card is the parent control surface for "what's running on this Mac".
*
* Why this lives only on desktop: web users don't have an embedded daemon;
* they bring their own (CLI-launched or remote VM) and just see runtimes in
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
*/
export function DaemonRuntimeActions() {
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
@@ -40,8 +57,14 @@ export function DaemonRuntimeActions() {
const wsId = useWorkspaceId();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
// Snapshot also includes each agent's latest terminal; the filter below
// drops anything that isn't running/dispatched, so terminal rows pass
// through harmlessly.
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
// Used both to count "how many CLIs am I contributing" and to figure
// out which active tasks would be impacted by a Stop.
const localRuntimeIds = useMemo(() => {
if (!status.daemonId) return new Set<string>();
return new Set(
@@ -53,6 +76,10 @@ export function DaemonRuntimeActions() {
const runtimeCount = localRuntimeIds.size;
// Tasks that are actually doing work on this daemon right now —
// running or dispatched. Queued tasks haven't claimed a runtime yet,
// so stopping the daemon won't break them (they'll wait for any
// available daemon). The number drives the Stop-confirmation dialog.
const affectedTasks = useMemo(
() =>
snapshot.filter(
@@ -81,6 +108,9 @@ export function DaemonRuntimeActions() {
}
}, []);
// The actual stop call, separated from the click handler so we can call
// it both from the direct path (no active tasks) and from the confirm
// dialog's confirm button.
const performStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
@@ -89,6 +119,8 @@ export function DaemonRuntimeActions() {
}
}, []);
// Click on the Stop button. If there's nothing running, just stop;
// otherwise pop a confirm dialog explaining the blast radius.
const handleStopClick = useCallback(() => {
if (affectedTasks.length === 0) {
void performStop();
@@ -104,6 +136,9 @@ export function DaemonRuntimeActions() {
toast.error("Failed to restart daemon", { description: result.error });
return;
}
// Success feedback — the daemon takes a few seconds to come back online,
// and the only other UI signal is the state badge flipping briefly. A
// toast confirms the click was received and tells the user what to expect.
toast.success("Restarting daemon", {
description: "Runtimes will be back online in a few seconds.",
});
@@ -118,114 +153,115 @@ export function DaemonRuntimeActions() {
}
}, []);
const handleReauth = useCallback(async () => {
setActionLoading(true);
await reauthenticateDaemon();
// onStatusChange resets actionLoading on the next status push; reset here
// too in case reauth logged out (unmount) or produced no status change.
setActionLoading(false);
}, []);
const isRunning = status.state === "running";
// The daemon runs somewhere the app can't drive (e.g. inside WSL2): the
// lifecycle CLI acts on the host process namespace and can't reach it. Hide
// Stop/Restart so they don't silently no-op, mirroring the Settings tab. The
// real guard is in the main process (stopDaemon/restartDaemon); this is the
// matching UX. See #3916.
const externallyManaged = status.externallyManaged === true;
const isStopped = status.state === "stopped";
const isCliMissing = status.state === "cli_not_found";
const isAuthExpired = status.state === "auth_expired";
const isTransitioning =
status.state === "starting" || status.state === "stopping";
const isInstalling = status.state === "installing_cli";
return (
<>
<div className="flex flex-wrap items-center justify-end gap-1.5">
{isRunning && (
<>
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
{externallyManaged ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Info className="size-3.5 shrink-0" />
Managed outside the app
<Card size="sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="size-4 text-muted-foreground" />
Local daemon
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
>
{DAEMON_STATE_LABELS[status.state]}
</span>
) : (
<>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
</span>
</CardTitle>
<CardDescription>
{daemonStateDescription(status.state, runtimeCount)}
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-1.5">
{isRunning && (
<>
<Button
size="sm"
variant="ghost"
onClick={() => setPanelOpen(true)}
>
<ScrollText className="size-3.5 mr-1.5" />
View logs
</Button>
<Button
size="sm"
variant="outline"
onClick={handleRestart}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
{isStopped && (
<Button
size="sm"
onClick={handleStart}
disabled={actionLoading}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRestart}
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
Retry setup
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
</>
)}
</>
)}
{isStopped && (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isCliMissing && (
<Button
size="sm"
variant="outline"
onClick={handleRetryInstall}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry setup
</Button>
)}
{isAuthExpired && (
<>
<span className="inline-flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="size-3.5 shrink-0" />
Sign-in expired
</span>
<Button size="sm" onClick={handleReauth} disabled={actionLoading}>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<LogIn className="size-3.5 mr-1.5" />
)}
Sign in again
</Button>
</>
)}
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
{(isTransitioning || isInstalling) && (
<Button size="sm" variant="outline" disabled>
<Activity className="size-3.5 mr-1.5 animate-pulse" />
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</CardAction>
</CardHeader>
</Card>
<DaemonPanel
open={panelOpen}

View File

@@ -1,9 +1,7 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { AlertCircle, Info, LogIn } from "lucide-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 { reauthenticateDaemon } from "../platform/daemon-reauth";
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
@@ -63,7 +61,6 @@ export function DaemonSettingsTab() {
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [reauthLoading, setReauthLoading] = useState(false);
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
@@ -72,12 +69,6 @@ export function DaemonSettingsTab() {
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const handleReauth = useCallback(async () => {
setReauthLoading(true);
await reauthenticateDaemon();
setReauthLoading(false);
}, []);
const updatePref = useCallback(
async (key: keyof DaemonPrefs, value: boolean) => {
setSaving(true);
@@ -88,12 +79,6 @@ export function DaemonSettingsTab() {
[],
);
// The daemon runs somewhere the app can't drive (e.g. inside WSL2 behind a
// Windows desktop): /health is reachable but the lifecycle CLI can't reach
// its process. Auto-start/auto-stop can't work, so disable them and say why
// rather than letting the toggles silently no-op. See #3916.
const externallyManaged = status.externallyManaged === true;
return (
<div>
<h2 className="text-lg font-semibold">Daemon</h2>
@@ -101,43 +86,6 @@ export function DaemonSettingsTab() {
Configure how the local agent daemon behaves with the desktop app.
</p>
{status.state === "auth_expired" && (
<div className="mt-4 flex items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3">
<AlertCircle className="mt-0.5 size-4 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-destructive">
Sign-in expired
</p>
<p className="mt-0.5 text-sm text-muted-foreground">
The local daemon couldn&apos;t authenticate, so this device
can&apos;t take tasks. Sign in again to restore it.
</p>
</div>
<Button
size="sm"
className="shrink-0"
onClick={handleReauth}
disabled={reauthLoading}
>
<LogIn className="size-3.5 mr-1.5" />
Sign in again
</Button>
</div>
)}
{externallyManaged && (
<div className="mt-4 flex items-start gap-3 rounded-lg border bg-muted/30 px-4 py-3">
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<p className="min-w-0 text-sm text-muted-foreground">
This device&apos;s daemon runs outside the app for example inside
WSL2 so the app can&apos;t start or stop it. Start or stop it from
that environment with{" "}
<code className="font-mono text-xs">multica daemon start</code> /{" "}
<code className="font-mono text-xs">multica daemon stop</code>.
</p>
</div>
)}
<div className="mt-6 divide-y">
<SettingRow
label="Auto-start on launch"
@@ -146,7 +94,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving || externallyManaged}
disabled={saving}
/>
</SettingRow>
@@ -157,7 +105,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving || externallyManaged}
disabled={saving}
/>
</SettingRow>

View File

@@ -1,54 +0,0 @@
import { useEffect, useState } from "react";
import { AgentsPage } from "@multica/views/agents";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `AgentsPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so the runtime
* machine filter can render the Local section the same way the Runtimes
* page does — without these props the page falls back to grouping
* every local-mode runtime under "Remote" with a generic title, which
* breaks the "drill from a machine into its agents" promise of the
* filter.
*
* Mirrors `DesktopRuntimesPage`: we cache the last seen daemon
* identity so the Local row doesn't get reclassified as Remote when
* the daemon is stopped (which would null out `status.daemonId`), and
* we fall back to the OS hostname so the section label stays useful
* even when the app doesn't manage the running daemon (WSL2 etc.).
*/
export function DesktopAgentsPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
const [hostName, setHostName] = useState<string | null>(null);
useEffect(() => {
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
window.daemonAPI.getHostName().then((name) => setHostName(name || null));
return window.daemonAPI.onStatusChange(apply);
}, []);
return (
<AgentsPage
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName ?? hostName}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared
// page synthesizes a placeholder local row so the filter dropdown
// still has a Local option to pick in the empty window.
hasLocalMachine
/>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useSyncExternalStore } from "react";
import { useEffect, useSyncExternalStore } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useTabHistory } from "@/hooks/use-tab-history";
@@ -13,8 +13,8 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { useNavigation } from "@multica/views/navigation";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
@@ -35,7 +35,6 @@ function SidebarTopBar() {
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
@@ -44,7 +43,6 @@ function SidebarTopBar() {
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
@@ -57,20 +55,6 @@ function SidebarTopBar() {
);
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
useEffect(() => {
return window.desktopAPI.onNavigationGesture((gesture) => {
if (gesture === "back") {
goBack();
} else {
goForward();
}
});
}, [goBack, goForward]);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
@@ -128,30 +112,18 @@ function useInternalLinkHandler() {
* 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.
*
* The click routes through `useNavigation().push` — NOT the
* `multica:navigate` event, whose handler `openTab`s into the ACTIVE
* workspace's tab group. The navigation adapter detects a cross-workspace
* path and translates it into `switchWorkspace(slug, path)`, so clicking a
* workspace-A notification while B is active performs a real workspace
* switch instead of mounting A's inbox inside B's tab group (#3766).
*/
function DesktopInboxBridge() {
const workspace = useCurrentWorkspace();
useDesktopUnreadBadge(workspace?.id ?? null);
const { push } = useNavigation();
// The adapter identity changes with the active tab's location; the ref
// keeps the main-process subscription stable across navigations.
const pushRef = useRef(push);
useEffect(() => {
pushRef.current = push;
}, [push]);
useEffect(() => {
return window.desktopAPI.onInboxOpen(({ slug, issueKey }) => {
if (!slug) return;
const inboxPath = `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(issueKey)}`;
pushRef.current(inboxPath);
window.dispatchEvent(
new CustomEvent("multica:navigate", { detail: { path: inboxPath } }),
);
});
}, []);
@@ -161,7 +133,6 @@ function DesktopInboxBridge() {
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
useNativeNavigationGestures();
// Reactive read of current workspace slug from the platform singleton.
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
@@ -198,6 +169,7 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeActions } from "./daemon-runtime-card";
import { DaemonRuntimeCard } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
@@ -19,34 +19,10 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
// Remember the last known daemonId/deviceName. After the daemon is
// stopped, `status.daemonId` goes back to undefined — without this
// sticky cache the local row would either disappear or get reclassified
// as a remote machine (since `isCurrent` requires a daemonId match),
// taking the Start button with it.
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
// The host's OS hostname, independent of any daemon. Used as the last
// fallback for the local machine name so consolidation still works when
// the app doesn't manage the running daemon (e.g. it lives in WSL2) and
// thus never reports a device name.
const [hostName, setHostName] = useState<string | null>(null);
useEffect(() => {
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
window.daemonAPI.getHostName().then((name) => setHostName(name || null));
return window.daemonAPI.onStatusChange(apply);
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
@@ -56,14 +32,7 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName ?? hostName}
localMachineActions={<DaemonRuntimeActions />}
// Desktop owns a local machine for the lifetime of the app, even
// while the daemon is stopped or hasn't registered yet. The shared
// page synthesizes a placeholder local row when no real runtime
// matches, so the Start button is always reachable.
hasLocalMachine
topSlot={<DaemonRuntimeCard />}
bootstrapping={bootstrapping}
/>
);

View File

@@ -116,7 +116,7 @@ describe("PageviewTracker", () => {
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a foreground tab is added (addTab path)", () => {
it("fires pageview when a new tab is opened (openInNewTab / addTab)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
@@ -128,11 +128,7 @@ describe("PageviewTracker", () => {
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Simulate a foreground new-tab action (e.g. an explicit "Open in new
// tab" toolbar button that passes `{ activate: true }`) — tC is
// appended AND becomes active. `openInNewTab` defaults to background
// (no `setActiveTab`); only the `activate: true` branch produces the
// state change this test exercises.
// Simulate openInNewTab("/acme/agents") → new tab tC added and activated.
state.byWorkspace = {
acme: {
activeTabId: "tC",

View File

@@ -7,7 +7,6 @@ import {
useTabStore,
} from "@/stores/tab-store";
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
/**
* Fires a PostHog $pageview whenever the user's visible surface changes,
@@ -91,16 +90,6 @@ export function PageviewTracker() {
const last = lastSurfaceRef.current;
const next = { kind, key, path };
const routeContext: RendererRouteContextInput = {
surface: kind,
path,
};
if (kind === "tab") {
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
routeContext.tabId = activeTabId ?? undefined;
}
reportRendererRouteContext(routeContext);
if (kind === "tab" && key !== null) {
const knownPath = observed.get(key);
const isReactivation =
@@ -123,13 +112,6 @@ export function PageviewTracker() {
return null;
}
function reportRendererRouteContext(context: RendererRouteContextInput) {
const desktopAPI = window.desktopAPI as
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
| undefined;
desktopAPI?.setRendererRouteContext?.(context);
}
function overlayPath(overlay: WindowOverlay): string {
switch (overlay.type) {
case "new-workspace":

View File

@@ -1,98 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { createMemoryRouter, RouterProvider } from "react-router-dom";
const openModal = vi.fn();
const reloadActiveTab = vi.fn();
const closeActiveTab = vi.fn();
vi.mock("@multica/core/modals", () => ({
useModalStore: {
getState: () => ({ open: openModal }),
},
}));
vi.mock("@/stores/tab-store", () => ({
useTabStore: {
getState: () => ({ reloadActiveTab, closeActiveTab }),
},
}));
import { DesktopRouteErrorPage, formatRouteErrorReport } from "./route-error-page";
function Boom(): null {
throw new Error("route render exploded");
return null;
}
describe("DesktopRouteErrorPage", () => {
beforeEach(() => {
openModal.mockReset();
reloadActiveTab.mockReset();
closeActiveTab.mockReset();
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("brands React Router route errors and offers tab reload", async () => {
const router = createMemoryRouter(
[{ path: "/", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
{ initialEntries: ["/"] },
);
render(<RouterProvider router={router} />);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Something went wrong in this tab",
);
fireEvent.click(screen.getByRole("button", { name: /reload tab/i }));
expect(reloadActiveTab).toHaveBeenCalledTimes(1);
});
it("offers Close tab as the always-safe escape from a crashing route", async () => {
const router = createMemoryRouter(
[{ path: "/acme/issues/1", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
{ initialEntries: ["/acme/issues/1"] },
);
render(<RouterProvider router={router} />);
fireEvent.click(await screen.findByRole("button", { name: /close tab/i }));
expect(closeActiveTab).toHaveBeenCalledTimes(1);
});
it("opens the existing feedback modal with a structured markdown report only after click", async () => {
const router = createMemoryRouter(
[{ path: "/acme/issues", element: <Boom />, errorElement: <DesktopRouteErrorPage /> }],
{ initialEntries: ["/acme/issues"] },
);
render(<RouterProvider router={router} />);
expect(openModal).not.toHaveBeenCalled();
fireEvent.click(await screen.findByRole("button", { name: /report error/i }));
expect(openModal).toHaveBeenCalledWith(
"feedback",
expect.objectContaining({
initialMessage: expect.stringContaining("kind: desktop_route_error"),
}),
);
});
it("documents the structured kind/context follow-up debt in the report template", () => {
const report = formatRouteErrorReport({
error: new Error("bad route"),
url: "app://desktop/acme/issues",
appInfo: { version: "1.2.3", os: "macos" },
trigger: "route-errorElement",
});
expect(report).toContain("kind: desktop_route_error");
expect(report).toContain("trigger: route-errorElement");
expect(report).toContain("TODO: promote kind/context to structured feedback fields");
});
});

View File

@@ -1,140 +0,0 @@
import { useMemo } from "react";
import { useLocation, useNavigate, useRouteError } from "react-router-dom";
import { AlertTriangle, RotateCw, Send, X } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useModalStore } from "@multica/core/modals";
import { useTabStore } from "@/stores/tab-store";
type DesktopAppInfo = {
version?: string;
os?: string;
};
export function formatRouteErrorReport({
error,
url,
appInfo,
trigger,
}: {
error: unknown;
url: string;
appInfo?: DesktopAppInfo;
trigger: string;
}) {
const normalized = normalizeError(error);
return [
"kind: desktop_route_error",
`trigger: ${trigger}`,
`url: ${url}`,
`app_version: ${appInfo?.version ?? "unknown"}`,
`runtime_os: ${appInfo?.os ?? "unknown"}`,
"",
"context:",
`- name: ${normalized.name}`,
`- message: ${normalized.message}`,
"",
"stack:",
"```",
normalized.stack ?? "<no stack>",
"```",
"",
"TODO: promote kind/context to structured feedback fields once the feedback API supports them.",
].join("\n");
}
export function DesktopRouteErrorPage() {
const error = useRouteError();
const location = useLocation();
const navigate = useNavigate();
const workspaceSlug = location.pathname.split("/").filter(Boolean)[0];
const safeRoute = workspaceSlug ? `/${workspaceSlug}/issues` : null;
const report = useMemo(
() =>
formatRouteErrorReport({
error,
url:
typeof window !== "undefined"
? `${window.location.origin}${location.pathname}${location.search}${location.hash}`
: location.pathname,
appInfo: typeof window !== "undefined" ? window.desktopAPI?.appInfo : undefined,
trigger: "route-errorElement",
}),
[error, location.hash, location.pathname, location.search],
);
const message = normalizeError(error).message;
return (
<div
role="alert"
className="flex h-full min-h-[20rem] flex-col items-center justify-center gap-4 p-8 text-center"
>
<div className="rounded-full bg-destructive/10 p-3 text-destructive">
<AlertTriangle className="h-6 w-6" aria-hidden="true" />
</div>
<div className="space-y-2">
<h2 className="text-lg font-semibold">Something went wrong in this tab</h2>
<p className="max-w-lg text-sm text-muted-foreground">
A route-level renderer error was contained before it could take down the
desktop shell. Reload this tab, or send the report if it keeps happening.
</p>
<p className="max-w-lg truncate text-xs text-muted-foreground">{message}</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
onClick={() => useTabStore.getState().reloadActiveTab()}
>
<RotateCw className="mr-2 h-4 w-4" aria-hidden="true" />
Reload tab
</Button>
{safeRoute ? (
<Button type="button" variant="outline" onClick={() => navigate(safeRoute, { replace: true })}>
Go to issues
</Button>
) : null}
<Button
type="button"
variant="outline"
onClick={() => useTabStore.getState().closeActiveTab()}
>
<X className="mr-2 h-4 w-4" aria-hidden="true" />
Close tab
</Button>
<Button
type="button"
onClick={() =>
useModalStore.getState().open("feedback", {
initialMessage: report,
})
}
>
<Send className="mr-2 h-4 w-4" aria-hidden="true" />
Report error
</Button>
</div>
</div>
);
}
function normalizeError(error: unknown): { name: string; message: string; stack?: string } {
if (error instanceof Error) {
return {
name: error.name || "Error",
message: error.message || "Unknown route error",
stack: error.stack,
};
}
if (typeof error === "string") {
return { name: "Error", message: error };
}
return { name: "Error", message: "Unknown route error", stack: safeJson(error) };
}
function safeJson(value: unknown) {
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}

View File

@@ -1,151 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, fireEvent, within } from "@testing-library/react";
type MockTab = {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
};
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
] as MockTab[],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
togglePin: vi.fn<(tabId: string) => void>(),
closeTab: vi.fn<(tabId: string) => void>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
moveTab: vi.fn<(from: number, to: number) => void>(),
addTab: vi.fn<(path: string, title: string, icon: string) => string>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
togglePin: state.togglePin,
closeTab: state.closeTab,
setActiveTab: state.setActiveTab,
moveTab: state.moveTab,
addTab: state.addTab,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const useActiveGroup = () =>
state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug] ?? null)
: null;
const resolveRouteIcon = () => "ListTodo";
return { useTabStore, useActiveGroup, resolveRouteIcon };
});
vi.mock("@multica/core/paths", () => ({
paths: {
workspace: (slug: string) => ({
issues: () => `/${slug}/issues`,
}),
},
}));
import { TabBar } from "./tab-bar";
function reset() {
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
],
},
};
state.togglePin.mockReset();
state.closeTab.mockReset();
state.setActiveTab.mockReset();
state.moveTab.mockReset();
state.addTab.mockReset();
}
beforeEach(reset);
describe("TabBar hover action buttons", () => {
it("renders a Pin button on every unpinned tab and an Unpin button on every pinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getAllByLabelText } = render(<TabBar />);
expect(getAllByLabelText("Unpin tab")).toHaveLength(1);
expect(getAllByLabelText("Pin tab")).toHaveLength(1);
});
it("clicking the Pin button calls togglePin for the tab", () => {
const { getAllByLabelText } = render(<TabBar />);
const pinButtons = getAllByLabelText("Pin tab");
fireEvent.click(pinButtons[1]); // click Pin on tB (Projects)
expect(state.togglePin).toHaveBeenCalledWith("tB");
});
it("clicking the Unpin button on a pinned tab calls togglePin", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
fireEvent.click(getByLabelText("Unpin tab"));
expect(state.togglePin).toHaveBeenCalledWith("tA");
});
it("hides the X close button on a pinned tab but keeps it on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { queryAllByLabelText } = render(<TabBar />);
// Only the unpinned tab exposes a Close affordance — pinned tab requires
// explicit Unpin first (RFC §3 D3c FINAL).
expect(queryAllByLabelText("Close tab")).toHaveLength(1);
});
it("keeps the full title visible on a pinned tab (no icon-only collapse)", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
expect(within(pinnedTab).getByText("Issues")).toBeTruthy();
});
it("renders the Pin glyph as the leading icon on a pinned tab and the route icon on an unpinned tab", () => {
state.byWorkspace.acme.tabs = [
{ id: "tA", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: true },
{ id: "tB", path: "/acme/projects", title: "Projects", icon: "ListTodo", pinned: false },
];
const { getByLabelText } = render(<TabBar />);
const pinnedTab = getByLabelText("Issues (pinned)");
const unpinnedTab = getByLabelText("Projects");
// lucide-react renders the icon name into the class list. The leading
// slot icon is size-3.5; the hover Pin/Unpin action button is size-2.5,
// so we qualify on size to avoid matching the action glyph.
expect(pinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeTruthy();
expect(pinnedTab.querySelector(".lucide-list-todo")).toBeNull();
expect(unpinnedTab.querySelector(".lucide-list-todo.size-3\\.5")).toBeTruthy();
expect(unpinnedTab.querySelector(".lucide-pin.size-3\\.5")).toBeNull();
});
});

View File

@@ -1,4 +1,3 @@
import { Fragment } from "react";
import {
Inbox,
CircleUser,
@@ -9,8 +8,6 @@ import {
Settings,
X,
Plus,
Pin,
PinOff,
type LucideIcon,
} from "lucide-react";
import {
@@ -31,20 +28,8 @@ import {
restrictToParentElement,
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@multica/ui/components/ui/context-menu";
import { cn } from "@multica/ui/lib/utils";
import {
useTabStore,
useActiveGroup,
resolveRouteIcon,
type Tab,
} from "@/stores/tab-store";
import { useTabStore, useActiveGroup, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
@@ -57,23 +42,9 @@ const TAB_ICONS: Record<string, LucideIcon> = {
Settings,
};
function SortableTabItem({
tab,
isActive,
isOnly,
}: {
tab: Tab;
isActive: boolean;
/**
* True iff this is the only tab in the workspace. Hiding X on the last
* tab matches existing behavior and avoids the surprise of the store's
* last-tab reseed kicking in. Pinned tabs always hide X (RFC §3 D3c).
*/
isOnly: boolean;
}) {
function SortableTabItem({ tab, isActive, isOnly }: { tab: Tab; isActive: boolean; isOnly: boolean }) {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const closeTab = useTabStore((s) => s.closeTab);
const togglePin = useTabStore((s) => s.togglePin);
const {
attributes,
@@ -84,11 +55,7 @@ function SortableTabItem({
isDragging,
} = useSortable({ id: tab.id });
// Pinned tabs swap the route icon for a Pin glyph as the static "I am
// pinned" indicator (RFC §3 D1v-iv FINAL). The route information is still
// present in the title, and this avoids a hard left accent border that read
// as visually heavy in light mode.
const LeadingIcon = tab.pinned ? Pin : TAB_ICONS[tab.icon];
const Icon = TAB_ICONS[tab.icon];
const style = {
transform: CSS.Transform.toString(transform),
@@ -107,31 +74,17 @@ function SortableTabItem({
closeTab(tab.id);
};
const handleTogglePin = (e: React.MouseEvent) => {
e.stopPropagation();
togglePin(tab.id);
};
const stopDragOnAction = (e: React.PointerEvent) => {
const stopDragOnClose = (e: React.PointerEvent) => {
e.stopPropagation();
};
// Pinned tabs keep their full title (RFC §3 D1v-ii FINAL). The only visual
// differences vs. unpinned tabs are the leading Pin icon (swapped in above)
// and the suppressed X (closing requires explicit Unpin). Pin/Unpin is
// reachable via the hover action button below and the right-click menu.
const showCloseButton = !tab.pinned && !isOnly;
const tabButton = (
return (
<button
type="button"
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
onClick={handleClick}
aria-label={tab.pinned ? `${tab.title} (pinned)` : tab.title}
title={tab.pinned ? `${tab.title} (pinned)` : undefined}
className={cn(
"group flex h-7 w-40 items-center gap-1.5 rounded-md px-2 text-xs transition-colors",
"select-none cursor-default",
@@ -141,7 +94,7 @@ function SortableTabItem({
isDragging && "opacity-60",
)}
>
{LeadingIcon && <LeadingIcon className="size-3.5 shrink-0" />}
{Icon && <Icon className="size-3.5 shrink-0" />}
<span
className="min-w-0 flex-1 overflow-hidden whitespace-nowrap text-left"
style={{
@@ -151,22 +104,10 @@ function SortableTabItem({
>
{tab.title}
</span>
<span
onClick={handleTogglePin}
onPointerDown={stopDragOnAction}
role="button"
aria-label={tab.pinned ? "Unpin tab" : "Pin tab"}
title={tab.pinned ? "Unpin tab" : "Pin tab"}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
{tab.pinned ? <PinOff className="size-2.5" /> : <Pin className="size-2.5" />}
</span>
{showCloseButton && (
{!isOnly && (
<span
onClick={handleClose}
onPointerDown={stopDragOnAction}
role="button"
aria-label="Close tab"
onPointerDown={stopDragOnClose}
className="hidden size-3.5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors group-hover:flex hover:bg-muted-foreground/20 hover:text-foreground"
>
<X className="size-2.5" />
@@ -174,36 +115,6 @@ function SortableTabItem({
)}
</button>
);
return (
<ContextMenu>
<ContextMenuTrigger render={tabButton} />
<ContextMenuContent>
<ContextMenuItem onClick={() => togglePin(tab.id)}>
{tab.pinned ? (
<>
<PinOff />
Unpin tab
</>
) : (
<>
<Pin />
Pin tab
</>
)}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={tab.pinned || isOnly}
onClick={() => closeTab(tab.id)}
>
<X />
Close tab
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
function NewTabButton() {
@@ -222,7 +133,6 @@ function NewTabButton() {
return (
<button
type="button"
onClick={handleClick}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-muted/50 hover:text-muted-foreground"
@@ -245,17 +155,12 @@ export function TabBar() {
const tabs = group?.tabs ?? [];
const activeTabId = group?.activeTabId ?? "";
const tabIds = tabs.map((t) => t.id);
const pinnedCount = tabs.filter((t) => t.pinned).length;
const unpinnedCount = tabs.length - pinnedCount;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const from = tabs.findIndex((t) => t.id === active.id);
const to = tabs.findIndex((t) => t.id === over.id);
// The store clamps the destination to within the source tab's zone
// (pinned vs unpinned), so this call is safe even when the user tries
// to drag across the boundary — the tab will land at the boundary.
if (from !== -1 && to !== -1) moveTab(from, to);
};
@@ -268,22 +173,13 @@ export function TabBar() {
onDragEnd={handleDragEnd}
>
<SortableContext items={tabIds} strategy={horizontalListSortingStrategy}>
{tabs.map((tab, index) => (
<Fragment key={tab.id}>
<SortableTabItem
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
{tab.pinned &&
index === pinnedCount - 1 &&
unpinnedCount > 0 && (
<div
aria-hidden
className="mx-1 h-4 w-px bg-border"
/>
)}
</Fragment>
{tabs.map((tab) => (
<SortableTabItem
key={tab.id}
tab={tab}
isActive={tab.id === activeTabId}
isOnly={tabs.length === 1}
/>
))}
</SortableContext>
</DndContext>

View File

@@ -3,7 +3,6 @@ import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { TabNavigationProvider } from "@/platform/navigation";
import { useTabRouterSync } from "@/hooks/use-tab-router-sync";
import { useTabScrollRestore } from "@/hooks/use-tab-scroll-restore";
import type { Tab } from "@/stores/tab-store";
/**
@@ -16,28 +15,6 @@ function TabRouterInner({ tab }: { tab: Tab }) {
return null;
}
/**
* Wraps a tab's subtree so its scroll position survives the round trip
* through `<Activity mode="hidden">`. Lives inside Activity so the hook's
* effects cycle with the tab's visibility — see `useTabScrollRestore` for
* the mechanism. `display: contents` keeps the wrapper transparent to
* the surrounding flex layout.
*/
function TabScrollRestoreWrapper({
tabPath,
children,
}: {
tabPath: string;
children: React.ReactNode;
}) {
const ref = useTabScrollRestore(tabPath);
return (
<div ref={ref} style={{ display: "contents" }}>
{children}
</div>
);
}
/**
* Renders the active workspace's tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
@@ -67,12 +44,10 @@ export function TabContent() {
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
>
<TabScrollRestoreWrapper tabPath={tab.path}>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
</TabScrollRestoreWrapper>
<TabNavigationProvider router={tab.router}>
<RouterProvider router={tab.router} />
<TabRouterInner tab={tab} />
</TabNavigationProvider>
</Activity>
))}
</>

View File

@@ -26,7 +26,6 @@ export function UpdateNotification() {
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
@@ -44,14 +43,12 @@ export function UpdateNotification() {
</p>
<div className="mt-2 flex items-center gap-1.5">
<button
type="button"
onClick={() => setDismissed(true)}
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
>
Later
</button>
<button
type="button"
onClick={() => window.updater.installUpdate()}
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>

View File

@@ -1,7 +1,6 @@
import { useCallback, useState } from "react";
import { AlertCircle, ArrowDownToLine, Check, Loader2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "@multica/views/i18n";
type CheckState =
| { status: "idle" }
@@ -11,7 +10,6 @@ type CheckState =
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const { t } = useT("settings");
const [state, setState] = useState<CheckState>({ status: "idle" });
const currentVersion = window.desktopAPI.appInfo.version;
@@ -31,15 +29,17 @@ export function UpdatesSettingsTab() {
return (
<div>
<h2 className="text-lg font-semibold">{t(($) => $.desktop.updates.title)}</h2>
<h2 className="text-lg font-semibold">Updates</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.desktop.updates.description)}
The desktop app checks for new versions automatically once an hour and
shortly after launch, downloading them in the background. You&apos;ll
be prompted to restart once an update is ready.
</p>
<div className="mt-6 divide-y">
<div className="flex items-center justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">{t(($) => $.desktop.updates.current_version)}</p>
<p className="text-sm font-medium">Current version</p>
<p className="text-sm text-muted-foreground mt-0.5 font-mono">
v{currentVersion}
</p>
@@ -48,20 +48,23 @@ export function UpdatesSettingsTab() {
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">{t(($) => $.desktop.updates.check_section_title)}</p>
<p className="text-sm font-medium">Check for updates</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t(($) => $.desktop.updates.check_section_description)}
Trigger a check now instead of waiting for the next automatic
poll. Available updates download in the background and show a
restart prompt when ready.
</p>
{state.status === "up-to-date" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<Check className="size-3.5 text-success" />
{t(($) => $.desktop.updates.up_to_date)}
You&apos;re on the latest version.
</p>
)}
{state.status === "available" && (
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
<ArrowDownToLine className="size-3.5 text-primary" />
{t(($) => $.desktop.updates.downloading, { version: state.latestVersion })}
v{state.latestVersion} is downloading in the background
you&apos;ll be notified when it&apos;s ready to install.
</p>
)}
{state.status === "error" && (
@@ -81,10 +84,10 @@ export function UpdatesSettingsTab() {
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
{t(($) => $.desktop.updates.checking)}
Checking
</>
) : (
t(($) => $.desktop.updates.check_now)
"Check now"
)}
</Button>
</div>

View File

@@ -62,25 +62,18 @@ function WindowOverlayInner() {
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws, issueId) => {
onComplete={(ws) => {
close();
// Runtime-connected onboarding lands on its single guide
// issue. Runtime-less exits still land on the issues list.
if (ws && issueId) {
push(paths.workspace(ws.slug).issueDetail(issueId));
} else if (ws) {
// Post-onboarding landing is always the workspace issues
// list. The welcome-issue flow moved into a dialog that
// renders on that page (StarterContentPrompt), so the
// flow doesn't need to thread a target issue id back here.
if (ws) {
push(paths.workspace(ws.slug).issues());
} else {
push(paths.root());
}
}}
// Restart the bundled daemon when the user hits Refresh on
// Step 3. The daemon's PATH probe runs once at boot, so a
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
// up until the daemon is bounced.
onRuntimeRefresh={async () => {
await window.daemonAPI?.restart?.();
}}
/>
)}
</div>

View File

@@ -1,147 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
// vi.hoisted shared state for all the stores / hooks the layout consumes.
const state = vi.hoisted(() => ({
user: null as { id: string } | null,
isAuthLoading: false,
overlay: null as { type: string } | null,
workspace: null as { id: string; slug: string } | null,
listFetched: true,
wsList: [] as { id: string; slug: string }[],
workspaceSeen: true,
modalRenders: 0,
modalAriaLabel: "source-backfill-modal-marker",
}));
vi.mock("@multica/core/auth", () => {
const useAuthStore = (selector: (s: typeof state) => unknown) => {
if (selector.toString().includes("isLoading"))
return state.isAuthLoading;
return state.user;
};
return { useAuthStore };
});
vi.mock("@multica/core/platform", () => ({
setCurrentWorkspace: vi.fn(),
}));
vi.mock("@multica/core/workspace", async () => {
const actual = await vi.importActual<typeof import("@multica/core/workspace")>(
"@multica/core/workspace",
);
return {
...actual,
workspaceBySlugOptions: () => ({
queryKey: ["workspace-by-slug"],
queryFn: async () => state.workspace,
}),
workspaceListOptions: () => ({
queryKey: ["workspace-list"],
queryFn: async () => state.wsList,
}),
};
});
vi.mock("@multica/core/paths", async () => {
const actual = await vi.importActual<typeof import("@multica/core/paths")>(
"@multica/core/paths",
);
return {
...actual,
WorkspaceSlugProvider: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
paths: {
...actual.paths,
login: () => "/login",
},
};
});
vi.mock("@multica/views/workspace/use-workspace-seen", () => ({
useWorkspaceSeen: () => state.workspaceSeen,
}));
vi.mock("@multica/views/workspace/welcome-after-onboarding", () => ({
WelcomeAfterOnboarding: () => null,
}));
vi.mock("@multica/views/layout", () => ({
WorkspacePresencePrefetch: () => null,
}));
// The point of this whole test: assert the desktop layout mounts the
// SourceBackfillModal. We stub the real component with a marker that
// renders only when the layout actually rendered it (and not e.g.
// suppressed by overlayActive).
vi.mock("@multica/views/onboarding", () => ({
SourceBackfillModal: () => {
state.modalRenders += 1;
return <div data-testid={state.modalAriaLabel} />;
},
}));
vi.mock("@/stores/tab-store", () => ({
useTabStore: Object.assign(() => null, {
getState: () => ({ validateWorkspaceSlugs: vi.fn() }),
}),
}));
vi.mock("@/stores/window-overlay-store", () => {
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useWindowOverlayStore };
});
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { WorkspaceRouteLayout } from "./workspace-route-layout";
function renderLayout() {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
// Seed the workspace queries so the gate inside the layout passes
// synchronously — the real hook reads from cache.
qc.setQueryData(["workspace-by-slug"], state.workspace);
qc.setQueryData(["workspace-list"], state.wsList);
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/acme/issues"]}>
<Routes>
<Route path=":workspaceSlug/*" element={<WorkspaceRouteLayout />}>
<Route path="*" element={<div data-testid="outlet" />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
beforeEach(() => {
state.user = { id: "u1" };
state.isAuthLoading = false;
state.overlay = null;
state.workspace = { id: "ws-1", slug: "acme" };
state.listFetched = true;
state.wsList = [{ id: "ws-1", slug: "acme" }];
state.workspaceSeen = true;
state.modalRenders = 0;
});
describe("WorkspaceRouteLayout", () => {
it("mounts SourceBackfillModal when no WindowOverlay is active", () => {
const { queryByTestId } = renderLayout();
expect(queryByTestId(state.modalAriaLabel)).not.toBeNull();
expect(state.modalRenders).toBeGreaterThan(0);
});
it("suppresses SourceBackfillModal while a WindowOverlay is active", () => {
state.overlay = { type: "new-workspace" };
const { queryByTestId } = renderLayout();
expect(queryByTestId(state.modalAriaLabel)).toBeNull();
expect(state.modalRenders).toBe(0);
});
});

View File

@@ -9,11 +9,8 @@ import {
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
import { WelcomeAfterOnboarding } from "@multica/views/workspace/welcome-after-onboarding";
import { WorkspacePresencePrefetch } from "@multica/views/layout";
import { SourceBackfillModal } from "@multica/views/onboarding";
import { useTabStore } from "@/stores/tab-store";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Desktop equivalent of apps/web/app/[workspaceSlug]/layout.tsx.
@@ -37,15 +34,6 @@ export function WorkspaceRouteLayout() {
const navigate = useNavigate();
const user = useAuthStore((s) => s.user);
const isAuthLoading = useAuthStore((s) => s.isLoading);
// While a WindowOverlay is open (onboarding, accept-invite, new-workspace),
// the underlying tab is still mounted in the React tree — so this layout
// and its WelcomeAfterOnboarding Modal would render UNDER the overlay.
// Because the modal uses a Portal that targets document.body, it ends up
// rendered LATER in the DOM and visually outranks the overlay's z-50.
// Suppress the modal whenever any overlay is active; the moment the
// overlay closes the welcome hook re-evaluates and pops if its store
// signal is still set.
const overlayActive = useWindowOverlayStore((s) => s.overlay !== null);
// Workspace routes require auth. If user is unauthenticated, bounce to /login.
useEffect(() => {
@@ -97,21 +85,6 @@ export function WorkspaceRouteLayout() {
<WorkspaceSlugProvider slug={workspaceSlug}>
<WorkspacePresencePrefetch />
<Outlet />
{/* Reads the welcome-store transient signal parked by
* OnboardingFlow.handleRuntimeNext. Suppressed while a WindowOverlay
* (onboarding / accept-invite / new-workspace) is open so the modal
* doesn't portal-jump in front of an active pre-workspace flow.
* Once the overlay closes the hook re-evaluates and pops the
* Modal — unless the store signal has already been consumed, in
* which case the hook renders null. */}
{!overlayActive && <WelcomeAfterOnboarding />}
{/* Source-attribution backfill: same Dialog the web shell mounts
* inside DashboardLayout. Desktop's WorkspaceRouteLayout doesn't
* wrap DashboardLayout, so the modal has to be wired in directly
* here. Same overlay-suppression rule as WelcomeAfterOnboarding —
* a portal-rendered Dialog at z-50 would otherwise sit above an
* active pre-workspace overlay. */}
{!overlayActive && <SourceBackfillModal />}
</WorkspaceSlugProvider>
);
}

View File

@@ -1,58 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"];
const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"];
const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"];
function expectChineseFontsBeforeKoreanFonts(source: string) {
const chineseIndexes = chineseFonts.map((font) => source.indexOf(font));
const koreanIndexes = koreanFonts.map((font) => source.indexOf(font));
expect(chineseIndexes).not.toContain(-1);
expect(koreanIndexes).not.toContain(-1);
for (const chineseIndex of chineseIndexes) {
for (const koreanIndex of koreanIndexes) {
expect(chineseIndex).toBeLessThan(koreanIndex);
}
}
}
// Japanese Kanji share the Han Unicode block with Chinese, so the global
// Chinese-first stack must stay Chinese-first (no zh regression) while a
// Japanese-first CJK stack is scoped to html[lang|="ja"]. App.tsx syncs
// document.documentElement.lang so the selector matches at runtime.
function expectJapaneseScopedOverride(source: string) {
expect(source).toContain('html[lang|="ja"]');
const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font));
expect(japaneseIndexes).not.toContain(-1);
const firstJapanese = Math.min(...japaneseIndexes);
const lastChinese = Math.max(
...chineseFonts.map((font) => source.lastIndexOf(font)),
);
expect(firstJapanese).toBeLessThan(lastChinese);
}
describe("CJK font fallback order", () => {
it("keeps desktop Chinese font fallbacks before Korean font fallbacks", () => {
const desktopCss = readFileSync(
resolve(process.cwd(), "src/renderer/src/globals.css"),
"utf8",
);
expectChineseFontsBeforeKoreanFonts(desktopCss);
});
it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => {
const desktopCss = readFileSync(
resolve(process.cwd(), "src/renderer/src/globals.css"),
"utf8",
);
expectJapaneseScopedOverride(desktopCss);
});
});

View File

@@ -6,15 +6,16 @@
@custom-variant dark (&:is(.dark *));
/* Font stack: Inter for Latin UI text + system CJK fonts for localized content.
/* Font stack: Inter for Latin UI text + system Chinese fonts for zh content.
Web app uses the same stack via next/font/google in apps/web/app/layout.tsx —
keep the CJK fallback tail in sync across both files. The Inter primary family
differs by design: next/font produces `__Inter_xxx` (with a synthetic size-adjusted
fallback face to prevent FOUT layout shift); desktop uses fontsource's "Inter Variable".
Both resolve to Inter glyphs, so rendering is identical in practice.
Per-character fallback: Latin chars render with Inter, CJK chars render with
the platform-native Chinese/Korean fallback when needed. Chinese fonts must stay
before Korean fonts so zh users do not receive Korean Hanja glyph shapes.
Currently covers English + Simplified Chinese. When ja/ko i18n lands, extend
the tail with Hiragino Kaku Gothic ProN / Yu Gothic / Apple SD Gothic Neo / Malgun Gothic.
Per-character fallback: Latin chars render with Inter, Chinese chars with
PingFang SC (macOS) / Microsoft YaHei (Windows) / Noto Sans CJK SC (Linux).
Mono font has no explicit CJK fallback: CJK chars in code blocks are inherently
non-aligned with a mono grid (Chinese is proportional), so listing CJK fonts
@@ -22,35 +23,14 @@
the rare mixed case correctly. */
:root {
--font-sans: "Inter Variable", "Inter", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang SC", "Microsoft YaHei",
"Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
"Noto Sans CJK KR", sans-serif;
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-serif;
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
"Apple Garamond", Baskerville, "Times New Roman", serif;
--font-mono: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
monospace;
}
/* Japanese-scoped CJK override. Japanese Kanji share the Han Unicode block
with Chinese, and CSS font-fallback order is not changed by <html lang> —
so the global Chinese-first stack above would give Japanese users Chinese
glyph shapes for shared ideographs. We keep the global stack Chinese-first
(no regression for zh users) and promote Japanese fonts ahead of the
Chinese/Korean families only when the locale is Japanese. App.tsx syncs
document.documentElement.lang to the active locale so this selector
matches. Mirrors the lang-scoped override in apps/web/app/layout.tsx.
`[lang|="ja"]` is the BCP-47 language-range selector: it matches exactly
`ja` or `ja-<region>` (App.tsx sets `ja-JP`), never unrelated subtags
such as `jam`. */
html[lang|="ja"] {
--font-sans: "Inter Variable", "Inter", "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Yu Gothic", "YuGothic", "Meiryo",
"Noto Sans CJK JP", "Noto Sans JP", -apple-system,
BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei",
"Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
"Noto Sans CJK KR", sans-serif;
}
@source "../../../../../packages/ui/**/*.tsx";
@source "../../../../../packages/core/**/*.{ts,tsx}";
@source "../../../../../packages/views/**/*.{ts,tsx}";

View File

@@ -1,116 +0,0 @@
import { Activity } from "react";
import { describe, expect, it } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import { useTabScrollRestore } from "./use-tab-scroll-restore";
function Harness({ path }: { path: string }) {
const ref = useTabScrollRestore(path);
return (
<div ref={ref} style={{ display: "contents" }}>
<div
data-tab-scroll-root
data-testid="scroller"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
<div
data-tab-scroll-root="aside"
data-testid="aside"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
<div
data-testid="unmarked"
style={{ height: 100, overflow: "auto" }}
>
<div style={{ height: 1000 }} />
</div>
</div>
);
}
function App({ visible, path }: { visible: boolean; path: string }) {
return (
<Activity mode={visible ? "visible" : "hidden"}>
<Harness path={path} />
</Activity>
);
}
function setScroll(el: HTMLElement, top: number) {
el.scrollTop = top;
fireEvent.scroll(el);
}
describe("useTabScrollRestore", () => {
it("restores scroll position when a tab cycles through hidden -> visible", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const scroller = getByTestId("scroller") as HTMLElement;
setScroll(scroller, 500);
expect(scroller.scrollTop).toBe(500);
// Simulate Activity hiding the subtree: layout would drop the offset.
rerender(<App visible={false} path="/acme/issues/1" />);
scroller.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(scroller.scrollTop).toBe(500);
});
it("restores multiple named scroll roots independently", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const main = getByTestId("scroller") as HTMLElement;
const aside = getByTestId("aside") as HTMLElement;
setScroll(main, 300);
setScroll(aside, 150);
rerender(<App visible={false} path="/acme/issues/1" />);
main.scrollTop = 0;
aside.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(main.scrollTop).toBe(300);
expect(aside.scrollTop).toBe(150);
});
it("ignores scroll on elements without the data-tab-scroll-root marker", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const unmarked = getByTestId("unmarked") as HTMLElement;
setScroll(unmarked, 250);
rerender(<App visible={false} path="/acme/issues/1" />);
unmarked.scrollTop = 0;
rerender(<App visible={true} path="/acme/issues/1" />);
expect(unmarked.scrollTop).toBe(0);
});
it("drops saved offsets when the tab path changes (intra-tab navigation)", () => {
const { rerender, getByTestId } = render(
<App visible={true} path="/acme/issues/1" />,
);
const scroller = getByTestId("scroller") as HTMLElement;
setScroll(scroller, 500);
// Navigating within the tab swaps the active route — same marker key,
// different page. We should NOT restore the prior page's offset.
rerender(<App visible={true} path="/acme/issues/2" />);
scroller.scrollTop = 0;
rerender(<App visible={false} path="/acme/issues/2" />);
rerender(<App visible={true} path="/acme/issues/2" />);
expect(scroller.scrollTop).toBe(0);
});
});

View File

@@ -1,106 +0,0 @@
import { useEffect, useLayoutEffect, useRef } from "react";
/**
* Persist a tab's scroll positions across <Activity> visibility transitions.
*
* Tabs render under `<Activity mode="visible|hidden">`, which keeps React
* state but loses DOM scrollTop — the subtree is taken out of layout while
* hidden and rejoins with scrollTop=0. This hook records every marked
* container's `scrollTop` while the tab is visible (continuously, via a
* capture-phase scroll listener) and restores them in a `useLayoutEffect`
* the next time the tab becomes visible, before the browser paints.
*
* Mark scroll containers in views with `data-tab-scroll-root`. The
* attribute value is the cache key — defaults to `"main"` for unnamed
* roots. Most pages have a single scroll container, so a bare attribute
* is enough; named keys are only needed when a page has multiple
* independently scrollable regions whose positions must all be restored.
*
* When the tab's path changes (intra-tab navigation), the saved offsets
* are dropped — the new route's container shares the same marker key but
* is a different page, and restoring the old offset would land the user
* somewhere arbitrary on the new page.
*
* For virtualized children (Virtuoso, react-virtual, etc.) the single
* synchronous `scrollTop = saved` inside useLayoutEffect isn't enough:
* the child registers its observers in a passive useEffect that fires
* later, so at restore time the container's scrollHeight has collapsed
* to clientHeight and the browser clamps our assignment to 0. The
* restore loops across rAF frames until the assignment sticks, which
* lets virtualization rehydrate before we give up.
*/
export function useTabScrollRestore(tabPath: string) {
const containerRef = useRef<HTMLDivElement>(null);
const savedRef = useRef<Map<string, number>>(new Map());
const prevPathRef = useRef(tabPath);
if (prevPathRef.current !== tabPath) {
savedRef.current.clear();
prevPathRef.current = tabPath;
}
// <Activity> cleans up effects on hidden and re-mounts them on visible,
// so an empty-deps useLayoutEffect runs exactly on every hidden → visible
// transition. Restoring here (before the browser paints) handles the
// common case without a flash.
//
// The synchronous set isn't enough for virtualized lists though
// (issue-detail uses Virtuoso with customScrollParent). Virtuoso wires
// its scroll/resize observers in a passive useEffect, which fires AFTER
// useLayoutEffect — so at the moment we try to restore, the spacer that
// gives the container its tall scrollHeight hasn't been re-established
// yet. The browser silently clamps `scrollTop = saved` down to 0 because
// `scrollHeight === clientHeight` in that window. Retry across rAF
// frames until the set sticks (or we time out around the time any sane
// child should have laid out, ~500ms).
useLayoutEffect(() => {
const root = containerRef.current;
if (!root) return;
const els = root.querySelectorAll<HTMLElement>("[data-tab-scroll-root]");
const cancellers: Array<() => void> = [];
els.forEach((el) => {
const key = scrollKey(el);
const saved = savedRef.current.get(key);
if (saved === undefined) return;
el.scrollTop = saved;
if (el.scrollTop === saved) return;
let cancelled = false;
let attempts = 0;
const maxAttempts = 30; // ~500ms at 60fps
const tick = () => {
if (cancelled) return;
el.scrollTop = saved;
attempts++;
if (el.scrollTop === saved) return;
if (attempts >= maxAttempts) return;
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
cancellers.push(() => {
cancelled = true;
});
});
return () => cancellers.forEach((c) => c());
}, []);
useEffect(() => {
const root = containerRef.current;
if (!root) return;
const onScroll = (e: Event) => {
const target = e.target;
if (!(target instanceof HTMLElement)) return;
if (!target.hasAttribute("data-tab-scroll-root")) return;
savedRef.current.set(scrollKey(target), target.scrollTop);
};
// Scroll events don't bubble, but capture catches them anyway.
root.addEventListener("scroll", onScroll, { capture: true, passive: true });
return () => root.removeEventListener("scroll", onScroll, true);
}, []);
return containerRef;
}
function scrollKey(el: HTMLElement): string {
return el.getAttribute("data-tab-scroll-root") || "main";
}

View File

@@ -1,16 +0,0 @@
import { useParams, useSearchParams } from "react-router-dom";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export function AttachmentPreviewRoute() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const filename = searchParams.get("name") ?? undefined;
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -1,6 +1,7 @@
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";
@@ -13,8 +14,9 @@ export function IssueDetailPage() {
useDocumentTitle(issue ? `${issue.identifier}: ${issue.title}` : "Issue");
if (!id) return null;
// Render errors bubble to the root route errorElement (DesktopRouteErrorPage),
// which contains the crash inside the tab content pane. No page-level boundary
// here — a whole-page wrapper duplicates the route-level error UI.
return <IssueDetail issueId={id} />;
return (
<ErrorBoundary resetKeys={[id]}>
<IssueDetail issueId={id} />
</ErrorBoundary>
);
}

View File

@@ -11,14 +11,7 @@ import type { AgentRuntime } from "@multica/core/types";
* to the desktop preload typings (which live in apps/desktop/src/preload).
*/
interface DaemonStatusLike {
state:
| "running"
| "stopped"
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
| "auth_expired";
state: "running" | "stopped" | "starting" | "stopping" | "installing_cli" | "cli_not_found";
daemonId?: string;
}
@@ -32,11 +25,7 @@ interface DaemonStatusLike {
* within 75s.
*/
function mergeDaemonStatus(rt: AgentRuntime, status: DaemonStatusLike): AgentRuntime {
if (
status.state === "stopped" ||
status.state === "stopping" ||
status.state === "auth_expired"
) {
if (status.state === "stopped" || status.state === "stopping") {
return { ...rt, status: "offline" };
}
if (status.state === "running") {

View File

@@ -1,98 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { mockGetState, logout } = vi.hoisted(() => ({
mockGetState: vi.fn(),
logout: vi.fn(),
}));
const { toastError } = vi.hoisted(() => ({ toastError: vi.fn() }));
vi.mock("@multica/core/auth", () => ({
useAuthStore: { getState: mockGetState },
}));
vi.mock("sonner", () => ({
toast: { error: toastError },
}));
import { reauthenticateDaemon } from "./daemon-reauth";
const daemonAPI = {
reauthenticate: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
(window as unknown as { daemonAPI: typeof daemonAPI }).daemonAPI = daemonAPI;
mockGetState.mockReturnValue({ user: { id: "user-1" }, logout });
});
describe("reauthenticateDaemon", () => {
it("re-mints + restarts the daemon when signed in, without logging out", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockResolvedValue({ ok: true });
await reauthenticateDaemon();
expect(daemonAPI.reauthenticate).toHaveBeenCalledWith("jwt-abc", "user-1");
expect(logout).not.toHaveBeenCalled();
expect(toastError).not.toHaveBeenCalled();
});
it("logs out only when the session token itself is rejected (401)", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockResolvedValue({
ok: false,
reason: "session_invalid",
});
await reauthenticateDaemon();
expect(logout).toHaveBeenCalledOnce();
expect(toastError).not.toHaveBeenCalled();
});
// The reviewer's must-fix: a non-401 (transient) failure must NOT log the
// user out — they stay signed in and can retry.
it("does NOT log out on a transient failure; shows a retryable toast", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockResolvedValue({
ok: false,
reason: "transient",
message: "mint PAT failed: 503 Service Unavailable",
});
await reauthenticateDaemon();
expect(logout).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledOnce();
});
it("does NOT log out when the IPC call itself throws unexpectedly", async () => {
localStorage.setItem("multica_token", "jwt-abc");
daemonAPI.reauthenticate.mockRejectedValue(new Error("ipc boom"));
await reauthenticateDaemon();
expect(logout).not.toHaveBeenCalled();
expect(toastError).toHaveBeenCalledOnce();
});
it("routes to login when there is no session token", async () => {
await reauthenticateDaemon();
expect(logout).toHaveBeenCalledOnce();
expect(daemonAPI.reauthenticate).not.toHaveBeenCalled();
});
it("routes to login when there is no signed-in user", async () => {
localStorage.setItem("multica_token", "jwt-abc");
mockGetState.mockReturnValue({ user: null, logout });
await reauthenticateDaemon();
expect(logout).toHaveBeenCalledOnce();
expect(daemonAPI.reauthenticate).not.toHaveBeenCalled();
});
});

View File

@@ -1,48 +0,0 @@
import { useAuthStore } from "@multica/core/auth";
import { toast } from "sonner";
/**
* Re-establish the local daemon's credentials after it failed to authenticate
* (daemon state "auth_expired", surfaced by daemon-manager's token probe — see
* #3512).
*
* The desktop owns the daemon's PAT: it mints one from the user's session token
* and caches it per profile. A stale/revoked cached PAT is the common cause (and
* merely restarting the app reuses the same bad PAT), so the main process drops
* the cached token, mints a fresh one, and restarts the daemon.
*
* Failure handling is deliberately conservative — we only force a full re-login
* when the session token itself is rejected (a real 401). A transient failure
* (mint 5xx, network blip, config write error, restart hiccup) keeps the user
* signed in and shows a retryable toast, so a momentary glitch never logs them
* out. The 401-vs-transient classification happens in the main process where the
* real HTTP status is available; here we just act on the verdict.
*/
export async function reauthenticateDaemon(): Promise<void> {
const user = useAuthStore.getState().user;
const token = localStorage.getItem("multica_token");
if (!user || !token) {
// No usable session at all — the standard recovery is the login page.
useAuthStore.getState().logout();
return;
}
try {
const result = await window.daemonAPI.reauthenticate(token, user.id);
if (result.ok) return; // daemon restarting; status flips via onStatusChange
if (result.reason === "session_invalid") {
// The session token itself is rejected (401) — full re-login.
useAuthStore.getState().logout();
return;
}
// Transient failure — keep the user signed in and let them retry.
toast.error("Couldn't reconnect the daemon", {
description: result.message || "Please try again in a moment.",
});
} catch (err) {
// An unexpected IPC error is not an auth failure — never log out on it.
toast.error("Couldn't reconnect the daemon", {
description: err instanceof Error ? err.message : "Please try again.",
});
}
}

View File

@@ -1,421 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
import { useEffect } from "react";
// Shared in-memory state that the mocked tab store reads / mutates. The test
// records every method call so we can assert openInNewTab does NOT activate
// the new tab (i.e. setActiveTab is never invoked on the same-workspace path).
type MockRouter = {
state: { location: { pathname: string; search: string; hash: string } };
navigate: ReturnType<typeof vi.fn>;
};
type MockTab = {
id: string;
path: string;
pinned: boolean;
router: MockRouter;
};
function makeMockRouter(pathname: string, search = "", hash = ""): MockRouter {
return {
state: { location: { pathname, search, hash } },
navigate: vi.fn(),
};
}
const state = vi.hoisted(() => ({
activeWorkspaceSlug: "acme" as string | null,
byWorkspace: {
acme: {
activeTabId: "tA",
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
] as MockTab[],
},
} as Record<string, { activeTabId: string; tabs: MockTab[] }>,
openTab: vi.fn<(path: string, title?: string, icon?: string) => string>(),
setActiveTab: vi.fn<(tabId: string) => void>(),
switchWorkspace: vi.fn<(slug: string, openPath?: string) => void>(),
}));
vi.mock("@/stores/tab-store", () => {
const store = {
get activeWorkspaceSlug() {
return state.activeWorkspaceSlug;
},
get byWorkspace() {
return state.byWorkspace;
},
openTab: state.openTab,
setActiveTab: state.setActiveTab,
switchWorkspace: state.switchWorkspace,
};
const useTabStore = Object.assign(
(selector?: (s: typeof store) => unknown) =>
selector ? selector(store) : store,
{ getState: () => store },
);
const getActiveTab = () => {
const slug = state.activeWorkspaceSlug;
if (!slug) return null;
const group = state.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
const useActiveTabRouter = () => null;
const resolveRouteIcon = () => "File";
return {
useTabStore,
getActiveTab,
useActiveTabIdentity,
useActiveTabRouter,
resolveRouteIcon,
};
});
vi.mock("@/stores/window-overlay-store", () => ({
useWindowOverlayStore: Object.assign(
() => null,
{ getState: () => ({ overlay: null, open: vi.fn(), close: vi.fn() }) },
),
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: Object.assign(
() => null,
{ getState: () => ({ logout: vi.fn() }) },
),
}));
vi.mock("@multica/core/paths", () => ({
isReservedSlug: (s: string) =>
["login", "workspaces", "invite", "onboarding", "invitations"].includes(s),
}));
// DesktopNavigationProvider reads window.desktopAPI.runtimeConfig synchronously.
beforeEach(() => {
state.openTab.mockReset();
state.setActiveTab.mockReset();
state.switchWorkspace.mockReset();
state.openTab.mockImplementation(() => "tNew");
state.activeWorkspaceSlug = "acme";
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{
id: "tA",
path: "/acme/issues",
pinned: false,
router: makeMockRouter("/acme/issues"),
},
],
},
};
Object.defineProperty(window, "desktopAPI", {
configurable: true,
value: {
runtimeConfig: { ok: true, config: { appUrl: "https://app.example" } },
},
});
});
import {
DesktopNavigationProvider,
TabNavigationProvider,
} from "./navigation";
import { useNavigation } from "@multica/views/navigation";
function captureAdapter(onAdapter: (adapter: ReturnType<typeof useNavigation>) => void) {
function Probe() {
const nav = useNavigation();
useEffect(() => {
onAdapter(nav);
}, [nav]);
return null;
}
return Probe;
}
describe("DesktopNavigationProvider.openInNewTab", () => {
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
expect(adapter).not.toBeNull();
adapter!.openInNewTab!("/acme/agents", "Agents");
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("activates the new tab when opts.activate is true (foreground)", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.openInNewTab!("/acme/agents", "Agents", { activate: true });
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("delegates to switchWorkspace for a cross-workspace path", () => {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.openInNewTab!("/butter/inbox");
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
});
describe("DesktopNavigationProvider.push with pinned active tab", () => {
function pinActive(pathname: string) {
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: makeMockRouter(pathname),
};
}
it("redirects push to a new foreground tab when pathname differs", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
});
it("allows in-tab navigation when only search/hash changes", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues?filter=open");
// Pathname unchanged → pinned interception declines and falls through to
// the router's own navigate — openTab / setActiveTab must not fire.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
});
it("leaves cross-workspace push to the workspace switcher (not pin)", () => {
pinActive("/acme/issues");
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/butter/inbox");
// Cross-workspace push runs through tryRouteToOtherWorkspace before
// tryRouteToPinnedNewTab, so switchWorkspace wins.
expect(state.switchWorkspace).toHaveBeenCalledWith("butter", "/butter/inbox");
expect(state.openTab).not.toHaveBeenCalled();
});
});
describe("DesktopNavigationProvider.push duplicate path guard", () => {
it("does not navigate when the target exactly matches the active tab location", () => {
const activeRouter = makeMockRouter("/acme/issues/child");
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: "/acme/issues/child",
pinned: false,
router: activeRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<DesktopNavigationProvider>
<Probe />
</DesktopNavigationProvider>,
);
adapter!.push("/acme/issues/child");
expect(activeRouter.navigate).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.openInNewTab", () => {
function renderTabProvider() {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
const fakeRouter = {
state: { location: { pathname: "/acme/issues", search: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return () => adapter!;
}
it("opens a background tab (no setActiveTab) for a same-workspace path", () => {
const getAdapter = renderTabProvider();
getAdapter().openInNewTab!("/acme/agents", "Agents");
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
it("activates the new tab when opts.activate is true (foreground)", () => {
const getAdapter = renderTabProvider();
getAdapter().openInNewTab!("/acme/agents", "Agents", { activate: true });
expect(state.openTab).toHaveBeenCalledWith("/acme/agents", "Agents", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
expect(state.switchWorkspace).not.toHaveBeenCalled();
});
});
describe("TabNavigationProvider.push duplicate path guard", () => {
function renderTabProviderAt(
pathname: string,
search = "",
hash = "",
) {
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
const fakeRouter = {
state: { location: { pathname, search, hash } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as Parameters<typeof TabNavigationProvider>[0]["router"];
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("does not navigate when the target exactly matches the current full location", () => {
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues/child");
getAdapter().push("/acme/issues/child");
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("still navigates when only search or hash differs", () => {
const { getAdapter, fakeRouter } = renderTabProviderAt("/acme/issues");
getAdapter().push("/acme/issues?filter=open#top");
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open#top");
});
});
describe("TabNavigationProvider.push with pinned active tab", () => {
type ProviderRouter = Parameters<typeof TabNavigationProvider>[0]["router"];
function renderPinnedTabProvider(pathname: string) {
// The active tab and the per-tab router must share the same pathname:
// tryRouteToPinnedNewTab reads the *active tab's* router for the current
// pathname (so query-only pushes routed via React Router still compare
// correctly), while the TabNavigationProvider falls back to *its own*
// router.navigate when no interception fires. In real desktop usage they
// are the same router instance; this helper mirrors that invariant.
const fakeRouter = {
state: { location: { pathname, search: "", hash: "" } },
subscribe: () => () => {},
navigate: vi.fn(),
} as unknown as ProviderRouter;
state.byWorkspace.acme.tabs[0] = {
id: "tA",
path: pathname,
pinned: true,
router: fakeRouter as unknown as MockRouter,
};
let adapter: ReturnType<typeof useNavigation> | null = null;
const Probe = captureAdapter((a) => {
adapter = a;
});
render(
<TabNavigationProvider router={fakeRouter}>
<Probe />
</TabNavigationProvider>,
);
return { getAdapter: () => adapter!, fakeRouter };
}
it("redirects push to a new foreground tab when pathname differs", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/projects");
expect(state.openTab).toHaveBeenCalledWith("/acme/projects", "/acme/projects", "File");
expect(state.setActiveTab).toHaveBeenCalledWith("tNew");
// Pinned interception short-circuits — the per-tab router must NOT
// navigate, otherwise the pinned tab itself would move off its path.
expect(fakeRouter.navigate).not.toHaveBeenCalled();
});
it("allows in-tab navigation when only search/hash changes", () => {
const { getAdapter, fakeRouter } = renderPinnedTabProvider("/acme/issues");
getAdapter().push("/acme/issues?filter=open");
// Same pathname → pinned interception declines, push falls through to
// the tab's own router.navigate, and no new tab is opened.
expect(state.openTab).not.toHaveBeenCalled();
expect(state.setActiveTab).not.toHaveBeenCalled();
expect(fakeRouter.navigate).toHaveBeenCalledWith("/acme/issues?filter=open");
});
});

View File

@@ -89,11 +89,6 @@ function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
return false;
}
function routerLocationPath(router: DataRouter): string {
const { pathname, search, hash } = router.state.location;
return `${pathname}${search ?? ""}${hash ?? ""}`;
}
/**
* Intercept pushes that change workspace. Returns `true` if the navigation
* was delegated to the tab store (caller should NOT proceed).
@@ -113,37 +108,6 @@ function tryRouteToOtherWorkspace(path: string): boolean {
return true;
}
/**
* Intercept pushes originating in a pinned tab and force them into a new
* tab. Returns `true` if the navigation was redirected (caller should NOT
* proceed). Pathname-only changes (search / hash / same-page state) are
* allowed through so pinned filter / drawer / form-state interactions
* still work — see RFC §3 D2a (FINAL: any pathname change → new tab) and
* D2b (FINAL: same pathname → allowed in pinned tab).
*
* Dedupe is preserved (D4a): `openTab` activates an existing same-path tab
* if one exists, otherwise creates a new one. The newly-focused tab is
* activated foreground — a pinned-tab push is an explicit user action, not
* a background cmd+click, so the focus follows.
*/
function tryRouteToPinnedNewTab(path: string): boolean {
const store = useTabStore.getState();
const active = getActiveTab(store);
if (!active?.pinned) return false;
// Use the live router pathname rather than `active.path` so query-only
// navigations performed via React Router (which only sync pathname back
// to the store) still compare correctly.
const currentPathname = active.router.state.location.pathname;
const newPathname = path.split("?")[0].split("#")[0];
if (currentPathname === newPathname) return false;
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, path, icon);
if (newId) store.setActiveTab(newId);
return true;
}
/**
* Root-level navigation provider for components outside the per-tab
* RouterProviders (sidebar, search dialog, modals, WindowOverlay contents).
@@ -200,9 +164,7 @@ export function DesktopNavigationProvider({
}
const active = currentActiveTab();
if (tryRouteToOverlay(path, active?.router)) return;
if (active && routerLocationPath(active.router) === path) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
active?.router.navigate(path);
},
replace: (path: string) => {
@@ -216,16 +178,9 @@ export function DesktopNavigationProvider({
},
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (
path: string,
title?: string,
opts?: { activate?: boolean },
) => {
openInNewTab: (path: string, title?: string) => {
// Cross-workspace "open in new tab" switches workspace and opens
// the path there (focus follows the user); same-workspace defaults
// to background tab (browser cmd+click semantics). Callers that
// represent an explicit "Open in new tab" CTA pass `activate: true`
// to bring the new tab to the foreground.
// the path there; same-workspace just adds a tab in the current group.
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
@@ -233,10 +188,8 @@ export function DesktopNavigationProvider({
return;
}
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, title ?? path, icon);
if (opts?.activate && newId) {
store.setActiveTab(newId);
}
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),
@@ -277,9 +230,7 @@ export function TabNavigationProvider({
() => ({
push: (path: string) => {
if (tryRouteToOverlay(path, router)) return;
if (routerLocationPath(router) === path) return;
if (tryRouteToOtherWorkspace(path)) return;
if (tryRouteToPinnedNewTab(path)) return;
router.navigate(path);
},
replace: (path: string) => {
@@ -290,11 +241,7 @@ export function TabNavigationProvider({
back: () => router.navigate(-1),
pathname: location.pathname,
searchParams: new URLSearchParams(location.search),
openInNewTab: (
path: string,
title?: string,
opts?: { activate?: boolean },
) => {
openInNewTab: (path: string, title?: string) => {
const slug = extractWorkspaceSlug(path);
const store = useTabStore.getState();
if (slug && slug !== store.activeWorkspaceSlug) {
@@ -302,10 +249,8 @@ export function TabNavigationProvider({
return;
}
const icon = resolveRouteIcon(path);
const newId = store.openTab(path, title ?? path, icon);
if (opts?.activate && newId) {
store.setActiveTab(newId);
}
const tabId = store.openTab(path, title ?? path, icon);
if (tabId) store.setActiveTab(tabId);
},
getShareableUrl: (path: string) => `${appUrl}${path}`,
}),

View File

@@ -13,7 +13,6 @@ import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { MemberDetailPage } from "./pages/member-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
@@ -21,43 +20,15 @@ import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { DesktopAgentsPage } from "./components/desktop-agents-page";
import { AgentsPage } from "@multica/views/agents";
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { useT } from "@multica/views/i18n";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
import { DesktopRouteErrorPage } from "./components/route-error-page";
/**
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
* from i18n. The route element has to be a component (not a literal JSX
* value) for `useT` to run.
*/
function DesktopSettingsRoute() {
const { t } = useT("settings");
return (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: t(($) => $.desktop.tabs.updates),
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
);
}
/**
* Sets document.title from the deepest matched route's handle.title.
@@ -109,7 +80,6 @@ function PageShell() {
export const appRoutes: RouteObject[] = [
{
element: <PageShell />,
errorElement: <DesktopRouteErrorPage />,
children: [
{ index: true, element: null },
{
@@ -119,7 +89,11 @@ export const appRoutes: RouteObject[] = [
{ index: true, element: <Navigate to="issues" replace /> },
{
path: "issues",
element: <IssuesPage />,
element: (
<ErrorBoundary>
<IssuesPage />
</ErrorBoundary>
),
handle: { title: "Issues" },
},
{
@@ -168,7 +142,7 @@ export const appRoutes: RouteObject[] = [
element: <SkillDetailPage />,
handle: { title: "Skill" },
},
{ path: "agents", element: <DesktopAgentsPage />, handle: { title: "Agents" } },
{ path: "agents", element: <AgentsPage />, handle: { title: "Agents" } },
{
path: "agents/:id",
element: <AgentDetailPage />,
@@ -186,11 +160,6 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "attachments/:id/preview",
element: <AttachmentPreviewRoute />,
handle: { title: "Attachment" },
},
{
path: "usage",
element: <DashboardPage />,
@@ -198,7 +167,24 @@ export const appRoutes: RouteObject[] = [
},
{
path: "settings",
element: <DesktopSettingsRoute />,
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),
handle: { title: "Settings" },
},
],

View File

@@ -17,7 +17,6 @@ vi.mock("../routes", () => ({
import {
sanitizeTabPath,
migrateV1ToV2,
migrateV2ToV3,
useTabStore,
} from "./tab-store";
@@ -259,47 +258,6 @@ describe("useTabStore actions", () => {
expect(s.activeWorkspaceSlug).toBeNull();
});
it("validateWorkspaceSlugs seeds the first valid workspace when no group exists", () => {
const store = useTabStore.getState();
store.validateWorkspaceSlugs(new Set(["acme", "butter"]));
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
});
it("validateWorkspaceSlugs reactivates an existing valid group before seeding", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const existingTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
useTabStore.setState({ activeWorkspaceSlug: null });
store.validateWorkspaceSlugs(new Set(["acme"]));
const s = useTabStore.getState();
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].id).toBe(existingTabId);
});
it("validateWorkspaceSlugs seeds a fresh tab for a valid slug after dropping all stale groups", () => {
const store = useTabStore.getState();
// The only persisted group points at a workspace the user has lost access
// to — the stale-tab heal path WorkspaceRouteLayout drives.
store.switchWorkspace("stale");
const staleRouter = useTabStore.getState().byWorkspace.stale.tabs[0].router;
store.validateWorkspaceSlugs(new Set(["acme"]));
const s = useTabStore.getState();
expect(Object.keys(s.byWorkspace)).toEqual(["acme"]);
expect(s.activeWorkspaceSlug).toBe("acme");
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].path).toBe("/acme/issues");
// The dropped stale group's router must be disposed, not leaked.
expect(staleRouter.dispose).toHaveBeenCalled();
});
it("reset wipes the whole store", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
@@ -319,155 +277,3 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
});
it("moves a newly-pinned tab to the start of the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs[0].id).toBe(agentsId);
expect(tabs[0].pinned).toBe(true);
expect(tabs[1].pinned).toBe(false);
expect(tabs[2].pinned).toBe(false);
});
it("appends a second pinned tab after the first pinned tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
store.togglePin(projectsId);
// Both pinned, in the order they were pinned (agents first, projects
// second), then the unpinned default tab.
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([
agentsId,
projectsId,
tabs[2].id,
]);
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
});
it("returns an unpinned tab to the start of the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
// Pin both, then unpin one.
store.togglePin(issuesId);
store.togglePin(projectsId);
store.togglePin(issuesId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
});
});
describe("moveTab boundary clamp", () => {
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag the pinned tab to index 2 (unpinned zone end).
store.moveTab(0, 2);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
expect(tabs[0].id).toBe(issuesId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag agents (index 2) to index 0 (pinned zone).
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// Clamped to index 1 — start of the unpinned zone.
expect(tabs[0].id).toBe(issuesId);
expect(tabs[1].id).toBe(agentsId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("reorders freely within the same zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
// All unpinned; move agents (2) to position 0.
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.path)).toEqual([
"/acme/agents",
"/acme/issues",
"/acme/projects",
]);
});
});
describe("migrateV2ToV3", () => {
it("adds pinned=false to every persisted tab", () => {
const v2 = {
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
],
},
},
};
const v3 = migrateV2ToV3(v2);
expect(v3.activeWorkspaceSlug).toBe("acme");
expect(v3.byWorkspace.acme.tabs).toEqual([
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
]);
});
it("handles missing byWorkspace gracefully", () => {
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
expect(v3.byWorkspace).toEqual({});
expect(v3.activeWorkspaceSlug).toBeNull();
});
});

View File

@@ -20,14 +20,6 @@ export interface Tab {
router: DataRouter;
historyIndex: number;
historyLength: number;
/**
* Pinned tabs render at the left of the tab bar as icon-only, suppress the
* X close button, and turn any `navigation.push()` originating in them into
* an `openInNewTab()` so they stay parked on their original path. Pinning
* is invariant-preserving: pinned tabs always come before unpinned tabs in
* a workspace's `tabs` array; `togglePin` / `moveTab` enforce this.
*/
pinned: boolean;
}
export interface WorkspaceTabGroup {
@@ -86,30 +78,8 @@ interface TabStore {
updateTab: (tabId: string, patch: Partial<Pick<Tab, "path" | "title" | "icon">>) => void;
/** Patch history tracking of a tab. Finds across groups. */
updateTabHistory: (tabId: string, historyIndex: number, historyLength: number) => void;
/** Recreate the active tab's router at the same path after a route-level crash. */
reloadActiveTab: () => void;
/**
* Close the active tab. The always-safe escape from a route-level crash:
* unlike reloadActiveTab (recreates the same crashing path) or navigating
* to a "safe" route (which may itself be the route that crashed), closing
* destroys the crashing router entirely and falls back to a sibling tab
* (or a reseeded default if it was the last tab).
*/
closeActiveTab: () => void;
/**
* Reorder within the active workspace's group only. Clamped so a tab can
* never cross the pinned / unpinned boundary — a drag that would move a
* pinned tab into the unpinned zone (or vice versa) is dropped at the
* boundary instead. This keeps the "pinned tabs first" invariant without
* requiring callers to know about it.
*/
/** Reorder within the active workspace's group only. */
moveTab: (fromIndex: number, toIndex: number) => void;
/**
* Flip a tab's pinned state. Pinning moves it to the end of the pinned
* zone; unpinning moves it to the start of the unpinned zone. Both
* preserve the "pinned tabs before unpinned tabs" invariant.
*/
togglePin: (tabId: string) => void;
/**
* After the workspace list arrives/changes (login, realtime delete), drop
* any tab group whose slug is no longer in `validSlugs`, and repoint
@@ -220,17 +190,9 @@ function makeTab(path: string, title: string, icon: string): Tab {
router: createTabRouter(path),
historyIndex: 0,
historyLength: 1,
pinned: false,
};
}
/** Index of the first unpinned tab in a group (== pinned count). */
function pinnedBoundary(tabs: Tab[]): number {
let i = 0;
while (i < tabs.length && tabs[i].pinned) i++;
return i;
}
/** Default entry point for a workspace — its issues list. */
function defaultPathFor(slug: string): string {
return `/${slug}/issues`;
@@ -485,101 +447,23 @@ export const useTabStore = create<TabStore>()(
});
},
reloadActiveTab() {
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
const index = group.tabs.findIndex((t) => t.id === group.activeTabId);
if (index < 0) return;
const current = group.tabs[index];
const nextTabs = [...group.tabs];
nextTabs[index] = {
...current,
router: createTabRouter(current.path),
historyIndex: 0,
historyLength: 1,
};
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: { ...group, tabs: nextTabs },
},
});
window.setTimeout(() => current.router.dispose(), 0);
},
closeActiveTab() {
const { activeWorkspaceSlug, byWorkspace, closeTab } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
closeTab(group.activeTabId);
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const { activeWorkspaceSlug, byWorkspace } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
if (fromIndex < 0 || fromIndex >= group.tabs.length) return;
// Clamp the drop position to within the source tab's group (pinned vs
// unpinned) so the "pinned tabs first" invariant survives drag-reorder.
// Pinned zone is [0, boundary); unpinned zone is [boundary, length).
const boundary = pinnedBoundary(group.tabs);
const source = group.tabs[fromIndex];
let clampedTo: number;
if (source.pinned) {
// boundary is exclusive upper bound for pinned-zone indices.
clampedTo = Math.max(0, Math.min(toIndex, boundary - 1));
} else {
clampedTo = Math.max(boundary, Math.min(toIndex, group.tabs.length - 1));
}
if (clampedTo === fromIndex) return;
set({
byWorkspace: {
...byWorkspace,
[activeWorkspaceSlug]: {
...group,
tabs: arrayMove(group.tabs, fromIndex, clampedTo),
tabs: arrayMove(group.tabs, fromIndex, toIndex),
},
},
});
},
togglePin(tabId) {
const { byWorkspace } = get();
const hit = findTabLocation(byWorkspace, tabId);
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
const nextTab: Tab = { ...current, pinned: !current.pinned };
// Remove from current position, then insert at the new zone boundary:
// pinning → end of pinned zone (just before first unpinned tab)
// unpinning → start of unpinned zone (right after last pinned tab)
const withoutCurrent = [
...group.tabs.slice(0, index),
...group.tabs.slice(index + 1),
];
const newBoundary = pinnedBoundary(withoutCurrent);
const insertAt = newBoundary;
const nextTabs = [
...withoutCurrent.slice(0, insertAt),
nextTab,
...withoutCurrent.slice(insertAt),
];
set({
byWorkspace: {
...byWorkspace,
[slug]: { ...group, tabs: nextTabs },
},
});
},
validateWorkspaceSlugs(validSlugs) {
const { activeWorkspaceSlug, byWorkspace } = get();
let changed = false;
@@ -599,24 +483,6 @@ export const useTabStore = create<TabStore>()(
changed = true;
}
if (!nextActive) {
nextActive = Object.keys(nextByWorkspace)[0] ?? null;
if (nextActive) changed = true;
}
if (!nextActive) {
const fallbackSlug = validSlugs.values().next().value;
if (fallbackSlug) {
const fresh = defaultTabFor(fallbackSlug);
nextByWorkspace[fallbackSlug] = {
tabs: [fresh],
activeTabId: fresh.id,
};
nextActive = fallbackSlug;
changed = true;
}
}
if (!changed) return;
set({ byWorkspace: nextByWorkspace, activeWorkspaceSlug: nextActive });
},
@@ -631,23 +497,17 @@ export const useTabStore = create<TabStore>()(
}),
{
name: "multica_tabs",
version: 3,
version: 2,
storage: createJSONStorage(() => createPersistStorage(defaultStorage)),
migrate: (persistedState, version) => {
// v1 → v2: flat `tabs` array → per-workspace grouping.
// Tabs whose path isn't workspace-scoped (root `/`, login, etc.)
// are dropped — they have no workspace to belong to, and the new
// model's invariant is "every tab lives in a workspace group".
let state = persistedState;
if (version < 2 && state && typeof state === "object") {
state = migrateV1ToV2(state as Partial<V1Persisted>);
if (version < 2 && persistedState && typeof persistedState === "object") {
return migrateV1ToV2(persistedState as Partial<V1Persisted>);
}
// v2 → v3: introduce `Tab.pinned`. Existing tabs default to
// unpinned; pin ordering invariant trivially holds (no pinned tabs).
if (version < 3 && state && typeof state === "object") {
state = migrateV2ToV3(state as V2Persisted);
}
return state as V3Persisted;
return persistedState as V2Persisted;
},
partialize: (state) => ({
activeWorkspaceSlug: state.activeWorkspaceSlug,
@@ -657,19 +517,15 @@ export const useTabStore = create<TabStore>()(
{
activeTabId: group.activeTabId,
tabs: group.tabs.map(
({
router: _router,
historyIndex: _hi,
historyLength: _hl,
...rest
}) => rest,
({ router: _router, historyIndex: _hi, historyLength: _hl, ...rest }) =>
rest,
),
},
]),
),
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial<V3Persisted> | undefined;
const persisted = persistedState as Partial<V2Persisted> | undefined;
if (!persisted?.byWorkspace) return currentState;
const byWorkspace: Record<string, WorkspaceTabGroup> = {};
@@ -696,14 +552,9 @@ export const useTabStore = create<TabStore>()(
router: createTabRouter(clean),
historyIndex: 0,
historyLength: 1,
pinned: pTab.pinned === true,
});
}
if (tabs.length === 0) continue;
// Enforce the "pinned first" invariant on rehydration in case a
// user (or a buggy older write) persisted the pinned tabs out of
// order. Stable sort preserves intra-group order.
tabs.sort((a, b) => (a.pinned === b.pinned ? 0 : a.pinned ? -1 : 1));
const activeTabId = tabs.some((t) => t.id === pGroup.activeTabId)
? pGroup.activeTabId
: tabs[0].id;
@@ -754,38 +605,6 @@ interface V2Persisted {
byWorkspace: Record<string, V2PersistedGroup>;
}
interface V3PersistedTab {
id: string;
path: string;
title: string;
icon: string;
pinned: boolean;
}
interface V3PersistedGroup {
tabs: V3PersistedTab[];
activeTabId: string;
}
interface V3Persisted {
activeWorkspaceSlug: string | null;
byWorkspace: Record<string, V3PersistedGroup>;
}
export function migrateV2ToV3(v2: V2Persisted): V3Persisted {
const byWorkspace: Record<string, V3PersistedGroup> = {};
for (const [slug, group] of Object.entries(v2.byWorkspace ?? {})) {
byWorkspace[slug] = {
activeTabId: group.activeTabId,
tabs: group.tabs.map((t) => ({ ...t, pinned: false })),
};
}
return {
activeWorkspaceSlug: v2.activeWorkspaceSlug ?? null,
byWorkspace,
};
}
export function migrateV1ToV2(v1: Partial<V1Persisted>): V2Persisted {
const byWorkspace: Record<string, V2PersistedGroup> = {};
const oldTabs = v1.tabs ?? [];

View File

@@ -1,22 +0,0 @@
import { describe, it, expect } from "vitest";
import { daemonStatusAlive } from "./daemon-types";
describe("daemonStatusAlive", () => {
it("treats a ready daemon as alive", () => {
expect(daemonStatusAlive("running")).toBe(true);
});
it("treats a still-booting daemon as alive", () => {
// /health binds before preflight and reports "starting" until ready; the
// Desktop must not spawn a second daemon over it (the CLI rejects that as
// "already running").
expect(daemonStatusAlive("starting")).toBe(true);
});
it("treats stopped / unknown / missing as not alive", () => {
expect(daemonStatusAlive("stopped")).toBe(false);
expect(daemonStatusAlive("bogus")).toBe(false);
expect(daemonStatusAlive("")).toBe(false);
expect(daemonStatusAlive(undefined)).toBe(false);
});
});

View File

@@ -4,11 +4,7 @@ export type DaemonState =
| "starting"
| "stopping"
| "installing_cli"
| "cli_not_found"
// The daemon can't start because the server rejected its credentials (the
// cached PAT expired / was revoked, or the session token is dead). Without
// this, an auth failure silently sticks at "starting" forever — see #3512.
| "auth_expired";
| "cli_not_found";
export interface DaemonStatus {
state: DaemonState;
@@ -22,16 +18,6 @@ export interface DaemonStatus {
profile?: string;
/** Backend URL the daemon connects to. */
serverUrl?: string;
/**
* True when a daemon is running but in an environment the app can't control
* — its reported OS differs from the desktop host's (e.g. a Linux daemon
* inside WSL2 behind a Windows desktop, reachable only via localhost
* forwarding). The app's start/stop CLI acts on the host process namespace,
* so auto-start/auto-stop can't reach it; the UI disables those toggles
* instead of silently no-op'ing. Only ever set on a running daemon, so it
* never disables the toggles for a normally-managed native daemon. See #3916.
*/
externallyManaged?: boolean;
}
export interface DaemonPrefs {
@@ -46,7 +32,6 @@ export const DAEMON_STATE_COLORS: Record<DaemonState, string> = {
stopping: "bg-amber-500 animate-pulse",
installing_cli: "bg-sky-500 animate-pulse",
cli_not_found: "bg-red-500",
auth_expired: "bg-red-500",
};
export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
@@ -56,7 +41,6 @@ export const DAEMON_STATE_LABELS: Record<DaemonState, string> = {
stopping: "Stopping…",
installing_cli: "Setting up…",
cli_not_found: "Setup Failed",
auth_expired: "Sign-in required",
};
export function formatUptime(uptime?: string): string {
@@ -68,19 +52,6 @@ export function formatUptime(uptime?: string): string {
return `${h}${m}`.trim() || uptime;
}
/**
* Whether a raw daemon `/health` `status` value means a live daemon is on the
* port — either fully "running" (ready) or still "starting" (port bound,
* preflight in progress). Mirrors the Go `daemonAlive()` in
* server/cmd/multica/cmd_daemon.go so the Desktop lifecycle agrees with the
* CLI: a "starting" daemon is already there and must not be spawned over (the
* CLI rejects that as "already running"). This is liveness, not readiness —
* version-restart decisions still gate on the stricter "running".
*/
export function daemonStatusAlive(status: string | undefined): boolean {
return status === "running" || status === "starting";
}
/**
* User-facing description for the local daemon's current state. Replaces the
* raw state label ("Running" / "Stopped") with a sentence that answers
@@ -110,7 +81,5 @@ export function daemonStateDescription(state: DaemonState, runtimeCount: number)
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.";
case "auth_expired":
return "Sign-in expired · sign in again to bring this device back online.";
}
}

View File

@@ -1,16 +0,0 @@
/**
* A freeze/crash breadcrumb persisted by the main process and flushed to
* telemetry by the next renderer boot. Shared across main, preload, and
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
* read/write logic and the rationale.
*/
export interface FreezeBreadcrumb {
/** "unresponsive" (hang) or "render-process-gone" (crash). */
kind: string;
/** Diagnostic context captured at failure time (route, window url, …). */
context: Record<string, unknown>;
/** Epoch ms when the failure was recorded. */
ts: number;
/** App version at failure time. */
version: string;
}

View File

@@ -1,27 +0,0 @@
import { describe, expect, it } from "vitest";
import {
isNavigationGesture,
navigationGestureFromSwipe,
} from "./navigation-gestures";
describe("navigationGestureFromSwipe", () => {
it("maps horizontal macOS swipe directions to browser-style history", () => {
expect(navigationGestureFromSwipe("right")).toBe("back");
expect(navigationGestureFromSwipe("left")).toBe("forward");
});
it("ignores vertical and unknown directions", () => {
expect(navigationGestureFromSwipe("up")).toBeNull();
expect(navigationGestureFromSwipe("down")).toBeNull();
expect(navigationGestureFromSwipe("sideways")).toBeNull();
});
});
describe("isNavigationGesture", () => {
it("accepts only the renderer navigation gestures", () => {
expect(isNavigationGesture("back")).toBe(true);
expect(isNavigationGesture("forward")).toBe(true);
expect(isNavigationGesture("right")).toBe(false);
expect(isNavigationGesture(null)).toBe(false);
});
});

View File

@@ -1,15 +0,0 @@
export const NAVIGATION_GESTURE_CHANNEL = "navigation:gesture";
export type NavigationGesture = "back" | "forward";
export function isNavigationGesture(value: unknown): value is NavigationGesture {
return value === "back" || value === "forward";
}
export function navigationGestureFromSwipe(
direction: string,
): NavigationGesture | null {
if (direction === "right") return "back";
if (direction === "left") return "forward";
return null;
}

View File

@@ -1,51 +0,0 @@
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
export type RendererRouteSurface = "login" | "overlay" | "tab";
export type RendererRouteContextInput = {
surface: RendererRouteSurface;
path: string;
workspaceSlug?: string;
tabId?: string;
};
export type RendererRouteContext = RendererRouteContextInput & {
reportedAt: string;
};
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
export function sanitizeRendererRouteContext(
value: unknown,
reportedAt = new Date(),
): RendererRouteContext | null {
if (!value || typeof value !== "object") return null;
const input = value as Record<string, unknown>;
if (!isRendererRouteSurface(input.surface)) return null;
const path = sanitizeString(input.path);
if (!path) return null;
const workspaceSlug = sanitizeString(input.workspaceSlug);
const tabId = sanitizeString(input.tabId);
return {
surface: input.surface,
path,
...(workspaceSlug ? { workspaceSlug } : {}),
...(tabId ? { tabId } : {}),
reportedAt: reportedAt.toISOString(),
};
}
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
return value === "login" || value === "overlay" || value === "tab";
}
function sanitizeString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
if (!trimmed) return undefined;
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
}

View File

@@ -11,7 +11,6 @@ import type { Metadata } from "next";
import { docsAlternates } from "@/lib/site";
import { i18n, type Lang } from "@/lib/i18n";
import { DocsLocaleProvider, LocaleLink } from "@/components/locale-link";
import { docsSlugStaticParams } from "@/lib/static-params";
function asLang(lang: string): Lang {
return (i18n.languages as readonly string[]).includes(lang)
@@ -23,11 +22,11 @@ export default async function Page(props: {
params: Promise<{ lang: string; slug: string[] }>;
}) {
const params = await props.params;
const lang = asLang(params.lang);
const page = source.getPage(params.slug, lang);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
const MDX = page.data.body;
const lang = asLang(params.lang);
return (
<DocsPage toc={page.data.toc}>
@@ -43,15 +42,14 @@ export default async function Page(props: {
}
export function generateStaticParams() {
return docsSlugStaticParams(source.generateParams());
return source.generateParams().filter((p) => p.slug.length > 0);
}
export async function generateMetadata(props: {
params: Promise<{ lang: string; slug: string[] }>;
}): Promise<Metadata> {
const params = await props.params;
const lang = asLang(params.lang);
const page = source.getPage(params.slug, lang);
const page = source.getPage(params.slug, params.lang);
if (!page) notFound();
return {

View File

@@ -11,13 +11,18 @@ import { i18n, type Lang } from "@/lib/i18n";
import { uiTranslations, localeLabels } from "@/lib/translations";
import { DocsSettings } from "@/components/docs-settings";
// Inter (Latin UI face) is exposed under `--font-inter`. The full `--font-sans`
// stack — Inter + the per-locale CJK fallback chain, including the Japanese-first
// override scoped to `<html lang="ja">` — is composed in static CSS in
// ./global.css (CSP-safe, no inline <style>). Mirrors apps/web/app/layout.tsx.
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
variable: "--font-sans",
fallback: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"PingFang SC",
"Microsoft YaHei",
"Noto Sans CJK SC",
"sans-serif",
],
});
const geistMono = Geist_Mono({

View File

@@ -17,42 +17,8 @@ function tokenizeCJK(raw: string): string[] {
return tokens;
}
// Japanese mixes Hiragana, Katakana and Kanji; the English regex strips them
// all, and the zh tokenizer only keeps Han (Kanji), dropping kana entirely.
// Tokenize each kana/Kanji codepoint on its own and keep Latin/digit runs
// whole — same character-level recall strategy as tokenizeCJK, extended to
// the Hiragana (\u3040-\u309f) and Katakana (\u30a0-\u30ff) blocks, plus the
// ideographic iteration mark \u3005 which sits just below the kana blocks and
// recurs in common words (e.g. the JP for "various", "daily", "individual").
function tokenizeJapanese(raw: string): string[] {
const tokens: string[] = [];
const regex = /[\u3005\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]|[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: {
ko: {
components: {
tokenizer: {
language: "english",
},
},
},
ja: {
components: {
tokenizer: {
language: "english",
normalizationCache: new Map(),
tokenize: tokenizeJapanese,
},
},
},
zh: {
components: {
tokenizer: {

View File

@@ -1,57 +0,0 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const chineseFonts = ["PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC"];
const koreanFonts = ["Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans CJK KR"];
const japaneseFonts = ["Hiragino Sans", "Yu Gothic", "Noto Sans CJK JP"];
function expectChineseFontsBeforeKoreanFonts(source: string) {
const chineseIndexes = chineseFonts.map((font) => source.indexOf(font));
const koreanIndexes = koreanFonts.map((font) => source.indexOf(font));
expect(chineseIndexes).not.toContain(-1);
expect(koreanIndexes).not.toContain(-1);
for (const chineseIndex of chineseIndexes) {
for (const koreanIndex of koreanIndexes) {
expect(chineseIndex).toBeLessThan(koreanIndex);
}
}
}
// Japanese Kanji share the Han Unicode block with Chinese, so the docs
// Japanese-first CJK stack must be scoped to html[lang|="ja"] (zh/en keep
// Chinese-first) and order Japanese fonts before the Chinese families.
function expectJapaneseScopedOverride(source: string) {
expect(source).toContain('html[lang|="ja"]');
const japaneseIndexes = japaneseFonts.map((font) => source.indexOf(font));
expect(japaneseIndexes).not.toContain(-1);
const firstJapanese = Math.min(...japaneseIndexes);
const lastChinese = Math.max(
...chineseFonts.map((font) => source.lastIndexOf(font)),
);
expect(firstJapanese).toBeLessThan(lastChinese);
}
describe("CJK font fallback order", () => {
it("keeps docs Chinese font fallbacks before Korean font fallbacks", () => {
const cssSource = readFileSync(
resolve(process.cwd(), "app/global.css"),
"utf8",
);
expectChineseFontsBeforeKoreanFonts(cssSource);
});
it("scopes the Japanese-first CJK stack to html[lang|='ja']", () => {
const cssSource = readFileSync(
resolve(process.cwd(), "app/global.css"),
"utf8",
);
expectJapaneseScopedOverride(cssSource);
});
});

View File

@@ -6,36 +6,6 @@
@source "../../../packages/ui/**/*.{ts,tsx}";
/* ---------------------------------------------------------------------------
* Font stack. `--font-inter` is the next/font Inter family (+ synthetic
* size-adjusted fallback), set on <html> by inter.variable in app/[lang]/layout.tsx.
* `--font-sans` is composed here in static CSS so it can be overridden per
* `<html lang>` and stays CSP-safe (no inline <style>). Tailwind's `font-sans`
* utility resolves `var(--font-sans)`. Mirrors apps/web/app/globals.css.
*
* Default (en / zh / ko): Latin → Inter, CJK → Chinese then Korean. Chinese MUST
* stay before Korean so zh users don't get Korean Hanja glyph shapes.
* ------------------------------------------------------------------------- */
:root {
--font-sans: var(--font-inter), -apple-system, BlinkMacSystemFont, "Segoe UI",
"PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Apple SD Gothic Neo",
"Malgun Gothic", "Noto Sans CJK KR", sans-serif;
}
/* Japanese: Kanji share the Han Unicode block with Chinese and CSS fallback
order is not affected by `<html lang>`, so promote a Japanese-first CJK chain
only for Japanese docs (`<html lang="ja">`). `[lang|="ja"]` is the BCP-47
language-range selector — matches exactly `ja` or `ja-<region>`, never
unrelated subtags like `jam`. Inter still leads for Latin. */
html[lang|="ja"] {
--font-sans: var(--font-inter), "Hiragino Sans", "Hiragino Kaku Gothic ProN",
"Yu Gothic", "YuGothic", "Meiryo", "Noto Sans CJK JP", "Noto Sans JP",
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Microsoft YaHei", "Noto Sans CJK SC", "Apple SD Gothic Neo", "Malgun Gothic",
"Noto Sans CJK KR", sans-serif;
}
/* ---------------------------------------------------------------------------
* Multica Docs — editorial visual identity (v2)
*

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import {
createContext,
use,
useContext,
type AnchorHTMLAttributes,
type ReactNode,
} from "react";
@@ -30,7 +30,7 @@ export function DocsLocaleProvider({
}
export function useDocsLocale(): Lang {
return use(DocsLocaleContext);
return useContext(DocsLocaleContext);
}
// Drop-in replacement for the MDX-rendered `<a>` element. Keeps the same

View File

@@ -1,127 +0,0 @@
---
title: エージェントの作成と構成
description: エージェントを作成するために必要な最小限のフィールドと、すべての任意設定 — システム指示、環境変数、公開範囲、同時実行制限、アーカイブ。
---
import { Callout } from "fumadocs-ui/components/callout";
[エージェント](/agents)を作成するのに必要なのは 2 つだけです。**名前**と **[AI コーディングツール](/providers)の選択**です。それ以外はすべて任意です — システム指示、モデル、環境変数、CLI 引数、公開範囲、同時実行制限 — デフォルト値でも問題なく動作します。まず動かしてから後で調整しましょう。すべてのフィールドはいつでも変更できます。
## エージェントを作成する
前提条件: 使用中のマシンにサポートされている [AI コーディングツール](/providers)が少なくとも 1 つインストールされておりClaude Code、Codex など)、[デーモン](/daemon-runtimes)が実行中であること。まだそこまで準備できていない場合は、[Cloud クイックスタート](/cloud-quickstart)または[セルフホストクイックスタート](/self-host-quickstart)から始めてください。
準備が整ったら、ワークスペースの **Agents** ページに移動して **+ New** をクリックするか、CLI を使用します。
```bash
multica agent create
```
このフォームには必須フィールドが 2 つだけあります。**name**(ワークスペース内で一意であること)と **runtime**= AI コーディングツールの選択)です。それ以外のすべてのフィールドは、以下でセクションごとに扱います。
## AI コーディングツールを選ぶ
各ランタイムは特定の AI コーディングツールを基盤としています。Multica はそのうち 12 個をサポートします。最も一般的な選択肢は次のとおりです。
| ツール | 適している場合 |
|---|---|
| **Claude Code** | Anthropic の公式ツールで、最も完成度の高い機能セットを提供します。**最初の選択として最適です** |
| **Codex** | OpenAI 製で、主流の代替手段です |
| **Cursor** | Cursor エディターのエコシステムを使うユーザー |
| **Copilot** | GitHub アカウントの権限を活用するチーム |
| **Gemini** | Google エコシステムのユーザー |
残りの 7 個Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClawと、各ツールの完全な機能比較表セッション再開、MCP、スキル注入パス、モデル選択は、[AI コーディングツール比較](/providers)で扱います。
## システム指示を書く
**システム指示**`instructions`)はすべてのタスクの先頭に追加され、エージェントがどんな役割を担い、どんなルールに従うべきかを伝えます。
```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.
```
空のままにすると(デフォルト)、エージェントは追加の制約なしに、基盤となる AI コーディングツールのネイティブな動作を使用します。
## モデルを選ぶ
ほとんどの AI コーディングツールはモデル選択をサポートしています(例えば Claude Code では Sonnet と Opus のどちらかを選べます)。空のままにするとツール自体のデフォルト値が使われ、明示的に 1 つを選ぶとそのモデルが実行されます。各ツールがサポートするモデルは、[AI コーディングツール比較](/providers)にまとめられています。
モデルの変更は**新しいタスクにのみ適用されます**。すでにディスパッチされたタスクは、ディスパッチ時点で固定されたモデルで実行を続けます。
## カスタム環境変数 (custom_env)
**カスタム環境変数**`custom_env`)を使うと、タスク実行時に追加の環境変数を注入できます。代表的な用途は API キーの設定やアップストリームエンドポイントの切り替えです。
```
ANTHROPIC_API_KEY = sk-...
ANTHROPIC_BASE_URL = https://my-proxy.example.com
```
システムにとって重要な変数は上書きできません。`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`、そして `MULTICA_*` で始まるすべてのキーは、デーモンが静かに無視します(警告ログは残しますが、エラーは発生しません)。
<Callout type="warning">
**`custom_env` の値は Multica サーバーのデータベースに平文で保存されます。** エージェントの list/get レスポンスには環境変数の値がまったく含まれなくなり、不透明な個数だけが返されます。実際の値を読み取るには、ワークスペースの owner または admin が、専用で監査される `GET /api/agents/{id}/env` エンドポイントCLI: `multica agent env get <id>`)を呼び出す必要があります。タスクを実行中のエージェントは、ホストの owner 資格情報を使って他のエージェントの環境変数を明らかにすることはできません。このエンドポイントはエージェントアクターのセッションを拒否します。
**価値の高いシークレットは `custom_env` に入れないでください**本番データベースのパスワード、root レベルのトークンなど)。エージェントには**権限範囲が限定された専用の資格情報**(読み取り専用 API キー、単一スコープの PATを使用し、定期的にローテーションしてください。データベースのバックアップと DB 監査は、依然として意味のある露出面として残ります。
</Callout>
## カスタム CLI 引数 (custom_args)
**カスタム CLI 引数**`custom_args`は、AI コーディングツールのコマンドラインに 1 つずつ順に付け足される文字列配列です。
```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" [...]
```
引数はシェルを介さずそのまま渡されるため(注入リスクなし)、特定のフラグが認識されるかどうかは AI コーディングツール自体に依存します。この部分はツールによって大きな差があります。
<Callout type="tip">
`custom_env` と `custom_args` には厳格な上限はありませんが、実際には**それぞれ 10 個以内に抑えてください**。多すぎるとコマンドラインが長くなり、起動が遅くなり、メンテナンスも難しくなります。
</Callout>
## 公開範囲
- **ワークスペース**`workspace` — ワークスペースのすべてのメンバーが割り当てできます
- **非公開**`private` — ワークスペースの owner、admin、またはエージェントの作成者だけが割り当てできます
新しいエージェントはデフォルトで `private` です。
**非公開だからといって隠されるわけではありません** — すべてのメンバーが一覧で非公開エージェントの名前と説明を見ることができ、ただし機微な構成は読み取れません(環境変数の値はエージェントの list/get レスポンスに決して現れず、MCP 構成は owner 以外のユーザーにはマスキングされます)。詳しい意味は[エージェント → 誰がエージェントを割り当てられるか](/agents#who-can-assign-an-agent)を参照してください。
## 同時実行制限
**同時実行制限**`max_concurrent_tasks`)は、このエージェントが一度に並列で実行できるタスク数を制御します。デフォルト値は **6** です。上限に達した新しいタスクは拒否されず、キューで待機します。
これは 2 段階の制限のうち「エージェント層」にすぎません。デーモン自体がより広い上限(デフォルト値 20を適用し、2 つのうちより厳しい方が優先されます。詳しくは[デーモンとランタイム → 並列で何個のタスクを実行できるか](/daemon-runtimes#how-many-tasks-can-run-in-parallel)にあります。
この値を変更しても**すでに実行中のタスクはキャンセルされず**、次に処理されるタスクからのみ適用されます。
## ドメインの専門性をつなぐ: スキル
作成したエージェントには**スキル**をアタッチできます — タスク実行時に AI コーディングツールへ自動的に届けられる**ナレッジパック**`SKILL.md` + 補助ファイルです。新しいスキルを作成したり、GitHub または ClawHub からインポートしたり、マシン上の既存のスキルディレクトリからスキャンしたりできます。[スキル](/skills)を参照してください。
## アーカイブと復元
もう使わないエージェントは**アーカイブ**できます — 日常的な画面からは消えますが、履歴データ(実行したタスク、投稿したコメント)はすべてそのまま保持されます。いつでも**復元**して再び作業に投入できます。
<Callout type="warning">
**アーカイブは、そのエージェントに属する未完了のすべてのタスクを即座にキャンセルします** — 実行中、ディスパッチ済み、キュー待ちのタスクがすべて `cancelled` としてマークされ、続行されません。進行中の重要なタスクがある場合は、アーカイブする前に最後まで完了させてください。
</Callout>
アーカイブ済みのエージェントには新しいタスクを割り当てられません。
## 次のステップ
- [スキル](/skills) — エージェントにナレッジパックをアタッチする
- [AI コーディングツール比較](/providers) — 12 個のツール全体の機能比較表
- [エージェントへのイシューの割り当て](/assigning-issues) — 新しく作ったエージェントを作業に投入する

View File

@@ -1,127 +0,0 @@
---
title: 에이전트 생성 및 구성
description: 에이전트를 생성하는 데 필요한 최소 필드와 모든 선택적 설정 — 시스템 지침, 환경 변수, 공개 범위, 동시 실행 제한, 보관.
---
import { Callout } from "fumadocs-ui/components/callout";
[에이전트](/agents)를 생성하는 데는 단 두 가지만 필요합니다. **이름** 하나와 **[AI 코딩 도구](/providers) 선택** 하나입니다. 나머지는 모두 선택 사항입니다 — 시스템 지침, 모델, 환경 변수, CLI 인자, 공개 범위, 동시 실행 제한 — 기본값으로도 문제없이 작동합니다. 먼저 실행해 보고 나중에 조정하세요. 모든 필드는 언제든지 변경할 수 있습니다.
## 에이전트 생성
전제 조건: 사용 중인 기기에 지원되는 [AI 코딩 도구](/providers)가 최소 하나는 설치되어 있고(Claude Code, Codex 등) [데몬](/daemon-runtimes)이 실행 중이어야 합니다. 아직 여기까지 준비되지 않았다면 [Cloud 빠른 시작](/cloud-quickstart)이나 [자체 호스팅 빠른 시작](/self-host-quickstart)부터 시작하세요.
준비가 끝나면 워크스페이스의 **에이전트** 페이지로 이동해 **+ New**를 클릭하거나 CLI를 사용하세요.
```bash
multica agent create
```
이 폼에는 필수 필드가 두 개뿐입니다. **이름**(워크스페이스 내에서 고유해야 함)과 **런타임**(= AI 코딩 도구 선택)입니다. 나머지 모든 필드는 아래에서 섹션별로 다룹니다.
## AI 코딩 도구 선택
각 런타임은 특정 AI 코딩 도구를 기반으로 합니다. Multica는 그중 12개를 지원합니다. 가장 일반적인 선택지는 다음과 같습니다.
| 도구 | 적합한 경우 |
|---|---|
| **Claude Code** | Anthropic의 공식 도구로, 가장 완성도 높은 기능 집합을 제공합니다. **첫 선택으로 가장 좋습니다** |
| **Codex** | OpenAI 제품으로, 주류 대안입니다 |
| **Cursor** | Cursor 에디터 생태계 사용자 |
| **Copilot** | GitHub 계정 권한을 활용하는 팀 |
| **Gemini** | Google 생태계 사용자 |
나머지 7개(Antigravity, Hermes, Kimi, Kiro CLI, OpenCode, Pi, OpenClaw)와 각 도구의 전체 기능 비교표(세션 재개, MCP, 스킬 주입 경로, 모델 선택)는 [AI 코딩 도구 비교](/providers)에서 다룹니다.
## 시스템 지침 작성
**시스템 지침**(`instructions`)은 모든 작업 앞에 추가되어, 에이전트가 어떤 역할을 맡고 어떤 규칙을 따라야 하는지 알려줍니다.
```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.
```
비워 두면(기본값) 에이전트는 추가 제약 없이 기반이 되는 AI 코딩 도구의 기본 동작을 사용합니다.
## 모델 선택
대부분의 AI 코딩 도구는 모델 선택을 지원합니다(예를 들어 Claude Code는 Sonnet과 Opus 중에서 고를 수 있습니다). 비워 두면 도구 자체의 기본값이 사용되고, 명시적으로 하나를 선택하면 그 모델이 실행됩니다. 각 도구가 지원하는 모델은 [AI 코딩 도구 비교](/providers)에 정리되어 있습니다.
모델 변경은 **새 작업에만 적용됩니다**. 이미 디스패치된 작업은 디스패치 시점에 고정된 모델로 계속 실행됩니다.
## 사용자 지정 환경 변수 (custom_env)
**사용자 지정 환경 변수**(`custom_env`)를 사용하면 작업 실행 시점에 추가 환경 변수를 주입할 수 있습니다. 대표적인 용도는 API 키 설정이나 업스트림 엔드포인트 전환입니다.
```
ANTHROPIC_API_KEY = sk-...
ANTHROPIC_BASE_URL = https://my-proxy.example.com
```
시스템에 핵심적인 변수는 재정의할 수 없습니다. `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, 그리고 `MULTICA_*`로 시작하는 모든 키는 데몬이 조용히 무시합니다(경고 로그는 남기지만 오류는 발생하지 않습니다).
<Callout type="warning">
**`custom_env`의 값은 Multica 서버 데이터베이스에 평문으로 저장됩니다.** 에이전트 list/get 응답은 더 이상 환경 변수 값을 전혀 포함하지 않으며, 불투명한 개수만 반환합니다. 실제 값을 읽으려면 워크스페이스 owner 또는 admin이 전용으로 감사되는 `GET /api/agents/{id}/env` 엔드포인트(CLI: `multica agent env get <id>`)를 호출해야 합니다. 작업을 실행 중인 에이전트는 호스트의 owner 자격 증명을 이용해 다른 에이전트의 환경 변수를 드러낼 수 없습니다. 이 엔드포인트는 에이전트 액터 세션을 거부합니다.
**가치가 높은 secret은 `custom_env`에 넣지 마세요**(운영 데이터베이스 비밀번호, root 수준 토큰 등). 에이전트에는 **권한 범위가 제한된 전용 자격 증명**(읽기 전용 API 키, 단일 스코프 PAT)을 사용하고 정기적으로 교체하세요. 데이터베이스 백업과 DB 감사는 여전히 의미 있는 노출 표면으로 남아 있습니다.
</Callout>
## 사용자 지정 CLI 인자 (custom_args)
**사용자 지정 CLI 인자**(`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" [...]
```
인자는 셸을 거치지 않고 있는 그대로 전달되므로(주입 위험 없음), 특정 플래그가 인식되는지 여부는 AI 코딩 도구 자체에 달려 있습니다. 이 부분은 도구마다 상당한 차이가 있습니다.
<Callout type="tip">
`custom_env`와 `custom_args`에는 엄격한 상한이 없지만, 실제로는 **각각 10개 이내로 유지하세요**. 너무 많으면 명령줄이 길어지고 시작이 느려지며 유지 관리도 어려워집니다.
</Callout>
## 공개 범위
- **워크스페이스**(`workspace`) — 워크스페이스의 모든 멤버가 할당할 수 있습니다
- **비공개**(`private`) — 워크스페이스 owner, admin, 또는 에이전트 생성자만 할당할 수 있습니다
새 에이전트는 기본적으로 `private`입니다.
**비공개라고 해서 숨겨지는 것은 아닙니다** — 모든 멤버가 목록에서 비공개 에이전트의 이름과 설명을 볼 수 있으며, 다만 민감한 구성은 읽을 수 없습니다(환경 변수 값은 에이전트 list/get 응답에 절대 나타나지 않으며, MCP 구성은 owner가 아닌 사용자에게는 마스킹됩니다). 자세한 의미는 [에이전트 → 누가 에이전트를 할당할 수 있나요](/agents#who-can-assign-an-agent)를 참고하세요.
## 동시 실행 제한
**동시 실행 제한**(`max_concurrent_tasks`)은 이 에이전트가 한 번에 병렬로 실행할 수 있는 작업 수를 제어합니다. 기본값은 **6**입니다. 상한에 도달한 새 작업은 거부되지 않고 대기열에서 대기합니다.
이것은 두 단계 제한 중 "에이전트 계층"에 불과합니다. 데몬 자체가 더 넓은 상한(기본값 20)을 적용하며, 둘 중 더 빡빡한 쪽이 우선합니다. 자세한 내용은 [데몬과 런타임 → 병렬로 몇 개의 작업을 실행할 수 있나요](/daemon-runtimes#how-many-tasks-can-run-in-parallel)에 있습니다.
이 값을 변경해도 **이미 실행 중인 작업은 취소되지 않으며**, 다음에 처리될 작업부터만 적용됩니다.
## 도메인 전문성 연결: 스킬
생성된 에이전트에는 **스킬**을 연결할 수 있습니다 — 작업 실행 시점에 AI 코딩 도구로 자동 전달되는 **지식 팩**(`SKILL.md` + 보조 파일)입니다. 새 스킬을 만들거나, GitHub 또는 ClawHub에서 가져오거나, 기기에 있는 기존 스킬 디렉터리에서 스캔할 수 있습니다. [스킬](/skills)을 참고하세요.
## 보관 및 복원
더 이상 사용하지 않는 에이전트는 **보관**할 수 있습니다 — 일상적인 화면에서는 사라지지만, 이력 데이터(실행한 작업, 작성한 댓글)는 모두 그대로 유지됩니다. 언제든지 **복원**하여 다시 작업에 투입할 수 있습니다.
<Callout type="warning">
**보관은 해당 에이전트에 속한 완료되지 않은 모든 작업을 즉시 취소합니다** — 실행 중, 디스패치됨, 대기 중인 작업이 모두 `cancelled`로 표시되며 계속 진행되지 않습니다. 진행 중인 중요한 작업이 있다면 보관하기 전에 끝까지 완료되도록 두세요.
</Callout>
보관된 에이전트에는 새 작업을 할당할 수 없습니다.
## 다음 단계
- [스킬](/skills) — 에이전트에 지식 팩 연결하기
- [AI 코딩 도구 비교](/providers) — 12개 도구 전체의 기능 비교표
- [에이전트에게 이슈 할당하기](/assigning-issues) — 새로 만든 에이전트를 작업에 투입하기

View File

@@ -21,7 +21,7 @@ The form has only two required fields: **name** (unique within the workspace) an
## Pick an AI coding tool
Each runtime is backed by a specific AI coding tool. Multica supports 12 of them. The most common choices:
Each runtime is backed by a specific AI coding tool. Multica supports 11 of them. The most common choices:
| Tool | Good for |
|---|---|
@@ -31,7 +31,7 @@ Each runtime is backed by a specific AI coding tool. Multica supports 12 of them
| **Copilot** | Teams leveraging their GitHub account entitlements |
| **Gemini** | Users in the Google ecosystem |
The other seven (Antigravity, 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).
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
@@ -64,9 +64,9 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
<Callout type="warning">
**Values in `custom_env` are stored in plaintext in Multica's server database.** Agent list/get responses no longer carry env values at all — only an opaque count. Reading values requires a workspace owner or admin to hit the dedicated, audited `GET /api/agents/{id}/env` endpoint (CLI: `multica agent env get <id>`). Agents running tasks can NOT use their host's owner credentials to reveal env on other agents — the endpoint denies agent-actor sessions.
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly. Database backups and DB audits remain a meaningful exposure surface.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
</Callout>
## Custom CLI arguments (custom_args)
@@ -96,7 +96,7 @@ Arguments are passed as-is, not through a shell (no injection risk), but whether
New agents default to `private`.
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't read sensitive config (env values never appear in agent list/get responses; MCP config is masked for non-owners). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
## Concurrency limit
@@ -123,5 +123,5 @@ 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 12 tools
- [AI coding tools comparison](/providers) — full capability matrix across all 11 tools
- [Assigning issues to agents](/assigning-issues) — put your new agent to work

View File

@@ -21,7 +21,7 @@ multica agent create
## 选一款 AI 编程工具
运行时背后是一款具体的 AI 编程工具。Multica 支持 12 款,最常用的几款:
运行时背后是一款具体的 AI 编程工具。Multica 支持 11 款,最常用的几款:
| 工具 | 适合 |
|---|---|
@@ -31,7 +31,7 @@ multica agent create
| **Copilot** | 用 GitHub 账号权益的团队 |
| **Gemini** | Google 生态用户 |
另外 7 款(Antigravity、Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
另外 6Hermes、Kimi、Kiro CLI、OpenCode、Pi、OpenClaw以及每款工具的完整能力差别会话恢复、MCP、skill 注入路径、模型选择)见 [AI 编程工具对照](/providers)。
## 写系统指令
@@ -64,7 +64,7 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn不会报错
<Callout type="warning">
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** agent list/get 接口不再返回任何 env 值——只返回键的数量。读取真实值需要 workspace owner / admin 调用专用且会审计的 `GET /api/agents/{id}/env`CLI: `multica agent env get <id>`)。正在执行任务的智能体即便宿主是 owner也无法借此读取其它智能体的 env——这个接口拒绝 agent-actor 会话。数据库备份、DB 审计里仍然能看到真实值
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
**不要把高价值 secret 放进 `custom_env`**生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT定期轮换。
</Callout>
@@ -96,7 +96,7 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
新建默认 `private`。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置env 值从不会出现在 agent list/get 响应里MCP 配置对非 owner 会被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
## 并发上限
@@ -123,5 +123,5 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
## 下一步
- [Skills](/skills) —— 给智能体挂专业知识包
- [AI 编程工具对照](/providers) —— 12 款工具的完整能力差别
- [AI 编程工具对照](/providers) —— 11 款工具的完整能力差别
- [把 issue 分配给智能体](/assigning-issues) —— 创建完之后怎么用起来

View File

@@ -1,49 +0,0 @@
---
title: エージェント
description: "エージェントは Multica ワークスペースの一級メンバーです — イシューを割り当てられ、コメントを投稿し、@ でメンションされることができます。人間との核心的な違いは、エージェントは自分から作業を始め、通知を受け取らない点です。"
---
import { Callout } from "fumadocs-ui/components/callout";
エージェントは Multica [ワークスペース](/workspaces)の **一級メンバー** です — 人間と同じように、[イシューを割り当てられ](/assigning-issues)、[コメント](/comments)で発言し、[`@` でメンションされ](/mentioning-agents)、[プロジェクト](/projects)をリードできます。核心的な違いはこれです。すべてのエージェントの背後には、あなたのマシンで動作する [AI コーディングツール](/providers)があります。エージェントにタスクを割り当てると、特に促さなくても **数秒以内に自分から作業を始めます** — 急かす必要も、オフラインになることもなく、24時間いつでも利用できます。
## エージェントができること
エージェントは人間と同じ「メンバー」の表面を使っており、UI ではほとんど区別されません。
- **[イシューを割り当てられる](/assigning-issues)** — 担当者に設定された瞬間、自動的に作業を始めます
- **[`@` でメンションされる](/mentioning-agents)** — コメントに `@agent-name` と書くと、目覚めてそのコメントを読みます
- **[コメント](/comments)を投稿する** — イシューの下で進捗を報告し、人々に返信します
- **[プロジェクト](/projects)をリードする** — 人間と同じように、プロジェクトリードに設定できます
- **自分で[イシュー](/issues)を開く** — タスクを実行している間に関連する問題を見つけると、直接新しいイシューを作成できます
協業ビューから見ると、エージェントはただのワークスペースのメンバーです — 人間と同じメンバー一覧に名前が並び、通常はその前に小さなロボットアイコンが付きます。
## 人間との違い
いくつかの重要な違いは、実際にエージェントを使い始めて初めて見えてきます。
- **自分から始めます** — イシューを割り当てたり `@` でメンションしたりすると、Multica が即座にそのタスクをエージェントのランタイムにディスパッチします。人間のようにメッセージを見て応答するまで待つことはありません。トリガーの詳細については、[エージェントにイシューを割り当てる](/assigning-issues)と[コメントでエージェントを @ メンションする](/mentioning-agents)を参照してください。
- **通知を受け取りません** — エージェントはあなたの[インボックス](/inbox)の向こう側に現れることは決してなく、`@all` の受信対象にも含まれません。エージェントは「メッセージを読む受信者」ではなく「タスクを実行するためにトリガーされる作業の単位」です。
- **1つの AI コーディングツールに紐づいています** — すべてのエージェントはランタイムに紐づいています(ランタイム = デーモン × 1つの AI コーディングツール。[デーモンとランタイム](/daemon-runtimes)を参照)。ツールがオフラインだとエージェントは作業できず、新しいタスクはランタイムが戻るまで待機します。
- **アーカイブできます** — もう使わないエージェントをアーカイブすると日常的なビューから消えます。いつでも好きなときに復元できます。アーカイブすると、現在実行中のタスクはすべてキャンセルされます。
## 誰がエージェントを割り当てられるか
エージェントを作成するとき、誰がそのエージェントをイシューに割り当てたりプロジェクトリードに設定したりできるかを制御する **可視性visibility** を選択します。
- **Workspace** — ワークスペースの任意のメンバーが割り当てられます
- **Private** — ワークスペースの owner、admin、またはエージェントの作成者だけが割り当てられます
新しいエージェントはデフォルトで **private** です。ワークスペース全体で利用できるようにするには、作成時に可視性を `workspace` に設定するか、後でエージェントの設定で変更してください。役割と権限の完全なマトリクスについては、[メンバーと役割](/members-roles)を参照してください。
<Callout type="info">
**private は「誰が割り当てられるかを制限する」という意味であって、「他の全員から隠す」という意味ではありません。** ワークスペースのすべてのメンバーは、エージェント一覧で private エージェントの名前と説明を見ることができます — 見えないのは設定の詳細だけですカスタム環境変数、MCP 設定、その他の機密フィールドはマスクされます。「1人だけに見える」ようにしたい場合、現時点では実現できません。
</Callout>
## 次のステップ
- [エージェントの作成と構成](/agents-create) — エージェントを作る方法
- [スキル](/skills) — エージェントに知識パックを添付する
- [スクワッド](/squads) — 適切なエージェントが適切なイシューを担当するよう、リーダーの下にエージェントをグループ化する
- [デーモンとランタイム](/daemon-runtimes) — エージェントが実際に動作するために必要なもの

View File

@@ -1,49 +0,0 @@
---
title: 에이전트
description: "에이전트는 Multica 워크스페이스의 일급 멤버입니다 — 이슈를 할당받고, 댓글을 달고, @로 멘션될 수 있습니다. 사람과의 핵심 차이는, 에이전트는 스스로 작업을 시작하며 알림을 받지 않는다는 점입니다."
---
import { Callout } from "fumadocs-ui/components/callout";
에이전트는 Multica [워크스페이스](/workspaces)의 **일급 멤버**입니다 — 사람과 마찬가지로 [이슈를 할당받고](/assigning-issues), [댓글](/comments)에서 발언하고, [`@`로 멘션되며](/mentioning-agents), [프로젝트](/projects)를 이끌 수 있습니다. 핵심 차이는 이것입니다. 모든 에이전트 뒤에는 여러분의 기기에서 실행되는 [AI 코딩 도구](/providers)가 있습니다. 에이전트에게 작업을 할당하면 별다른 재촉 없이 **수 초 내에 스스로 작업을 시작**합니다 — 닦달할 필요도, 오프라인이 되지도 않으며, 24시간 내내 가용합니다.
## 에이전트가 할 수 있는 일
에이전트는 사람과 동일한 "멤버" 표면을 사용하며, UI에서는 거의 구분되지 않습니다.
- **[이슈를 할당받기](/assigning-issues)** — 담당자로 지정되는 순간 자동으로 작업을 시작합니다
- **[`@`로 멘션되기](/mentioning-agents)** — 댓글에 `@agent-name`을 쓰면 깨어나 해당 댓글을 읽습니다
- **[댓글](/comments) 작성** — 이슈 아래에서 진행 상황을 보고하고 사람들에게 답글을 답니다
- **[프로젝트](/projects) 이끌기** — 사람과 마찬가지로 프로젝트 리더로 지정될 수 있습니다
- **스스로 [이슈](/issues) 열기** — 작업을 실행하는 동안 관련 문제를 발견하면 직접 새 이슈를 생성할 수 있습니다
협업 뷰에서 보면 에이전트는 그저 워크스페이스의 한 멤버일 뿐입니다 — 사람과 같은 멤버 목록에 이름이 자리하며, 보통 앞에 작은 로봇 아이콘이 붙습니다.
## 사람과 다른 점
몇 가지 핵심 차이는 실제로 에이전트를 사용하기 시작해야 비로소 드러납니다.
- **스스로 시작합니다** — 이슈를 할당하거나 `@`로 멘션하면 Multica가 즉시 해당 작업을 에이전트의 런타임에 디스패치합니다. 사람처럼 메시지를 보고 응답할 때까지 기다리지 않습니다. 트리거에 대한 자세한 내용은 [에이전트에게 이슈 할당하기](/assigning-issues)와 [댓글에서 에이전트 @-멘션하기](/mentioning-agents)를 참고하세요.
- **알림을 받지 않습니다** — 에이전트는 여러분의 [인박스](/inbox) 건너편에 결코 나타나지 않으며, `@all`의 수신 대상에도 포함되지 않습니다. 에이전트는 "메시지를 읽는 수신자"가 아니라 "작업을 실행하도록 트리거되는 작업 단위"입니다.
- **하나의 AI 코딩 도구에 묶여 있습니다** — 모든 에이전트는 런타임에 묶여 있습니다(런타임 = 데몬 × 하나의 AI 코딩 도구. [데몬과 런타임](/daemon-runtimes) 참고). 도구가 오프라인이면 에이전트는 작업할 수 없으며, 새 작업은 런타임이 돌아올 때까지 대기합니다.
- **보관할 수 있습니다** — 더 이상 사용하지 않는 에이전트를 보관하면 일상적인 뷰에서 사라지며, 원하면 언제든지 복원할 수 있습니다. 보관하면 현재 실행 중인 작업은 모두 취소됩니다.
## 누가 에이전트를 할당할 수 있나
에이전트를 생성할 때, 누가 그 에이전트를 이슈에 할당하거나 프로젝트 리더로 지정할 수 있는지를 제어하는 **가시성(visibility)** 을 선택합니다.
- **워크스페이스(Workspace)** — 워크스페이스의 모든 멤버가 할당할 수 있습니다
- **비공개(Private)** — 워크스페이스의 owner, admin, 또는 에이전트 생성자만 할당할 수 있습니다
새 에이전트는 기본적으로 **비공개**입니다. 전체 워크스페이스에서 사용할 수 있게 하려면, 생성 시 가시성을 `workspace`로 설정하거나 이후에 에이전트 설정에서 변경하세요. 전체 역할-권한 매트릭스는 [멤버와 역할](/members-roles)을 참고하세요.
<Callout type="info">
**비공개는 "누가 할당할 수 있는지를 제한"한다는 뜻이지, "다른 모든 사람에게 숨긴다"는 뜻이 아닙니다.** 워크스페이스의 모든 멤버는 에이전트 목록에서 비공개 에이전트의 이름과 설명을 볼 수 있습니다 — 단지 설정 세부 정보는 볼 수 없을 뿐입니다(사용자 정의 환경 변수, MCP 설정 및 기타 민감한 필드는 마스킹됩니다). "단 한 사람에게만 보이게" 하고 싶다면, 현재로서는 불가능합니다.
</Callout>
## 다음 단계
- [에이전트 생성 및 구성](/agents-create) — 에이전트를 만드는 방법
- [스킬](/skills) — 에이전트에 지식 팩 연결하기
- [스쿼드](/squads) — 적합한 에이전트가 적합한 이슈를 맡도록 리더 아래 에이전트를 그룹으로 묶기
- [데몬과 런타임](/daemon-runtimes) — 에이전트가 실제로 실행되기 위해 필요한 것

View File

@@ -5,7 +5,7 @@ description: "An agent is a first-class member of a Multica workspace — it can
import { Callout } from "fumadocs-ui/components/callout";
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/projects). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
An agent is a **first-class member** of a Multica [workspace](/workspaces) — like a human, it can be [assigned issues](/assigning-issues), speak up in [comments](/comments), be [`@`-mentioned](/mentioning-agents), and lead a [project](/issues). The core difference: behind every agent is an [AI coding tool](/providers) running on your machine. Assign it a task and it **starts working within seconds** on its own — no nudging, no going offline, available 24/7.
## What an agent can do
@@ -14,7 +14,7 @@ Agents use the same "member" surface as humans, and the UI barely distinguishes
- **[Be assigned issues](/assigning-issues)** — once set as the assignee, it starts working automatically
- **[Be `@`-mentioned](/mentioning-agents)** — write `@agent-name` in a comment and it wakes up to read that comment
- **Post [comments](/comments)** — it reports progress and replies to people under the issue
- **Lead a [project](/projects)** — it can be set as project lead, same as a human
- **Lead a [project](/issues)** — it can be set as project lead, same as a human
- **Open [issues](/issues) itself** — while running a task, if it spots a related problem, it can create a new issue directly
From the collaboration view, an agent is just a member of the workspace — its name sits in the same member list as humans, usually with a small robot icon in front.

View File

@@ -5,7 +5,7 @@ description: 智能体agent是 Multica 工作区里的一等公民成员
import { Callout } from "fumadocs-ui/components/callout";
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/projects) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
智能体agent是 Multica [工作区](/workspaces) 里的**一等公民成员**——和人一样能被 [分配 issue](/assigning-issues)、在 [评论](/comments) 里发言、被 [`@` 点名](/mentioning-agents)、作为 [project](/issues) 的负责人。和人的核心差别是:它背后是一款跑在你本机的 [AI 编程工具](/providers);分配任务给它,它会**在几秒内自己开始干**——不用催、不下线、7×24 随时接活。
## 智能体能做什么
@@ -14,7 +14,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **[被分配 issue](/assigning-issues)** —— 作为 assignee分配后它会自动开工
- **[被 `@` 点名](/mentioning-agents)** —— 在评论里写 `@agent-name`,它会被立刻唤醒去看这条评论
- **发 [评论](/comments)** —— 它会在 issue 底下汇报进展、回复别人
- **作为 [project](/projects) 的负责人** —— 和人一样能被设为 project lead
- **作为 [project](/issues) 的负责人** —— 和人一样能被设为 project lead
- **自己开 [issue](/issues)** —— 跑任务时如果发现了关联问题,它能直接创建新的 issue
从协作视图上看,智能体就是工作区里的一个成员;它和人的名字排在同一张成员列表里,只是前面通常有一个机器人图标。

View File

@@ -1,83 +0,0 @@
---
title: エージェントにイシューを割り当てる
description: イシューをエージェントに渡すと、作業が終わるまで公式の担当者として引き継ぎます — 完全なコンテキストを持ち、イシューのステータスやフィールドを変更できます。
---
import { Callout } from "fumadocs-ui/components/callout";
[イシュー](/issues)を[エージェント](/agents)に割り当てると、作業が終わるまで**公式の担当者**として働きます — イシューの完全なコンテキスト(説明 + すべての[コメント](/comments))を読み、ステータスを変更し、コメントを投稿し、フィールドを編集できます。これは Multica の 4 つのトリガー経路の中で**最も一般的で、最も重い**方式です。同じフローは[スクワッド](/squads)を担当者として受け付けることもできます — その場合、Multica は代わりにスクワッドの**リーダーエージェント**をトリガーします。
| 経路 | 使う場面 | イシューの変更 | コンテキスト | 優先度 | 自動リトライ |
|---|---|---|---|---|---|
| **割り当て** | エージェントに所有権を渡す | 担当者を変更 | イシュー + すべてのコメント | イシューから継承 | ✓ |
| [**@メンション**](/mentioning-agents) | ちょっと見てもらうために呼び込む | 変更なし | イシュー + トリガーコメント | イシューから継承 | ✓ |
| [**チャット**](/chat) | イシューと無関係な 1 対 1 の会話 | イシューは関与しない | 現在の会話履歴 | 固定で medium | ✓ |
| [**オートパイロット**](/autopilots) | スケジュールまたは手動の自動化 | モードによる | モードによる | オートパイロットが設定 | ✗ |
「自動リトライ」とは、インフラ障害(ランタイムのオフライン、タイムアウト)後のリトライを指します。エージェント側のビジネスエラー(たとえばモデルがエラーを報告する場合)はリトライされません。詳しくは [**タスク**](/tasks)を参照してください。
## UI から割り当てる
イシュー詳細ページで、**担当者**ピッカーをクリックしてください。ワークスペースのすべてのメンバー、アーカイブされていないすべてのエージェント、アーカイブされていないすべての[スクワッド](/squads)が一覧表示されます。エージェント(またはスクワッド)を選ぶと、イシューはすぐに割り当てられます。
いくつかのルールがあります。
- **ワークスペースエージェント**はどのメンバーでも割り当てられます。**プライベートエージェント**はその owner またはワークスペースの admin のみが割り当てられます。
- **オンラインのランタイムを持つ**エージェントにのみ割り当てられます — 誰も実行していないエージェントはピッカーで利用不可と表示されます。
- イシューのステータスが **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` はメンバーのユーザー名またはエージェント名(あいまい一致)を受け付けます。名前が重複するとき — たとえばエージェント `J` の隣に `Cursor - J` がある場合 — は、代わりに `--to-id <uuid>` を渡してください。このとき `multica workspace member list --output json` の `user_id`(メンバー)または `multica agent list --output json` の `id`エージェントを使います。UUID 一致は厳密かつ曖昧さがないため、スクリプトや CLI を駆動するエージェントに適しています。`--to` と `--to-id` は同時に使えません。
割り当て解除:
```bash
multica issue assign MUL-42 --unassign
```
## 割り当て後に起こること
Backlog ではないイシューがエージェントに割り当てられると、Multica はすぐにバックグラウンドで次のことを行います。
1. イシューから継承した優先度で `queued` 状態の `task` をキューに入れ、エージェントが存在するランタイムへルーティングします。
2. エージェントのデーモンが次のポーリング時に `task` を取得し、`dispatched` に遷移させます。
3. エージェントが作業を開始すると `task` が `running` に移ります。完了すると `completed` または `failed` になります。
4. 実行中、エージェントはイシューのステータスを変更し、コメントを投稿し、フィールドを編集できます — これらの操作はエージェントの ID で表示されます。
**エージェントがオフラインの場合**、`task` はキューで待機します — **5 分後に `runtime_offline` の理由でタイムアウトして失敗します**。リトライ可能なソース(割り当て、@メンション、チャットについては、Multica が自動的に再度キューに入れます。完全なリトライルールは [**タスク**](/tasks)を参照してください。
割り当てると、エージェントはイシューに自動的に購読されます — ただし Multica では**エージェントはインボックス通知を受け取りません**(メンバーのみ受け取ります)。この購読は内部的な記録管理にすぎず、ユーザーに見える副作用はありません。
## 再割り当てまたは割り当て解除
担当者をエージェント A からエージェント B に変更すると、
1. **A が進行中だったものはすべてキャンセルされます** — `queued`、`dispatched`、`running` 状態のすべての `task` が `cancelled` と表示されます。
2. **B にはすぐに新しい `task` がキューに入ります**(イシューが Backlog でなく、B にオンラインのランタイムがある場合)。
<Callout type="warning">
**再割り当てはこのイシューのすべてのアクティブな `task` をキャンセルします — 以前の担当者のものだけではありません。** 別のエージェントが @メンションによってこのイシューで作業中の場合、その `task` も一緒にキャンセルされます。現在のところ、単一のエージェントの `task` だけを個別にキャンセルする UI 操作はありません。
</Callout>
割り当て解除(`--unassign` またはピッカーで「none」を選択は、すべてのアクティブな `task` 項目を `cancelled` と表示し、**新しい項目をキューに入れません**。既存の購読は自動的にクリアされません — 以前の担当者は購読リストに残ります(ただし依然としてインボックス通知は受け取りません)。
## イシューごとエージェントごとにアクティブな `task` が 1 つだけの理由
**単一のエージェントは、同じイシューで任意の時点に最大 1 つの `queued` または `dispatched` の `task` しか持てません。** データベースレベルの一意インデックスとクレームロジックがこれを強制します — 重複したキュー登録と、同時実行が互いを上書きすることを防ぎます。
しかし**異なるエージェントは同じイシューで並列に作業できます** — たとえばエージェント A が担当者で、エージェント B が @メンションされた場合、2 つの `task` 項目がそれぞれ自分のランタイムで実行されながら共存できます。完全な直列・並列ルールは [**タスク**](/tasks)を参照してください。
## 次へ
- [**コメントでエージェントを @メンションする**](/mentioning-agents) — 担当者とステータスを変えない、より軽いトリガー
- [**スクワッド**](/squads) — エージェントのグループに割り当て、リーダーに誰が引き受けるかを決めさせる
- [**チャット**](/chat) — イシューと無関係な 1 対 1 の会話
- [**オートパイロット**](/autopilots) — エージェントがスケジュールに沿って自動的に作業を開始するようにする

View File

@@ -1,83 +0,0 @@
---
title: 에이전트에게 이슈 할당하기
description: 이슈를 에이전트에게 넘기면 작업이 끝날 때까지 공식 담당자로 인계받습니다 — 전체 컨텍스트를 갖고 이슈 상태와 필드를 변경할 수 있습니다.
---
import { Callout } from "fumadocs-ui/components/callout";
[이슈](/issues)를 [에이전트](/agents)에게 할당하면, 작업이 끝날 때까지 **공식 담당자**로서 일합니다 — 이슈의 전체 컨텍스트(설명 + 모든 [댓글](/comments))를 읽을 수 있고, 상태를 변경하고, 댓글을 남기고, 필드를 수정할 수 있습니다. 이것은 Multica의 네 가지 트리거 경로 중 **가장 일반적이고 가장 무거운** 방식입니다. 동일한 흐름은 [스쿼드](/squads)를 담당자로 받을 수도 있습니다 — 이 경우 Multica는 대신 스쿼드의 **리더 에이전트**를 트리거합니다.
| 경로 | 사용 시점 | 이슈 변경 | 컨텍스트 | 우선순위 | 자동 재시도 |
|---|---|---|---|---|---|
| **할당** | 에이전트에게 소유권을 넘김 | 담당자 변경 | 이슈 + 모든 댓글 | 이슈에서 상속 | ✓ |
| [**@-멘션**](/mentioning-agents) | 잠깐 살펴보도록 끌어들임 | 변경 없음 | 이슈 + 트리거 댓글 | 이슈에서 상속 | ✓ |
| [**채팅**](/chat) | 이슈와 무관한 일대일 대화 | 이슈 관여 없음 | 현재 대화 기록 | 고정 중간 | ✓ |
| [**오토파일럿**](/autopilots) | 예약 또는 수동 자동화 | 모드에 따라 다름 | 모드에 따라 다름 | 오토파일럿이 설정 | ✗ |
"자동 재시도"는 인프라 장애(런타임 오프라인, 타임아웃) 이후의 재시도를 의미합니다. 에이전트 쪽의 비즈니스 오류(예: 모델이 오류를 보고하는 경우)는 재시도되지 않습니다. 자세한 내용은 [**작업**](/tasks)을 참고하세요.
## UI에서 할당하기
이슈 상세 페이지에서 **담당자** 선택기를 클릭하세요. 워크스페이스의 모든 멤버, 보관되지 않은 모든 에이전트, 보관되지 않은 모든 [스쿼드](/squads)가 목록에 표시됩니다. 에이전트(또는 스쿼드)를 선택하면 이슈가 즉시 할당됩니다.
몇 가지 규칙이 있습니다.
- **워크스페이스 에이전트**는 어떤 멤버든 할당할 수 있습니다. **프라이빗 에이전트**는 owner 또는 워크스페이스 admin만 할당할 수 있습니다.
- **온라인 런타임이 있는** 에이전트에게만 할당할 수 있습니다 — 아무도 실행하고 있지 않은 에이전트는 선택기에서 사용 불가로 표시됩니다.
- 이슈 상태가 **백로그**일 때 할당하면 **에이전트가 트리거되지 않습니다** — 백로그는 임시 보관소이며, 이슈를 할 일 또는 진행 중으로 옮겨야만 에이전트가 대기열에 들어갑니다.
## CLI에서 할당하기
명령줄에서의 동등한 작업입니다.
```bash
multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to`는 멤버 사용자 이름 또는 에이전트 이름(퍼지 매칭)을 받습니다. 이름이 겹칠 때 — 예를 들어 에이전트 `J` 옆에 `Cursor - J`가 있을 때 — 대신 `--to-id <uuid>`를 전달하세요. 이때 `multica workspace member list --output json`의 `user_id`(멤버) 또는 `multica agent list --output json`의 `id`(에이전트)를 사용합니다. UUID 매칭은 엄격하고 모호하지 않으므로, 스크립트나 CLI를 구동하는 에이전트에게 적합합니다. `--to`와 `--to-id`는 함께 쓸 수 없습니다.
할당 해제:
```bash
multica issue assign MUL-42 --unassign
```
## 할당 이후에 일어나는 일
백로그가 아닌 이슈가 에이전트에게 할당되면, Multica는 즉시 백그라운드에서 다음을 수행합니다.
1. 이슈에서 상속한 우선순위로 `queued` 상태의 `task`를 대기열에 넣고, 에이전트가 있는 런타임으로 라우팅합니다.
2. 에이전트의 데몬이 다음 폴링 시 `task`를 가져가 `dispatched`로 전환합니다.
3. 에이전트가 작업을 시작하면 `task`가 `running`으로 이동합니다. 완료되면 `completed` 또는 `failed`가 됩니다.
4. 실행 중에 에이전트는 이슈의 상태를 변경하고, 댓글을 남기고, 필드를 수정할 수 있습니다 — 이러한 동작은 에이전트의 신원으로 표시됩니다.
**에이전트가 오프라인인 경우**, `task`는 대기열에서 기다립니다 — **5분 후 `runtime_offline` 사유로 타임아웃되어 실패합니다**. 재시도 가능한 소스(할당, @-멘션, 채팅)에 대해서는 Multica가 자동으로 다시 대기열에 넣습니다. 전체 재시도 규칙은 [**작업**](/tasks)을 참고하세요.
할당하면 에이전트가 이슈에 자동으로 구독됩니다 — 다만 Multica에서는 **에이전트가 인박스 알림을 받지 않습니다**(멤버만 받습니다). 이 구독은 내부 기록 관리일 뿐이며 사용자에게 보이는 부작용은 없습니다.
## 재할당 또는 할당 해제
담당자를 에이전트 A에서 에이전트 B로 변경하면:
1. **A가 진행 중이던 모든 것이 취소됩니다** — `queued`, `dispatched`, `running` 상태의 모든 `task`가 `cancelled`로 표시됩니다.
2. **B에게 즉시 새 `task`가 대기열에 들어갑니다**(이슈가 백로그가 아니고 B에게 온라인 런타임이 있는 경우).
<Callout type="warning">
**재할당은 이 이슈의 모든 활성 `task`를 취소합니다 — 이전 담당자의 것만이 아닙니다.** 다른 에이전트가 @-멘션 때문에 이 이슈에서 작업 중이라면, 그 `task`도 함께 취소됩니다. 현재로서는 단일 에이전트의 `task`만 따로 취소하는 UI 동작이 없습니다.
</Callout>
할당 해제(`--unassign` 또는 선택기에서 "none" 선택)는 모든 활성 `task` 항목을 `cancelled`로 표시하며 **새 항목을 대기열에 넣지 않습니다**. 기존 구독은 자동으로 정리되지 않습니다 — 이전 담당자는 구독 목록에 남아 있습니다(다만 여전히 인박스 알림은 받지 않습니다).
## 이슈당 에이전트당 활성 `task`가 하나뿐인 이유
**단일 에이전트는 같은 이슈에서 어느 시점에든 최대 하나의 `queued` 또는 `dispatched` `task`만 가질 수 있습니다.** 데이터베이스 수준의 고유 인덱스와 클레임 로직이 이를 강제합니다 — 중복 대기열 등록과 동시 실행이 서로를 덮어쓰는 것을 방지합니다.
하지만 **서로 다른 에이전트는 같은 이슈에서 병렬로 작업할 수 있습니다** — 예를 들어 에이전트 A가 담당자이고 에이전트 B가 @-멘션된 경우, 두 `task` 항목이 각자의 런타임에서 실행되며 공존할 수 있습니다. 전체 직렬/동시 실행 규칙은 [**작업**](/tasks)을 참고하세요.
## 다음 단계
- [**댓글에서 에이전트를 @-멘션하기**](/mentioning-agents) — 담당자와 상태를 건드리지 않는 더 가벼운 트리거
- [**스쿼드**](/squads) — 에이전트 그룹에게 할당하고 리더가 누가 맡을지 결정하도록 함
- [**채팅**](/chat) — 이슈와 무관한 일대일 대화
- [**오토파일럿**](/autopilots) — 에이전트가 예약된 일정에 따라 자동으로 작업을 시작하도록 함

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace member list --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
`--to` takes a member username or an agent name (fuzzy match). When names overlap — e.g. an agent `J` alongside `Cursor - J` — pass `--to-id <uuid>` instead, using the `user_id` (member) or `id` (agent) from `multica workspace members --output json` / `multica agent list --output json`. UUID matching is strict and unambiguous, which is what you want from scripts and from agents driving the CLI. `--to` and `--to-id` are mutually exclusive.
Unassign:

View File

@@ -35,7 +35,7 @@ multica issue assign MUL-42 --to alice
multica issue assign MUL-42 --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace member list --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
`--to` 后跟成员用户名或智能体名字(模糊匹配)。如果工作区里有同名 / 互相含子串的成员或智能体(例如 agent `J` 旁边还有 `Cursor - J`),改用 `--to-id <uuid>`UUID 来自 `multica workspace members --output json` 的 `user_id` 或 `multica agent list --output json` 的 `id`,是唯一精确的方式,特别适合脚本和驱动 CLI 的智能体。`--to` 和 `--to-id` 互斥。
取消分配:

View File

@@ -1,186 +0,0 @@
---
title: ログインとサインアップの構成
description: メール + 認証コードログイン、Google OAuth、サインアップ許可リスト、ローカルテストコードを構成します。
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica は 2 つのログイン方式をサポートしています。**メール + 認証コード**(デフォルト)と **Google OAuth**(オプション)です。ログインに成功すると、サーバーは 30 日間有効な JWT クッキーを発行します。このページでは、各方式の構成方法、誰がサインアップできるかを制限する方法、そしてセルフホストのデプロイで最も陥りやすい落とし穴を 1 つ取り上げます。
以下で参照する環境変数の一覧は[環境変数](/environment-variables)を参照してください。トークンの使い方とライフサイクルの詳細は[認証とトークン](/auth-tokens)を参照してください。
## メール + 認証コードログインの仕組み
ユーザーがログインページでメールを入力します → サーバーが 6 桁のコードを送信します → ユーザーがコードを入力します → サーバーがコードを検証します → JWT クッキーが発行されます。標準的なフローです。2 つの送信バックエンドがサポートされているので、デプロイ環境に合うほうを選んでください。
### オプション A: Resendクラウド / 公開インターネットのデプロイに推奨)
1. [Resend](https://resend.com/) アカウントを作成し、ドメインを認証します
2. API キーを作成します
3. 環境変数を設定します:
```bash
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
```
4. サーバーを再起動します
### オプション B: SMTP relayセルフホスト / オンプレミスのデプロイ用)
デプロイ環境から `api.resend.com` に到達できない場合や、すでに内部メール relayMicrosoft Exchange、Postfix、オンプレミスの SendGrid など)がある場合に使用してください。両方が設定されている場合は `SMTP_HOST` が `RESEND_API_KEY` より優先されます。`SMTP_HOST` が空でなければ、`RESEND_API_KEY` も併せて構成されていても、サーバーは常に SMTP を経由するため、認証メールと招待メールが内部ネットワークの外に出ることは決してありません。
SMTP 経路は、ほとんどのオンプレミスメールサーバー(特に Microsoft Exchange の receive connectorが公開する 3 つの relay モードをサポートします。
| モード | ポート | 認証 | TLS |
|---|---|---|---|
| 匿名内部 relay | `25` | なし — IP / サブネットで送信を信頼 | 伝送経路上はなし(内部セグメント専用) |
| 認証付き送信submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS、自動アップグレード |
| 暗黙的 TLSSMTPS | `465` | 任意(`SMTP_USERNAME` + `SMTP_PASSWORD` | 接続時に TLS ハンドシェイク — ポート `465` で自動的に有効化、非標準ポートでは `SMTP_TLS=implicit` で強制 |
**ポート 25 の匿名 Exchange relay** — 認証情報なしで信頼されたサブネットからのメールを受け入れる、典型的な「internal SMTP relay」/ Exchange 匿名 receive connector:
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**ポート 587 の認証付き送信** — サービスアカウントを必要とする relay 用。サーバーが STARTTLS のサポートを通知すると自動的にアップグレードされます:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**ポート 465 の暗黙的 TLSSMTPS** — SMTPS のみを提供し STARTTLS を通知しないプロバイダー(例: Aliyun / Tencent のエンタープライズメール)向け。ポート `465` は暗黙的 TLS を自動的に有効化します。`SMTP_TLS=implicit`(別名: `smtps`、`ssl`)は非標準の SMTPS ポートでこれを強制します:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # implicit TLS auto-enabled on 465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**厳格な公開 relay例: Google Workspace `smtp-relay.gmail.com`** はさらに有効な EHLO 名を必要とします。これらの relay は公開 IP からのデフォルトの `localhost` 挨拶を拒否し、relay が接続を切断します — これは挨拶の時点ではなく、後続のコマンドで不明瞭な `EOF``smtp auth: EOF`)として表面化します。`SMTP_EHLO_NAME` を relay が期待する FQDN に設定してください。デフォルトはマシンのホスト名で、コンテナ内では通常は有効な FQDN ではありません。
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
起動時に、サーバーは選択したプロバイダーを、ネゴシエートされた TLS モードも含めて出力します。例えば `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` や `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(または `Resend API` / `DEV mode`)のように表示されます。パスワードがログに記録されることは決してありません。再起動後に SMTP の行が見えない場合は `SMTP_HOST` がプロセスに届いていないので、コンテナ環境(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)を確認してください。
**どちらも設定しない場合**: サーバーはエラーを出しませんが、**送信されるはずだったすべてのメールがサーバーの stdout にのみ書き出されます**。ローカル開発には便利ですが(ログからコードをコピーできます)、プロダクションではブラックホールになります。
## 固定ローカルテストコード
<Callout type="warning">
**公開アクセス可能なインスタンスでは固定の認証コードを有効にしないでください。**
非プロダクションのインスタンスがデフォルトで `888888` を受け入れていた従来の動作は削除されました。明示的に構成しない限り、`888888` の入力は他の誤ったコードと同じように扱われます。
メールバックエンドをまったく構成していないResend も SMTP もない)ローカル開発では、サーバーログに出力される生成されたコードを使用してください。決定論的なローカル / プライベートの自動化が必要な場合は、`MULTICA_DEV_VERIFICATION_CODE` を `888888` のような 6 桁の値に設定し、`APP_ENV` を非プロダクションに保ってください:
```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 の構成
オプションです。構成しないとメール + 認証コードのみが利用可能で、構成するとログインページに「Sign in with Google」ボタンが追加されます。
1. [Google Cloud Console](https://console.cloud.google.com/) で OAuth 2.0 クライアントを作成します
2. **Authorized redirect URIs** を Multica フロントエンドのアドレスに `/auth/callback` を加えた値に設定します。例:
```text
https://multica.yourdomain.com/auth/callback
```
3. クライアント ID とクライアント secret を取得したら、3 つの環境変数を設定します:
```bash
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
```
4. サーバーを再起動します。
**ランタイムで反映されます**: フロントエンドは `/api/config` を通じてランタイムにこれらの設定を読み込みます — 変更後にサーバーを再起動すると、フロントエンドはリビルドや再デプロイなしで新しい値を取得します。
<Callout type="warning">
**リダイレクト URI は Google Console と `GOOGLE_REDIRECT_URI` の両方で完全に一致している必要があります** — プロトコル(`http` と `https`)、末尾のスラッシュ、ポートを含みます。少しでも一致しないと Google は OAuth フロー全体を拒否し、ユーザーに表示されるエラーは `redirect_uri_mismatch` です。
</Callout>
## 誰がサインアップできるかを制限する
3 つの環境変数が優先順位に従って組み合わされます。
<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
`} />
**既存のユーザーはいつでも再ログインできます** — サインアップ許可リストは**初回サインアップ**にのみ適用され、戻ってくるユーザーは妨げられません。
- **`ALLOWED_EMAILS`**(最高優先度) — 明示的なメール許可リスト、カンマ区切り。**空でない場合、リストにあるメールのみがサインアップできます。**
- **`ALLOWED_EMAIL_DOMAINS`** — ドメイン許可リスト、カンマ区切り(例: `company.io,partner.com`)。
- **`ALLOW_SIGNUP`** — マスタースイッチ、デフォルト `true`。`false` に設定するとサインアップが完全に無効になります。
<Callout type="warning">
**3 つの層は OR ではなく AND のセマンティクスです。** よくある誤った直感は、`ALLOWED_EMAIL_DOMAINS=company.io` + `ALLOW_SIGNUP=true` が「company.io に加えて他の全員を許可する」という意味だと考えることです。そうでは**ありません**。いずれかの層に空でない値があると、**それに一致しないメールはただちに拒否され**、`ALLOW_SIGNUP=true` はそれを無効にできません。
実際に「全員を許可」するには、3 つの変数をすべて空のままにしてください(または `ALLOW_SIGNUP=true` を維持してください)。
</Callout>
**典型的な構成**:
| 目的 | 構成 |
|---|---|
| 内部専用、`company.io` の従業員のみ | `ALLOWED_EMAIL_DOMAINS=company.io` |
| 内部 + 少数の外部コラボレーター | `ALLOWED_EMAIL_DOMAINS=company.io` + コラボレーターのアドレスを `ALLOWED_EMAILS` に追加 |
| セルフサービスのサインアップを完全に無効化、招待のみ | `ALLOW_SIGNUP=false` |
| 開放型サインアップ(プロダクションには非推奨) | 3 つすべて空 |
## サインアップを無効にしても人を招待できますか?
**すでに Multica アカウントを持っている人のみ可能です。** 招待の受諾はサインアップ許可リストをチェックしません — 招待された人がすでにサインアップ済み(例えば別のワークスペースで)であれば、招待リンクをクリックしてログインすれば受諾できます。
**しかし一度もサインアップしていない人は招待で救うことはできません。** 受諾する前にまずログインする必要があり、ログインの最初のステップ(認証コードの要求)はサインアップ許可リストのチェックを通過します。`ALLOW_SIGNUP=false` であるか、そのメールが `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` にない場合、**サインアップを完了できず**、したがって招待を受諾することもできません。
まだサインアップしていない外部コラボレーターを招待するには: そのメールを `ALLOWED_EMAILS` に一時的に追加し、その人がサインアップして招待を受諾するのを待ってから、エントリを削除してください。
招待の作成と使用方法については[メンバーとロール](/members-roles)を参照してください。
## 次に
- [環境変数](/environment-variables) — このページで使用するすべての変数の完全な定義
- [認証とトークン](/auth-tokens) — JWT / PAT / デーモントークンの分類と使い方
- [トラブルシューティング](/troubleshooting) — 認証コードが届かない、OAuth `redirect_uri_mismatch`、サインアップ拒否

View File

@@ -1,186 +0,0 @@
---
title: 로그인 및 회원가입 구성
description: 이메일 + 인증 코드 로그인, Google OAuth, 회원가입 허용 목록, 로컬 테스트 코드를 구성합니다.
---
import { Callout } from "fumadocs-ui/components/callout";
import { Mermaid } from "@/components/mermaid";
Multica는 두 가지 로그인 방식을 지원합니다. **이메일 + 인증 코드**(기본값)와 **Google OAuth**(선택). 로그인에 성공하면 서버가 30일 수명의 JWT 쿠키를 발급합니다. 이 페이지에서는 각 방식을 구성하는 방법, 누가 회원가입할 수 있는지 제한하는 방법, 그리고 자체 호스팅 배포에서 가장 빠지기 쉬운 함정 하나를 다룹니다.
아래에서 참조하는 환경 변수 목록은 [환경 변수](/environment-variables)를 참고하세요. 토큰 사용법과 수명 주기 세부 사항은 [인증 및 토큰](/auth-tokens)을 참고하세요.
## 이메일 + 인증 코드 로그인의 작동 방식
사용자가 로그인 페이지에서 이메일을 입력합니다 → 서버가 6자리 코드를 보냅니다 → 사용자가 코드를 입력합니다 → 서버가 코드를 검증합니다 → JWT 쿠키가 발급됩니다. 표준 흐름입니다. 두 가지 전송 백엔드가 지원되므로 배포 환경에 맞는 쪽을 선택하세요.
### 옵션 A: Resend (클라우드 / 공용 인터넷 배포에 권장)
1. [Resend](https://resend.com/) 계정을 만들고 도메인을 인증합니다
2. API 키를 생성합니다
3. 환경 변수를 설정합니다:
```bash
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
RESEND_FROM_EMAIL=noreply@yourdomain.com # must be a domain verified in Resend
```
4. 서버를 재시작합니다
### 옵션 B: SMTP relay (자체 호스팅 / 온프레미스 배포용)
배포 환경에서 `api.resend.com`에 접근할 수 없거나 이미 내부 메일 relay(Microsoft Exchange, Postfix, 온프레미스 SendGrid 등)가 있는 경우에 사용하세요. 둘 다 설정된 경우 `SMTP_HOST`가 `RESEND_API_KEY`보다 우선합니다. `SMTP_HOST`가 비어 있지 않으면 `RESEND_API_KEY`도 함께 구성되어 있더라도 서버는 항상 SMTP를 통하므로, 인증 및 초대 메일이 내부 네트워크를 벗어나는 일이 결코 없습니다.
SMTP 경로는 대부분의 온프레미스 메일 서버(특히 Microsoft Exchange의 receive connector)가 노출하는 세 가지 relay 모드를 지원합니다:
| 모드 | 포트 | 인증 | TLS |
|---|---|---|---|
| 익명 내부 relay | `25` | 없음 — IP / 서브넷으로 제출을 신뢰 | 전송 경로상 없음(내부 세그먼트 전용) |
| 인증된 제출(submission) | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, 자동 업그레이드 |
| 암묵적 TLS (SMTPS) | `465` | 선택 사항(`SMTP_USERNAME` + `SMTP_PASSWORD`) | 연결 시 TLS 핸드셰이크 — 포트 `465`에서 자동 활성화, 비표준 포트에서는 `SMTP_TLS=implicit`로 강제 |
**포트 25의 익명 Exchange relay** — 자격 증명 없이 신뢰된 서브넷에서 오는 메일을 받아들이는 일반적인 "internal SMTP relay" / Exchange 익명 receive connector:
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**포트 587의 인증된 제출** — 서비스 계정이 필요한 relay용. 서버가 STARTTLS 지원을 알리면 자동으로 업그레이드됩니다:
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**포트 465의 암묵적 TLS(SMTPS)** — SMTPS만 제공하고 STARTTLS를 알리지 않는 제공자(예: Aliyun / Tencent 엔터프라이즈 메일)용. 포트 `465`는 암묵적 TLS를 자동으로 활성화하며, `SMTP_TLS=implicit`(별칭: `smtps`, `ssl`)는 비표준 SMTPS 포트에서 이를 강제합니다:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # implicit TLS auto-enabled on 465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**엄격한 공개 relay(예: Google Workspace `smtp-relay.gmail.com`)** 는 추가로 유효한 EHLO 이름을 요구합니다. 이들은 공개 IP에서 보내는 기본 `localhost` greeting을 거부하며, relay가 연결을 끊습니다 — 이는 greeting 단계가 아니라 이후 명령에서 불투명한 `EOF`(`smtp auth: EOF`)로 나타납니다. relay가 기대하는 FQDN으로 `SMTP_EHLO_NAME`을 설정하세요. 기본값은 머신 호스트명이며, 컨테이너 안에서는 보통 유효한 FQDN이 아닙니다:
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
시작 시 서버는 협상된 TLS 모드를 포함하여 선택한 제공자를 출력합니다. 예를 들어 `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` 또는 `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…`(또는 `Resend API` / `DEV mode`)와 같이 표시됩니다. 비밀번호는 절대 로그에 기록되지 않습니다. 재시작 후 SMTP 줄이 보이지 않는다면 `SMTP_HOST`가 프로세스에 도달하지 못한 것이므로, 컨테이너 환경(`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`)을 확인하세요.
**둘 다 설정하지 않으면**: 서버는 오류를 내지 않지만, **전송되어야 했던 모든 이메일이 서버의 stdout에만 기록됩니다**. 로컬 개발에는 편리하지만(로그에서 코드를 복사하면 됩니다), 프로덕션에서는 블랙홀이 됩니다.
## 고정 로컬 테스트 코드
<Callout type="warning">
**공용으로 접근 가능한 인스턴스에서는 고정 인증 코드를 활성화하지 마세요.**
프로덕션이 아닌 인스턴스가 기본적으로 `888888`을 받아들이던 기존 동작은 제거되었습니다. 명시적으로 구성하지 않는 한 `888888`을 입력하는 것은 다른 잘못된 코드와 동일하게 처리됩니다.
이메일 백엔드를 전혀 구성하지 않은(Resend도 SMTP도 없는) 로컬 개발에서는 서버 로그에 출력되는 생성된 코드를 사용해야 합니다. 결정적인 로컬/사설 자동화가 필요하다면 `MULTICA_DEV_VERIFICATION_CODE`를 `888888` 같은 6자리 값으로 설정하고 `APP_ENV`를 프로덕션이 아닌 값으로 유지하세요:
```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 구성
선택 사항입니다. 구성하지 않으면 이메일 + 인증 코드만 사용할 수 있고, 구성하면 로그인 페이지에 "Google로 로그인" 버튼이 추가됩니다.
1. [Google Cloud Console](https://console.cloud.google.com/)에서 OAuth 2.0 클라이언트를 생성합니다
2. **승인된 리디렉션 URI**(Authorized redirect URIs)를 Multica 프런트엔드 주소에 `/auth/callback`을 더한 값으로 설정합니다. 예:
```text
https://multica.yourdomain.com/auth/callback
```
3. 클라이언트 ID와 클라이언트 secret을 얻은 후 세 개의 환경 변수를 설정합니다:
```bash
GOOGLE_CLIENT_ID=xxxxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=GOCSPX-xxxxxxxxxxxxxxx
GOOGLE_REDIRECT_URI=https://multica.yourdomain.com/auth/callback
```
4. 서버를 재시작합니다.
**런타임에 적용됨**: 프런트엔드는 `/api/config`를 통해 런타임에 이 설정을 읽습니다. 변경 후 서버를 재시작하면 프런트엔드가 다시 빌드하거나 다시 배포할 필요 없이 새 값을 가져옵니다.
<Callout type="warning">
**리디렉션 URI는 Google Console과 `GOOGLE_REDIRECT_URI` 양쪽에서 정확히 일치해야 합니다** — 프로토콜(`http` vs `https`), 끝의 슬래시, 포트까지 포함합니다. 조금이라도 일치하지 않으면 Google이 전체 OAuth 흐름을 거부하며, 사용자에게 표시되는 오류는 `redirect_uri_mismatch`입니다.
</Callout>
## 누가 회원가입할 수 있는지 제한하기
세 개의 환경 변수가 우선순위에 따라 조합됩니다:
<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
`} />
**기존 사용자는 언제든 다시 로그인할 수 있습니다** — 회원가입 허용 목록은 **최초 회원가입**에만 적용되며, 돌아오는 사용자는 막지 않습니다.
- **`ALLOWED_EMAILS`** (최고 우선순위) — 명시적 이메일 허용 목록, 쉼표로 구분합니다. **비어 있지 않으면 목록에 있는 이메일만 회원가입할 수 있습니다.**
- **`ALLOWED_EMAIL_DOMAINS`** — 도메인 허용 목록, 쉼표로 구분합니다(예: `company.io,partner.com`).
- **`ALLOW_SIGNUP`** — 마스터 스위치, 기본값 `true`. `false`로 설정하면 회원가입이 완전히 비활성화됩니다.
<Callout type="warning">
**세 계층은 OR가 아니라 AND 의미입니다.** 흔한 잘못된 직관은 `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 계정이 있는 사람만 가능합니다.** 초대 수락은 회원가입 허용 목록을 확인하지 않습니다. 초대받은 사람이 이미 회원가입한 상태라면(예: 다른 워크스페이스에서), 초대 링크를 클릭하고 로그인하면 수락할 수 있습니다.
**하지만 한 번도 회원가입하지 않은 사람은 초대로 구제할 수 없습니다.** 수락하기 전에 먼저 로그인해야 하고, 로그인의 첫 단계(인증 코드 요청)는 회원가입 허용 목록 검사를 거칩니다. `ALLOW_SIGNUP=false`이거나 그들의 이메일이 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS`에 없으면 **회원가입을 완료할 수 없으며**, 따라서 초대도 수락할 수 없습니다.
아직 회원가입하지 않은 외부 협업자를 초대하려면: 그들의 이메일을 `ALLOWED_EMAILS`에 임시로 추가하고, 그들이 회원가입하고 초대를 수락하기를 기다린 다음 항목을 제거하세요.
초대를 만들고 사용하는 방법은 [멤버 및 역할](/members-roles)을 참고하세요.
## 다음
- [환경 변수](/environment-variables) — 이 페이지에서 사용하는 모든 변수의 전체 정의
- [인증 및 토큰](/auth-tokens) — JWT / PAT / 데몬 토큰의 분류와 사용법
- [문제 해결](/troubleshooting) — 인증 코드 미수신, OAuth `redirect_uri_mismatch`, 회원가입 거부

View File

@@ -29,59 +29,18 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Microsoft Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set — if `SMTP_HOST` is non-empty the server always goes through SMTP, even if `RESEND_API_KEY` is also configured, so verification and invite mail never leaves the internal network.
The SMTP path supports the three relay modes most on-premise mail servers (notably Microsoft Exchange's receive connectors) expose:
| Mode | Port | Auth | TLS |
|---|---|---|---|
| Anonymous internal relay | `25` | none — submission is trusted by IP / subnet | none on the wire (internal segment only) |
| Authenticated submission | `587` | `SMTP_USERNAME` + `SMTP_PASSWORD` | STARTTLS, upgraded automatically |
| Implicit TLS (SMTPS) | `465` | optional (`SMTP_USERNAME` + `SMTP_PASSWORD`) | TLS handshake on connect — auto-enabled on port `465`, or force on a non-standard port with `SMTP_TLS=implicit` |
**Anonymous Exchange relay on port 25** — the typical "internal SMTP relay" / Exchange anonymous receive connector that accepts mail from a trusted subnet without credentials:
```bash
SMTP_HOST=exchange.internal.example.com
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_TLS_INSECURE=false
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**Authenticated submission on port 587** — for relays that require a service account; STARTTLS is upgraded automatically when the server advertises it:
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587
SMTP_USERNAME=multica
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
SMTP_USERNAME=multica # leave empty for unauthenticated relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
RESEND_FROM_EMAIL=noreply@yourdomain.com
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
```
**Implicit TLS (SMTPS) on port 465** — for providers that only offer SMTPS and don't advertise STARTTLS (e.g. Aliyun / Tencent enterprise mail). Port `465` auto-enables implicit TLS; `SMTP_TLS=implicit` (aliases: `smtps`, `ssl`) forces it on a non-standard SMTPS port:
```bash
SMTP_HOST=smtp.qiye.aliyun.com
SMTP_PORT=465 # implicit TLS auto-enabled on 465
SMTP_USERNAME=multica@yourdomain.com
SMTP_PASSWORD=...
SMTP_TLS=implicit # optional on 465; required on a non-standard SMTPS port
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
**Strict public relays (e.g. Google Workspace `smtp-relay.gmail.com`)** additionally require a valid EHLO name. They reject the default `localhost` greeting from a public IP, and the relay drops the connection — which surfaces as an opaque `EOF` on a later command (`smtp auth: EOF`) rather than at the greeting. Set `SMTP_EHLO_NAME` to the FQDN the relay expects; it defaults to the machine hostname, which inside a container is usually not a valid FQDN:
```bash
SMTP_HOST=smtp-relay.gmail.com
SMTP_PORT=587
SMTP_EHLO_NAME=mail.yourdomain.com # FQDN the relay accepts; defaults to the (non-FQDN) container hostname
RESEND_FROM_EMAIL=noreply@yourdomain.com
```
At startup the server prints which provider it picked, including the negotiated TLS mode — for example `EmailService: SMTP relay exchange.internal.example.com:25 (starttls) from=noreply@example.com` or `… smtp.qiye.aliyun.com:465 (implicit-tls) from=…` (or `Resend API` / `DEV mode`). The password is never logged. If you don't see the SMTP line after restart, `SMTP_HOST` didn't reach the process — check the container env (`docker compose -f docker-compose.selfhost.yml exec backend env | grep SMTP`).
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.

Some files were not shown because too many files have changed in this diff Show More