Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
557ac45e93 refactor(search): collapse cmd+k empty-state commands to primary action
Previously every registered Command (New Issue, New Project, three theme
switches, plus contextual Copy actions on issue pages) surfaced on empty
query, leaving only 3–5 rows for Recent in a 400px panel. Low-frequency
commands (theme, copy, New Project) are now revealed by typing, matching
the progressive-disclosure pattern already used for Pages and Switch
Workspace. Refs MUL-991.
2026-04-17 08:59:06 +08:00
2510 changed files with 31016 additions and 438223 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

@@ -4,62 +4,12 @@ POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
# You can also set pool_max_conns / pool_min_conns as query params on
# DATABASE_URL; env vars below take precedence over URL params.
# DATABASE_MAX_CONNS=25
# DATABASE_MIN_CONNS=5
# Server
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
# "production" by default. Local dev can leave it unset.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
# Optional local/testing shortcut. Empty by default, so there is no fixed
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
# If you need deterministic local automation, set a 6-digit value such as
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# 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=
@@ -71,208 +21,45 @@ MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
MULTICA_CODEX_TIMEOUT=20m
# Self-host image channel
# Default stable release channel. Pin to an exact release like v0.2.4 if you
# want to stay on a specific version. If the selected tag has not been
# published to GHCR yet, use make selfhost-build / the build override instead.
MULTICA_IMAGE_TAG=latest
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
# Email
# Two delivery options - only one needs to be configured:
#
# Option A: Resend (SaaS, recommended for cloud deployments)
# Set RESEND_API_KEY to a key from resend.com and verify your sending domain there.
# For local/dev use, leave RESEND_API_KEY empty - codes print to stdout. To
# accept a fixed local code, also set MULTICA_DEV_VERIFICATION_CODE above
# (ignored when APP_ENV=production).
# Email (Resend)
# For local/dev use, leave RESEND_API_KEY empty — codes print to stdout, and master code 888888 works.
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@multica.ai
#
# Option B: SMTP relay (for self-hosted / on-premise deployments)
# Takes priority over Resend when SMTP_HOST is set.
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
# SMTP_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
# changing it only requires restarting the backend / compose stack. No web
# 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
NEXT_PUBLIC_GOOGLE_CLIENT_ID=
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
S3_BUCKET=
S3_REGION=us-west-2
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=
CLOUDFRONT_DOMAIN=
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
# Leave empty for single-host deployments (localhost, LAN IP, or a single
# hostname) — session cookies become host-only, which is what the browser
# wants. Only set it when the frontend and backend sit on different
# subdomains of one registered domain (e.g. ".example.com"). Do NOT set it
# to an IP address: RFC 6265 forbids IP literals in the cookie 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=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
# callers with no forwarding headers and returns 404 to everything else —
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
# terminating TLS in front of localhost:8080) MUST set this token, since
# proxied requests look like loopback at the Go layer; with no token, those
# requests are refused with 404. Pass the token as
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → GitHub "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
# 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=
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
ALLOWED_ORIGINS=
# 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=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to completely disable new user signups (recommended for private instances)
ALLOW_SIGNUP=true
# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this
# only requires restarting the backend / compose stack — not rebuilding web.
# It is not hot-reloaded.
# Optional: Only allow emails from these domains (comma-separated)
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
# will run a no-op analytics client and ship nothing. See docs/analytics.md.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Optional override for the `environment` PostHog event property.
# Defaults from APP_ENV and normalizes to production / staging / dev.
ANALYTICS_ENVIRONMENT=
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=

View File

@@ -7,10 +7,10 @@ body:
id: deployment
attributes:
label: Deployment type
description: Are you using the Official App (multica.ai) or a self-hosted instance?
description: Are you using the hosted version or a self-hosted instance?
options:
- Official App
- self-host
- multica.ai (hosted)
- Self-hosted
validations:
required: true

View File

@@ -7,10 +7,10 @@ body:
id: deployment
attributes:
label: Deployment type
description: Are you using the Official App (multica.ai) or a self-hosted instance?
description: Are you using the hosted version or a self-hosted instance?
options:
- Official App
- self-host
- multica.ai (hosted)
- Self-hosted
validations:
required: true

View File

@@ -40,8 +40,6 @@ Closes #
- [ ] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after screenshots
- [ ] I have updated relevant documentation to reflect my changes
- [ ] If I added a new runtime / coding tool / UI tab, I synced the change to **landing copy** (`apps/web/features/landing/i18n/`) 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,25 +29,8 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
# source directly, so a passing diff here proves both sides
# share one source of truth.
run: |
pnpm generate:reserved-slugs
git diff --exit-code -- packages/core/paths/reserved-slugs.ts
- name: Build, type check, lint, and test
# 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'
- name: Build, type check, and test
run: pnpm build && pnpm typecheck && pnpm test
backend:
runs-on: ubuntu-latest
@@ -65,22 +48,8 @@ jobs:
--health-interval 5s
--health-timeout 5s
--health-retries 20
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Wires up the RedisLocalSkill*_test.go suite. Distinct from REDIS_URL
# (which would flip the server binary itself onto the Redis-backed
# realtime relay + request stores); the tests talk to this Redis
# directly so they run alongside the Postgres-backed suite.
REDIS_TEST_URL: redis://localhost:6379/1
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -99,20 +68,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,59 +0,0 @@
name: Desktop Smoke Build
on:
workflow_dispatch:
permissions:
contents: read
jobs:
desktop:
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish never
- name: Upload Desktop artifacts (${{ matrix.target }})
uses: actions/upload-artifact@v4
with:
name: desktop-${{ matrix.target }}
path: apps/desktop/dist
if-no-files-found: error

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

@@ -3,48 +3,20 @@ name: Release
on:
push:
tags:
# GitHub Actions uses glob patterns here, not regex. Match versioned
# tags broadly at the trigger layer, then enforce strict semver below.
- "v*.*.*"
- "!v*-dirty*"
- "v*"
permissions:
contents: write
packages: write
jobs:
verify:
release:
runs-on: ubuntu-latest
outputs:
tag_name: ${{ steps.release_meta.outputs.tag_name }}
is_stable: ${{ steps.release_meta.outputs.is_stable }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Validate tag name
id: release_meta
shell: bash
run: |
tag="${GITHUB_REF_NAME}"
echo "Triggered by tag: $tag"
if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Release tags must look like vX.Y.Z or vX.Y.Z-suffix; got '$tag'."
exit 1
fi
if [[ "$tag" == *-dirty* ]]; then
echo "::error::Refusing to release from dirty tag '$tag'."
exit 1
fi
echo "tag_name=$tag" >> "$GITHUB_OUTPUT"
if [[ "$tag" == *-* ]]; then
echo "is_stable=false" >> "$GITHUB_OUTPUT"
else
echo "is_stable=true" >> "$GITHUB_OUTPUT"
fi
- name: Setup Go
uses: actions/setup-go@v5
with:
@@ -54,27 +26,6 @@ jobs:
- name: Run tests
run: cd server && go test ./...
release:
needs: verify
# Only run on the canonical upstream repo. Forks don't have the
# HOMEBREW_TAP_GITHUB_TOKEN secret and should not be publishing to
# `multica-ai/homebrew-tap` anyway. Without this guard, every fork's
# tag push fails this job (401 against the upstream tap), which makes
# downstream CI go red without affecting the actual artifact pipeline.
if: github.repository_owner == 'multica-ai'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -83,339 +34,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
# Multi-arch images are built natively per platform on dedicated runners
# (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm) and merged into a
# manifest list. This avoids QEMU emulation, which was making the Next.js
# arm64 build run for 30+ minutes per release.
docker-backend-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute backend image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
labels: |
org.opencontainers.image.title=Multica Backend
org.opencontainers.image.description=Multica self-hosted backend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-backend-${{ env.PLATFORM_PAIR }}
build-args: |
VERSION=${{ needs.verify.outputs.tag_name }}
COMMIT=${{ github.sha }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-backend,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-backend-merge:
needs: [verify, docker-backend-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-backend-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute backend image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-backend
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-backend@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect \
ghcr.io/${{ github.repository_owner }}/multica-backend:${{ steps.meta.outputs.version }}
docker-web-build:
needs: verify
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runs-on: ubuntu-latest
- platform: linux/arm64
runs-on: ubuntu-24.04-arm
runs-on: ${{ matrix.runs-on }}
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Checkout
uses: actions/checkout@v4
- name: Compute web image labels
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
labels: |
org.opencontainers.image.title=Multica Web
org.opencontainers.image.description=Multica self-hosted web frontend
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.web
pull: true
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=release-web-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=release-web-${{ env.PLATFORM_PAIR }}
build-args: |
REMOTE_API_URL=http://backend:8080
NEXT_PUBLIC_APP_VERSION=${{ needs.verify.outputs.tag_name }}
outputs: type=image,name=ghcr.io/${{ github.repository_owner }}/multica-web,push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-web-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
docker-web-merge:
needs: [verify, docker-web-build]
runs-on: ubuntu-latest
concurrency:
group: release-docker-web-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-web-*
merge-multiple: true
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Compute web image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/multica-web
flavor: |
latest=false
tags: |
type=raw,value=latest,enable=${{ needs.verify.outputs.is_stable == 'true' }}
type=raw,value=${{ needs.verify.outputs.tag_name }}
type=sha,prefix=sha-
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create manifest list and push
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/multica-web@sha256:%s ' *)
- name: Inspect image
run: |
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
# can be signed + notarized with Apple Developer credentials that are
# not (yet) wired into CI.
desktop:
needs: release
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: linux
- os: windows-latest
target: win
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install rpmbuild (Linux)
if: matrix.target == 'linux'
run: sudo apt-get update && sudo apt-get install -y rpm
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: server/go.mod
cache-dependency-path: server/go.sum
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Package Desktop installers (${{ matrix.target }})
working-directory: apps/desktop
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# electron-builder's GitHub publisher reads this:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable code signing on Linux/Windows for now — the public
# release is unsigned for these platforms, the CLI carries the
# trust boundary. Set CSC_LINK in repo secrets to enable
# Windows signing later.
CSC_IDENTITY_AUTO_DISCOVERY: "false"
run: node scripts/package.mjs --${{ matrix.target }} --x64 --arm64 --publish always

11
.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/
@@ -67,4 +57,3 @@ _features/
server/server
data/
.kilo
.idea

View File

@@ -21,12 +21,12 @@ builds:
goarch:
- amd64
- arm64
ignore:
- goos: windows
goarch: arm64
archives:
# Legacy archive name kept so already-released CLIs (whose `multica update`
# looks for `multica_{os}_{arch}.{ext}`) can keep self-updating. Remove
# once those versions are no longer in use.
- id: legacy
- id: default
formats:
- tar.gz
format_overrides:
@@ -34,16 +34,6 @@ archives:
formats:
- zip
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
# Versioned archive name used by current CLI / install scripts /
# desktop bootstrap going forward.
- id: versioned
formats:
- tar.gz
format_overrides:
- goos: windows
formats:
- zip
name_template: "{{ .ProjectName }}-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
checksum:
name_template: "checksums.txt"
@@ -58,8 +48,6 @@ changelog:
brews:
- name: multica
ids:
- versioned
repository:
owner: multica-ai
name: homebrew-tap

View File

@@ -1,85 +0,0 @@
# Deploy the frontend apps from the monorepo root.
# Keep apps/web, apps/docs, shared packages, and root workspace metadata.
# Exclude unrelated workspaces and local artifacts that can make
# `vercel deploy` upload far more than the app needs.
.agent_context
.claude
.context
.env*
.envrc
.tool-versions
_features
.kilo
.idea
.DS_Store
.husky
.vscode
/.dockerignore
/.goreleaser.yml
/AGENTS.md
/CLAUDE.md
/CLI_AND_DAEMON.md
/CLI_INSTALL.md
/CONTRIBUTING.md
/Dockerfile
/Dockerfile.web
/HANDOFF_ARCHITECTURE_AUDIT.md
/Makefile
/README.md
/README.zh-CN.md
/SELF_HOSTING.md
/SELF_HOSTING_ADVANCED.md
/SELF_HOSTING_AI.md
/docker-compose*.yml
/playwright.config.ts
/skills-lock.json
/.github/
/docker/
/docs/
/e2e/
/server/
/apps/desktop/
/scripts/
*.log
*.pid
*.tsbuildinfo
.cache
.next
.pnpm-store
.turbo
.vercel
coverage
test-results
playwright-report
data
node_modules
bin
dist
out
build
dist-electron
# Deployment-only trims: tests and lint configs are not used by `next build`.
**/__tests__/**
**/test/**
**/*.test.*
**/*.spec.*
/packages/eslint-config/
/apps/web/components.json
/apps/web/eslint.config.mjs
/apps/web/vitest.config.ts
# Root repo metadata not needed in the deployment source.
/.env.example
/.gitattributes
/.gitignore
/LICENSE
*.app
*.dmg

142
CLAUDE.md
View File

@@ -2,21 +2,6 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Conventions reference
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
Read that page before:
- Writing or editing translations (`packages/views/locales/`)
- Naming a new route, package, file, DB column, or TS type
- Writing Chinese product copy (UI strings, error messages, docs)
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
## Project Context
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
@@ -32,14 +17,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 +37,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 +54,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 +96,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)
@@ -145,7 +106,6 @@ pnpm ui:add badge # Adds component to packages/ui/components/ui/
# Infrastructure
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
make db-down # Stop shared PostgreSQL
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
```
### CI Requirements
@@ -170,63 +130,26 @@ make start-worktree # Start using .env.worktree
- Go code follows standard Go conventions (gofmt, go vet).
- Keep comments in code **English only**.
- Prefer existing patterns/components over introducing parallel abstractions.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims.
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
- Avoid broad refactors unless required by the task.
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
- 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
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
When writing code that consumes an API response, follow these rules:
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
### Backend Handler UUID Parsing Convention
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
When adding a `Queries.Delete*` or `Queries.Update*` call, ask: "Where did this UUID come from?" If the answer is "raw user input that hasn't been validated," route it through `parseUUIDOrBadRequest` or a loader first.
### 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,66 +157,25 @@ 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*.
2. **Wire it in both apps** → add a route in `apps/web/app/` (Next.js page file) AND in the desktop router.
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
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.
### Route categories
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
### Workspace context
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
### Workspace destructive operations
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
1. Read destination from cached workspace list.
2. `setCurrentWorkspace(null, null)`.
3. `navigation.push(destination)`.
4. THEN `await mutation.mutateAsync(workspaceId)`.
### Tab isolation
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
### Drag region (macOS)
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
## UI/UX Rules
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.

View File

@@ -70,10 +70,10 @@ Opens your browser for OAuth authentication, creates a 90-day personal access to
### Token Login
```bash
multica login --token <mul_...>
multica login --token
```
Authenticate using a personal access token directly. Useful for headless environments. Pass `--token=` with an empty value to be prompted interactively (so the token never lands in shell history).
Authenticate by pasting a personal access token directly. Useful for headless environments.
### Check Status
@@ -140,15 +140,12 @@ The daemon auto-detects these AI CLIs on your PATH:
|-----|---------|-------------|
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `claude` | Anthropic's coding agent |
| [Codex](https://github.com/openai/codex) | `codex` | OpenAI's coding agent |
| [GitHub Copilot CLI](https://docs.github.com/en/copilot) | `copilot` | GitHub's coding agent (model routed by your GitHub entitlement) |
| OpenCode | `opencode` | Open-source coding agent |
| OpenClaw | `openclaw` | Open-source coding agent |
| Hermes | `hermes` | Nous Research coding agent |
| Gemini | `gemini` | Google's coding agent |
| [Pi](https://pi.dev/) | `pi` | Pi coding agent |
| [Cursor Agent](https://cursor.com/) | `cursor-agent` | Cursor's headless coding agent |
| Kimi | `kimi` | Moonshot coding agent |
| Kiro CLI | `kiro-cli` | Kiro ACP coding agent |
You need at least one installed. The daemon registers each detected CLI as an available runtime.
@@ -168,29 +165,12 @@ 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) |
| Codex semantic inactivity timeout | `--codex-semantic-inactivity-timeout` | `MULTICA_CODEX_SEMANTIC_INACTIVITY_TIMEOUT` | `10m` |
| Agent timeout | `--agent-timeout` | `MULTICA_AGENT_TIMEOUT` | `2h` |
| Max concurrent tasks | `--max-concurrent-tasks` | `MULTICA_DAEMON_MAX_CONCURRENT_TASKS` | `20` |
| Daemon ID | `--daemon-id` | `MULTICA_DAEMON_ID` | hostname |
| Device name | `--device-name` | `MULTICA_DAEMON_DEVICE_NAME` | hostname |
| Runtime name | `--runtime-name` | `MULTICA_AGENT_RUNTIME_NAME` | `Local Agent` |
| Workspaces root | — | `MULTICA_WORKSPACES_ROOT` | `~/multica_workspaces` |
| GC enabled | — | `MULTICA_GC_ENABLED` | `true` (set `false`/`0` to disable) |
| GC scan interval | — | `MULTICA_GC_INTERVAL` | `1h` |
| GC TTL (done/cancelled issues) | — | `MULTICA_GC_TTL` | `24h` |
| GC orphan TTL (no `.gc_meta.json`) | — | `MULTICA_GC_ORPHAN_TTL` | `72h` |
| GC artifact TTL (open issues) | — | `MULTICA_GC_ARTIFACT_TTL` | `12h` (set `0` to disable) |
| GC artifact patterns | — | `MULTICA_GC_ARTIFACT_PATTERNS` | `node_modules,.next,.turbo` |
#### Workspace garbage collection
The daemon periodically scans `MULTICA_WORKSPACES_ROOT` and reclaims disk space in three modes:
- **Full task cleanup** — when an issue's status is `done` or `cancelled` and has been idle for `MULTICA_GC_TTL`, the entire task directory is removed.
- **Orphan cleanup** — task directories with no `.gc_meta.json` (e.g. left over from a daemon crash) are removed once they exceed `MULTICA_GC_ORPHAN_TTL`.
- **Artifact-only cleanup** — when a task has been completed for at least `MULTICA_GC_ARTIFACT_TTL` but the issue is still open, regenerable build outputs whose directory basename matches `MULTICA_GC_ARTIFACT_PATTERNS` are removed; the rest of the workdir (source, `.git`, `output/`, `logs/`, `.gc_meta.json`) is preserved so the agent can resume the same workdir on the next task.
Patterns are basename-only — entries containing `/` or `\` are silently dropped — and `.git` subtrees are never descended into. The default list (`node_modules`, `.next`, `.turbo`) is intentionally narrow; extend it per deployment if your repos consistently produce other regenerable directories (for example, `MULTICA_GC_ARTIFACT_PATTERNS=node_modules,.next,.turbo,target,__pycache__`). To disable artifact cleanup entirely, set `MULTICA_GC_ARTIFACT_TTL=0`.
Agent-specific overrides:
@@ -198,12 +178,8 @@ Agent-specific overrides:
|----------|-------------|
| `MULTICA_CLAUDE_PATH` | Custom path to the `claude` binary |
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CLAUDE_ARGS` | Default extra arguments for Claude Code runs |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_CODEX_ARGS` | Default extra arguments for Codex runs |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -216,12 +192,6 @@ Agent-specific overrides:
| `MULTICA_PI_MODEL` | Override the Pi model used |
| `MULTICA_CURSOR_PATH` | Custom path to the `cursor-agent` binary |
| `MULTICA_CURSOR_MODEL` | Override the Cursor Agent model used |
| `MULTICA_KIMI_PATH` | Custom path to the `kimi` binary |
| `MULTICA_KIMI_MODEL` | Override the Kimi model used |
| `MULTICA_KIRO_PATH` | Custom path to the `kiro-cli` binary |
| `MULTICA_KIRO_MODEL` | Override the Kiro model used |
`MULTICA_CLAUDE_ARGS` and `MULTICA_CODEX_ARGS` are parsed with POSIX shellword quoting, so values such as `--model "gpt-5.1 codex" --sandbox read-only` are split like a shell command line. Agent arguments are applied in this order: hardcoded Multica defaults, daemon-wide env defaults, then per-agent `custom_args` from the task.
### Self-Hosted Server
@@ -269,37 +239,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 +261,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
@@ -323,19 +275,10 @@ multica workspace member list <workspace-id>
multica issue list
multica issue list --status in_progress
multica issue list --priority urgent --assignee "Agent Name"
multica issue list --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
```bash
multica issue list --metadata pipeline_status=waiting_review
multica issue list --metadata pr_number=482 --metadata is_blocked=true
```
Available filters: `--status`, `--priority`, `--assignee`, `--limit`.
### Get Issue
@@ -348,10 +291,9 @@ multica issue get <id> --output json
```bash
multica issue create --title "Fix login bug" --description "..." --priority high --assignee "Lambda"
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee`, `--parent`, `--due-date`.
### Update Issue
@@ -363,12 +305,9 @@ multica issue update <id> --title "New title" --priority urgent
```bash
multica issue assign <id> --to "Lambda"
multica issue assign <id> --to-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
multica issue assign <id> --unassign
```
Pass `--to-id <uuid>` to assign by canonical UUID (mutually exclusive with `--to`); useful when names overlap across members and agents.
### Change Status
```bash
@@ -380,44 +319,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,159 +332,22 @@ 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
# List subscribers of an issue
multica issue subscriber list <issue-id>
# Subscribe yourself to an issue
multica issue subscriber add <issue-id>
# Subscribe another member or agent by name
multica issue subscriber add <issue-id> --user "Lambda"
# Unsubscribe yourself
multica issue subscriber remove <issue-id>
# Unsubscribe another member or agent
multica issue subscriber remove <issue-id> --user "Lambda"
```
Subscribers receive notifications about issue activity (new comments, status changes, etc.). Without `--user`, the command acts on the caller.
### Execution History
```bash
# List all execution runs for an issue
multica issue runs <issue-id>
multica issue runs <issue-id> --full-id
multica issue runs <issue-id> --output json
# View messages for a specific execution run
multica issue run-messages <task-id>
multica issue run-messages <short-task-id> --issue <issue-id>
multica issue run-messages <task-id> --output json
# Incremental fetch (only messages after a given sequence number)
multica issue run-messages <task-id> --since 42 --output json
```
The `runs` command shows all past and current executions for an issue, including running tasks. Table output uses short task UUID prefixes by default; pass `--full-id` to print canonical task UUIDs. The `run-messages` command accepts full task UUIDs directly; copied short task prefixes must be scoped with `--issue <issue-id>` so the CLI only checks that issue's runs. It shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Projects
Projects group related issues (e.g. a sprint, an epic, a workstream). Every project
belongs to a workspace and can optionally have a lead (member or agent).
### List Projects
```bash
multica project list
multica project list --status in_progress
multica project list --output json
```
Available filters: `--status`.
### Get Project
```bash
multica project get <id>
multica project get <id> --output json
```
### Create Project
```bash
multica project create --title "2026 Week 16 Sprint" --icon "🏃" --lead "Lambda"
```
Flags: `--title` (required), `--description`, `--status`, `--icon`, `--lead`.
### Update Project
```bash
multica project update <id> --title "New title" --status in_progress
multica project update <id> --lead "Lambda"
```
Flags: `--title`, `--description`, `--status`, `--icon`, `--lead`.
### Change Status
```bash
multica project status <id> in_progress
```
Valid statuses: `planned`, `in_progress`, `paused`, `completed`, `cancelled`.
### Delete Project
```bash
multica project delete <id>
```
### Associating Issues with Projects
Use the `--project` flag on `issue create` / `issue update` to attach an issue to a
project, or on `issue list` to filter issues by project:
```bash
multica issue create --title "Login bug" --project <project-id>
multica issue update <issue-id> --project <project-id>
multica issue list --project <project-id>
```
The `runs` command shows all past and current executions for an issue, including running tasks. The `run-messages` command shows the detailed message log (tool calls, thinking, text, errors) for a single run. Use `--since` for efficient polling of in-progress runs.
## Setup
@@ -618,68 +385,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).
### List Autopilots
```bash
multica autopilot list
multica autopilot list --full-id
multica autopilot list --status active --output json
```
Autopilot table IDs are short UUID prefixes; follow-up autopilot commands accept copied prefixes when they are unique in the current workspace. Use `--full-id` to print canonical UUIDs.
### Get Autopilot Details
```bash
multica autopilot get <id>
multica autopilot get <id> --output json # includes triggers
```
### Create / Update / Delete
```bash
multica autopilot create \
--title "Nightly bug triage" \
--description "Scan todo issues and prioritize." \
--agent "Lambda" \
--mode create_issue
multica autopilot update <id> --status paused
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.
### Manual Trigger
```bash
multica autopilot trigger <id> # Fires the autopilot once, returns the run
```
### Run History
```bash
multica autopilot runs <id>
multica autopilot runs <id> --limit 50 --output json
```
### Schedule Triggers
```bash
multica autopilot trigger-add <autopilot-id> --cron "0 9 * * 1-5" --timezone "America/New_York"
multica autopilot trigger-update <autopilot-id> <trigger-id> --enabled=false
multica autopilot trigger-delete <autopilot-id> <trigger-id>
```
Only cron-based `schedule` triggers are currently exposed via the CLI. The data model also defines `webhook` and `api` kinds, but there is no server endpoint that fires them yet, so they're not surfaced here.
## Other Commands
```bash
@@ -699,79 +404,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

@@ -76,8 +76,7 @@ fi
LATEST=$(curl -sI https://github.com/multica-ai/multica/releases/latest | grep -i '^location:' | sed 's/.*tag\///' | tr -d '\r\n')
# Download and extract
VERSION="${LATEST#v}"
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica-cli-${VERSION}-${OS}-${ARCH}.tar.gz" -o /tmp/multica.tar.gz
curl -sL "https://github.com/multica-ai/multica/releases/download/${LATEST}/multica_${OS}_${ARCH}.tar.gz" -o /tmp/multica.tar.gz
tar -xzf /tmp/multica.tar.gz -C /tmp multica
sudo mv /tmp/multica /usr/local/bin/multica
rm /tmp/multica.tar.gz
@@ -140,7 +139,7 @@ multica auth status
Expected output should show the authenticated user and server URL.
**If login fails:**
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token <mul_...>` (use `--token=` with an empty value to be prompted interactively).
- If no browser is available (headless environment), the user can generate a Personal Access Token at `https://app.multica.ai/settings` and run: `multica login --token`
- If the server URL needs to be customized: `multica config set server_url <url>` before logging in.
---
@@ -166,12 +165,12 @@ Wait 3 seconds, then verify:
multica daemon status
```
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
Expected output should show `running` status with detected agents (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, `cursor-agent`).
**If daemon fails to start:**
- Check logs: `multica daemon logs`
- If a port conflict occurs, the daemon may already be running under a different profile.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
- If no agents are detected, ensure at least one AI CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`) is installed and on the `$PATH`.
---
@@ -185,12 +184,12 @@ multica daemon status
Confirm:
1. Status is `running`
2. At least one agent is listed (e.g. `claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
2. At least one agent is listed (e.g. `claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`)
3. At least one workspace is being watched
If the agents list is empty, tell the user:
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `copilot`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
> "The Multica daemon is running but no AI agent CLIs were detected. Please install at least one supported CLI (`claude`, `codex`, `opencode`, `openclaw`, `hermes`, `gemini`, `pi`, or `cursor-agent`), then restart the daemon with `multica daemon stop && multica daemon start`."
---

View File

@@ -373,8 +373,7 @@ done
#### 2. Create a test user and token (automated auth)
For deterministic local automation, set `MULTICA_DEV_VERIFICATION_CODE=888888`
in your env file before starting the backend:
In non-production environments the verification code is fixed at `888888`:
```bash
curl -s -X POST "$SERVER/auth/send-code" \
@@ -477,9 +476,7 @@ This automatically:
3. Starts and manages its own daemon instance
4. Connects to the local backend
Login in the Desktop UI with `dev@localhost` and the generated code from the
backend logs. If you set `MULTICA_DEV_VERIFICATION_CODE=888888` before starting
the backend, you can use `888888` instead.
Login in the Desktop UI with `dev@localhost` and code `888888`.
If the backend runs on a non-default port (worktree), create
`apps/desktop/.env.development.local`:
@@ -595,19 +592,6 @@ If you want to stop PostgreSQL and keep your local databases:
make db-down
```
If you want a fresh database for the current checkout only (drops the
database named in `POSTGRES_DB`, recreates it, and runs all migrations):
```bash
make stop # stop backend/frontend first
make db-reset
make start
```
- only affects the current env's database; other worktree databases are untouched
- refuses to run if `DATABASE_URL` points at a remote host
- pass `ENV_FILE=.env.worktree` to target a specific worktree
If you want to wipe all local PostgreSQL data for this repo:
```bash

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" -o bin/server ./cmd/server
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" -o bin/multica ./cmd/multica
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/migrate ./cmd/migrate
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_task_usage_hourly ./cmd/backfill_task_usage_hourly
# --- Runtime stage ---
FROM alpine:3.21
@@ -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/
@@ -38,11 +36,11 @@ RUN pnpm install --frozen-lockfile --offline
# Set build-time env: tells Next.js rewrites to proxy API calls to the backend service
ARG REMOTE_API_URL=http://backend:8080
ARG NEXT_PUBLIC_GOOGLE_CLIENT_ID
ARG NEXT_PUBLIC_WS_URL
ARG NEXT_PUBLIC_APP_VERSION=dev
ENV REMOTE_API_URL=$REMOTE_API_URL
ENV NEXT_PUBLIC_GOOGLE_CLIENT_ID=$NEXT_PUBLIC_GOOGLE_CLIENT_ID
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV STANDALONE=true
# Build the web app (standalone output for minimal runtime)

View File

@@ -0,0 +1,383 @@
# Architecture Audit — Workspace & Realtime Cache
> 基于代码审计整理的 4 个任务。优先级P0 一个、P1 一个、P2 两个。每个任务都包含问题、根因、受影响的 issue、复现步骤、修复方案、改动范围。
---
## 任务 1 — [P0] 空闲后列表数据陈旧
**关联 issue**[#951](https://github.com/multica-ai/multica/issues/951)
### 问题
用户登录后静置一段时间Issue 列表里缺失一部分数据(其他成员期间新建/变更的 issue 不出现)。登出再登入可以恢复。`ec5af33b` 声称 "Closes #951",但 issue 仍为 OPEN 状态 —— 因为它只修了 401 一种场景,没修 WS 半开这一种。
### 根因
系统把 cache 新鲜度的全部责任压给了 WebSocket 推送:
- `packages/core/query-client.ts:7``staleTime: Infinity`cache 永不主动过期
- `packages/core/query-client.ts:9``refetchOnWindowFocus: false`tab 重新获得焦点也不 refetch
- 依赖 WS 推送 `issue:created` / `issue:updated` 事件 invalidate cache
但 WS 层存在一个**不对称**
- **服务端**`server/internal/realtime/hub.go:83-96, 420-475` 有 54s ping / 60s pongWait会清理死连接
- **客户端**`packages/core/api/ws-client.ts`142 行全貌)**完全没有心跳检测**,只靠 `onclose` 事件触发重连
浏览器原生 `WebSocket` API 不把 ping/pong 帧暴露给 JS所以 JS 层无法主动探测 "半开" 连接。当 NAT / 负载均衡器 / 笔记本睡眠导致 TCP 连接被静默切断时:
1. 浏览器 `readyState` 仍是 `OPEN`
2. `onclose` 不触发
3. `ws-client.ts:70-73` 的 3 秒重连逻辑不跑
4. `packages/core/realtime/use-realtime-sync.ts:462-487``onReconnect` 全量 invalidate 不跑
5. 期间的 WS 事件进黑洞
6. cache 保持旧快照
### 复现
**浏览器 DevTools 里的 "Block request URL" 不行** —— 那会触发 `onclose`,走正常重连 → 不复现。真正的半开需要在网络层静默丢包。
**方法 A推荐最接近真实场景**macOS 用 pfctl 丢包
```bash
# 假设后端在 8080
sudo pfctl -E
echo "block drop out quick proto tcp to any port 8080" | sudo pfctl -f -
# 观察:
# - Console 里没有 "disconnected, reconnecting in 3s" 日志
# - Network 里 WS 连接仍显示 Pending / 101
# 用另一个账号/CLI 创建一个 issue
# 回到原客户端: 列表不更新
# 登出再登入: 列表恢复完整
sudo pfctl -d # 解除
```
**方法 B不动网络**:临时修改代码,在 `packages/core/api/ws-client.ts:52``onmessage` 处理器里加一行 `return;` 在前面,吞掉所有入站消息。效果等价于半开。
### 修复方案(三个选项,推荐 C
#### 选项 A — 浏览器端心跳探活(治本,改动大)
`ws-client.ts` 加客户端侧的心跳检测:记录 `lastMessageTime`,定时器检查若超过 N 秒没收到任何消息就主动 `ws.close()`,触发现有重连逻辑。
- 优点:从根本上解决半开问题
- 缺点:浏览器原生 API 没有 ping 能力,需要服务端配合发"应用层 heartbeat"消息供客户端更新 `lastMessageTime`;服务端改 + 客户端改
#### 选项 B — Page Visibility API 触发 invalidate治标改动小
`packages/core/platform/core-provider.tsx``visibilitychange` 监听tab 重新可见时强制 `queryClient.invalidateQueries({ queryKey: issueKeys.all(wsId) })`(及其他关键 key
- 优点:~10 行代码,能兜住 80% 场景(睡眠、切后台 tab
- 缺点treats symptom, 不是真正的半开检测;对"一直保持 tab 可见但网络层断了"的场景无效
#### 选项 C — **A + B 组合**(推荐)
- 短期上 B立刻止血
- 中期上 A把 cache 新鲜度从"只信 WS"改成"WS 是优化Visibility 是兜底"
- 可选加 `refetchOnWindowFocus: true` 或把 `staleTime` 改成一个有限值(比如 5 min作为第三层保险
### 改动范围
| 方案 | 文件 | 改动规模 |
|---|---|---|
| B | `packages/core/platform/core-provider.tsx` | ~10 行 |
| A 客户端 | `packages/core/api/ws-client.ts` | ~30 行 |
| A 服务端 | `server/internal/realtime/hub.go` | 加 app-level heartbeat message |
### 验证
修完之后:
1. 跑方法 A 复现流程,确认数据不再丢失
2. 加 e2e 测试:模拟 `document.dispatchEvent(new Event('visibilitychange'))` + 验证 issue list 被 refetch
---
## 任务 2 — [P1] Workspace 不在 URL 路径中
**关联 issue**MUL-723slug 不在 URL、MUL-43切换 workspace 报错、MUL-509手机端无法切换
> **注意**:审计中提到的 MUL-43 / MUL-476 issue 编号需要当面核对一次 —— agent 查询 GitHub 后返回的标题对不上(看起来是别的 PR。交接时请让执行人以具体症状为准。
### 问题
当前 workspace 身份完全靠 `X-Workspace-ID` HTTP header + Zustand store + localStorage 承载URL 里没有 workspace 信息。所有路径都是 `/issues``/issues/:id` 这种 workspace-agnostic 的。
### 根因
**数据库和 API 已经支持 slug**
- `server/migrations/001_init.up.sql:15-23` — workspace 表有 `slug TEXT UNIQUE NOT NULL`
- `server/pkg/db/queries/workspace.sql:11-13` — 有 `GetWorkspaceBySlug` 查询
- `packages/core/types/workspace.ts:8-19` — Workspace 类型里有 slug 字段
**但前端路由和导航层没用它**
- Web 路由:`apps/web/app/(dashboard)/` 下 25 个 route file 都是 workspace-implicit
- Desktop 路由:`apps/desktop/src/renderer/src/routes.tsx:71-143` 同样
- Navigation 适配器 `apps/web/platform/navigation.tsx` 直接透传 `router.push`,没有任何 workspace 前缀逻辑
**workspace 切换只靠 sidebar UI**`packages/views/layout/app-sidebar.tsx:284-286`
```tsx
if (ws.id !== workspace?.id) {
push("/issues"); // 硬跳 /issuesworkspace-implicit
switchWorkspace(ws); // 然后改 store
}
```
这种设计使得:
- 手机端因为没 sidebar UI也没 URL 层切换入口,**完全切不了 workspace**MUL-509
-`/issues/xxx` 链接发给处于不同 workspace 的同事,会打开错误 workspace 下的 issue或找不到报错MUL-43 系列)
- 分享链接没有 workspace 上下文,接收方必须先手动切对 workspace
### 复现
1. **MUL-723**:登录 → 观察地址栏,没有任何 workspace 标识
2. **MUL-43**
- 加入两个 workspace A 和 B
- 在 A 中打开某个 issue `/issues/abc123`
- 切到 BURL 不变 → 访问失败 / 显示错数据
3. **MUL-509**:手机浏览器打开,尝试切 workspace → 无法切换UI 不显示 sidebar 触发器或触发器无法切)
### 修复方案(三个选项,推荐 A
#### 选项 A — `/ws/:slug/...` URL 前缀(根本方案,推荐)
所有路径加上 workspace slug 前缀。例如 `/issues/abc123``/ws/my-team/issues/abc123`
**要改的地方**
1. **Web 路由目录结构**`apps/web/app/(dashboard)/` 下全部搬到 `apps/web/app/(dashboard)/ws/[slug]/...`~25 个文件)
2. **Desktop 路由**`apps/desktop/src/renderer/src/routes.tsx:71-143` 给所有路径加 `/ws/:slug` 前缀
3. **Navigation 适配器**
- `apps/web/platform/navigation.tsx``push(path)` 内部前置 `/ws/${workspace.slug}``pathname` 读取时去掉前缀
- `apps/desktop/src/renderer/src/platform/navigation.tsx` — 同上
4. **Sidebar 切换逻辑**`packages/views/layout/app-sidebar.tsx:284-286` 改成 `push('/ws/${ws.slug}/issues')`(或依赖适配器自动加前缀就不用改)
5. **服务端中间件**`server/internal/middleware/workspace.go:41-46` 增加 "从 URL path 解析 slug → 查 ID → 校验 membership" 的逻辑header 继续作为 fallback迁移期兼容
**预计改动**~50-100 个文件(大部分是 route 搬迁,不是逻辑改动)、~5-7 人天
**不改也能工作的部分**
- `packages/core/api/client.ts` — 仍旧走 header不用改
- 所有 `packages/views/` 下的组件 —— 它们用 `useNavigation().push()` 抽象,适配器层处理前缀就行
**风险**
- 旧的 bookmark URL 失效(如果产品还没正式 ship问题不大
- E2E 测试需要更新所有 URL 断言
#### 选项 B — `?ws=slug` query param折中
URL 形如 `/issues?ws=my-team`。改动更小(~30 个文件URL 丑但向后兼容。推荐度低于 A。
#### 选项 C — 只修症状不动架构
`switchWorkspace` 和各个 query 之间加 debounce、error boundary 等 workaround。不解决根因技术债越攒越多。**不推荐**。
### 改动范围(选项 A
| 模块 | 文件数 | 备注 |
|---|---|---|
| Web routes | ~25 | 目录搬迁 |
| Desktop routes | 1 | 路径前缀 |
| Navigation adapters | 2 | 前缀逻辑 |
| Server middleware | 1-2 | slug → ID 解析 |
| 组件(不用改) | 30-40 | 用 `useNavigation` 的不受影响 |
| E2E tests | 20-30 | URL 断言更新 |
---
## 任务 3 — [P1] Workspace 切换时 navigation 状态未隔离
**关联 issue**MUL-43切换报错、MUL-476本地缓存未按 workspace 隔离)
> 同上,这两个编号建议交接时核对症状。
### 问题
绝大多数 workspace-scoped 的 Zustand store 都正确使用了 `createWorkspaceAwareStorage`key 后缀加 wsId 自动隔离),但 **`useNavigationStore` 是个例外**:它持久化了 `lastPath`,但用的是 global storage切换 workspace 后里面仍是上个 workspace 的路径。
### 根因
**`packages/core/navigation/store.ts:15-31`**
```typescript
export const useNavigationStore = create<NavigationState>()(
persist(
(set) => ({
lastPath: "/issues",
onPathChange: (path) => { /* ... */ set({ lastPath: path }); },
}),
{
name: "multica_navigation",
storage: createJSONStorage(() => createPersistStorage(defaultStorage)), // ← 这里用的是 global不是 workspace-aware
partialize: (state) => ({ lastPath: state.lastPath }),
}
)
);
// ← 没有调 registerForWorkspaceRehydration
```
**对比:其他 store 都是正确的**
| Store | 是否 workspace-aware | 是否注册 rehydration |
|---|---|---|
| useNavigationStore | ❌ | ❌ |
| useIssuesScopeStore | ✅ | ✅ |
| useIssueDraftStore | ✅ | ✅ |
| useRecentIssuesStore | ✅ | ✅ |
| useIssueViewStore | ✅ | ✅ |
| myIssuesViewStore | ✅ | ✅ |
| useChatStore | ✅(手动用 wsKey| ✅ |
另外 `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 列表里也漏了 `multica_navigation`
**现有的 workaround**`packages/views/layout/app-sidebar.tsx:285` 切 workspace 时硬跳到 `/issues`,正是为了绕开这个 bug。修好 navigation store 之后这行 hack 可以删掉。
### 复现
1. 在 workspace A 中打开一个具体 issue `/issues/abc123`
2. 切到 workspace B
3. 观察:如果没有 sidebar 的硬跳 workaround会尝试恢复到 `/issues/abc123`,但那个 issue 不属于 B导致 404 或错误
目前因为有硬跳 workaround症状表现为"切 workspace 后总是回到 issue 首页"—— 这本身也是 bug用户期望记住上次位置
### 修复方案(推荐 Option C组合
**三处改动**
1. `packages/core/navigation/store.ts:28` —— 把 `createPersistStorage(defaultStorage)` 改成 `createWorkspaceAwareStorage(defaultStorage)`
2. 同文件在末尾加:`registerForWorkspaceRehydration(() => useNavigationStore.persist.rehydrate());`
3. `packages/core/platform/storage-cleanup.ts:10-19``WORKSPACE_SCOPED_KEYS` 数组里加 `"multica_navigation"`
**可选**:清理 `packages/views/layout/app-sidebar.tsx:285``push("/issues")` workaround改完之后不再需要
### 改动范围
| 文件 | 改动 |
|---|---|
| `packages/core/navigation/store.ts` | 改 storage 类型、加 rehydration 注册(~3 行) |
| `packages/core/platform/storage-cleanup.ts` | 数组加一行 |
| `packages/core/platform/workspace-storage.test.ts` | 加 rehydration 的单测 |
| `packages/views/layout/app-sidebar.tsx`(可选) | 移除硬跳 workaround |
**风险**:极低。只是把 navigation store 对齐到其他 store 已经在用的模式。
---
## 任务 4 — [P2] Workspace 生命周期副作用散落
**关联 issue**MUL-727创建后闪页、MUL-728删除确认、MUL-820接受邀请不自动切
### 问题
创建 / 删除 / 切换 / 加入 workspace 的副作用分散在 mutation 的 `onSuccess` 和各处 UI 回调里,没有统一抽象。几个具体 bug
### 4.1 MUL-727 — 创建 workspace 后闪一下 `/issues` 再跳 `/onboarding`
**根因**:两个 `onSuccess` 回调同时跑,顺序不确定。
- `packages/core/workspace/mutations.ts:7-21``useCreateWorkspace.onSuccess` 里调了 `switchWorkspace(newWs)` —— 同步改 Zustand`/issues` 路由开始用新 workspace 渲染
- `packages/views/modals/create-workspace.tsx:68-70` 的 UI `onSuccess` 里调了 `router.push("/onboarding")` —— 异步 schedule 导航
于是:`/issues` 先渲染(闪一下)→ 导航到 `/onboarding`
**修复**:把 `switchWorkspace` 从 mutation 里拿出来,让 UI 层主导。在 `create-workspace.tsx``onSuccess` 里先 `switchWorkspace``push`,保证同一个微任务里完成。
**文件**`packages/core/workspace/mutations.ts``packages/views/modals/create-workspace.tsx`、可能 `packages/views/onboarding/step-workspace.tsx`
### 4.2 MUL-728 — 删除 workspace 的"缺少确认"
**核查结果**`packages/views/settings/components/workspace-tab.tsx:102-119, 236-255` **已经有 AlertDialog 确认**了。
**真实问题**:删除成功后**没有导航**,用户停在 `/settings`,而当前 workspace 已经是删除后系统挑的另一个。
**修复**:在 `handleDeleteWorkspace``onConfirm` 成功分支里加 `push("/issues")`
**文件**`packages/views/settings/components/workspace-tab.tsx`(加一行)
### 4.3 MUL-820 — 接受邀请不自动切换 workspace
**核查结果**:有两条路径:
-`/invite/:id` 独立页(`packages/views/invite/invite-page.tsx:32-52`)是**正确的**accept → switchWorkspace → push("/issues")
-**Sidebar 下拉里的 "Join" 按钮**`packages/views/layout/app-sidebar.tsx:203-209, 321-324`**是错的**:只 invalidate cache不切也不跳
**修复(推荐 Option 2**Sidebar 的 "Join" 改成跳转到 `/invite/:id` 页面,不再就地接受。单一入口、单一行为。
```tsx
<DropdownMenuItem onClick={() => push(`/invite/${inv.id}`)}>
{inv.workspace_name}
</DropdownMenuItem>
```
**文件**`packages/views/layout/app-sidebar.tsx`~10 行)
### 复现
| Issue | 步骤 |
|---|---|
| MUL-727 | 创建新 workspace → 仔细看是否闪了一下 `/issues` 再跳 `/onboarding` |
| MUL-728 | 删除当前 workspace → 观察删完后是否留在 `/settings` 页面BUG: 没有自动跳走) |
| MUL-820 | 被邀请用户登录 → sidebar 下拉 → 点 "Join" → 观察当前 workspace 是否切过去BUG: 不切)|
### 长期架构建议(可选)
抽一个 `useWorkspaceLifecycle` hook 统一管这些副作用。Agent 报告里有完整设计,文件:`packages/core/workspace/hooks.ts`(新建)。但建议先修 MUL-727/728/820 三个具体 bughook 抽象作为后续迭代。
### 改动范围
| Issue | 文件 | 改动规模 |
|---|---|---|
| MUL-727 | mutations.ts + create-workspace.tsx | ~10 行 |
| MUL-728 | workspace-tab.tsx | ~1 行 |
| MUL-820 | app-sidebar.tsx | ~10 行 |
---
## 总览
| 任务 | Issue | 优先级 | 预估规模 | 风险 |
|---|---|---|---|---|
| 1. WS 半开 + 陈旧 cache | #951 | **P0** | Option B ~10 行Option C ~1-2 天 | 低 |
| 2. Workspace URL 化 | MUL-723/43/509 | P1 | 5-7 人天(大部分是搬迁)| 中影响面大、e2e 要改)|
| 3. Navigation store 隔离 | MUL-43/476 | P1 | ~0.5 天 | 低 |
| 4. Workspace 生命周期 bug | MUL-727/728/820 | P2 | ~1 天 | 低 |
### 建议推进顺序
1. **立刻做**:任务 1 的 Option Bvisibilitychange 触发 invalidate—— 代码最少、收益最明显,能当天止血
2. **同步开始**:任务 3navigation store 隔离)—— 影响小、风险低、顺便清掉一个 workaround
3. **规划立项**:任务 2URL 化)—— 大改造,需要单独开一个 iteration
4. **次要修补**:任务 4 的三个小 bug —— 可以拆成独立 PR各自 review
### 重要澄清
- **Issue 编号核对**MUL-43 / MUL-476 的编号需要核对一次agent 查询 GitHub 返回的标题看起来对不上(可能是内部 issue tracker 编号 vs GitHub 编号混用)。以症状为准。
- **MUL-728 实际状态**:确认对话框已经存在,真实缺的是"删除后跳走"。
- **MUL-820 实际状态**`/invite/:id` 页面路径工作正常,只是 sidebar 下拉按钮坏了。
### 所有关键代码位置索引
```
packages/core/query-client.ts:7-10 # staleTime: Infinity
packages/core/api/ws-client.ts:1-142 # 客户端 WS无心跳
packages/core/realtime/use-realtime-sync.ts:462-487 # onReconnect 全量 invalidate
packages/core/platform/core-provider.tsx # 加 visibilitychange 的位置
packages/core/navigation/store.ts:15-31 # lastPath 未隔离
packages/core/platform/storage-cleanup.ts:10-19 # WORKSPACE_SCOPED_KEYS
packages/core/workspace/store.ts:43-77 # hydrateWorkspace / switchWorkspace
packages/core/workspace/mutations.ts:7-57 # create/leave/delete 三个 mutation
packages/views/layout/app-sidebar.tsx:203-324 # 侧边栏切 workspace、接受邀请入口
packages/views/modals/create-workspace.tsx:63-82 # 创建 workspace 入口
packages/views/settings/components/workspace-tab.tsx:102-119 # 删除 workspace 入口
packages/views/invite/invite-page.tsx:32-52 # 接受邀请正确实现参考
server/internal/realtime/hub.go:83-96 # 服务端 WS 心跳
server/internal/middleware/workspace.go:41-46 # wsId resolution
server/migrations/001_init.up.sql:15-23 # workspace.slug 已存在
```

181
Makefile
View File

@@ -1,4 +1,4 @@
.PHONY: help makehelp dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down db-reset selfhost selfhost-build selfhost-stop
.PHONY: dev server daemon cli multica build test migrate-up migrate-down sqlc seed clean setup start stop check worktree-env setup-main start-main stop-main check-main setup-worktree start-worktree stop-worktree check-worktree db-up db-down selfhost selfhost-stop
MAIN_ENV_FILE ?= .env
WORKTREE_ENV_FILE ?= .env.worktree
@@ -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
@@ -37,49 +36,23 @@ define REQUIRE_ENV
fi
endef
# Default target changed from selfhost to help: bare `make` now prints this help
# instead of launching a full Docker Compose build, which is safer for onboarding.
.DEFAULT_GOAL := help
##@ Help
help: ## Show available make targets and common local workflows
@awk 'BEGIN {FS = ":.*## "; printf "\nUsage:\n make \033[36m<target>\033[0m\n\nQuick start:\n \033[36mmake dev\033[0m Bootstrap the current checkout and start everything\n \033[36mmake check\033[0m Run the full local verification pipeline\n\nCheckout modes:\n Main checkout uses \033[36m.env\033[0m\n Worktrees use \033[36m.env.worktree\033[0m (generate with \033[36mmake worktree-env\033[0m)\n\n"} \
/^##@/ {printf "\n\033[1m%s\033[0m\n", substr($$0, 5); next} \
/^[a-zA-Z0-9_.-]+:.*## / {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
makehelp: help ## Alias for `make help`
# ---------- Self-hosting (Docker Compose) ----------
##@ Self-hosting
selfhost: ## Create .env if needed, then pull and start the official self-hosted images
# One-command self-host: create env, start Docker Compose, wait for health
selfhost:
@if [ ! -f .env ]; then \
echo "==> Creating .env from .env.example..."; \
cp .env.example .env; \
JWT=$$(openssl rand -hex 32); \
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"; \
fi
@echo "==> Pulling official Multica images..."
@if ! docker compose -f docker-compose.selfhost.yml pull; then \
echo ""; \
echo "Official images for tag '$${MULTICA_IMAGE_TAG:-latest}' are not published yet."; \
echo "If this is before the first GHCR release, build from the current checkout:"; \
echo " make selfhost-build"; \
exit 1; \
echo "==> Generated random JWT_SECRET"; \
fi
@echo "==> Starting Multica via Docker Compose..."
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
@@ -93,11 +66,7 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Images: $${MULTICA_BACKEND_IMAGE:-ghcr.io/multica-ai/multica-backend}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo " $${MULTICA_WEB_IMAGE:-ghcr.io/multica-ai/multica-web}:$${MULTICA_IMAGE_TAG:-latest}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or read the generated code from backend logs when Resend is unset."; \
echo "Log in with any email + verification code: 888888"; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
@@ -108,62 +77,16 @@ selfhost: ## Create .env if needed, then pull and start the official self-hosted
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-build: ## Build backend/web from the current checkout and start the self-hosted stack
@if [ ! -f .env ]; then \
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"; \
fi
@echo "==> Building Multica from the current checkout..."
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
@echo "==> Waiting for backend to be ready..."
@for i in $$(seq 1 30); do \
if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
break; \
fi; \
sleep 2; \
done
@if curl -sf http://localhost:$${PORT:-8080}/health > /dev/null 2>&1; then \
echo ""; \
echo "✓ Multica is running!"; \
echo " Frontend: http://localhost:$${FRONTEND_PORT:-3000}"; \
echo " Backend: http://localhost:$${PORT:-8080}"; \
echo ""; \
echo "Log in: configure RESEND_API_KEY in .env for email codes,"; \
echo " or read the generated code from backend logs when Resend is unset."; \
echo ""; \
echo "Built images locally via docker-compose.selfhost.build.yml."; \
echo "Local tags: multica-backend:dev and multica-web:dev."; \
echo ""; \
echo "Next — install the CLI and connect your machine:"; \
echo " brew install multica-ai/tap/multica"; \
echo " multica setup self-host"; \
else \
echo ""; \
echo "Services are still starting. Check logs:"; \
echo " docker compose -f docker-compose.selfhost.yml logs"; \
fi
selfhost-stop: ## Stop the self-hosted Docker Compose stack
# Stop all Docker Compose self-host services
selfhost-stop:
@echo "==> Stopping Multica services..."
docker compose -f docker-compose.selfhost.yml down
@echo "✓ All services stopped."
# ---------- One-click commands ----------
##@ One-click
setup: ## Prepare the current checkout from its env file: install deps, ensure DB, run migrations
# First-time setup: install deps, start DB, run migrations
setup:
$(REQUIRE_ENV)
@echo "==> Using env file: $(ENV_FILE)"
@echo "==> Installing dependencies..."
@@ -174,7 +97,8 @@ setup: ## Prepare the current checkout from its env file: install deps, ensure D
@echo ""
@echo "✓ Setup complete! Run 'make start' to launch the app."
start: ## Start backend and frontend for the current checkout and run migrations first
# Start all services (backend + frontend)
start:
$(REQUIRE_ENV)
@echo "Using env file: $(ENV_FILE)"
@echo "Backend: http://localhost:$(PORT)"
@@ -188,7 +112,8 @@ start: ## Start backend and frontend for the current checkout and run migrations
pnpm dev:web & \
wait
stop: ## Stop backend and frontend processes for the current checkout
# Stop all services
stop:
$(REQUIRE_ENV)
@echo "Stopping services..."
@-lsof -ti:$(PORT) | xargs kill -9 2>/dev/null
@@ -200,52 +125,33 @@ stop: ## Stop backend and frontend processes for the current checkout
echo "✓ App processes stopped. Remote PostgreSQL was not affected." ;; \
esac
check: ## Run typecheck, TS tests, Go tests, and Playwright E2E for the current checkout
# Full verification: typecheck + unit tests + Go tests + E2E
check:
$(REQUIRE_ENV)
@ENV_FILE="$(ENV_FILE)" bash scripts/check.sh
db-up: ## Start the shared PostgreSQL container used by main and worktrees
db-up:
@$(COMPOSE) up -d postgres
db-down: ## Stop the shared PostgreSQL container without removing its Docker volume
db-down:
@$(COMPOSE) down
# Drop + recreate the current env's database, then run all migrations.
# Use for a clean slate in local dev. Only affects the DB named in
# ENV_FILE (POSTGRES_DB); the shared postgres container and other
# worktree DBs are untouched. Refuses to run against a remote host.
db-reset: ## Drop and recreate the current env's database, then re-run all migrations
$(REQUIRE_ENV)
@case "$(DATABASE_URL)" in \
""|*@localhost:*|*@localhost/*|*@127.0.0.1:*|*@127.0.0.1/*|*@\[::1\]:*|*@\[::1\]/*) ;; \
*) echo "Refusing to reset: DATABASE_URL points at a remote host."; exit 1 ;; \
esac
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
@echo "==> Dropping and recreating database '$(POSTGRES_DB)'..."
@$(COMPOSE) exec -T postgres psql -U $(POSTGRES_USER) -d postgres -v ON_ERROR_STOP=1 \
-c "DROP DATABASE IF EXISTS \"$(POSTGRES_DB)\" WITH (FORCE);" \
-c "CREATE DATABASE \"$(POSTGRES_DB)\";"
@echo "==> Running migrations..."
cd server && go run ./cmd/migrate up
@echo ""
@echo "✓ Database '$(POSTGRES_DB)' reset. Run 'make start' to launch the app."
worktree-env: ## Generate .env.worktree with a unique DB name and app ports for this worktree
worktree-env:
@bash scripts/init-worktree-env.sh .env.worktree
setup-main: ## Prepare the main checkout using .env
setup-main:
@$(MAKE) setup ENV_FILE=$(MAIN_ENV_FILE)
start-main: ## Start the main checkout using .env
start-main:
@$(MAKE) start ENV_FILE=$(MAIN_ENV_FILE)
stop-main: ## Stop the main checkout processes defined by .env
stop-main:
@$(MAKE) stop ENV_FILE=$(MAIN_ENV_FILE)
check-main: ## Run the full verification pipeline for the main checkout
check-main:
@ENV_FILE=$(MAIN_ENV_FILE) bash scripts/check.sh
setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
setup-worktree:
@if [ ! -f "$(WORKTREE_ENV_FILE)" ]; then \
echo "==> Generating $(WORKTREE_ENV_FILE) with unique ports..."; \
bash scripts/init-worktree-env.sh $(WORKTREE_ENV_FILE); \
@@ -254,68 +160,65 @@ setup-worktree: ## Ensure .env.worktree exists, then prepare this worktree
fi
@$(MAKE) setup ENV_FILE=$(WORKTREE_ENV_FILE)
start-worktree: ## Start this worktree using .env.worktree
start-worktree:
@$(MAKE) start ENV_FILE=$(WORKTREE_ENV_FILE)
stop-worktree: ## Stop this worktree's backend and frontend processes
stop-worktree:
@$(MAKE) stop ENV_FILE=$(WORKTREE_ENV_FILE)
check-worktree: ## Run the full verification pipeline for this worktree
check-worktree:
@ENV_FILE=$(WORKTREE_ENV_FILE) bash scripts/check.sh
# ---------- Individual commands ----------
##@ Individual commands
dev: ## Bootstrap this checkout end-to-end: create env if needed, ensure DB, migrate, start services
# One-command dev: auto-setup env/deps/db/migrations, then start all services
dev:
@bash scripts/dev.sh
server: ## Run only the Go server for the current checkout
# Go server only
server:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/server
daemon: ## Restart the local agent daemon using the CLI's stored auth/session
daemon:
@$(MAKE) multica MULTICA_ARGS="daemon restart --profile local"
cli: ## Run the multica CLI with ARGS or MULTICA_ARGS from source
cli:
@$(MAKE) multica MULTICA_ARGS="$(MULTICA_ARGS)"
multica: ## Run the multica CLI entrypoint directly from the Go source tree
multica:
cd server && go run ./cmd/multica $(MULTICA_ARGS)
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE ?= $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
build: ## Build the server, CLI, and migrate binaries into server/bin
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT)" -o bin/server ./cmd/server
build:
cd server && go build -o bin/server ./cmd/server
cd server && go build -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)" -o bin/multica ./cmd/multica
cd server && go build -o bin/migrate ./cmd/migrate
test: ## Run Go tests after ensuring the target DB exists and migrations are applied
test:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
cd server && go test ./...
# Database
##@ Database
migrate-up: ## Create the target DB if needed, then apply database migrations
migrate-up:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate up
migrate-down: ## Create the target DB if needed, then roll back database migrations
migrate-down:
$(REQUIRE_ENV)
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
cd server && go run ./cmd/migrate down
sqlc: ## Regenerate sqlc code
sqlc:
cd server && sqlc generate
# Cleanup
##@ Cleanup
clean: ## Remove generated server binaries and temp files
clean:
rm -rf server/bin server/tmp

View File

@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -30,34 +30,18 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
Multica turns coding agents into real teammates. Assign issues to an agent like you'd assign to a colleague — they'll pick up the work, write code, report blockers, and update statuses autonomously.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, and **Cursor Agent**.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
## Why "Multica"?
Multica — **Mul**tiplexed **I**nformation and **C**omputing **A**gent.
The name is a nod to Multics, the pioneering operating system of the 1960s that introduced time-sharing — letting multiple users share a single machine as if each had it to themselves. Unix was born as a deliberate simplification of Multics: one user, one task, one elegant philosophy.
We think the same inflection is happening again. For decades, software teams have been single-threaded — one engineer, one task, one context switch at a time. AI agents change that equation. Multica brings time-sharing back, but for an era where the "users" multiplexing the system are both humans and autonomous agents.
In Multica, agents are first-class teammates. They get assigned issues, report progress, raise blockers, and ship code — just like their human colleagues. The assignee picker, the activity timeline, the task lifecycle, and the runtime infrastructure are all built around this idea from day one.
Like Multics before it, the bet is on multiplexing: a small team shouldn't feel small. With the right system, two engineers and a fleet of agents can move like twenty.
## Features
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
- **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.
@@ -101,8 +85,7 @@ multica setup # Connect to Multica Cloud, log in, start daemon
> multica setup self-host
> ```
>
> This pulls the official Multica images from GHCR (latest stable by default). Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
> If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` from a checkout.
> Requires Docker. See the [Self-Hosting Guide](SELF_HOSTING.md) for details.
---
@@ -114,7 +97,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`, `openclaw`, `opencode`, `hermes`, `gemini`, `pi`, `cursor-agent`) on your PATH.
### 2. Verify your runtime
@@ -124,7 +107,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, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent). Give your agent a name — this is how it will appear on the board, in comments, and in assignments.
### 4. Assign your first task
@@ -132,6 +115,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
| **Deployment** | Cloud-first | Local-first |
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
| **Extensibility** | Skills system | Skills + Plugin system |
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
@@ -143,8 +141,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 |
@@ -163,9 +159,9 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
┌──────┴───────┐
│ Agent Daemon │ runs on your machine
└──────────────┘ (Claude Code, Codex, GitHub Copilot CLI,
OpenCode, OpenClaw, Hermes, Gemini,
Pi, Cursor Agent, Kimi, Kiro CLI)
└──────────────┘ (Claude Code, Codex, OpenCode,
OpenClaw, Hermes, Gemini,
Pi, Cursor Agent)
```
| Layer | Stack |
@@ -173,7 +169,7 @@ See the [CLI and Daemon Guide](CLI_AND_DAEMON.md) for the full command reference
| Frontend | Next.js 16 (App Router) |
| Backend | Go (Chi router, sqlc, gorilla/websocket) |
| Database | PostgreSQL 17 with pgvector |
| Agent Runtime | Local daemon executing Claude Code, Codex, GitHub Copilot CLI, OpenClaw, OpenCode, Hermes, Gemini, Pi, Cursor Agent, Kimi, or Kiro CLI |
| Agent Runtime | Local daemon executing Claude Code, Codex, OpenClaw, OpenCode, Hermes, Gemini, Pi, or Cursor Agent |
## Development
@@ -189,4 +185,12 @@ make dev
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.
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -20,7 +20,7 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -30,34 +30,18 @@
Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配给 Agent——它们会自主接手工作、编写代码、报告阻塞问题、更新状态。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**、**Kimi** 和 **Kiro CLI**
面向更大的团队Squads小队提供稳定的路由层把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi****Cursor Agent**
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
## 为什么叫 "Multica"
Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
这个名字是在向 20 世纪 60 年代具有开创意义的操作系统 Multics 致意。Multics 首创了分时系统让多个用户能够共享同一台机器同时又像各自独占它一样使用。Unix 则是在有意简化 Multics 的基础上诞生的,强调一个用户、一个任务、一种优雅的哲学。
我们认为类似的转折点正在再次出现。几十年来软件团队一直处于一种单线程的工作模式一个工程师处理一个任务一次只专注于一个上下文。AI agents 改变了这个等式。Multica 将"分时"重新带回这个时代,只不过今天在系统中进行多路复用的"用户",既包括人类,也包括自主代理。
在 Multica 中agents 是一级团队成员。它们会被分配 issue汇报进展提出阻塞并交付代码就像人类同事一样。任务分配、活动时间线、任务生命周期以及运行时基础设施Multica 从第一天起就是围绕这一理念构建的。
和当年的 Multics 一样,这一判断建立在"多路复用"之上。一个小团队不该因为人数少就显得能力有限。有了合适的系统,两名工程师加上一组 agents就能发挥出二十人团队的推进速度。
## 功能特性
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **Squads小队** — 把多个 Agent以及人类成员组合成由 leader agent 带队的小队直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **自动化Autopilots** — 为 Agent 安排周期性工作。定时Cron、Webhook 或手动触发,自动化会自动创建 Issue 并分配给 Agent——日报、周报、定期巡检都能让它自己跑起来。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
- **多工作区** — 按团队组织工作,工作区级别隔离。每个工作区有独立的 Agent、Issue 和设置。
@@ -115,7 +99,7 @@ multica setup # 连接 Multica Cloud登录启动 daemon
multica setup # 配置、认证、启动 daemon一条命令搞定
```
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``copilot``openclaw``opencode``hermes``gemini``pi``cursor-agent``kimi``kiro-cli`)。
daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动检测 PATH 中可用的 Agent CLI`claude``codex``openclaw``opencode``hermes``gemini``pi``cursor-agent`)。
### 2. 确认运行时已连接
@@ -125,7 +109,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
### 3. 创建 Agent
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI),并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
进入 **设置 → Agents**,点击 **新建 Agent**。选择你刚连接的 Runtime选择 ProviderClaude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent并为 Agent 起个名字——它将以这个名字出现在看板、评论和任务分配中。
### 4. 分配你的第一个任务
@@ -135,6 +119,19 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
| **部署** | 云端优先 | 本地优先 |
| **管理深度** | 轻量Issue / Project / Labels | 重度(组织架构 / 审批 / 预算) |
| **扩展** | Skills 系统 | Skills + 插件系统 |
**简单来说Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
## 架构
```
@@ -145,9 +142,9 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
┌──────┴───────┐
│ Agent Daemon │ 运行在你的机器上
└──────────────┘ Claude Code、Codex、GitHub Copilot CLI
OpenCode、OpenClaw、Hermes、Gemini、
Pi、Cursor Agent、Kimi、Kiro CLI
└──────────────┘ Claude Code、Codex、OpenCode
OpenClaw、Hermes、Gemini、
Pi、Cursor Agent
```
| 层级 | 技术栈 |
@@ -155,7 +152,7 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
| 前端 | Next.js 16 (App Router) |
| 后端 | Go (Chi router, sqlc, gorilla/websocket) |
| 数据库 | PostgreSQL 17 with pgvector |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、GitHub Copilot CLI、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent、Kimi 或 Kiro CLI |
| Agent 运行时 | 本地 daemon 执行 Claude Code、Codex、OpenClaw、OpenCode、Hermes、Gemini、PiCursor Agent |
## 开发
@@ -172,8 +169,16 @@ 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)
## Star History
<a href="https://www.star-history.com/?repos=multica-ai%2Fmultica&type=date&legend=bottom-right">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=multica-ai/multica&type=date&legend=top-left" />
</picture>
</a>

View File

@@ -24,9 +24,9 @@ curl -fsSL https://raw.githubusercontent.com/multica-ai/multica/main/scripts/ins
multica setup self-host
```
This installs the `multica` CLI, checks out the latest self-host assets, pulls the official Multica images from GHCR, and configures everything for localhost.
This clones the repository, starts all services via Docker Compose, installs the `multica` CLI, then configures it for localhost.
Open http://localhost:3000. To log in, configure `RESEND_API_KEY` in `.env` for email-based codes (recommended), or leave Resend unset and copy the generated code from the backend logs. See [Step 2 — Log In](#step-2--log-in) for details.
Open http://localhost:3000, log in with any email + verification code **`888888`**.
> **Prerequisites:** Docker and Docker Compose must be installed. The script checks for this and provides install links if missing.
>
@@ -54,10 +54,6 @@ make selfhost
`make selfhost` automatically creates `.env` from the example, generates a random `JWT_SECRET`, and starts all services via Docker Compose.
By default it pulls the latest stable release images from GHCR. To build the backend/web from your current checkout instead, run `make selfhost-build`.
If the selected GHCR tag has not been published yet, `make selfhost` now tells you to fall back to `make selfhost-build`.
`make selfhost-build` uses local `multica-backend:dev` / `multica-web:dev` tags, so it does not overwrite the pulled `:latest` images.
Once ready:
- **Frontend:** http://localhost:3000
@@ -67,15 +63,9 @@ Once ready:
### Step 2 — Log In
Open http://localhost:3000 in your browser. The Docker self-host stack defaults to `APP_ENV=production` (set in `docker-compose.selfhost.yml`), and there is no fixed verification code by default. Pick one of the following to log in:
Open http://localhost:3000 in your browser. Enter any email address and use verification code **`888888`** to log in.
- **Recommended (production):** configure `RESEND_API_KEY` in `.env`, then restart the backend. Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Without email configured:** the verification code is generated server-side and printed to the backend container logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing on a single machine.
- **Deterministic local/private testing:** set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env`, then restart the backend. This fixed code is ignored when `APP_ENV=production`.
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.
> **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.
> This master code works in all non-production environments (i.e. when `APP_ENV` is not set to `production`). For production, configure an email provider — see [Advanced Configuration](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
### Step 3 — Install CLI & Start Daemon
@@ -92,15 +82,12 @@ brew install multica-ai/tap/multica
You also need at least one AI agent CLI installed:
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) (`claude` on PATH)
- [Codex](https://github.com/openai/codex) (`codex` on PATH)
- [GitHub Copilot CLI](https://docs.github.com/en/copilot) (`copilot` on PATH)
- [OpenClaw](https://github.com/openclaw/openclaw) (`openclaw` on PATH)
- [OpenCode](https://github.com/anomalyco/opencode) (`opencode` on PATH)
- [Hermes](https://github.com/NousResearch/hermes) (`hermes` on PATH)
- Gemini (`gemini` on PATH)
- [Pi](https://pi.dev/) (`pi` on PATH)
- [Cursor Agent](https://cursor.com/) (`cursor-agent` on PATH)
- Kimi (`kimi` on PATH)
- Kiro CLI (`kiro-cli` on PATH)
### b) One-command setup
@@ -135,258 +122,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:
@@ -417,17 +152,14 @@ This reconfigures the CLI for multica.ai, re-authenticates, and restarts the dae
> Your local Docker services are unaffected. Stop them separately if you no longer need them.
## Upgrading
## Rebuilding After Updates
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
make selfhost
```
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).
Migrations run automatically on backend startup.
---
@@ -450,7 +182,6 @@ JWT_SECRET=$(openssl rand -hex 32)
Then start everything:
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
```

View File

@@ -14,43 +14,16 @@ All configuration is done via environment variables. Copy `.env.example` as a st
| `JWT_SECRET` | **Must change from default.** Secret key for signing JWT tokens. Use a long random string. | `openssl rand -hex 32` |
| `FRONTEND_ORIGIN` | URL where the frontend is served (used for CORS) | `https://app.example.com` |
### Database Pool Tuning (Optional)
These have sensible defaults and only need to be set when tuning a large or constrained deployment. Precedence (highest first): env var → `pool_*` query params on `DATABASE_URL` → built-in default.
| Variable | Description | Default |
|----------|-------------|---------|
| `DATABASE_MAX_CONNS` | pgxpool max connections per pod. `pod_count × DATABASE_MAX_CONNS` should stay well below the Postgres `max_connections` ceiling. With a connection pooler (PgBouncer / RDS Proxy / Supavisor) in front, this can be raised significantly. | `25` |
| `DATABASE_MIN_CONNS` | pgxpool warm baseline connections per pod. Auto-clamped to `DATABASE_MAX_CONNS`. | `5` |
### Email (Required for Authentication)
Multica supports two email backends. `SMTP_HOST` takes priority when set; otherwise `RESEND_API_KEY` is used. With neither configured, verification codes are printed to the server log — copy them from there to log in.
#### Option A: Resend (recommended for cloud deployments)
Multica uses email-based magic link authentication via [Resend](https://resend.com).
| Variable | Description |
|----------|-------------|
| `RESEND_API_KEY` | Your Resend API key |
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
#### Option B: SMTP relay (for self-hosted / on-premise deployments)
Use this option when your deployment cannot reach the public internet or you already have an internal mail relay (e.g. Exchange, Postfix, SendGrid on-prem).
| Variable | Description | Default |
|----------|-------------|----------|
| `SMTP_HOST` | SMTP relay hostname (setting this activates SMTP mode) | - |
| `SMTP_PORT` | SMTP port | `25` |
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
| `SMTP_PASSWORD` | SMTP password | - |
| `SMTP_TLS` | 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.
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
> **Note:** For local/development deployments without email configured, you can use the master verification code `888888` to log in.
### Google OAuth (Optional)
@@ -60,62 +33,26 @@ STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS /
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
| `GOOGLE_REDIRECT_URI` | OAuth callback URL (e.g. `https://app.example.com/auth/callback`) |
Changes take effect after restarting the backend / compose stack. The web UI reads `GOOGLE_CLIENT_ID` from `/api/config` at runtime, so no web rebuild is needed.
### Signup Controls (Optional)
| Variable | Description |
|----------|-------------|
| `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`.
### File Storage (Optional)
For file uploads and attachments, configure S3 and (optionally) CloudFront:
For file uploads and attachments, configure S3 and CloudFront:
| Variable | Description |
|----------|-------------|
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches 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`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `S3_BUCKET` | S3 bucket name |
| `S3_REGION` | AWS region (default: `us-west-2`) |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
### Cookies
| Variable | Description |
|----------|-------------|
| `COOKIE_DOMAIN` | Optional `Domain` attribute for session + CloudFront cookies. **Leave empty** for single-host deployments (localhost, LAN IP, or a single hostname). Only set it when the frontend and backend sit on different subdomains of one registered domain (e.g. `.example.com`). **Do not use an IP literal** — RFC 6265 forbids IP addresses in the cookie `Domain` attribute and browsers will drop such `Set-Cookie` headers. |
The `Secure` flag on session cookies is derived automatically from the scheme of `FRONTEND_ORIGIN`: HTTPS origins get `Secure` cookies; plain-HTTP origins (LAN / private-network self-host) get non-secure cookies so the browser can actually store them.
| `COOKIE_DOMAIN` | Domain for CloudFront auth cookies |
### Server
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend server port |
| `METRICS_ADDR` | empty | Optional Prometheus metrics listener, for example `127.0.0.1:9090` |
| `FRONTEND_PORT` | `3000` | Frontend port |
| `CORS_ALLOWED_ORIGINS` | Value of `FRONTEND_ORIGIN` | Comma-separated list of allowed origins. 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
@@ -137,8 +74,6 @@ Agent-specific overrides:
| `MULTICA_CLAUDE_MODEL` | Override the Claude model used |
| `MULTICA_CODEX_PATH` | Custom path to the `codex` binary |
| `MULTICA_CODEX_MODEL` | Override the Codex model used |
| `MULTICA_COPILOT_PATH` | Custom path to the `copilot` (GitHub Copilot CLI) binary |
| `MULTICA_COPILOT_MODEL` | Override the Copilot model used (note: GitHub Copilot routes models through your account entitlement, so this may not be honoured) |
| `MULTICA_OPENCODE_PATH` | Custom path to the `opencode` binary |
| `MULTICA_OPENCODE_MODEL` | Override the OpenCode model used |
| `MULTICA_OPENCLAW_PATH` | Custom path to the `openclaw` binary |
@@ -182,64 +117,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:
@@ -276,49 +153,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
### Caddy (Recommended)
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
```
multica.example.com {
# WebSocket route — must come before the catch-all
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
# Everything else → frontend
reverse_proxy localhost:3000
}
```
> 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:
```
app.example.com {
reverse_proxy localhost:3000
}
api.example.com {
@multica_ws path /ws /ws/*
handle @multica_ws {
reverse_proxy localhost:8080 {
flush_interval -1
}
}
reverse_proxy localhost:8080
}
```
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
### Nginx
```nginx
@@ -374,7 +218,7 @@ When using separate domains for frontend and backend, set these environment vari
FRONTEND_ORIGIN=https://app.example.com
CORS_ALLOWED_ORIGINS=https://app.example.com
# Frontend (only if you are building the web image from source via docker-compose.selfhost.build.yml)
# Frontend (set before building the frontend image)
REMOTE_API_URL=https://api.example.com
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_WS_URL=wss://api.example.com/ws
@@ -390,82 +234,32 @@ FRONTEND_ORIGIN=http://192.168.1.100:3000
CORS_ALLOWED_ORIGINS=http://192.168.1.100:3000
```
Then restart the stack:
Then rebuild:
```bash
docker compose -f docker-compose.selfhost.yml up -d
docker compose -f docker-compose.selfhost.yml up -d --build
```
### WebSocket for LAN / Non-localhost Access
The frontend automatically derives the WebSocket URL from the page address, so real-time features (chat streaming, live issue updates, notifications) work over LAN without extra configuration.
HTTP requests (issues, comments, uploads) work on LAN out of the box — Next.js rewrites proxy `/api`, `/auth`, and `/uploads` to the backend. **WebSockets do not**: Next.js rewrites only forward HTTP requests, not the `Upgrade` handshake a WebSocket needs. If you open the app on `http://<lan-ip>:3000`, real-time features (chat streaming, live issue updates, notifications) will fail to connect until you do one of the following:
1. **Put a reverse proxy in front of the stack (recommended).** Nginx or Caddy terminates the WebSocket upgrade and forwards it to the backend on port 8080. See the [Reverse Proxy](#reverse-proxy) section above — the Nginx example already includes a `location /ws { ... }` block with the correct `Upgrade` / `Connection` headers. Once a proxy is in place the browser connects directly through it, so no frontend rebuild is needed.
2. **Bake a WebSocket URL into the web image.** If you are not running a reverse proxy, rebuild the web image with `NEXT_PUBLIC_WS_URL` pointing straight at the backend (port 8080 must be reachable from the browser):
```bash
# In .env
NEXT_PUBLIC_WS_URL=ws://<lan-ip>:8080/ws
# Rebuild the web image so the build-time value is baked in
docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build
```
`NEXT_PUBLIC_WS_URL` is a build-time variable (see `Dockerfile.web`), so setting it only in `environment:` on the pre-built image has no effect — you must use the `selfhost.build.yml` override that rebuilds the image.
**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`.
> **Note:** If you need to override the WebSocket URL explicitly (e.g. when using a separate backend domain), set `NEXT_PUBLIC_WS_URL` in `.env` and rebuild the frontend image.
## Health Check
The backend exposes public health endpoints:
The backend exposes a health check endpoint:
```text
```
GET /health
→ {"status":"ok"}
GET /readyz
→ {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
GET /healthz
→ same response as /readyz
```
Use `/health` for basic liveness / reachability checks. Use `/readyz` for
dependency-aware readiness probes and external monitoring that should fail when
the database is unavailable or migrations are not fully applied. `/healthz` is
kept as an alias for operator familiarity.
## Prometheus Metrics
The backend can expose Prometheus metrics on a separate management listener:
```bash
METRICS_ADDR=127.0.0.1:9090 ./server/bin/server
curl http://127.0.0.1:9090/metrics
```
`METRICS_ADDR` is empty by default, so no metrics listener is started. The
public API port does not serve `/metrics`; keep it that way for internet-facing
deployments. HTTP request metrics start accumulating only after the metrics
listener is enabled. Metrics can reveal internal routes, traffic volume,
dependency state, and runtime health.
For Docker or Kubernetes deployments, prefer a private scrape path: bind the
metrics listener to an internal interface and protect it with private
networking, allowlists, NetworkPolicy, or proxy authentication. If you bind
`METRICS_ADDR=0.0.0.0:9090` inside a container, only publish that port to a
trusted network, for example a host-local mapping such as
`127.0.0.1:9090:9090`.
Use this for load balancer health checks or monitoring.
## Upgrading
```bash
docker compose -f docker-compose.selfhost.yml pull
docker compose -f docker-compose.selfhost.yml up -d
git pull
docker compose -f docker-compose.selfhost.yml up -d --build
```
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact release like `v0.2.4` if you want to stay on a specific version. Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.
If the selected GHCR tag has not been published yet, fall back to `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
Migrations run automatically on backend startup. They are idempotent — running them multiple times has no effect.

View File

@@ -37,7 +37,7 @@ multica setup self-host
The `multica setup self-host` command will:
1. Configure CLI to connect to localhost:8080 / localhost:3000
2. Open a browser for login — use the emailed code, or the generated code printed in backend logs when Resend is unset
2. Open a browser for login — use verification code `888888` with any email
3. Discover workspaces automatically
4. Start the daemon in the background
@@ -73,4 +73,4 @@ If the default ports (8080/3000) are in use:
- **Backend not ready:** `docker compose -f docker-compose.selfhost.yml logs backend`
- **Frontend not ready:** `docker compose -f docker-compose.selfhost.yml logs frontend`
- **Daemon issues:** `multica daemon logs`
- **Health checks:** `curl http://localhost:8080/health` for liveness, `curl http://localhost:8080/readyz` for dependency-aware readiness
- **Health check:** `curl http://localhost:8080/health`

View File

@@ -0,0 +1,12 @@
# Production environment for `pnpm package` / `pnpm build`.
# electron-vite (Vite under the hood) reads this automatically in
# production mode and inlines the values into the renderer bundle via
# import.meta.env.VITE_*. These are public URLs, not secrets.
# Backend API + websocket the desktop app talks to.
VITE_API_URL=https://api.multica.ai
VITE_WS_URL=wss://api.multica.ai/ws
# Public web app URL — used to build shareable links like "Copy link to
# issue" that users paste into Slack / messages. See platform/navigation.tsx.
VITE_APP_URL=https://multica.ai

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -21,81 +21,25 @@ mac:
- zip
# Hardcoded name avoids the `@multica/desktop-*` subdirectory that
# `${name}` produces for scoped package names.
# Naming scheme: multica-desktop-<version>-<platform>-<arch>.<ext>
# so the filename alone surfaces kind, version, platform, and CPU arch.
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
artifactName: multica-desktop-${version}-${arch}.${ext}
# Notarize via notarytool. Requires APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD
# + APPLE_TEAM_ID env vars at package time. Non-mac contributors are
# unaffected because `pnpm package` already requires the Developer ID
# signing cert — notarization is a strict superset.
notarize: true
dmg:
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
artifactName: multica-desktop-${version}-${arch}.${ext}
linux:
# Override the Linux executable name to avoid leaking the scoped npm
# package name (`@multica/desktop`) into the installed binary, the
# `.desktop` file, and the hicolor icon filename. Without this override
# electron-builder defaults `executableName` to the package `name`,
# which after slash-stripping becomes `@multicadesktop` — producing
# `/usr/share/applications/@multicadesktop.desktop`,
# `Icon=@multicadesktop`, and
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
# violates the freedesktop desktop-entry naming guidance, so GNOME /
# Ubuntu fail to associate the running window with the `.desktop` entry
# and fall back to the theme's default app icon (the Settings gear on
# Yaru). Forcing `multica` makes every Linux identity slot agree and
# matches `StartupWMClass=Multica` (productName-derived).
executableName: multica
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
# ASAR's `package.json` — `productName` if present, otherwise `name`.
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
# directly; it does not. With our source package.json carrying only
# `name: "@multica/desktop"`, packaged Electron emitted
# `WM_CLASS=@multica/desktop`, which broke association with this entry
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
# outside this file — `productName: "Multica"` on the source
# package.json (so the ASAR carries it) and `app.setName("Multica")`
# in the production branch of `src/main/index.ts` (belt-and-braces).
# Keep `StartupWMClass: Multica` pinned here so any future drift in
# those two anchors shows up as a diff against this declaration.
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
# window prints `Multica` for both fields.
desktop:
entry:
StartupWMClass: Multica
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
# auto-generation silently shipped only the 1024×1024 source in our
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
# with no usable size and falling back to the theme default. Shipping
# the sizes from source removes the toolchain dependency entirely.
icon: build/icons
target:
- AppImage
- deb
- rpm
artifactName: multica-desktop-${version}-linux-${arch}.${ext}
rpm:
# Disable RPM build-id symlinks. Electron apps embed the upstream Electron
# binary, whose GNU build-id is identical across every app shipping the same
# Electron version (Slack, VS Code, Discord, ...). Without this, our RPM
# would own /usr/lib/.build-id/<hash> paths and collide with any other
# Electron RPM already installed, breaking `dnf install` on Fedora/RHEL.
fpm:
- "--rpm-rpmbuild-define=_build_id_links none"
artifactName: ${name}-${version}-${arch}.${ext}
win:
target:
- nsis
artifactName: multica-desktop-${version}-windows-${arch}.${ext}
artifactName: ${name}-${version}-setup.${ext}
publish:
provider: github
owner: multica-ai
repo: multica
# Align with our CLI release flow which pre-creates a *published* GitHub
# Release via `gh release create`. The electron-builder default of
# `releaseType: draft` conflicts with `existingType=release` and causes
# uploads of the DMG/ZIP/blockmaps/latest-mac.yml to be silently skipped,
# which breaks electron-updater auto-update on installed clients.
releaseType: release
npmRebuild: false

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

@@ -10,35 +10,4 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell or the
// native download system must flow through the safe wrappers in
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
// direct shell.openExternal / webContents.downloadURL calls cannot silently
// regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"CallExpression[callee.object.name='shell'][callee.property.name='openExternal']",
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
{
selector:
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
message:
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},
{
files: ["src/main/external-url.ts"],
rules: {
"no-restricted-syntax": "off",
},
},
];

View File

@@ -1,33 +1,18 @@
{
"name": "@multica/desktop",
"productName": "Multica",
"version": "0.1.0",
"private": true,
"description": "Multica Desktop — native desktop client for the Multica platform.",
"homepage": "https://multica.ai",
"repository": {
"type": "git",
"url": "https://github.com/multica-ai/multica.git",
"directory": "apps/desktop"
},
"author": {
"name": "Multica",
"email": "support@multica.ai"
},
"license": "UNLICENSED",
"main": "./out/main/index.js",
"scripts": {
"bundle-cli": "node scripts/bundle-cli.mjs",
"brand-dev-electron": "node scripts/brand-dev-electron.mjs",
"dev": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev",
"dev:staging": "pnpm run bundle-cli && pnpm run brand-dev-electron && electron-vite dev --mode staging",
"build": "pnpm run bundle-cli && electron-vite build",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"preview": "electron-vite preview",
"package": "node scripts/package.mjs",
"package:all": "node scripts/package.mjs --all-platforms --publish never",
"lint": "eslint .",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
@@ -40,26 +25,19 @@
"@electron-toolkit/preload": "^3.0.2",
"@electron-toolkit/utils": "^4.0.0",
"@fontsource-variable/inter": "^5.2.5",
"@fontsource-variable/source-serif-4": "^5.2.9",
"@fontsource/geist-mono": "^5.2.7",
"@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 +49,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

@@ -13,7 +13,7 @@
// skip the build and fall through to auto-install at runtime. A genuine
// Go compile error is fatal — you want that to block dev, not hide.
import { access, chmod, copyFile, mkdir, rm } from "node:fs/promises";
import { access, chmod, copyFile, mkdir } from "node:fs/promises";
import { constants } from "node:fs";
import { execFileSync, execSync } from "node:child_process";
import { dirname, join, resolve } from "node:path";
@@ -23,54 +23,8 @@ const here = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(here, "..", "..", "..");
const serverDir = join(repoRoot, "server");
const PLATFORM_TO_GOOS = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const SUPPORTED_ARCHS = new Set(["x64", "arm64"]);
function runtimePlatformFromArgs(argv) {
const flagIndex = argv.indexOf("--target-platform");
if (flagIndex === -1) return process.platform;
return argv[flagIndex + 1] ?? "";
}
function runtimeArchFromArgs(argv) {
const flagIndex = argv.indexOf("--target-arch");
if (flagIndex === -1) return process.arch;
return argv[flagIndex + 1] ?? "";
}
function normalizeRuntimePlatform(platform) {
if (platform in PLATFORM_TO_GOOS) return platform;
throw new Error(
`[bundle-cli] unsupported target platform: ${platform}. ` +
"Use darwin, linux, or win32.",
);
}
function normalizeRuntimeArch(arch) {
if (SUPPORTED_ARCHS.has(arch)) return arch;
throw new Error(
`[bundle-cli] unsupported target architecture: ${arch}. ` +
"Use x64 or arm64.",
);
}
function binaryNameForPlatform(platform) {
return platform === "win32" ? "multica.exe" : "multica";
}
const targetPlatform = normalizeRuntimePlatform(
runtimePlatformFromArgs(process.argv.slice(2)),
);
const targetArch = normalizeRuntimeArch(runtimeArchFromArgs(process.argv.slice(2)));
const goos = PLATFORM_TO_GOOS[targetPlatform];
const goarch = targetArch === "x64" ? "amd64" : targetArch;
const binName = binaryNameForPlatform(targetPlatform);
const srcBinary = join(serverDir, "bin", `${goos}-${goarch}`, binName);
const binName = process.platform === "win32" ? "multica.exe" : "multica";
const srcBinary = join(serverDir, "bin", binName);
const destDir = join(repoRoot, "apps", "desktop", "resources", "bin");
const destBinary = join(destDir, binName);
@@ -107,9 +61,8 @@ if (hasGo()) {
const ldflags = `-X main.version=${version} -X main.commit=${commit} -X main.date=${date}`;
console.log(
`[bundle-cli] go build → ${srcBinary} (${goos}/${goarch}, version=${version} commit=${commit})`,
`[bundle-cli] go build → ${srcBinary} (version=${version} commit=${commit})`,
);
await mkdir(join(serverDir, "bin", `${goos}-${goarch}`), { recursive: true });
execFileSync(
"go",
[
@@ -117,19 +70,10 @@ if (hasGo()) {
"-ldflags",
ldflags,
"-o",
srcBinary,
join("bin", binName),
"./cmd/multica",
],
{
cwd: serverDir,
stdio: "inherit",
env: {
...process.env,
CGO_ENABLED: "0",
GOOS: goos,
GOARCH: goarch,
},
},
{ cwd: serverDir, stdio: "inherit" },
);
} else {
console.warn(
@@ -144,11 +88,9 @@ if (!(await exists(srcBinary))) {
`[bundle-cli] ${srcBinary} not present — Desktop will fall back to ` +
`auto-installing the latest release at runtime.`,
);
await rm(destDir, { recursive: true, force: true });
process.exit(0);
}
await rm(destDir, { recursive: true, force: true });
await mkdir(destDir, { recursive: true });
await copyFile(srcBinary, destBinary);
await chmod(destBinary, 0o755);

View File

@@ -5,11 +5,11 @@
// binary via the `main.version` ldflag — so a single `vX.Y.Z` tag push
// produces matching CLI and Desktop versions.
//
// Builds the Electron bundles once, then for each requested target
// (platform + arch) compiles the matching Go CLI into resources/bin/ and
// invokes electron-builder with `-c.extraMetadata.version=<derived>` so
// the override applies at build time without mutating the tracked
// package.json.
// Runs bundle-cli.mjs first (so the Go binary is compiled and copied
// into resources/bin/), then `electron-vite build` to produce the
// main/preload/renderer bundles under out/, then invokes electron-builder
// with `-c.extraMetadata.version=<derived>` so the override applies at
// build time without mutating the tracked package.json.
//
// The electron-vite step is important: electron-builder only packages
// whatever is already in out/, so skipping it (or relying on stale
@@ -25,50 +25,11 @@
// version-derivation logic without shelling out.
import { execFileSync, spawnSync, execSync } from "node:child_process";
import { delimiter, dirname, resolve } from "node:path";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const desktopRoot = resolve(here, "..");
const bundleCliScript = resolve(here, "bundle-cli.mjs");
const PLATFORM_CONFIG = {
mac: {
aliases: new Set(["--mac", "--macos", "-m"]),
builderFlag: "--mac",
runtimePlatform: "darwin",
label: "macOS",
},
win: {
aliases: new Set(["--win", "--windows", "-w"]),
builderFlag: "--win",
runtimePlatform: "win32",
label: "Windows",
},
linux: {
aliases: new Set(["--linux", "-l"]),
builderFlag: "--linux",
runtimePlatform: "linux",
label: "Linux",
},
};
const ARCH_FLAGS = new Map([
["--x64", "x64"],
["--arm64", "arm64"],
["--ia32", "ia32"],
["--armv7l", "armv7l"],
["--universal", "universal"],
]);
const SUPPORTED_CLI_ARCHS = new Set(["x64", "arm64"]);
const MAC_ALL_PLATFORM_TARGETS = [
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
];
function sh(cmd) {
try {
@@ -116,231 +77,20 @@ function deriveVersion() {
return normalizeGitVersion(sh("git describe --tags --always --dirty"));
}
function uniqueOrdered(values) {
return [...new Set(values)];
}
export function envWithLocalBins(env = process.env, root = desktopRoot) {
const pathKey =
Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
const existingPath = env[pathKey] ?? "";
const localBins = uniqueOrdered([
resolve(root, "node_modules", ".bin"),
resolve(root, "..", "..", "node_modules", ".bin"),
]);
const mergedPath = uniqueOrdered([
...localBins,
...String(existingPath)
.split(delimiter)
.filter(Boolean),
]).join(delimiter);
return { ...env, [pathKey]: mergedPath };
}
function hostPlatformKey(platform = process.platform) {
if (platform === "darwin") return "mac";
if (platform === "win32") return "win";
if (platform === "linux") return "linux";
throw new Error(`[package] unsupported host platform: ${platform}`);
}
function hostArchKey(arch = process.arch) {
if (SUPPORTED_CLI_ARCHS.has(arch)) return arch;
throw new Error(
`[package] unsupported host architecture for Desktop CLI bundling: ${arch}`,
);
}
function expandPlatformShorthand(token) {
if (!/^-[mwl]{2,}$/.test(token)) return null;
const expanded = [];
for (const char of token.slice(1)) {
if (char === "m") expanded.push("mac");
if (char === "w") expanded.push("win");
if (char === "l") expanded.push("linux");
}
return uniqueOrdered(expanded);
}
function platformKeyForToken(token) {
for (const [platform, config] of Object.entries(PLATFORM_CONFIG)) {
if (config.aliases.has(token)) return platform;
}
return null;
}
function platformTargetsTemplate() {
return { mac: [], win: [], linux: [] };
}
export function parsePackageArgs(argv) {
const sharedArgs = [];
const platformTargets = platformTargetsTemplate();
const requestedPlatforms = [];
const requestedArchs = [];
let allPlatforms = false;
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (token === "--all-platforms") {
allPlatforms = true;
continue;
}
const expandedPlatforms = expandPlatformShorthand(token);
if (expandedPlatforms) {
requestedPlatforms.push(...expandedPlatforms);
continue;
}
const platform = platformKeyForToken(token);
if (platform) {
requestedPlatforms.push(platform);
while (i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
platformTargets[platform].push(argv[i + 1]);
i += 1;
}
continue;
}
const arch = ARCH_FLAGS.get(token);
if (arch) {
requestedArchs.push(arch);
continue;
}
sharedArgs.push(token);
}
return {
allPlatforms,
sharedArgs,
platformTargets,
requestedPlatforms: uniqueOrdered(requestedPlatforms),
requestedArchs: uniqueOrdered(requestedArchs),
};
}
export function resolveBuildMatrix(parsed, platform = process.platform, arch = process.arch) {
if (parsed.allPlatforms) {
if (parsed.requestedPlatforms.length > 0 || parsed.requestedArchs.length > 0) {
throw new Error(
"[package] --all-platforms cannot be combined with explicit platform or arch flags",
);
}
if (platform !== "darwin") {
throw new Error(
`[package] --all-platforms is only supported on macOS hosts (current: ${platform})`,
);
}
return MAC_ALL_PLATFORM_TARGETS.map((target) => ({ ...target }));
}
const platforms =
parsed.requestedPlatforms.length > 0
? parsed.requestedPlatforms
: [hostPlatformKey(platform)];
const archs =
parsed.requestedArchs.length > 0
? parsed.requestedArchs
: [hostArchKey(arch)];
const unsupported = archs.filter((value) => !SUPPORTED_CLI_ARCHS.has(value));
if (unsupported.length > 0) {
throw new Error(
`[package] unsupported Desktop CLI architecture(s): ${unsupported.join(", ")}. ` +
"Use --x64 or --arm64.",
);
}
return platforms.flatMap((targetPlatform) =>
archs.map((targetArch) => ({
platform: targetPlatform,
arch: targetArch,
})),
);
}
function formatTarget(target) {
return `${PLATFORM_CONFIG[target.platform].label} ${target.arch}`;
}
export function builderArgsForTarget(
target,
parsed,
version,
{
disableMacNotarize = false,
hostPlatform = process.platform,
useScopedOutputDir = false,
} = {},
) {
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
if (disableMacNotarize) builderArgs.push("-c.mac.notarize=false");
builderArgs.push(PLATFORM_CONFIG[target.platform].builderFlag);
const requestedTargets = parsed.platformTargets[target.platform];
if (
target.platform === "linux" &&
hostPlatform !== "linux" &&
requestedTargets.length === 0
) {
// electron-builder only guarantees AppImage/Snap when cross-building
// Linux from macOS/Windows. Keep `package:all` portable by defaulting
// to AppImage unless the caller explicitly requests Linux targets.
builderArgs.push("AppImage");
} else {
builderArgs.push(...requestedTargets);
}
builderArgs.push(`--${target.arch}`);
builderArgs.push(...parsed.sharedArgs);
if (useScopedOutputDir) {
builderArgs.push(
`-c.directories.output=dist/${target.platform}-${target.arch}`,
);
}
// electron-builder's update metadata file is `latest.yml` for Windows
// regardless of arch (only Linux gets an arch suffix automatically — see
// app-builder-lib's getArchPrefixForUpdateFile). Without an explicit
// channel override, building Windows x64 and arm64 in two invocations
// makes both publish `latest.yml` to the same GitHub Release, so the
// second upload overwrites the first and one of the two architectures
// ends up with no auto-update metadata. Route Windows arm64 to its own
// channel so x64 keeps `latest.yml` and arm64 ships `latest-arm64.yml`;
// the renderer-side updater pins the matching channel per arch.
if (target.platform === "win" && target.arch === "arm64") {
builderArgs.push("-c.publish.channel=latest-arm64");
}
return builderArgs;
}
function main() {
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const parsed = parsePackageArgs(passthrough);
const buildMatrix = resolveBuildMatrix(parsed);
console.log(
`[package] build matrix → ${buildMatrix.map(formatTarget).join(", ")}`,
);
// Step 1: build + bundle the Go CLI via the existing script.
execFileSync("node", [resolve(here, "bundle-cli.mjs")], {
stdio: "inherit",
cwd: desktopRoot,
});
// Step 1: build the Electron main/preload/renderer bundles. Without
// Step 2: build the Electron main/preload/renderer bundles. Without
// this step electron-builder silently packages whatever is already in
// out/, which on a fresh checkout (or after a partial build) ships an
// app that white-screens because the renderer bundle is missing.
//
// CI invokes this script via `node scripts/package.mjs`, so we cannot
// rely on pnpm/npm to inject package-local binaries into PATH.
//
// `shell: true` is required on Windows: `node_modules/.bin/electron-vite`
// ships as a `.cmd` shim there, and Node's `spawnSync` does not honour
// PATHEXT when spawning a bare command without a shell — it would fail
// with `ENOENT`. On POSIX hosts the shim is a real executable so going
// through the shell is harmless. See
// https://nodejs.org/api/child_process.html#spawning-bat-and-cmd-files-on-windows
const viteResult = spawnSync("electron-vite", ["build"], {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (viteResult.error) {
console.error(
@@ -353,7 +103,7 @@ function main() {
process.exit(viteResult.status ?? 1);
}
// Step 2: derive the version that should be written into the app.
// Step 3: derive the version that should be written into the app.
const version = deriveVersion();
if (version) {
console.log(`[package] Desktop version → ${version} (from git describe)`);
@@ -363,62 +113,43 @@ function main() {
);
}
const disableMacNotarize = !process.env.APPLE_TEAM_ID;
if (disableMacNotarize) {
// Step 4: assemble electron-builder args.
const passthrough = stripLeadingSeparator(process.argv.slice(2));
const builderArgs = [];
if (version) builderArgs.push(`-c.extraMetadata.version=${version}`);
// Step 5: gracefully degrade for local dev builds. electron-builder.yml
// sets `notarize: true` so real releases notarize in-build (keeping the
// stapled .app consistent with latest-mac.yml's SHA512). But a mac dev
// who just wants to smoke-test a local package doesn't have Apple
// credentials, and would otherwise hit a hard failure at the notarize
// step. Detect the missing env and flip notarize off for this run only.
if (!process.env.APPLE_TEAM_ID) {
console.warn(
"[package] APPLE_TEAM_ID not set — skipping notarization (local dev build). " +
"Set APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID for a release build.",
);
builderArgs.push("-c.mac.notarize=false");
}
const useScopedOutputDir = buildMatrix.length > 1;
builderArgs.push(...passthrough);
// Step 3: for each requested target, build the matching CLI into
// resources/bin/ and package that target in isolation.
for (const target of buildMatrix) {
console.log(`[package] bundling CLI → ${formatTarget(target)}`);
execFileSync(
"node",
[
bundleCliScript,
"--target-platform",
PLATFORM_CONFIG[target.platform].runtimePlatform,
"--target-arch",
target.arch,
],
{
stdio: "inherit",
cwd: desktopRoot,
},
// Step 6: invoke electron-builder. pnpm puts node_modules/.bin on PATH
// for the script run, so spawnSync finds the binary without needing a
// shell wrapper (avoids any risk of argv interpolation).
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
const builderArgs = builderArgsForTarget(target, parsed, version, {
disableMacNotarize,
hostPlatform: process.platform,
useScopedOutputDir,
});
// Step 4: invoke electron-builder for the current target only.
// `shell: true` for the same Windows `.cmd` shim reason as the
// electron-vite invocation above.
const result = spawnSync("electron-builder", builderArgs, {
stdio: "inherit",
cwd: desktopRoot,
env: envWithLocalBins(),
shell: true,
});
if (result.error) {
console.error(
"[package] failed to spawn electron-builder:",
result.error.message,
);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
process.exit(1);
}
process.exit(result.status ?? 1);
}
// Only run when invoked as a CLI, not when imported by a test file.

View File

@@ -1,13 +1,5 @@
import { delimiter, resolve } from "node:path";
import { describe, it, expect } from "vitest";
import {
builderArgsForTarget,
envWithLocalBins,
normalizeGitVersion,
parsePackageArgs,
resolveBuildMatrix,
stripLeadingSeparator,
} from "./package.mjs";
import { normalizeGitVersion, stripLeadingSeparator } from "./package.mjs";
describe("normalizeGitVersion", () => {
it("returns null for empty / nullish input", () => {
@@ -67,207 +59,3 @@ describe("stripLeadingSeparator", () => {
expect(stripLeadingSeparator([])).toEqual([]);
});
});
describe("parsePackageArgs", () => {
it("collects per-platform targets and shared args", () => {
expect(
parsePackageArgs([
"--win", "nsis",
"--mac", "dmg", "zip",
"--arm64",
"--publish", "never",
]),
).toEqual({
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: {
mac: ["dmg", "zip"],
win: ["nsis"],
linux: [],
},
requestedPlatforms: ["win", "mac"],
requestedArchs: ["arm64"],
});
});
it("expands combined short flags", () => {
expect(parsePackageArgs(["-mw", "--x64"]).requestedPlatforms).toEqual([
"mac",
"win",
]);
});
it("tracks the all-platforms shortcut", () => {
expect(parsePackageArgs(["--all-platforms", "--publish", "never"]).allPlatforms).toBe(true);
});
});
describe("resolveBuildMatrix", () => {
it("defaults to the current host platform and arch", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([{ platform: "mac", arch: "arm64" }]);
});
it("expands all-platforms on macOS", () => {
expect(
resolveBuildMatrix(
{
allPlatforms: true,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: [],
requestedArchs: [],
},
"darwin",
"arm64",
),
).toEqual([
{ platform: "mac", arch: "arm64" },
{ platform: "win", arch: "x64" },
{ platform: "win", arch: "arm64" },
{ platform: "linux", arch: "x64" },
{ platform: "linux", arch: "arm64" },
]);
});
it("rejects unsupported architectures", () => {
expect(() =>
resolveBuildMatrix(
{
allPlatforms: false,
sharedArgs: [],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["universal"],
},
"darwin",
"arm64",
),
).toThrow(/unsupported Desktop CLI architecture/);
});
});
describe("builderArgsForTarget", () => {
it("adds scoped output directories for multi-target builds", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "arm64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["arm64"],
},
"1.2.3",
{
disableMacNotarize: true,
hostPlatform: "darwin",
useScopedOutputDir: true,
},
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"-c.mac.notarize=false",
"--win",
"nsis",
"--arm64",
"--publish",
"never",
"-c.directories.output=dist/win-arm64",
"-c.publish.channel=latest-arm64",
]);
});
it("does not override the publish channel for Windows x64 (default latest.yml)", () => {
expect(
builderArgsForTarget(
{ platform: "win", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "always"],
platformTargets: { mac: [], win: ["nsis"], linux: [] },
requestedPlatforms: ["win"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "win32", useScopedOutputDir: true },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--win",
"nsis",
"--x64",
"--publish",
"always",
"-c.directories.output=dist/win-x64",
]);
});
it("defaults linux cross-builds to AppImage on non-Linux hosts", () => {
expect(
builderArgsForTarget(
{ platform: "linux", arch: "x64" },
{
allPlatforms: false,
sharedArgs: ["--publish", "never"],
platformTargets: { mac: [], win: [], linux: [] },
requestedPlatforms: ["linux"],
requestedArchs: ["x64"],
},
"1.2.3",
{ hostPlatform: "darwin" },
),
).toEqual([
"-c.extraMetadata.version=1.2.3",
"--linux",
"AppImage",
"--x64",
"--publish",
"never",
]);
});
});
describe("envWithLocalBins", () => {
it("prepends desktop-local binary directories to PATH", () => {
const desktopRoot = "/repo/apps/desktop";
const result = envWithLocalBins(
{ PATH: ["/usr/local/bin", "/usr/bin"].join(delimiter) },
desktopRoot,
);
expect(result.PATH.split(delimiter)).toEqual([
resolve(desktopRoot, "node_modules", ".bin"),
resolve(desktopRoot, "..", "..", "node_modules", ".bin"),
"/usr/local/bin",
"/usr/bin",
]);
});
it("preserves an existing Path key and avoids duplicate entries", () => {
const desktopRoot = "/repo/apps/desktop";
const desktopBin = resolve(desktopRoot, "node_modules", ".bin");
const workspaceBin = resolve(desktopRoot, "..", "..", "node_modules", ".bin");
const result = envWithLocalBins(
{ Path: [desktopBin, "runner-bin", workspaceBin].join(delimiter) },
desktopRoot,
);
expect(result).not.toHaveProperty("PATH");
expect(result.Path.split(delimiter)).toEqual([
desktopBin,
workspaceBin,
"runner-bin",
]);
});
});

View File

@@ -1,33 +0,0 @@
import { app } from "electron";
import { execSync } from "node:child_process";
/**
* Resolve the running app version. In packaged builds this is the value
* `electron-builder` baked into package.json via `extraMetadata.version`
* (driven by `git describe` — see `apps/desktop/scripts/package.mjs`), so
* `app.getVersion()` matches the GitHub Release tag exactly.
*
* In dev (`pnpm dev:desktop`) `app.getVersion()` only sees the static
* `apps/desktop/package.json` value, which is "0.1.0" and never bumped —
* the Settings → Updates panel and any other UI surfacing the version
* would mislead developers into thinking they're running ancient builds.
* Fall back to `git describe --tags --always --dirty` (same source the
* packager uses) so dev shows e.g. `0.2.19-14-gabcdef-dirty`. If git is
* unavailable for whatever reason, we just return the package.json value.
*/
export function getAppVersion(): string {
if (app.isPackaged) {
return app.getVersion();
}
try {
const raw = execSync("git describe --tags --always --dirty", {
cwd: app.getAppPath(),
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
}).trim();
if (!raw) return app.getVersion();
return raw.replace(/^v/, "");
} catch {
return app.getVersion();
}
}

View File

@@ -8,15 +8,35 @@ import { pipeline } from "stream/promises";
import { tmpdir } from "os";
import { Readable } from "stream";
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
// Desktop prefers the bundled `multica` CLI shipped inside the app for
// same-repo builds, but it can also repair or bootstrap a managed copy in
// userData on first launch when the bundled binary is missing or unusable.
// Desktop bootstraps its own copy of the `multica` CLI into userData on first
// launch, so users never have to brew-install anything. Build-time decoupled:
// we don't bundle the binary into the .app, we download whatever the upstream
// release is at first run.
const GITHUB_LATEST_BASE =
"https://github.com/multica-ai/multica/releases/latest/download";
function platformAssetName(): string {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[process.platform];
const arch = archMap[process.arch];
if (!os || !arch) {
throw new Error(
`unsupported platform for CLI auto-install: ${process.platform}/${process.arch}`,
);
}
const ext = process.platform === "win32" ? "zip" : "tar.gz";
return `multica_${os}_${arch}.${ext}`;
}
function binaryName(): string {
return process.platform === "win32" ? "multica.exe" : "multica";
}
@@ -72,8 +92,14 @@ async function sha256OfFile(path: string): Promise<string> {
async function verifyChecksum(
archivePath: string,
assetName: string,
expected: string,
): Promise<void> {
const checksums = await fetchChecksums();
const expected = checksums.get(assetName);
if (!expected) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const actual = await sha256OfFile(archivePath);
if (actual.toLowerCase() !== expected) {
throw new Error(
@@ -92,14 +118,7 @@ async function extractArchive(archive: string, dest: string): Promise<void> {
async function installFresh(): Promise<string> {
const target = managedCliPath();
const checksums = await fetchChecksums();
const assetName = selectPlatformReleaseAssetName(checksums.keys());
const expectedChecksum = checksums.get(assetName);
if (!expectedChecksum) {
throw new Error(
`no checksum for ${assetName} in checksums.txt — refusing to install unverified binary`,
);
}
const assetName = platformAssetName();
const url = `${GITHUB_LATEST_BASE}/${assetName}`;
const workDir = join(tmpdir(), `multica-cli-${Date.now()}`);
@@ -111,7 +130,7 @@ async function installFresh(): Promise<string> {
await downloadToFile(url, archivePath);
console.log(`[cli-bootstrap] verifying ${assetName} against checksums.txt`);
await verifyChecksum(archivePath, assetName, expectedChecksum);
await verifyChecksum(archivePath, assetName);
console.log(`[cli-bootstrap] extracting ${assetName}`);
await extractArchive(archivePath, workDir);
@@ -124,7 +143,6 @@ async function installFresh(): Promise<string> {
}
await mkdir(dirname(target), { recursive: true });
await rm(target, { force: true }).catch(() => {});
await rename(extractedBin, target);
await chmod(target, 0o755);
@@ -148,10 +166,8 @@ async function installFresh(): Promise<string> {
* the managed userData location, returns it immediately. Otherwise downloads
* the latest release asset for the current platform and installs it.
*/
export async function ensureManagedCli(
options: { forceInstall?: boolean } = {},
): Promise<string> {
export async function ensureManagedCli(): Promise<string> {
const target = managedCliPath();
if (existsSync(target) && !options.forceInstall) return target;
if (existsSync(target)) return target;
return installFresh();
}

View File

@@ -1,59 +0,0 @@
import { describe, expect, it } from "vitest";
import { selectPlatformReleaseAssetName } from "./cli-release-asset";
describe("selectPlatformReleaseAssetName", () => {
it("prefers the versioned archive name when both exist", () => {
const assetNames = [
"checksums.txt",
"multica_darwin_amd64.tar.gz",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("falls back to the legacy archive name when only legacy is present", () => {
const assetNames = ["checksums.txt", "multica_darwin_amd64.tar.gz"];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica_darwin_amd64.tar.gz",
);
});
it("matches the renamed darwin archive from release assets", () => {
const assetNames = [
"checksums.txt",
"multica-cli-1.2.3-darwin-amd64.tar.gz",
"multica-cli-1.2.3-darwin-arm64.tar.gz",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "darwin", "x64")).toBe(
"multica-cli-1.2.3-darwin-amd64.tar.gz",
);
});
it("matches the renamed windows zip archive", () => {
const assetNames = [
"multica-cli-1.2.3-windows-amd64.zip",
"multica-cli-1.2.3-linux-amd64.tar.gz",
];
expect(selectPlatformReleaseAssetName(assetNames, "win32", "x64")).toBe(
"multica-cli-1.2.3-windows-amd64.zip",
);
});
it("fails when the current platform asset is missing", () => {
expect(() =>
selectPlatformReleaseAssetName(
["multica-cli-1.2.3-linux-amd64.tar.gz", "multica_linux_amd64.tar.gz"],
"darwin",
"arm64",
),
).toThrow(/no release asset found/);
});
});

View File

@@ -1,62 +0,0 @@
const RELEASE_ARCHIVE_PREFIX = "multica-cli-";
function platformArchiveDescriptor(
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): { os: string; arch: string; ext: string } {
const osMap: Record<string, string> = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap: Record<string, string> = {
x64: "amd64",
arm64: "arm64",
};
const os = osMap[platform];
const mappedArch = archMap[arch];
if (!os || !mappedArch) {
throw new Error(
`unsupported platform for CLI auto-install: ${platform}/${arch}`,
);
}
const ext = platform === "win32" ? "zip" : "tar.gz";
return { os, arch: mappedArch, ext };
}
export function selectPlatformReleaseAssetName(
assetNames: Iterable<string>,
platform: NodeJS.Platform = process.platform,
arch: string = process.arch,
): string {
const { os, arch: mappedArch, ext } = platformArchiveDescriptor(
platform,
arch,
);
const names = [...assetNames];
// Prefer the versioned `multica-cli-<v>-<os>-<arch>.<ext>` name; fall
// back to the legacy `multica_<os>_<arch>.<ext>` so older releases that
// only ship the legacy archive keep working.
const suffix = `-${os}-${mappedArch}.${ext}`;
const matches = names.filter(
(name) =>
name.startsWith(RELEASE_ARCHIVE_PREFIX) && name.endsWith(suffix),
);
if (matches.length === 1) {
return matches[0];
}
if (matches.length > 1) {
throw new Error(
`multiple release assets matched current platform ${suffix}: ${matches.join(", ")}`,
);
}
const legacyName = `multica_${os}_${mappedArch}.${ext}`;
if (names.includes(legacyName)) {
return legacyName;
}
throw new Error(`no release asset found for current platform: ${suffix}`);
}

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,138 +0,0 @@
import {
BrowserWindow,
Menu,
MenuItem,
app,
clipboard,
type WebContents,
} from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
// 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 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();
if (isEditable && editFlags.canCut) {
menu.append(new MenuItem({ role: "cut" }));
}
if (hasSelection && editFlags.canCopy) {
menu.append(new MenuItem({ role: "copy" }));
}
if (isEditable && editFlags.canPaste) {
menu.append(new MenuItem({ role: "paste" }));
}
if (isEditable && editFlags.canSelectAll) {
if (menu.items.length > 0) {
menu.append(new MenuItem({ type: "separator" }));
}
menu.append(new MenuItem({ role: "selectAll" }));
}
// 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

@@ -1,4 +1,4 @@
import { app, ipcMain, BrowserWindow, shell } from "electron";
import { app, ipcMain, BrowserWindow } from "electron";
import { execFile } from "child_process";
import {
readFile,
@@ -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,
};
}
@@ -426,36 +316,6 @@ function bundledCliPath(): string {
);
}
async function probeCliBinary(
bin: string,
source: "bundled" | "managed" | "path",
): Promise<string | null> {
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
if (typeof parsed.version === "string" && parsed.version.length > 0) {
return parsed.version;
}
console.warn(
`[daemon] ignoring ${source} CLI at ${bin}: version output was missing or invalid`,
);
return null;
} catch (err) {
console.warn(`[daemon] ignoring ${source} CLI at ${bin}:`, err);
return null;
}
}
/**
* Returns a usable `multica` binary path. Priority:
* 1. Cached result from a previous successful resolve.
@@ -479,55 +339,27 @@ async function resolveCliBinary(): Promise<string | null> {
cliResolvePromise = (async () => {
const bundled = bundledCliPath();
if (existsSync(bundled)) {
const version = await probeCliBinary(bundled, "bundled");
if (version) {
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
cachedCliBinaryVersion = version;
return bundled;
}
console.log(`[daemon] using bundled CLI at ${bundled}`);
cachedCliBinary = bundled;
return bundled;
}
const managed = managedCliPath();
if (existsSync(managed)) {
const version = await probeCliBinary(managed, "managed");
if (version) {
cachedCliBinary = managed;
cachedCliBinaryVersion = version;
return managed;
}
cachedCliBinary = managed;
return managed;
}
try {
const installed = await ensureManagedCli({
forceInstall: existsSync(managed),
});
const version = await probeCliBinary(installed, "managed");
if (version) {
cachedCliBinary = installed;
cachedCliBinaryVersion = version;
return installed;
}
console.warn(
`[daemon] managed CLI at ${installed} failed validation after install`,
);
const installed = await ensureManagedCli();
cachedCliBinary = installed;
return installed;
} catch (err) {
console.warn("[daemon] CLI auto-install failed, falling back to PATH:", err);
const onPath = findCliOnPath();
cachedCliBinary = onPath;
return onPath;
}
const onPath = findCliOnPath();
if (onPath) {
const version = await probeCliBinary(onPath, "path");
if (version) {
cachedCliBinary = onPath;
cachedCliBinaryVersion = version;
return onPath;
}
}
cachedCliBinary = null;
cachedCliBinaryVersion = null;
return null;
})();
try {
@@ -538,10 +370,11 @@ async function resolveCliBinary(): Promise<string | null> {
}
/**
* Reads the version of the currently resolved CLI binary. Cached for the
* process lifetime — the bundled binary doesn't change after bundle time.
* Reads the version of the currently resolved CLI binary by invoking
* `multica version --output json`. Cached for the process lifetime — the
* bundled binary doesn't change after `bundle-cli.mjs` runs at dev/build time.
* Returns null on any failure (unknown `go` at bundle time, broken binary,
* wrong-arch bundled binary, etc.) so callers can fail open.
* etc.) so callers can fail open.
*/
async function getCliBinaryVersion(): Promise<string | null> {
if (cachedCliBinaryVersion !== undefined) return cachedCliBinaryVersion;
@@ -550,7 +383,24 @@ async function getCliBinaryVersion(): Promise<string | null> {
cachedCliBinaryVersion = null;
return null;
}
cachedCliBinaryVersion = await probeCliBinary(bin, "path");
try {
const stdout = await new Promise<string>((resolve, reject) => {
execFile(
bin,
["version", "--output", "json"],
{ timeout: 5_000 },
(err, out) => {
if (err) reject(err);
else resolve(out);
},
);
});
const parsed = JSON.parse(stdout) as { version?: string };
cachedCliBinaryVersion = parsed.version ?? null;
} catch (err) {
console.warn("[daemon] failed to read CLI binary version:", err);
cachedCliBinaryVersion = null;
}
return cachedCliBinaryVersion;
}
@@ -574,15 +424,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 +475,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 +540,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 +580,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 +611,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 +625,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 +643,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 +667,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 +824,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;
@@ -1137,20 +874,6 @@ export function setupDaemonManager(
stopLogTail();
});
// Reveal the daemon's log file in the user's default editor / Console
// app. Acts as the escape hatch when the in-app log viewer isn't enough
// (full history, complex search, copy-to-clipboard at scale).
ipcMain.handle("daemon:open-log-file", async () => {
const active = await ensureActiveProfile();
const logPath = profileLogPath(active.name);
if (!existsSync(logPath)) {
return { success: false, error: "Log file not found yet" };
}
// shell.openPath returns "" on success, error string on failure.
const error = await shell.openPath(logPath);
return error === "" ? { success: true } : { success: false, error };
});
// First-run CLI install kicks off here. Status bar shows "Setting up…"
// until the managed binary is on disk (instant on subsequent launches).
currentState = "installing_cli";
@@ -1168,8 +891,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,73 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("electron", () => ({
shell: { openExternal: vi.fn().mockResolvedValue(undefined) },
}));
import { shell } from "electron";
import { isSafeExternalHttpUrl, openExternalSafely } from "./external-url";
describe("isSafeExternalHttpUrl", () => {
it("allows http and https URLs", () => {
expect(isSafeExternalHttpUrl("https://multica.ai")).toBe(true);
expect(isSafeExternalHttpUrl("http://localhost:3000/auth")).toBe(true);
});
it("allows https URLs with embedded credentials", () => {
// WHATWG URL parses these as https; OS-level handling is the shell's concern.
expect(isSafeExternalHttpUrl("https://user:pass@example.com")).toBe(true);
});
it("normalizes scheme casing so uppercase variants can't bypass", () => {
expect(isSafeExternalHttpUrl("HTTPS://example.com")).toBe(true);
expect(isSafeExternalHttpUrl("FILE:///etc/passwd")).toBe(false);
});
it("rejects dangerous pseudo-schemes", () => {
expect(isSafeExternalHttpUrl("javascript:alert(1)")).toBe(false);
expect(
isSafeExternalHttpUrl("data:text/html,<script>alert(1)</script>"),
).toBe(false);
});
it("rejects filesystem and network transport schemes", () => {
expect(isSafeExternalHttpUrl("file:///etc/passwd")).toBe(false);
expect(isSafeExternalHttpUrl("ftp://example.com/x")).toBe(false);
expect(isSafeExternalHttpUrl("smb://share/x")).toBe(false);
});
it("rejects local-handler schemes used in past RCE chains", () => {
expect(isSafeExternalHttpUrl("vscode://file/test")).toBe(false);
expect(isSafeExternalHttpUrl("ms-msdt:/id%20PCWDiagnostic")).toBe(false);
});
it("rejects mailto and other non-web schemes", () => {
expect(isSafeExternalHttpUrl("mailto:test@example.com")).toBe(false);
expect(isSafeExternalHttpUrl("tel:+15551234567")).toBe(false);
});
it("rejects empty, whitespace, and malformed input", () => {
expect(isSafeExternalHttpUrl("")).toBe(false);
expect(isSafeExternalHttpUrl(" ")).toBe(false);
expect(isSafeExternalHttpUrl("not a url")).toBe(false);
expect(isSafeExternalHttpUrl("http://")).toBe(false);
});
});
describe("openExternalSafely", () => {
beforeEach(() => {
vi.mocked(shell.openExternal).mockClear();
});
it("forwards http/https URLs to shell.openExternal", () => {
openExternalSafely("https://multica.ai");
expect(shell.openExternal).toHaveBeenCalledWith("https://multica.ai");
});
it("does not call shell.openExternal for rejected schemes", () => {
openExternalSafely("file:///etc/passwd");
openExternalSafely("javascript:alert(1)");
openExternalSafely("not a url");
expect(shell.openExternal).not.toHaveBeenCalled();
});
});

View File

@@ -1,51 +0,0 @@
import { shell, type BrowserWindow } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
// URL parser lowercases the protocol field.
export function isSafeExternalHttpUrl(url: string): boolean {
return getHttpProtocol(url) !== null;
}
// Canonical wrapper around shell.openExternal. All renderer-controlled URLs
// that eventually reach the OS shell MUST flow through here; direct calls
// to `shell.openExternal` elsewhere in the main process are banned by the
// no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
export function openExternalSafely(url: string): Promise<void> | void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked openExternal: ${describeScheme(url)}`);
return;
}
return shell.openExternal(url);
}
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
// URLs that trigger a native download MUST flow through here; direct calls
// to `webContents.downloadURL` elsewhere in the main process are banned by
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
// Reuses the same http/https allowlist as openExternalSafely.
export function downloadURLSafely(win: BrowserWindow, url: string): void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
return;
}
win.webContents.downloadURL(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);
if (protocol === "http:" || protocol === "https:") return protocol;
return null;
} catch {
return null;
}
}
function describeScheme(url: string): string {
try {
return `scheme=${new URL(url).protocol}`;
} catch {
return "invalid URL";
}
}

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,53 +1,15 @@
import { app, BrowserWindow, dialog, ipcMain, nativeImage, Notification } from "electron";
import { app, shell, BrowserWindow, ipcMain, nativeImage } 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,
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
// direct-launch doesn't register the .desktop entry, so GNOME has no path
// from the running window to the hicolor icon and falls back to the
// theme default. Consumed in createWindow() (all platforms in dev, Linux
// in prod) and the macOS dev dock branch.
//
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
// Linux native window-icon code path expects a real filesystem path
// (unlike Electron's nativeImage loader which transparently reads from
// asar), so swap the segment — same pattern as bundledCliPath() in
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
// is a no-op.
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
"app.asar",
"app.asar.unpacked",
);
// Bundled icon used for dev-mode dock/taskbar branding. In production the
// app bundle icon (from electron-builder) wins; this path is only consumed
// by the `is.dev` branch below.
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
@@ -71,19 +33,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" },
};
// --- Deep link helpers ---------------------------------------------------
@@ -98,19 +48,6 @@ function handleDeepLink(url: string): void {
if (token && mainWindow) {
mainWindow.webContents.send("auth:token", token);
}
return;
}
// multica://invite/<invitationId>
// Dispatched from the web invite page when the user chooses "Open in
// desktop app". The renderer opens the invite overlay — no tab, no
// route persistence, so deep-linking the same invite twice stays safe.
if (parsed.hostname === "invite") {
const id = parsed.pathname.replace(/^\//, "");
if (id && mainWindow) {
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
}
return;
}
} catch {
// Ignore malformed URLs
@@ -119,25 +56,7 @@ function handleDeepLink(url: string): void {
// --- Window creation -----------------------------------------------------
// Tracks the OS-preferred language as last seen by the running process.
// Updated on each window-focus check so we can emit a `locale:system-changed`
// event to the renderer when the user changes their OS language without
// quitting the app — without restart, app.getPreferredSystemLanguages()
// would still report the boot value forever.
let lastKnownSystemLocale = "en";
function getSystemLocale(): string {
return app.getPreferredSystemLanguages()[0] ?? "en";
}
function createWindow(): void {
// Pass the OS-preferred language to the renderer via additionalArguments
// instead of a sync IPC call. process.argv is available to the preload
// script before the first network request, so the renderer's i18next
// instance can initialize with the right locale on the very first paint.
const systemLocale = getSystemLocale();
lastKnownSystemLocale = systemLocale;
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
@@ -147,55 +66,19 @@ function createWindow(): void {
trafficLightPosition: { x: 16, y: 13 },
show: false,
autoHideMenuBar: true,
// Windows/Linux pick up the window/taskbar icon from this option.
// On macOS it's ignored (dock comes from app.dock.setIcon below).
// Linux production needs this explicitly because AppImage direct-launch
// does not install a .desktop entry, so the WM has no other path to
// the bundled icon; without it Ubuntu falls back to the theme default.
...(is.dev || process.platform === "linux"
? { icon: BUNDLED_ICON_PATH }
: {}),
// Windows/Linux pick up the window/taskbar icon from this option in
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
webPreferences: {
preload: join(__dirname, "../preload/index.js"),
sandbox: false,
webSecurity: false,
// Required for the Chromium PDF viewer (PDFium) to activate inside
// iframes — used by the attachment preview modal for application/pdf
// files. Default is false in Electron; without it <iframe src=*.pdf>
// renders blank.
//
// Security trade-off, accepted intentionally:
// 1. This window already runs with `webSecurity: false` + `sandbox: false`,
// so `plugins: true` does NOT meaningfully widen the renderer's
// attack surface beyond what is already accepted.
// 2. The only PDFs that reach an iframe here are signed CloudFront URLs
// we ourselves issued (see useDownloadAttachment); user-supplied URLs
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
// cannot land in this renderer.
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
// and only handles the `application/pdf` MIME — it does not expose
// Flash, Java, or other historical plugin surfaces.
//
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
// to that view, keeping the main renderer plugin-free.
plugins: true,
additionalArguments: [`--multica-locale=${systemLocale}`],
},
});
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,114 +86,19 @@ 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
// dedicated event for this on any platform, so we poll on focus regain —
// catches the common case where users switch System Settings → Language
// and bring the app back. The renderer decides whether to act (it ignores
// the signal when the user has an explicit Settings choice).
window.on("focus", () => {
const current = getSystemLocale();
if (current === lastKnownSystemLocale) return;
lastKnownSystemLocale = current;
window.webContents.send("locale:system-changed", current);
});
window.webContents.setWindowOpenHandler((details) => {
openExternalSafely(details.url);
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(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();
}
});
// 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);
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"));
}
}
@@ -333,14 +121,6 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
if (is.dev) {
app.setName(DEV_APP_NAME);
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
} else {
// Pin the production app name in code. Electron's Linux WM_CLASS is set
// from app.getName() when the first BrowserWindow is realized; the
// packaged ASAR's package.json `productName` already steers app.getName()
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
// (declared in electron-builder.yml) survive a regression in
// productName / the build pipeline. Must run before requestSingleInstanceLock().
app.setName("Multica");
}
// --- Protocol registration -----------------------------------------------
@@ -373,25 +153,7 @@ if (!gotTheLock) {
if (deepLinkUrl) handleDeepLink(deepLinkUrl);
});
app.whenReady().then(async () => {
const viteEnv = import.meta.env as ImportMetaEnv & {
readonly VITE_API_URL?: string;
readonly VITE_WS_URL?: string;
readonly VITE_APP_URL?: string;
};
runtimeConfigResult = await loadRuntimeConfig({
isDev: is.dev,
// electron-vite exposes VITE_* on import.meta.env for the main process;
// keep dev URL overrides on the same source the renderer used before
// runtime config moved endpoint resolution into main/preload.
env: {
apiUrl: viteEnv.VITE_API_URL,
wsUrl: viteEnv.VITE_WS_URL,
appUrl: viteEnv.VITE_APP_URL,
},
});
app.whenReady().then(() => {
electronApp.setAppUserModelId(
is.dev ? "ai.multica.desktop.dev" : "ai.multica.desktop",
);
@@ -400,7 +162,7 @@ if (!gotTheLock) {
// so the Canary dev build is visually distinct from a stock Electron
// run. `app.dock` is macOS-only — guard the call.
if (is.dev && process.platform === "darwin" && app.dock) {
const icon = nativeImage.createFromPath(BUNDLED_ICON_PATH);
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
if (!icon.isEmpty()) app.dock.setIcon(icon);
}
@@ -408,58 +170,9 @@ if (!gotTheLock) {
optimizer.watchWindowShortcuts(window);
});
// IPC: open URL in default browser (used by renderer for Google login).
// All scheme-allowlist enforcement lives in openExternalSafely — this
// is the single audit point for renderer-controlled URLs reaching the
// OS shell under the app's intentional webSecurity: false + sandbox:
// false configuration.
// IPC: open URL in default browser (used by renderer for Google login)
ipcMain.handle("shell:openExternal", (_event, url: string) => {
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");
return;
}
downloadURLSafely(mainWindow, url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer
// already carries X-Client-Version and X-Client-OS.
ipcMain.on("app:get-info", (event) => {
const p = process.platform;
const os = p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
event.returnValue = { version: getAppVersion(), os };
});
// Sync IPC: 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.
ipcMain.on("runtime-config:get", (event) => {
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;
return shell.openExternal(url);
});
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
@@ -470,69 +183,10 @@ if (!gotTheLock) {
mainWindow?.setWindowButtonVisibility(!immersive);
});
// IPC: show a native OS notification for a new inbox item. The renderer
// only fires this when the app is unfocused (it gates on
// `document.hasFocus()`), so we don't fight macOS foreground suppression
// here. Clicking the banner focuses the main window and routes to the
// inbox item via a renderer-side listener.
ipcMain.on(
"notification:show",
(
_event,
{
slug,
itemId,
issueKey,
title,
body,
}: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
},
) => {
if (!Notification.isSupported()) return;
const notification = new Notification({ title, body });
notification.on("click", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
// Ship the full context back — the renderer pins the route to the
// source workspace (slug), marks the row read (itemId), and uses
// issueKey as the ?issue=<…> selector.
mainWindow.webContents.send("inbox:open", {
slug,
itemId,
issueKey,
});
});
notification.show();
},
);
// IPC: update the dock / taskbar unread badge. Values above 99 render as
// "99+". macOS is the primary target (user-visible dock badge); Linux
// Unity launchers also respect `setBadgeCount`. Windows' taskbar overlay
// needs a pre-rendered PNG and is deferred — the OS notification + the
// in-app inbox sidebar cover the core UX there for now.
ipcMain.on("badge:set", (_event, rawCount: number) => {
const count = Math.max(0, Math.floor(rawCount));
if (process.platform === "darwin") {
const label = count === 0 ? "" : count > 99 ? "99+" : String(count);
app.dock?.setBadge(label);
} else {
app.setBadgeCount(count);
}
});
createWindow();
setupAutoUpdater(() => mainWindow);
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,90 +0,0 @@
import { mkdtemp, writeFile } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";
import { describe, expect, it } from "vitest";
import { loadRuntimeConfig } from "./runtime-config-loader";
describe("loadRuntimeConfig", () => {
it("uses dev env and ignores desktop.json during electron-vite dev", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(
configPath,
JSON.stringify({ schemaVersion: 1, apiUrl: "https://prod.example.com" }),
);
await expect(
loadRuntimeConfig({
isDev: true,
configPath,
env: {
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
},
}),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "http://localhost:8080",
wsUrl: "ws://localhost:8080/ws",
appUrl: "http://localhost:3000",
},
});
});
it("uses cloud defaults when packaged config is absent", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
await expect(
loadRuntimeConfig({
isDev: false,
configPath: join(dir, "missing.json"),
env: {},
}),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "https://api.multica.ai",
wsUrl: "wss://api.multica.ai/ws",
appUrl: "https://multica.ai",
},
});
});
it("parses a valid packaged desktop.json", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(
configPath,
JSON.stringify({ schemaVersion: 1, apiUrl: "https://api.example.com" }),
);
await expect(
loadRuntimeConfig({ isDev: false, configPath, env: {} }),
).resolves.toEqual({
ok: true,
config: {
schemaVersion: 1,
apiUrl: "https://api.example.com",
wsUrl: "wss://api.example.com/ws",
appUrl: "https://example.com",
},
});
});
it("fails closed when packaged desktop.json is invalid", async () => {
const dir = await mkdtemp(join(tmpdir(), "multica-desktop-config-"));
const configPath = join(dir, "desktop.json");
await writeFile(configPath, "{");
const result = await loadRuntimeConfig({ isDev: false, configPath, env: {} });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toContain(configPath);
expect(result.error.message).toContain("Invalid desktop runtime config JSON");
}
});
});

View File

@@ -1,60 +0,0 @@
import { app } from "electron";
import { readFile } from "fs/promises";
import { join } from "path";
import {
DEFAULT_RUNTIME_CONFIG,
parseRuntimeConfig,
runtimeConfigFromDevEnv,
type RuntimeConfig,
type RuntimeConfigEnv,
type RuntimeConfigResult,
} from "../shared/runtime-config";
export async function loadRuntimeConfig(options: {
isDev: boolean;
env: RuntimeConfigEnv;
configPath?: string;
}): Promise<RuntimeConfigResult> {
if (options.isDev) {
try {
return { ok: true, config: runtimeConfigFromDevEnv(options.env) };
} catch (err) {
return { ok: false, error: { message: errorMessage(err) } };
}
}
const configPath = options.configPath ?? desktopConfigPath();
try {
const raw = await readFile(configPath, "utf-8");
return { ok: true, config: parseRuntimeConfig(raw) };
} catch (err) {
if (isMissingFileError(err)) {
return { ok: true, config: { ...DEFAULT_RUNTIME_CONFIG } };
}
return {
ok: false,
error: {
message: `Invalid ${configPath}: ${errorMessage(err)}`,
},
};
}
}
export function desktopConfigPath(): string {
return join(app.getPath("home"), ".multica", "desktop.json");
}
function isMissingFileError(err: unknown): boolean {
return Boolean(
err &&
typeof err === "object" &&
"code" in err &&
(err as NodeJS.ErrnoException).code === "ENOENT",
);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
export type { RuntimeConfig, RuntimeConfigResult };

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,118 +1,34 @@
import { autoUpdater, type UpdateDownloadedEvent } from "electron-updater";
import { app, type BrowserWindow, ipcMain } from "electron";
import { autoUpdater } from "electron-updater";
import { BrowserWindow, ipcMain } from "electron";
// Silent background updates: electron-updater downloads on its own as soon
// as `update-available` fires; we only surface UI when the package is fully
// downloaded and ready to install on next quit.
autoUpdater.autoDownload = true;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = true;
// Windows arm64 ships its own update metadata channel because
// electron-builder's `latest.yml` is not arch-suffixed on Windows — both
// arches would otherwise collide on the same file in the GitHub Release.
// See scripts/package.mjs (builderArgsForTarget) for the publish-side half
// of this pact. Pin the channel here so arm64 clients fetch
// `latest-arm64.yml` instead of the x64 metadata.
if (process.platform === "win32" && process.arch === "arm64") {
autoUpdater.channel = "latest-arm64";
}
const STARTUP_CHECK_DELAY_MS = 5_000;
const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
export type ManualUpdateCheckResult =
| {
ok: true;
currentVersion: string;
latestVersion: string;
available: boolean;
}
| { 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
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
// callers onto the same in-flight promise.
let inFlightCheck: Promise<unknown> | null = null;
function checkForUpdatesOnce(): Promise<unknown> {
if (inFlightCheck) return inFlightCheck;
const p = autoUpdater
.checkForUpdates()
.then((result) => {
// checkForUpdates resolves as soon as metadata is fetched; the actual
// download (when autoDownload=true) is exposed on result.downloadPromise.
// Without a handler a download failure becomes an unhandled rejection
// in the main process — Node may terminate it on future versions.
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
(err) => {
console.error("Failed to download update:", err);
},
);
return result;
})
.finally(() => {
if (inFlightCheck === p) inFlightCheck = null;
});
inFlightCheck = p;
return p;
}
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
autoUpdater.on("update-available", (info) => {
// Forwarded for renderer-side state tracking only; the notification UI
// does not render an "available" affordance with autoDownload=true.
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", {
version: info.version,
releaseNotes: info.releaseNotes,
});
autoUpdater.on("update-downloaded", () => {
const win = getMainWindow();
win?.webContents.send("updater:update-downloaded");
});
autoUpdater.on("error", (err) => {
console.error("Auto-updater error:", err);
});
// Retained for IPC back-compat with older renderer bundles. With
// autoDownload=true the renderer no longer triggers this path.
ipcMain.handle("updater:download", () => {
return autoUpdater.downloadUpdate();
});
@@ -121,44 +37,10 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
autoUpdater.quitAndInstall(false, true);
});
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
try {
const result = (await checkForUpdatesOnce()) as
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
| null;
const currentVersion = app.getVersion();
// Trust electron-updater's own decision rather than re-deriving it from
// a version-string compare. The two diverge for pre-release channels,
// staged rollouts, downgrades, and minimum-system-version gates — in
// those cases updateInfo.version differs from app.getVersion() but no
// `update-available` event fires, so showing "available" here would
// promise a download prompt that never appears.
return {
ok: true,
currentVersion,
latestVersion: result?.updateInfo.version ?? currentVersion,
available: result?.isUpdateAvailable ?? false,
};
} catch (err) {
return {
ok: false,
error: err instanceof Error ? err.message : String(err),
};
}
});
// Initial check shortly after startup so we don't block boot.
// Check for updates after a short delay to avoid blocking startup
setTimeout(() => {
checkForUpdatesOnce().catch((err) => {
autoUpdater.checkForUpdates().catch((err) => {
console.error("Failed to check for updates:", err);
});
}, STARTUP_CHECK_DELAY_MS);
// Background poll so long-running sessions still pick up new releases
// without requiring the user to restart the app.
setInterval(() => {
checkForUpdatesOnce().catch((err) => {
console.error("Periodic update check failed:", err);
});
}, PERIODIC_CHECK_INTERVAL_MS);
}, 5000);
}

View File

@@ -1,99 +1,16 @@
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. */
appInfo: {
version: string;
os: "macos" | "windows" | "linux" | "unknown";
};
/** OS-preferred locale (BCP 47) injected by main via additionalArguments. */
systemLocale: string;
/** Subscribe to OS language changes detected after boot. Returns an unsubscribe function. */
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig: RuntimeConfigResult;
/** 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. */
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Download a file by URL through Electron's native download system.
* Shows a native save dialog. On non-desktop platforms this is undefined. */
downloadURL: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */
showNotification: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => void;
/** Update the OS dock / taskbar unread badge. Pass 0 to clear. */
setUnreadBadge: (count: number) => void;
/** Listen for "open inbox row" requests from notification clicks. Returns an unsubscribe function. */
onInboxOpen: (
callback: (payload: {
slug: string;
itemId: string;
issueKey: string;
}) => void,
) => () => void;
/** 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 +26,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>;
@@ -136,21 +43,14 @@ interface DaemonAPI {
startLogStream: () => void;
stopLogStream: () => void;
onLogLine: (callback: (line: string) => void) => () => void;
openLogFile: () => Promise<{ success: boolean; error?: string }>;
}
interface UpdaterAPI {
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
onUpdateDownloaded: (
callback: (info: { version: string; releaseNotes?: string }) => void,
) => () => void;
onUpdateDownloaded: (callback: () => void) => () => void;
downloadUpdate: () => Promise<void>;
installUpdate: () => Promise<void>;
checkForUpdates: () => Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
>;
}
declare global {

View File

@@ -1,94 +1,7 @@
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
// (async ipc.invoke) would race the ApiClient construction in initCore and
// the first few HTTP requests would go out without X-Client-Version/OS.
function fetchAppInfo(): { version: string; os: "macos" | "windows" | "linux" | "unknown" } {
try {
const info = ipcRenderer.sendSync("app:get-info") as
| { version: string; os: "macos" | "windows" | "linux" | "unknown" }
| undefined;
if (info && typeof info.version === "string" && typeof info.os === "string") return info;
} catch {
// fall through
}
// Fallback: derive OS from process.platform; version unknown.
const p = process.platform;
const os: "macos" | "windows" | "linux" | "unknown" =
p === "darwin" ? "macos" : p === "win32" ? "windows" : p === "linux" ? "linux" : "unknown";
return { version: "unknown", os };
}
function fetchRuntimeConfig(): RuntimeConfigResult {
try {
const result = ipcRenderer.sendSync("runtime-config:get") as RuntimeConfigResult | undefined;
if (result && typeof result === "object" && "ok" in result) return result;
} catch (err) {
return {
ok: false,
error: {
message: err instanceof Error ? err.message : String(err),
},
};
}
return { ok: false, error: { message: "Runtime config unavailable" } };
}
const appInfo = fetchAppInfo();
const runtimeConfig = fetchRuntimeConfig();
// Read the OS-preferred locale that main injected via additionalArguments.
// Zero IPC, zero blocking — process.argv is populated before preload runs.
function fetchSystemLocale(): string {
const arg = process.argv.find((a) => a.startsWith("--multica-locale="));
return arg?.split("=")[1] ?? "en";
}
const systemLocale = fetchSystemLocale();
const desktopAPI = {
/** App version + normalized OS. Read once at preload time so the renderer
* can use it synchronously when initializing the API client. */
appInfo,
/** OS-preferred locale (BCP 47), passed from main via additionalArguments.
* Used by the renderer's LocaleAdapter as the system-preference signal. */
systemLocale,
/** Subscribe to OS language changes detected after boot. The renderer
* decides whether to act (no-op when the user has an explicit Settings
* choice). Returns an unsubscribe function. */
onSystemLocaleChanged: (callback: (locale: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, locale: string) =>
callback(locale);
ipcRenderer.on("locale:system-changed", handler);
return () => {
ipcRenderer.removeListener("locale:system-changed", handler);
};
},
/** Validated runtime endpoint config, or a blocking config error. */
runtimeConfig,
/** 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) =>
@@ -98,111 +11,15 @@ const desktopAPI = {
ipcRenderer.removeListener("auth:token", handler);
};
},
/** Listen for invitation IDs delivered via deep link */
onInviteOpen: (callback: (invitationId: string) => void) => {
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
callback(invitationId);
ipcRenderer.on("invite:open", handler);
return () => {
ipcRenderer.removeListener("invite:open", handler);
};
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Download a file by URL through Electron's native download system.
* Shows a save dialog and saves to disk. Unlike openExternal, this
* avoids browser rendering of HTML files on Linux.
* On non-desktop platforms this property is undefined. */
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
/**
* Show a native OS notification for a new inbox item. Fired from the
* renderer only when the app is unfocused — in-focus feedback is the
* inbox sidebar's unread styling. `slug`, `itemId`, and `issueKey` are
* all round-tripped on click: slug pins routing to the source workspace
* (the user may switch workspaces before clicking the banner), itemId
* lets the renderer mark the row read, issueKey maps to the inbox URL
* param.
*/
showNotification: (payload: {
slug: string;
itemId: string;
issueKey: string;
title: string;
body: string;
}) => ipcRenderer.send("notification:show", payload),
/**
* Update the OS dock / taskbar unread badge. Pass 0 to clear. Values
* above 99 render as "99+" (capping is handled in the main process).
*/
setUnreadBadge: (count: number) =>
ipcRenderer.send("badge:set", Math.max(0, Math.floor(count))),
/**
* Subscribe to "open this inbox row" requests sent by the main process
* when the user clicks an OS notification banner. Returns an unsubscribe
* function. The payload echoes the `slug`, `itemId`, and `issueKey` that
* were passed to `showNotification`.
*/
onInboxOpen: (
callback: (payload: {
slug: string;
itemId: string;
issueKey: string;
}) => void,
) => {
const handler = (
_event: Electron.IpcRendererEvent,
payload: { slug: string; itemId: string; issueKey: string },
) => callback(payload);
ipcRenderer.on("inbox:open", handler);
return () => {
ipcRenderer.removeListener("inbox:open", handler);
};
},
/** 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 +30,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 +39,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 +50,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 }> =>
@@ -262,8 +67,6 @@ const daemonAPI = {
ipcRenderer.on("daemon:log-line", handler);
return () => ipcRenderer.removeListener("daemon:log-line", handler);
},
openLogFile: (): Promise<{ success: boolean; error?: string }> =>
ipcRenderer.invoke("daemon:open-log-file"),
};
const updaterAPI = {
@@ -277,20 +80,13 @@ const updaterAPI = {
ipcRenderer.on("updater:download-progress", handler);
return () => ipcRenderer.removeListener("updater:download-progress", handler);
},
onUpdateDownloaded: (
callback: (info: { version: string; releaseNotes?: string }) => void,
) => {
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
callback(info);
onUpdateDownloaded: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on("updater:update-downloaded", handler);
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
},
downloadUpdate: () => ipcRenderer.invoke("updater:download"),
installUpdate: () => ipcRenderer.invoke("updater:install"),
checkForUpdates: (): Promise<
| { ok: true; currentVersion: string; latestVersion: string; available: boolean }
| { ok: false; error: string }
> => ipcRenderer.invoke("updater:check"),
};
if (process.contextIsolated) {

View File

@@ -1,76 +1,21 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useEffect, 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 { 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";
import { Toaster } from "sonner";
import { DesktopLoginPage } from "./pages/login";
import { DesktopShell } from "./components/desktop-layout";
import { PageviewTracker } from "./components/pageview-tracker";
import { UpdateNotification } from "./components/update-notification";
import { useTabStore } from "./stores/tab-store";
import { useWindowOverlayStore } from "./stores/window-overlay-store";
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
import { 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
@@ -80,26 +25,10 @@ function AppContent() {
// first render.
const [bootstrapping, setBootstrapping] = useState(false);
const runtimeConfig = window.desktopAPI.runtimeConfig.ok
? window.desktopAPI.runtimeConfig.config
: null;
// Tell the main process which backend URL we talk to, so daemon-manager
// can pick the matching CLI profile (server_url from ~/.multica config).
useEffect(() => {
if (!runtimeConfig) return;
window.daemonAPI.setTargetApiUrl(runtimeConfig.apiUrl);
}, [runtimeConfig]);
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
// We open the overlay regardless of login state — if the user isn't logged
// in, InvitePage's queries will fail and render the "not found" state,
// which is acceptable; the expected pre-flight happens in the web app
// (login + next=/invite/... dance) before the deep link is ever dispatched.
useEffect(() => {
return window.desktopAPI.onInviteOpen((invitationId) => {
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
});
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
}, []);
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
@@ -148,107 +77,28 @@ function AppContent() {
// account switches (user A logout → user B login) should not trigger a
// daemon restart here — daemon-manager already restarts on user change
// via syncToken.
const { data: workspaces = [], isFetched: workspaceListFetched } = useQuery({
const { data: workspaces, isFetched: workspaceListFetched } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
const wsCount = workspaces.length;
const hasOnboarded = useHasOnboarded();
const wsCount = workspaces?.length ?? 0;
// Bridge local daemon IPC status into the runtimes cache so this user's
// own daemon flips to offline/online sub-second instead of waiting on the
// server's 75s sweeper. Resolves wsId from the active tab so workspace
// switches automatically rebind the subscription.
const activeWorkspaceSlug = useTabStore((s) => s.activeWorkspaceSlug);
const activeWsId = activeWorkspaceSlug
? workspaces.find((w) => w.slug === activeWorkspaceSlug)?.id
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web 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
// Validate persisted tab paths against the current user's workspace list.
// Tabs survive across app restarts and account switches (persisted to
// localStorage `multica_tabs`), so a tab path like `/naiyuan/issues` may
// reference a workspace the current user can't access — showing
// NoAccessPage every time they open the app.
//
// 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.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (hasOnboarded && 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
// missed invites later once they're onboarded.
let cancelled = false;
void api
.listMyInvitations()
.then((invites) => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
if (invites.length > 0) {
qc.setQueryData(workspaceKeys.myInvitations(), invites);
latestOpen({ type: "invitations" });
} else {
latestOpen({ type: "onboarding" });
}
})
.catch(() => {
if (cancelled) return;
const { overlay: latestOverlay, open: latestOpen } =
useWindowOverlayStore.getState();
if (latestOverlay) return;
latestOpen({ type: "onboarding" });
});
return () => {
cancelled = true;
};
}
open({ type: "new-workspace" });
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
// phase — the original render-phase pattern triggered React's
// "Cannot update a component while rendering a different component"
// warning because `switchWorkspace` is a Zustand setState that the
// TabBar is subscribed to. useLayoutEffect flushes both renders before
// the user sees anything, so there's no visible flicker.
//
// Gate on `workspaceListFetched`: useQuery defaults `data` to `[]` before
// the first fetch, so without this guard we'd run validation against an
// empty slug set, wipe the persisted `activeWorkspaceSlug`, then fall
// back to `workspaces[0]` once the real list arrives — losing the user's
// last-opened workspace on every app start.
useLayoutEffect(() => {
if (!workspaceListFetched) return;
// Run synchronously in render phase rather than in useEffect so the first
// render already sees validated tabs. useEffect runs AFTER commit, which
// means the initial render would briefly show NoAccessPage before the
// effect resets the tab. Zustand supports render-phase setState; the
// validator is idempotent (exits early if nothing changed) so this
// doesn't loop.
if (workspaces) {
const validSlugs = new Set(workspaces.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
const { activeWorkspaceSlug, switchWorkspace } = useTabStore.getState();
if (!activeWorkspaceSlug && workspaces.length > 0) {
switchWorkspace(workspaces[0].slug);
}
}, [workspaces, workspaceListFetched]);
}
// null = undecided (pre-login or list hasn't settled yet)
// true = session started with zero workspaces; next transition to >=1 triggers restart
// false = session started with >=1 workspace, OR we've already restarted; skip
@@ -277,44 +127,17 @@ function AppContent() {
);
}
// Pageview tracker sits at the app root so it covers every visible
// surface (login, overlays, tab paths) — mounting it inside DesktopShell
// would miss the logged-out and overlay states.
return (
<>
<PageviewTracker />
{user ? <DesktopShell /> : <DesktopLoginPage />}
</>
);
if (!user) return <DesktopLoginPage />;
return <DesktopShell />;
}
function BlockingRuntimeConfigError({ message }: { message: string }) {
return (
<div className="flex h-screen items-center justify-center bg-background p-8 text-foreground">
<div className="max-w-xl rounded-lg border bg-card p-6 shadow-sm">
<h1 className="text-lg font-semibold">Desktop configuration error</h1>
<p className="mt-3 text-sm text-muted-foreground">
Multica Desktop could not load <code>~/.multica/desktop.json</code>. Fix or remove the file and restart the app.
</p>
<pre className="mt-4 whitespace-pre-wrap rounded-md bg-muted p-3 text-xs text-muted-foreground">
{message}
</pre>
</div>
</div>
);
}
// Backend the daemon should connect to — same URL the renderer talks to.
const DAEMON_TARGET_API_URL =
import.meta.env.VITE_API_URL || "http://localhost:8080";
// On logout, wipe desktop-only in-memory state and stop the daemon so that
// a subsequent login as a different user never inherits the previous user's
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
// useLogout clears the storage key, but the live stores stay populated until
// we explicitly reset them here.
// On logout, clear any cached PAT and stop the daemon so that a subsequent
// login as a different user never inherits the previous user's credentials.
async function handleDaemonLogout() {
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 {
@@ -328,93 +151,15 @@ async function handleDaemonLogout() {
}
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(
() => ({ platform: "desktop", version, os }),
[version, os],
);
// Locale resolution happens once at app boot. Switching language goes
// through window.location.reload() to avoid hydration mismatch.
const localeAdapter = useMemo(
() => createDesktopLocaleAdapter(systemLocale),
[systemLocale],
);
const locale = useMemo(() => pickLocale(localeAdapter), [localeAdapter]);
const resources = useMemo(
() => ({ [locale]: RESOURCES[locale] }),
[locale],
);
// 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
// for the explicit-choice case is handled inside CoreProvider.
useEffect(() => {
return window.desktopAPI.onSystemLocaleChanged((nextSystemLocale) => {
if (localeAdapter.getUserChoice()) return;
const next = pickLocale({
...localeAdapter,
getSystemPreferences: () =>
nextSystemLocale ? [nextSystemLocale] : [],
});
if (next === locale) return;
localeAdapter.persist(next);
window.location.reload();
});
}, [localeAdapter, locale]);
return (
<ThemeProvider>
{runtimeConfigResult.ok ? (
<CoreProvider
apiBaseUrl={runtimeConfigResult.config.apiUrl}
wsUrl={runtimeConfigResult.config.wsUrl}
onLogout={handleDaemonLogout}
identity={identity}
locale={locale}
resources={resources}
localeAdapter={localeAdapter}
>
<AppContent />
</CoreProvider>
) : (
<BlockingRuntimeConfigError message={runtimeConfigResult.error.message} />
)}
<CoreProvider
apiBaseUrl={import.meta.env.VITE_API_URL || "http://localhost:8080"}
wsUrl={import.meta.env.VITE_WS_URL || "ws://localhost:8080/ws"}
onLogout={handleDaemonLogout}
>
<AppContent />
</CoreProvider>
<Toaster />
<UpdateNotification />
</ThemeProvider>

View File

@@ -1,259 +1,150 @@
import { useState, useEffect, useRef, useCallback } from "react";
import {
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
ArrowDown,
Copy as CopyIcon,
Search,
Play,
Square,
RotateCw,
Server,
Trash2,
ChevronDown,
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { toast } from "sonner";
import type { DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
formatUptime,
} from "../../../shared/daemon-types";
import { parseLogLine, type LogLevel, type ParsedLogLine } from "./parse-daemon-log";
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@multica/ui/components/ui/sheet";
import type { DaemonStatus, DaemonState } from "../../../shared/daemon-types";
import { DAEMON_STATE_COLORS, DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
interface DaemonPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
status: DaemonStatus;
/** Number of runtimes this local daemon has registered (for the context badge). */
runtimeCount: number;
}
const LOG_LEVEL_COLORS: Record<string, string> = {
INFO: "text-info",
WARN: "text-warning",
ERROR: "text-destructive",
DEBUG: "text-muted-foreground",
};
function colorizeLogLine(line: string): { level: string; className: string } {
for (const [level, className] of Object.entries(LOG_LEVEL_COLORS)) {
if (line.includes(level)) return { level, className };
}
return { level: "", className: "text-muted-foreground" };
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-baseline justify-between gap-4 py-1">
<span className="shrink-0 text-xs text-muted-foreground">{label}</span>
<span className="truncate text-right text-sm">{value}</span>
</div>
);
}
function StatusDot({ state }: { state: DaemonState }) {
return <span className={cn("inline-block size-2 rounded-full", DAEMON_STATE_COLORS[state])} />;
}
interface LogEntry {
id: number;
line: string;
}
const MAX_LOG_LINES = 500;
const LEVELS: readonly LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
let logIdCounter = 0;
const LEVEL_BADGE_CLASS: Record<LogLevel, string> = {
DEBUG: "border-muted-foreground/25 text-muted-foreground/70",
INFO: "border-foreground/15 text-foreground/80",
WARN: "border-warning/40 text-warning",
ERROR: "border-destructive/40 text-destructive",
};
// What gets rendered in the viewport — a single line or a folded group of
// consecutive lines that share the same `message`. The group form is what
// turns a wall of `DBG poll: no tasks` into a single placeholder.
type DisplayItem =
| { kind: "line"; line: ParsedLogLine }
| { kind: "group"; first: ParsedLogLine; rest: ParsedLogLine[] };
export function DaemonPanel({
open,
onOpenChange,
status,
runtimeCount,
}: DaemonPanelProps) {
const [logs, setLogs] = useState<ParsedLogLine[]>([]);
const [search, setSearch] = useState("");
// Each level chip is an independent toggle. DEBUG is off by default so
// poll-loop noise doesn't drown out real events when the panel opens —
// users opt in if they want to see it.
const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
() => new Set<LogLevel>(["INFO", "WARN", "ERROR"]),
);
export function DaemonPanel({ open, onOpenChange, status }: DaemonPanelProps) {
const [logs, setLogs] = useState<LogEntry[]>([]);
const [autoScroll, setAutoScroll] = useState(true);
const [expandedFields, setExpandedFields] = useState<Set<number>>(new Set());
const [expandedGroups, setExpandedGroups] = useState<Set<number>>(new Set());
const idCounterRef = useRef(0);
const [actionLoading, setActionLoading] = useState(false);
const logContainerRef = useRef<HTMLDivElement>(null);
// --- Log stream subscription ---
// Active only while the modal is open. On open we replay the file's tail
// (~200 lines) so users have context for "what just happened"; on close
// we tear down the watcher so the main process isn't doing work for a
// hidden UI.
useEffect(() => {
if (!open) return;
setLogs([]);
setExpandedFields(new Set());
setExpandedGroups(new Set());
idCounterRef.current = 0;
window.daemonAPI.startLogStream();
const unsub = window.daemonAPI.onLogLine((line) => {
setLogs((prev) => {
const id = ++idCounterRef.current;
const parsed = parseLogLine(line, id);
const next =
prev.length >= MAX_LOG_LINES
? [...prev.slice(prev.length - MAX_LOG_LINES + 1), parsed]
: [...prev, parsed];
return next;
const next = [...prev, { id: ++logIdCounter, line }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
return () => {
unsub();
window.daemonAPI.stopLogStream();
};
}, [open]);
// --- Derived: counts per level (for filter chip badges) ---
const levelCounts = useMemo(() => {
const counts: Record<LogLevel, number> = {
DEBUG: 0,
INFO: 0,
WARN: 0,
ERROR: 0,
};
for (const l of logs) {
if (l.level) counts[l.level] += 1;
}
return counts;
}, [logs]);
// --- Derived: filtered list (level toggle + search) ---
// Lines that didn't parse (level = null) always pass — they're typically
// panic stack traces / partial writes; never silently drop them.
const filtered = useMemo(() => {
let result = logs;
result = result.filter((l) => {
if (!l.level) return true;
return enabledLevels.has(l.level);
});
if (search) {
const q = search.toLowerCase();
result = result.filter((l) => l.raw.toLowerCase().includes(q));
}
return result;
}, [logs, enabledLevels, search]);
// --- Derived: collapse runs of consecutive lines that share the same
// message into a single group placeholder. The most common case is the
// 1-min `DBG poll: no tasks` heartbeat that otherwise pushes real events
// off-screen. Grouping happens AFTER filtering so toggling DEBUG off
// doesn't strand groups.
const displayed = useMemo<DisplayItem[]>(() => {
const out: DisplayItem[] = [];
for (const line of filtered) {
const last = out[out.length - 1];
if (!last) {
out.push({ kind: "line", line });
continue;
}
const lastMessage =
last.kind === "line" ? last.line.message : last.first.message;
if (lastMessage && lastMessage === line.message) {
if (last.kind === "line") {
out[out.length - 1] = {
kind: "group",
first: last.line,
rest: [line],
};
} else {
last.rest.push(line);
}
} else {
out.push({ kind: "line", line });
}
}
return out;
}, [filtered]);
// --- Auto-scroll: pin to bottom while live; release on user scroll ---
useEffect(() => {
if (!autoScroll) return;
const el = logContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [displayed, autoScroll]);
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [logs, autoScroll]);
const handleScroll = useCallback(() => {
const handleLogScroll = useCallback(() => {
const el = logContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
// Only flip auto-scroll OFF on user-initiated scroll-up; never flip ON
// here. Re-enabling lives in the "Jump to latest" footer button so a
// burst of lines doesn't yank a reading user back to the bottom.
if (!atBottom && autoScroll) setAutoScroll(false);
}, [autoScroll]);
const handleResume = useCallback(() => {
setAutoScroll(true);
const el = logContainerRef.current;
if (el) el.scrollTop = el.scrollHeight;
setAutoScroll(atBottom);
}, []);
const handleCopy = useCallback(async () => {
const text = filtered.map((l) => l.raw).join("\n");
if (await copyText(text)) {
toast.success(
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
);
} else {
toast.error("Failed to copy");
const scrollToBottom = useCallback(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
setAutoScroll(true);
}
}, [filtered]);
const handleClear = useCallback(() => {
setLogs([]);
setExpandedFields(new Set());
setExpandedGroups(new Set());
}, []);
const toggleLevel = useCallback((lv: LogLevel) => {
setEnabledLevels((prev) => {
const next = new Set(prev);
if (next.has(lv)) next.delete(lv);
else next.add(lv);
return next;
});
const handleStart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.start();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to start daemon", { description: result.error });
}
}, []);
const toggleFields = useCallback((id: number) => {
setExpandedFields((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to stop daemon", { description: result.error });
}
}, []);
const toggleGroup = useCallback((id: number) => {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
setActionLoading(false);
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
}
}, []);
const hasActiveFilter = !!search || enabledLevels.size < LEVELS.length;
const isTransitioning = status.state === "starting" || status.state === "stopping";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="flex h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-5xl"
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="flex flex-col sm:max-w-md"
showCloseButton={false}
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
{/* Header */}
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex min-w-0 items-center gap-2">
<Server className="size-4 shrink-0 text-muted-foreground" />
<DialogTitle className="text-sm font-medium">
Local daemon logs
</DialogTitle>
<ContextBadge status={status} runtimeCount={runtimeCount} />
</div>
<SheetHeader className="flex-row items-center justify-between gap-2 pr-3">
<SheetTitle className="flex items-center gap-2">
<Server className="size-4" />
Local Daemon
</SheetTitle>
<button
type="button"
onClick={() => onOpenChange(false)}
@@ -262,412 +153,157 @@ export function DaemonPanel({
>
<X className="size-4" />
</button>
</div>
</SheetHeader>
{/* Toolbar */}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-4 py-2">
{/* Search */}
<div className="relative w-56">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="h-7 w-full rounded-md border bg-background pl-7 pr-2 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
<div className="flex-1 min-h-0 flex flex-col gap-4 px-4">
<div className="shrink-0 space-y-4">
{/* Status info */}
<div className="rounded-lg border p-3 space-y-0.5">
<InfoRow
label="Status"
value={
<span className="flex items-center gap-1.5">
<StatusDot state={status.state} />
{DAEMON_STATE_LABELS[status.state]}
</span>
}
/>
</div>
{/* Level toggle chips. Each chip is independent — click to
show/hide that level. DEBUG starts hidden because the
poll-loop heartbeat dominates otherwise. */}
<div className="flex items-center gap-1">
{LEVELS.map((lv) => (
<FilterChip
key={lv}
active={enabledLevels.has(lv)}
onClick={() => toggleLevel(lv)}
label={lv}
count={levelCounts[lv]}
variant={lv}
{status.uptime && <InfoRow label="Uptime" value={status.uptime} />}
<InfoRow label="Profile" value={status.profile || "default"} />
{status.serverUrl && (
<InfoRow
label="Server"
value={
<span className="font-mono text-xs" title={status.serverUrl}>
{status.serverUrl}
</span>
}
/>
))}
)}
{status.agents && status.agents.length > 0 && (
<InfoRow label="Agents" value={status.agents.join(", ")} />
)}
{status.deviceName && <InfoRow label="Device" value={status.deviceName} />}
{status.daemonId && (
<InfoRow
label="Daemon ID"
value={<span className="font-mono text-xs">{status.daemonId}</span>}
/>
)}
{typeof status.workspaceCount === "number" && (
<InfoRow label="Workspaces" value={status.workspaceCount} />
)}
{status.pid && (
<InfoRow
label="PID"
value={<span className="font-mono text-xs">{status.pid}</span>}
/>
)}
</div>
{/* Right-aligned actions */}
<div className="ml-auto flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={handleCopy}
disabled={filtered.length === 0}
>
<CopyIcon className="size-3.5 mr-1.5" />
Copy
</Button>
<Button
variant="ghost"
size="sm"
className="h-7"
onClick={handleClear}
disabled={logs.length === 0}
>
<Trash2 className="size-3.5 mr-1.5" />
Clear
</Button>
</div>
</div>
{/* Logs viewport */}
<div
ref={logContainerRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-y-auto bg-muted/20 px-2 py-1 font-mono text-xs"
>
{displayed.length === 0 ? (
<EmptyState
hasLogs={logs.length > 0}
hasFilter={hasActiveFilter}
isRunning={status.state === "running"}
/>
{/* Actions */}
{status.state === "installing_cli" ? (
<div className="rounded-lg border border-dashed p-3 text-sm text-muted-foreground">
Setting up the local runtime this only happens the first time.
</div>
) : status.state === "cli_not_found" ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3 space-y-2">
<p className="text-sm">
Couldn&apos;t download the local runtime. Check your network
connection and try again.
</p>
<Button
size="sm"
variant="outline"
onClick={async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}}
disabled={actionLoading}
>
<RotateCw className="size-3.5 mr-1.5" />
Retry
</Button>
</div>
) : (
<div className="flex flex-col">
{displayed.map((item) =>
item.kind === "line" ? (
<LogLineRow
key={item.line.id}
line={item.line}
expanded={expandedFields.has(item.line.id)}
onToggle={() => toggleFields(item.line.id)}
search={search}
/>
) : (
<GroupRows
key={item.first.id}
first={item.first}
rest={item.rest}
expanded={expandedGroups.has(item.first.id)}
onToggle={() => toggleGroup(item.first.id)}
expandedFields={expandedFields}
onToggleFields={toggleFields}
search={search}
/>
),
<div className="flex gap-2">
{status.state === "stopped" ? (
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
<Play className="size-3.5 mr-1.5" />
Start
</Button>
) : (
<>
<Button
variant="outline"
size="sm"
onClick={handleStop}
disabled={actionLoading || isTransitioning}
>
<Square className="size-3.5 mr-1.5" />
Stop
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRestart}
disabled={actionLoading || isTransitioning}
>
<RotateCw className="size-3.5 mr-1.5" />
Restart
</Button>
</>
)}
</div>
)}
</div>
{/* Status bar — count only. The "is the user following" state is
communicated implicitly by the presence of the Jump-to-latest
button below; an explicit "Paused" word read as "log stream is
paused" (it isn't — data keeps flowing into the buffer). */}
<div className="flex shrink-0 items-center justify-between border-t bg-muted/30 px-4 py-1.5 text-xs text-muted-foreground">
<span className="tabular-nums">
Showing {filtered.length} of {logs.length}
{logs.length === MAX_LOG_LINES && (
<span className="ml-1 text-muted-foreground/60">
(buffer full)
</span>
)}
</span>
{!autoScroll && (
<button
type="button"
onClick={handleResume}
className="inline-flex items-center gap-1 rounded-md px-2 py-0.5 hover:bg-muted hover:text-foreground"
>
<ArrowDown className="size-3" />
Jump to latest
</button>
)}
</div>
</DialogContent>
</Dialog>
);
}
// ---------- Sub-components ----------
function ContextBadge({
status,
runtimeCount,
}: {
status: DaemonStatus;
runtimeCount: number;
}) {
const isRunning = status.state === "running";
return (
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
<span
className={cn(
"tabular-nums",
isRunning ? "text-foreground" : "text-muted-foreground",
)}
>
{DAEMON_STATE_LABELS[status.state]}
</span>
{isRunning && status.uptime && (
<span className="text-muted-foreground">
· {formatUptime(status.uptime)}
</span>
)}
{isRunning && runtimeCount > 0 && (
<span className="text-muted-foreground">
· {runtimeCount} runtime{runtimeCount === 1 ? "" : "s"}
</span>
)}
</span>
);
}
function FilterChip({
active,
onClick,
label,
count,
variant,
}: {
active: boolean;
onClick: () => void;
label: string;
count: number;
variant?: LogLevel;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-xs transition-colors hover:bg-accent",
active
? variant
? LEVEL_BADGE_CLASS[variant]
: "bg-accent text-accent-foreground"
: "border-dashed text-muted-foreground/50",
)}
>
{label}
<span
className={cn(
"tabular-nums",
active ? "text-current/80" : "text-muted-foreground/40",
)}
>
{count}
</span>
</button>
);
}
function LevelBadge({ level }: { level: LogLevel }) {
return (
<span
className={cn(
"inline-flex h-4 shrink-0 items-center rounded border px-1 text-[10px] font-medium uppercase tracking-wide",
LEVEL_BADGE_CLASS[level],
)}
>
{level}
</span>
);
}
function LogLineRow({
line,
expanded,
onToggle,
search,
}: {
line: ParsedLogLine;
expanded: boolean;
onToggle: () => void;
search: string;
}) {
const fieldEntries = Object.entries(line.fields);
const hasFields = fieldEntries.length > 0;
// Unparseable line — render the raw text so nothing is hidden. Common
// for panic stack traces and partial writes during log rotation.
if (!line.timestamp || !line.level) {
return (
<div className="break-all whitespace-pre-wrap px-2 py-0.5 text-muted-foreground/70">
{highlight(line.raw, search)}
</div>
);
}
return (
<div
className={cn(
"grid grid-cols-[auto_auto_minmax(0,1fr)] items-baseline gap-2 rounded px-2 py-0.5 hover:bg-accent/30",
hasFields && "cursor-pointer",
)}
onClick={hasFields ? onToggle : undefined}
>
<span className="shrink-0 tabular-nums text-muted-foreground/60">
{line.timestamp}
</span>
<LevelBadge level={line.level} />
<div className="min-w-0">
<div className="flex min-w-0 items-baseline gap-2">
<span className="break-words">{highlight(line.message, search)}</span>
{hasFields && !expanded && (
<span className="min-w-0 truncate text-muted-foreground/60">
{fieldEntries
.map(([k, v]) => `${k}=${truncateValue(v)}`)
.join(" ")}
</span>
)}
</div>
{expanded && hasFields && (
<div className="ml-1 mt-1 grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 text-muted-foreground">
{fieldEntries.map(([k, v]) => (
<Fragment key={k}>
<span className="text-muted-foreground/70">{k}</span>
<span className="break-all text-foreground/85">{v}</span>
</Fragment>
))}
</div>
)}
</div>
</div>
);
}
function GroupRows({
first,
rest,
expanded,
onToggle,
expandedFields,
onToggleFields,
search,
}: {
first: ParsedLogLine;
rest: ParsedLogLine[];
expanded: boolean;
onToggle: () => void;
expandedFields: Set<number>;
onToggleFields: (id: number) => void;
search: string;
}) {
// Folded: show the first occurrence so the user still sees a sample
// (timestamp, level, message), then a click-to-expand placeholder for
// the suppressed run. The placeholder uses a dashed border + italics
// so the eye reads it as "not a real line".
if (!expanded) {
return (
<>
<LogLineRow
line={first}
expanded={expandedFields.has(first.id)}
onToggle={() => onToggleFields(first.id)}
search={search}
/>
<button
type="button"
onClick={onToggle}
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 bg-muted/30 px-2 py-0.5 text-[11px] italic text-muted-foreground/70 hover:bg-muted/60 hover:text-foreground"
>
<span>···</span>
<span>
{rest.length} more &ldquo;{truncateValue(first.message, 48)}
&rdquo; click to expand
</span>
</button>
</>
);
}
// Unfolded: render every line, then a small "collapse" affordance at
// the end so the user can put the toothpaste back in the tube.
return (
<>
<LogLineRow
line={first}
expanded={expandedFields.has(first.id)}
onToggle={() => onToggleFields(first.id)}
search={search}
/>
{rest.map((l) => (
<LogLineRow
key={l.id}
line={l}
expanded={expandedFields.has(l.id)}
onToggle={() => onToggleFields(l.id)}
search={search}
/>
))}
<button
type="button"
onClick={onToggle}
className="my-0.5 ml-2 inline-flex w-fit items-center gap-2 rounded border border-dashed border-muted-foreground/25 px-2 py-0.5 text-[11px] italic text-muted-foreground/60 hover:text-foreground"
>
<span>···</span>
<span>collapse {rest.length + 1} repeated</span>
</button>
</>
);
}
function EmptyState({
hasLogs,
hasFilter,
isRunning,
}: {
hasLogs: boolean;
hasFilter: boolean;
isRunning: boolean;
}) {
let title: string;
let subtitle: string;
if (hasFilter) {
title = "No matching log lines";
subtitle = "Try a different search or level toggle.";
} else if (!isRunning) {
title = "Daemon isn't running";
subtitle = "Start the daemon to see logs here.";
} else if (!hasLogs) {
title = "Waiting for logs…";
subtitle = "New entries will appear in real time.";
} else {
title = "";
subtitle = "";
}
return (
<div className="flex h-full flex-col items-center justify-center gap-1 text-center text-muted-foreground/70">
<p className="text-sm">{title}</p>
<p className="text-xs text-muted-foreground/50">{subtitle}</p>
</div>
);
}
// ---------- Helpers ----------
function truncateValue(value: string, max = 32): string {
return value.length > max ? `${value.slice(0, max)}` : value;
}
function highlight(text: string, query: string): ReactNode {
if (!query) return text;
const q = query.toLowerCase();
const lower = text.toLowerCase();
const idx = lower.indexOf(q);
if (idx === -1) return text;
return (
<>
{text.slice(0, idx)}
<mark className="rounded bg-warning/30 px-0.5 text-foreground">
{text.slice(idx, idx + query.length)}
</mark>
{text.slice(idx + query.length)}
</>
{/* Logs — fills remaining vertical space down to the sheet bottom */}
<div className="flex-1 min-h-0 flex flex-col gap-2 pb-4">
<div className="flex items-center justify-between shrink-0">
<h3 className="text-sm font-medium">Logs</h3>
{!autoScroll && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={scrollToBottom}
>
<ChevronDown className="size-3 mr-1" />
Scroll to bottom
</Button>
)}
</div>
<div
ref={logContainerRef}
onScroll={handleLogScroll}
className="flex-1 min-h-0 overflow-y-auto rounded-lg border bg-muted/30 p-2 font-mono text-xs leading-relaxed"
>
{logs.length === 0 ? (
<p className="text-muted-foreground/50 text-center py-8">
{status.state === "running"
? "Waiting for logs…"
: "Start the daemon to see logs"}
</p>
) : (
logs.map((entry) => {
const { className } = colorizeLogLine(entry.line);
return (
<div key={entry.id} className={cn("whitespace-pre-wrap break-all", className)}>
{entry.line}
</div>
);
})
)}
</div>
</div>
</div>
</SheetContent>
</Sheet>
);
}

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

@@ -1,67 +1,22 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback } from "react";
import {
AlertCircle,
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} 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, 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.
*/
export function DaemonRuntimeActions() {
export function DaemonRuntimeCard() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [panelOpen, setPanelOpen] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const [confirmStop, setConfirmStop] = useState(false);
const wsId = useWorkspaceId();
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const localRuntimeIds = useMemo(() => {
if (!status.daemonId) return new Set<string>();
return new Set(
runtimes
.filter((r) => r.daemon_id === status.daemonId)
.map((r) => r.id),
);
}, [runtimes, status.daemonId]);
const runtimeCount = localRuntimeIds.size;
const affectedTasks = useMemo(
() =>
snapshot.filter(
(t) =>
localRuntimeIds.has(t.runtime_id) &&
(t.status === "running" || t.status === "dispatched"),
),
[snapshot, localRuntimeIds],
);
useEffect(() => {
window.daemonAPI.getStatus().then((s) => setStatus(s));
@@ -81,7 +36,7 @@ export function DaemonRuntimeActions() {
}
}, []);
const performStop = useCallback(async () => {
const handleStop = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.stop();
if (!result.success) {
@@ -89,76 +44,84 @@ export function DaemonRuntimeActions() {
}
}, []);
const handleStopClick = useCallback(() => {
if (affectedTasks.length === 0) {
void performStop();
} else {
setConfirmStop(true);
}
}, [affectedTasks.length, performStop]);
const handleRestart = useCallback(async () => {
setActionLoading(true);
const result = await window.daemonAPI.restart();
if (!result.success) {
toast.error("Failed to restart daemon", { description: result.error });
return;
}
toast.success("Restarting daemon", {
description: "Runtimes will be back online in a few seconds.",
});
}, []);
const handleRetryInstall = useCallback(async () => {
setActionLoading(true);
try {
await window.daemonAPI.retryInstall();
} finally {
setActionLoading(false);
}
}, []);
const 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 isTransitioning = status.state === "starting" || status.state === "stopping";
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";
const isStopped = status.state === "stopped" || status.state === "cli_not_found";
const stopPropagation = (e: React.MouseEvent) => e.stopPropagation();
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
</span>
) : (
<div
role="button"
tabIndex={0}
onClick={() => setPanelOpen(true)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setPanelOpen(true);
}
}}
className="border-b px-4 py-3 cursor-pointer transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:bg-muted/40"
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-lg bg-muted">
<Server className="size-4 text-muted-foreground" />
</div>
<div>
<h3 className="text-sm font-medium">Local Daemon</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={cn("size-1.5 rounded-full", DAEMON_STATE_COLORS[status.state])} />
<span className="text-xs text-muted-foreground">{DAEMON_STATE_LABELS[status.state]}</span>
{isRunning && status.uptime && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{formatUptime(status.uptime)}</span>
</>
)}
{isRunning && status.agents && status.agents.length > 0 && (
<>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{status.agents.join(", ")}</span>
</>
)}
</div>
</div>
</div>
<div
className="flex items-center gap-1.5 shrink-0"
onClick={stopPropagation}
>
{isStopped && (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={actionLoading || status.state === "cli_not_found"}
>
{actionLoading ? (
<Activity className="size-3.5 mr-1.5 animate-pulse" />
) : (
<Play className="size-3.5 mr-1.5" />
)}
Start
</Button>
)}
{isRunning && (
<>
<Button
size="sm"
variant="outline"
variant="ghost"
onClick={handleRestart}
disabled={actionLoading}
>
@@ -167,8 +130,8 @@ export function DaemonRuntimeActions() {
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStopClick}
variant="outline"
onClick={handleStop}
disabled={actionLoading}
>
<Square className="size-3.5 mr-1.5" />
@@ -176,121 +139,17 @@ export function DaemonRuntimeActions() {
</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 ? (
{isTransitioning && (
<Button size="sm" variant="outline" disabled>
<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>
)}
{DAEMON_STATE_LABELS[status.state]}
</Button>
)}
</div>
</div>
</div>
<DaemonPanel
open={panelOpen}
onOpenChange={setPanelOpen}
status={status}
runtimeCount={runtimeCount}
/>
<StopConfirmDialog
open={confirmStop}
onOpenChange={setConfirmStop}
affectedCount={affectedTasks.length}
onConfirm={() => {
setConfirmStop(false);
void performStop();
}}
/>
<DaemonPanel open={panelOpen} onOpenChange={setPanelOpen} status={status} />
</>
);
}
// ---------- Sub-components ----------
function StopConfirmDialog({
open,
onOpenChange,
affectedCount,
onConfirm,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
affectedCount: number;
onConfirm: () => void;
}) {
const plural = affectedCount === 1 ? "" : "s";
const verb = affectedCount === 1 ? "is" : "are";
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm" showCloseButton={false}>
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-destructive/10">
<AlertCircle className="h-5 w-5 text-destructive" />
</div>
<DialogHeader className="flex-1 gap-1">
<DialogTitle className="text-sm font-semibold">
Stop daemon with {affectedCount} active task{plural}?
</DialogTitle>
<DialogDescription className="text-xs leading-relaxed">
{affectedCount} task{plural} {verb} currently running on this
device. Stopping now will interrupt {affectedCount === 1 ? "it" : "them"}{" "}
affected tasks get marked <strong>failed</strong> once the
timeout hits. The daemon won&apos;t auto-restart.
</DialogDescription>
</DialogHeader>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="destructive" onClick={onConfirm}>
Stop daemon
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,15 +1,7 @@
import { useState, useEffect, useCallback, type ReactNode } from "react";
import { AlertCircle, Info, LogIn } from "lucide-react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { reauthenticateDaemon } from "../platform/daemon-reauth";
import type { DaemonPrefs, DaemonStatus } from "../../../shared/daemon-types";
import {
DAEMON_STATE_COLORS,
DAEMON_STATE_LABELS,
formatUptime,
} from "../../../shared/daemon-types";
import type { DaemonPrefs } from "../../../shared/daemon-types";
function SettingRow({
label,
@@ -18,7 +10,7 @@ function SettingRow({
}: {
label: string;
description: string;
children: ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex items-center justify-between gap-6 py-4">
@@ -31,51 +23,14 @@ function SettingRow({
);
}
// One row inside the diagnostics block. Values that are likely to be
// long IDs / URLs render as monospaced + truncated with a tooltip.
function DiagnosticsRow({
label,
value,
mono,
}: {
label: string;
value: ReactNode;
mono?: boolean;
}) {
return (
<div className="grid grid-cols-[140px_minmax(0,1fr)] items-baseline gap-3 py-1.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span
className={cn(
"min-w-0 truncate text-sm",
mono && "font-mono text-xs",
)}
title={typeof value === "string" ? value : undefined}
>
{value}
</span>
</div>
);
}
export function DaemonSettingsTab() {
const [prefs, setPrefs] = useState<DaemonPrefs>({ autoStart: true, autoStop: false });
const [cliInstalled, setCliInstalled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
const [reauthLoading, setReauthLoading] = useState(false);
useEffect(() => {
window.daemonAPI.getPrefs().then(setPrefs);
window.daemonAPI.isCliInstalled().then(setCliInstalled);
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const handleReauth = useCallback(async () => {
setReauthLoading(true);
await reauthenticateDaemon();
setReauthLoading(false);
}, []);
const updatePref = useCallback(
@@ -88,12 +43,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 +50,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 +58,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStart}
onCheckedChange={(checked) => updatePref("autoStart", checked)}
disabled={saving || externallyManaged}
disabled={saving}
/>
</SettingRow>
@@ -157,7 +69,7 @@ export function DaemonSettingsTab() {
<Switch
checked={prefs.autoStop}
onCheckedChange={(checked) => updatePref("autoStop", checked)}
disabled={saving || externallyManaged}
disabled={saving}
/>
</SettingRow>
@@ -186,68 +98,6 @@ export function DaemonSettingsTab() {
)}
</div>
</div>
{/* Diagnostics — moved out of the logs panel so the panel can focus
on logs. These fields matter for support tickets and bug reports,
not for everyday use. */}
<div className="mt-8">
<h3 className="text-sm font-semibold">Diagnostics</h3>
<p className="text-xs text-muted-foreground mt-1">
Identification and connection details. Useful when filing a bug
report or investigating why a runtime isn&apos;t showing up.
</p>
<div className="mt-3 rounded-lg border bg-muted/20 px-4 py-2">
<DiagnosticsRow
label="State"
value={
<span className="inline-flex items-center gap-1.5">
<span
className={cn(
"size-1.5 rounded-full",
DAEMON_STATE_COLORS[status.state],
)}
/>
{DAEMON_STATE_LABELS[status.state]}
</span>
}
/>
<DiagnosticsRow
label="Uptime"
value={status.uptime ? formatUptime(status.uptime) : "—"}
/>
<DiagnosticsRow
label="PID"
value={status.pid ?? "—"}
mono={!!status.pid}
/>
<DiagnosticsRow
label="Daemon ID"
value={status.daemonId ?? "—"}
mono={!!status.daemonId}
/>
<DiagnosticsRow
label="Profile"
value={status.profile || "default"}
/>
<DiagnosticsRow
label="Server URL"
value={status.serverUrl ?? "—"}
mono={!!status.serverUrl}
/>
<DiagnosticsRow
label="Device name"
value={status.deviceName ?? "—"}
/>
<DiagnosticsRow
label="Workspaces"
value={
typeof status.workspaceCount === "number"
? status.workspaceCount
: "—"
}
/>
</div>
</div>
</div>
);
}

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,14 +13,11 @@ 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 { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { useNavigation } from "@multica/views/navigation";
import { WorkspaceSlugProvider } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
import { DesktopNavigationProvider } from "@/platform/navigation";
import { TabBar } from "./tab-bar";
import { TabContent } from "./tab-content";
import { WindowOverlay } from "./window-overlay";
function SidebarTopBar() {
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
@@ -35,7 +32,6 @@ function SidebarTopBar() {
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
>
<button
type="button"
onClick={goBack}
disabled={!canGoBack}
aria-label="Go back"
@@ -44,7 +40,6 @@ function SidebarTopBar() {
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={goForward}
disabled={!canGoForward}
aria-label="Go forward"
@@ -57,20 +52,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
@@ -114,54 +95,9 @@ function useInternalLinkHandler() {
}, []);
}
/**
* Bridge between the renderer and the Electron main process for inbox-level
* OS integration. Mounted inside WorkspaceSlugProvider so it can resolve the
* current workspace's id for the badge hook.
*
* Two responsibilities:
* 1. Mirror the unread inbox count onto the dock/taskbar badge.
* 2. When the user clicks an OS notification, open the notified
* workspace's inbox focused on that item. The route uses the `slug`
* that the notification was *emitted* with — not the currently active
* workspace — so a notification from workspace A always opens A's
* inbox even if the user has since switched to workspace B. Marking
* the row read is handled by InboxPage's selected-item effect, which
* covers both click-to-select and URL-param-select paths.
*
* 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);
});
}, []);
return null;
}
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
@@ -177,10 +113,8 @@ export function DesktopShell() {
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
to populate the slug. The sidebar gates on slug being present
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
users see the window-level overlay (new-workspace flow)
triggered by IndexRedirect, not a route. */}
users are routed to /workspaces/new by IndexRedirect. */}
<WorkspaceSlugProvider slug={slug}>
<DesktopInboxBridge />
<div className="flex h-screen">
<SidebarProvider className="flex-1">
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
@@ -198,7 +132,6 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>
);

View File

@@ -1,70 +0,0 @@
import { useEffect, useState } from "react";
import { RuntimesPage } from "@multica/views/runtimes";
import { DaemonRuntimeActions } from "./daemon-runtime-card";
import type { DaemonStatus } from "../../../shared/daemon-types";
/**
* Desktop wrapper around the shared `RuntimesPage`. Bridges the Electron
* `daemonAPI` (main-process daemon state) into the page so its empty
* state can distinguish "no runtime registered" from "runtime is on its
* way" — without the bundled daemon's status, the page shows a
* misleading "Run multica daemon start" hint during the few seconds
* between page load and the daemon's first registration.
*
* `bootstrapping` is true while the daemon is installing, starting, or
* already running but hasn't surfaced as a server-side runtime yet.
* RuntimeList only shows the spinner when the runtime list is also
* empty, so once the daemon registers (and the list fills) the flag
* has no visible effect.
*/
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);
}, []);
const bootstrapping =
status.state === "installing_cli" ||
status.state === "starting" ||
status.state === "running";
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
bootstrapping={bootstrapping}
/>
);
}

View File

@@ -1,247 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render } from "@testing-library/react";
// vi.hoisted shared state — every store mock reads the same object so each
// test can mutate it then re-render to drive the tracker.
const state = vi.hoisted(() => ({
user: null as { id: string } | null,
overlay: null as { type: string; invitationId?: string } | null,
activeWorkspaceSlug: null as string | null,
byWorkspace: {} as Record<
string,
{ activeTabId: string; tabs: { id: string; path: string }[] }
>,
capturePageview: vi.fn<(path?: string) => void>(),
}));
vi.mock("@multica/core/analytics", () => ({
capturePageview: state.capturePageview,
}));
// Auth store — single selector pattern (`s => s.user`).
vi.mock("@multica/core/auth", () => {
const useAuthStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useAuthStore };
});
// Window overlay store — same shape.
vi.mock("@/stores/window-overlay-store", () => {
const useWindowOverlayStore = (selector: (s: typeof state) => unknown) =>
selector(state);
return { useWindowOverlayStore };
});
// Tab store — selectors read activeWorkspaceSlug + byWorkspace. Also expose
// getState() for the seed pass and the helpers the tracker imports
// (useActiveTabIdentity, getActiveTab) so we don't have to re-import them
// from the real store inside a mocked module.
vi.mock("@/stores/tab-store", () => {
const useTabStore = Object.assign(
(selector: (s: typeof state) => unknown) => selector(state),
{ getState: () => state },
);
const getActiveTab = (s: typeof state) => {
const slug = s.activeWorkspaceSlug;
if (!slug) return null;
const group = s.byWorkspace[slug];
if (!group) return null;
return group.tabs.find((t) => t.id === group.activeTabId) ?? null;
};
const useActiveTabIdentity = () => ({
slug: state.activeWorkspaceSlug,
tabId: state.activeWorkspaceSlug
? (state.byWorkspace[state.activeWorkspaceSlug]?.activeTabId ?? null)
: null,
});
return { useTabStore, getActiveTab, useActiveTabIdentity };
});
import { PageviewTracker } from "./pageview-tracker";
function reset() {
state.user = { id: "u1" };
state.overlay = null;
state.activeWorkspaceSlug = null;
state.byWorkspace = {};
state.capturePageview.mockClear();
}
beforeEach(() => {
reset();
});
describe("PageviewTracker", () => {
it("suppresses pageview when switching to a previously-visible tab on its existing path", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
// Initial mount on tA — seeded as observed, no pageview because both
// tabs were already in the persisted store before the tracker mounted.
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch to tB (already-known tab on its already-known path).
state.byWorkspace = {
acme: {
activeTabId: "tB",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Switch back to tA — still no pageview.
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tB", path: "/acme/inbox" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
});
it("fires pageview when a foreground tab is added (addTab path)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
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.
state.byWorkspace = {
acme: {
activeTabId: "tC",
tabs: [
{ id: "tA", path: "/acme/issues" },
{ id: "tC", path: "/acme/agents" },
],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/agents");
});
it("fires pageview when switchWorkspace opens a new path in another workspace", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Cross-workspace navigation: switchWorkspace("butter", "/butter/inbox")
// creates a fresh tab in the destination workspace and makes it active.
state.byWorkspace = {
acme: { activeTabId: "tA", tabs: [{ id: "tA", path: "/acme/issues" }] },
butter: {
activeTabId: "tD",
tabs: [{ id: "tD", path: "/butter/inbox" }],
},
};
state.activeWorkspaceSlug = "butter";
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/butter/inbox");
});
it("fires pageview on intra-tab navigation (path changes for the same tabId)", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues/123" }],
},
};
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenCalledTimes(1);
expect(state.capturePageview).toHaveBeenCalledWith("/acme/issues/123");
});
it("fires overlay and login pageviews and suppresses re-entry into the same tab afterward", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
const { rerender } = render(<PageviewTracker />);
state.capturePageview.mockClear();
// Open onboarding overlay.
state.overlay = { type: "onboarding" };
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/onboarding");
// Close overlay back to the tab — the tab is already observed on
// /acme/issues so this is a re-activation, no pageview.
state.capturePageview.mockClear();
state.overlay = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).not.toHaveBeenCalled();
// Logout fires /login.
state.user = null;
rerender(<PageviewTracker />);
expect(state.capturePageview).toHaveBeenLastCalledWith("/login");
});
it("suppresses on initial mount when the active tab was restored from persistence", () => {
state.byWorkspace = {
acme: {
activeTabId: "tA",
tabs: [{ id: "tA", path: "/acme/issues" }],
},
};
state.activeWorkspaceSlug = "acme";
render(<PageviewTracker />);
// Restored tab — seeded, treated as a re-activation.
expect(state.capturePageview).not.toHaveBeenCalled();
});
});

View File

@@ -1,144 +0,0 @@
import { useEffect, useRef } from "react";
import { capturePageview } from "@multica/core/analytics";
import { useAuthStore } from "@multica/core/auth";
import {
getActiveTab,
useActiveTabIdentity,
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,
* EXCEPT for re-activations of an already-known tab on its already-known
* path.
*
* Desktop has three layers that can own the visible page:
*
* 1. Logged-out state → `/login`. No workspace context, no tabs.
* 2. Window overlays (onboarding, new-workspace, invite) → synthetic paths
* that match the equivalent web routes. Overlays are NOT tab routes on
* desktop (see `stores/window-overlay-store.ts` + `routes.tsx`), so the
* tab path alone would either miss them or mislabel them as "/".
* 3. Otherwise → the active tab's path (workspace-scoped, e.g.
* `/acme/issues/123`). Kept in sync by `useTabRouterSync`.
*
* Tab-switch suppression: re-activating an already-open tab surfaces a
* previously-visited path under a `(workspace, tabId)` we have already
* seen — the pageview was emitted when the user originally navigated
* there, so re-emitting on every switch just inflates PostHog billing
* without adding signal (real-data audit: desktop tab switches were
* ~50% of all `$pageview` events).
*
* Newly opened tabs (`openInNewTab`, `addTab`) and cross-workspace
* `switchWorkspace(slug, path)` to a previously-unseen tab still fire,
* because their key is not in the observed map yet. The map is seeded
* from the persisted tab store on first render so tabs restored from a
* previous session don't all re-emit on first activation.
*
* PostHog's `capture_pageview: true` auto-capture is intentionally off (see
* `initAnalytics`) so this component owns the event shape, matching the web
* implementation in `apps/web/components/pageview-tracker.tsx`.
*/
export function PageviewTracker() {
const user = useAuthStore((s) => s.user);
const overlay = useWindowOverlayStore((s) => s.overlay);
const { slug: activeWorkspaceSlug, tabId: activeTabId } = useActiveTabIdentity();
const activeTabPath = useTabStore((s) => getActiveTab(s)?.path ?? null);
// (slug:tabId) → last path observed while that tab was visible. Lets us
// tell "re-activating a tab on a path we already saw" (suppress) apart
// from "newly opened tab" or "intra-tab navigation" (fire). Seeded
// synchronously on first render from the persisted tab store so
// session-restored tabs don't re-emit on first click.
const observedTabsRef = useRef<Map<string, string> | null>(null);
if (observedTabsRef.current === null) {
const seed = new Map<string, string>();
for (const [slug, group] of Object.entries(useTabStore.getState().byWorkspace)) {
for (const tab of group.tabs) {
seed.set(`${slug}:${tab.id}`, tab.path);
}
}
observedTabsRef.current = seed;
}
const lastSurfaceRef = useRef<{
kind: "login" | "overlay" | "tab" | null;
key: string | null;
path: string | null;
}>({ kind: null, key: null, path: null });
useEffect(() => {
let kind: "login" | "overlay" | "tab";
let path: string;
let key: string | null = null;
if (!user) {
kind = "login";
path = "/login";
} else if (overlay) {
kind = "overlay";
path = overlayPath(overlay);
} else if (activeTabPath && activeTabId && activeWorkspaceSlug) {
kind = "tab";
key = `${activeWorkspaceSlug}:${activeTabId}`;
path = activeTabPath;
} else {
return;
}
const observed = observedTabsRef.current!;
const last = lastSurfaceRef.current;
const next = { kind, key, path };
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 =
last.key !== key && knownPath !== undefined && knownPath === path;
observed.set(key, path);
if (isReactivation) {
lastSurfaceRef.current = next;
return;
}
}
const unchanged =
last.kind === kind && last.key === key && last.path === path;
if (unchanged) return;
capturePageview(path);
lastSurfaceRef.current = next;
}, [user, overlay, activeWorkspaceSlug, activeTabId, activeTabPath]);
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":
return "/workspaces/new";
case "onboarding":
return "/onboarding";
case "invite":
return `/invite/${overlay.invitationId}`;
case "invitations":
return "/invitations";
}
}

View File

@@ -1,124 +0,0 @@
import { describe, it, expect } from "vitest";
import { parseLogLine } from "./parse-daemon-log";
// All sample lines below are taken verbatim from real daemon output (Go
// `slog` + `lmittmann/tint` v1.1.3 with NoColor=true). The parser must
// stay aligned with what tint actually writes — not what we assume.
describe("parseLogLine", () => {
it("parses tint's 3-letter INF level", () => {
const line =
"17:52:35.587 INF task completed component=daemon task=c45266e5 status=completed";
const r = parseLogLine(line, 1);
expect(r.timestamp).toBe("17:52:35.587");
expect(r.level).toBe("INFO");
expect(r.message).toBe("task completed");
expect(r.fields).toEqual({
component: "daemon",
task: "c45266e5",
status: "completed",
});
});
it("parses 3-letter DBG / WRN / ERR levels", () => {
expect(parseLogLine("17:53:06.644 DBG agent component=daemon", 1).level).toBe("DEBUG");
expect(parseLogLine("07:48:09.391 WRN claim task failed component=daemon", 1).level).toBe("WARN");
expect(parseLogLine("12:00:00.000 ERR something bad component=daemon", 1).level).toBe("ERROR");
});
it("still accepts 4-letter level names (defensive against config changes)", () => {
const r = parseLogLine("12:00:00.000 INFO regular component=daemon", 1);
expect(r.level).toBe("INFO");
expect(r.message).toBe("regular");
});
it("tolerates the +N / -N delta tint appends for non-standard slog levels", () => {
// tint emits e.g. "INF+1" when slog.Log is called with LevelInfo+1.
// We treat the base level as canonical and drop the delta from the UI.
const r = parseLogLine("12:00:00.000 INF+1 unusual delta component=daemon", 1);
expect(r.level).toBe("INFO");
expect(r.message).toBe("unusual delta");
});
it("preserves message text containing colons and special chars", () => {
// Real sample: "tool #1: Skill component=daemon task=..."
const r = parseLogLine(
"17:52:54.578 INF tool #1: Skill component=daemon task=8791b717",
1,
);
expect(r.message).toBe("tool #1: Skill");
expect(r.fields).toEqual({ component: "daemon", task: "8791b717" });
});
it("unquotes a double-quoted value containing escaped quotes", () => {
// Real sample with escaped quotes inside the agent's emitted text.
const line =
'17:53:06.644 DBG agent component=daemon task=8791b717 text="The issue is just \\"ping\\" with no description."';
const r = parseLogLine(line, 1);
expect(r.message).toBe("agent");
expect(r.fields.text).toBe('The issue is just "ping" with no description.');
expect(r.fields.task).toBe("8791b717");
});
it("handles a quoted value containing a URL with embedded escaped quotes and a colon", () => {
// Real sample: error="Post \"http://...\": dial tcp ..."
const line =
'07:48:09.391 WRN claim task failed component=daemon runtime_id=03f8ff17-276d error="Post \\"http://localhost:8080/api/daemon/runtimes/abc/tasks/claim\\": dial tcp [::1]:8080: connect: connection refused"';
const r = parseLogLine(line, 1);
expect(r.level).toBe("WARN");
expect(r.message).toBe("claim task failed");
expect(r.fields.runtime_id).toBe("03f8ff17-276d");
expect(r.fields.error).toBe(
'Post "http://localhost:8080/api/daemon/runtimes/abc/tasks/claim": dial tcp [::1]:8080: connect: connection refused',
);
});
it("handles a quoted value with internal whitespace (e.g. args array)", () => {
const line =
'17:52:48.757 INF agent command component=daemon exec=claude args="[-p --output-format stream-json --verbose]"';
const r = parseLogLine(line, 1);
expect(r.message).toBe("agent command");
expect(r.fields.exec).toBe("claude");
expect(r.fields.args).toBe("[-p --output-format stream-json --verbose]");
});
it("handles message words ending with characters before the field block", () => {
// 'execenv:' is part of the message — the colon shouldn't confuse parsing.
const r = parseLogLine(
"17:52:48.757 INF execenv: prepared env component=daemon repos_available=0",
1,
);
expect(r.message).toBe("execenv: prepared env");
expect(r.fields).toEqual({ component: "daemon", repos_available: "0" });
});
it("falls back to raw rendering for non-matching lines (panic stack frame)", () => {
const r = parseLogLine("\tat github.com/multica/foo (line 42)", 1);
expect(r.timestamp).toBeNull();
expect(r.level).toBeNull();
expect(r.message).toBe("\tat github.com/multica/foo (line 42)");
expect(r.fields).toEqual({});
expect(r.raw).toBe("\tat github.com/multica/foo (line 42)");
});
it("falls back to raw rendering for unrecognised level tokens", () => {
// If tint ever emits something we don't know, never crash; show raw.
const r = parseLogLine("12:00:00.000 TRACE something exotic", 1);
expect(r.timestamp).toBeNull();
expect(r.level).toBeNull();
expect(r.raw).toBe("12:00:00.000 TRACE something exotic");
});
it("attaches an id to every parsed line for stable React keys", () => {
const a = parseLogLine("17:52:35.587 INF first component=daemon", 7);
const b = parseLogLine("17:52:35.588 INF second component=daemon", 8);
expect(a.id).toBe(7);
expect(b.id).toBe(8);
});
it("returns empty fields object when there are no key=value pairs", () => {
const r = parseLogLine("17:52:35.587 INF a bare message with no fields", 1);
expect(r.message).toBe("a bare message with no fields");
expect(r.fields).toEqual({});
});
});

View File

@@ -1,96 +0,0 @@
// Pure parser for daemon log lines. The daemon writes via Go's slog with
// the `tint` handler in NoColor mode (the file isn't a TTY), so each line
// has a stable shape:
//
// HH:MM:SS.mmm LEVEL message text key=value key2="quoted value"
//
// We split it into structured pieces so the UI can render timestamp,
// level, message and structured fields in separate columns and let users
// filter / search across them. Anything that doesn't match (panic stack
// traces, third-party prints, partial writes during log rotation) falls
// back to a raw view — we never drop input.
export type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
export interface ParsedLogLine {
/** Monotonic id assigned at receive time; stable across re-renders. */
id: number;
/** "HH:MM:SS.mmm" or null when the line didn't match the standard shape. */
timestamp: string | null;
level: LogLevel | null;
/** Human-readable message body, with structured fields stripped off. */
message: string;
/** key/value pairs trailing the message. Empty if there were none. */
fields: Record<string, string>;
/** The original line, kept for fallback rendering and copy-to-clipboard. */
raw: string;
}
// `tint` v1.x emits the 3-letter short form (DBG / INF / WRN / ERR) and,
// for non-standard slog levels, appends a signed delta (e.g. "INF+1",
// "DBG-2"). We accept both the short and 4-letter long forms (defensive
// against future config changes) and normalize them to a canonical
// 4-letter LogLevel. The optional `[+-]\d+` suffix is captured into the
// regex and discarded — surfacing `INF+1` to the UI doesn't help users
// and complicates the level filter chips.
const HEADER_RE =
/^(\d{2}:\d{2}:\d{2}\.\d{3})\s+(DEBUG|DBG|INFO|INF|WARN|WRN|ERROR|ERR)(?:[+-]\d+)?\s+(.+)$/;
const LEVEL_NORMALIZE: Record<string, LogLevel> = {
DEBUG: "DEBUG",
DBG: "DEBUG",
INFO: "INFO",
INF: "INFO",
WARN: "WARN",
WRN: "WARN",
ERROR: "ERROR",
ERR: "ERROR",
};
// Anchored to the END of the remaining string so we peel one field at a
// time from the right. `value` is either a double-quoted string (which may
// contain escaped chars) or any non-whitespace run.
const TRAILING_FIELD_RE = /\s+([a-zA-Z_][a-zA-Z0-9_.]*)=("(?:[^"\\]|\\.)*"|\S+)$/;
function unquote(value: string): string {
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
return value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
}
return value;
}
function extractTrailingFields(rest: string): {
message: string;
fields: Record<string, string>;
} {
const fields: Record<string, string> = {};
let work = rest;
while (true) {
const match = work.match(TRAILING_FIELD_RE);
if (!match || match.index === undefined) break;
fields[match[1]!] = unquote(match[2]!);
work = work.slice(0, match.index);
}
return { message: work.trim(), fields };
}
export function parseLogLine(raw: string, id: number): ParsedLogLine {
const match = raw.match(HEADER_RE);
if (!match) {
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
}
const [, timestamp, level, rest] = match;
const normalized = LEVEL_NORMALIZE[level!];
if (!normalized) {
// Unknown level token — keep raw shape so we don't mis-categorize.
return { id, timestamp: null, level: null, message: raw, fields: {}, raw };
}
const { message, fields } = extractTrailingFields(rest!);
return {
id,
timestamp: timestamp!,
level: normalized,
message,
fields,
raw,
};
}

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,21 +28,9 @@ 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 { paths } from "@multica/core/paths";
import { useTabStore, resolveRouteIcon, type Tab } from "@/stores/tab-store";
import { isGlobalPath, paths } from "@multica/core/paths";
const TAB_ICONS: Record<string, LucideIcon> = {
Inbox,
@@ -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),
@@ -100,38 +67,27 @@ function SortableTabItem({
const handleClick = () => {
if (isActive) return;
setActiveTab(tab.id);
// No navigate() — Activity handles visibility
};
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation();
closeTab(tab.id);
// No navigate() — store handles activeTabId switch
};
const handleTogglePin = (e: React.MouseEvent) => {
e.stopPropagation();
togglePin(tab.id);
};
const stopDragOnAction = (e: React.PointerEvent) => {
// Stop pointer down on close so it doesn't start a drag on the parent button.
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 +97,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 +107,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 +118,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() {
@@ -211,18 +125,26 @@ function NewTabButton() {
const setActiveTab = useTabStore((s) => s.setActiveTab);
const handleClick = () => {
// New tab opens in the currently active workspace — tabs are scoped
// per workspace, so there is no cross-workspace ambiguity to resolve.
const activeSlug = useTabStore.getState().activeWorkspaceSlug;
if (!activeSlug) return;
const path = paths.workspace(activeSlug).issues();
// Inherit the active tab's workspace. Terminal/IDE convention: new tab
// opens in the same context as the active one. Read the slug from the
// active tab's path directly rather than from getCurrentSlug(), because
// that singleton is "last tab to render" (non-deterministic with N tabs
// mounted under <Activity>), while activeTabId is the unambiguous truth.
// Falls back to "/" (→ IndexRedirect → first workspace) when the active
// tab is on a global route (e.g. /workspaces/new, /login).
const { tabs, activeTabId } = useTabStore.getState();
const activePath = tabs.find((t) => t.id === activeTabId)?.path ?? "/";
let slug: string | null = null;
if (activePath !== "/" && !isGlobalPath(activePath)) {
slug = activePath.split("/").filter(Boolean)[0] ?? null;
}
const path = slug ? paths.workspace(slug).issues() : "/";
const tabId = addTab(path, "Issues", resolveRouteIcon(path));
if (tabId) setActiveTab(tabId);
setActiveTab(tabId);
};
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"
@@ -233,29 +155,24 @@ function NewTabButton() {
}
export function TabBar() {
const group = useActiveGroup();
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
const moveTab = useTabStore((s) => s.moveTab);
// distance: 5 — pointer must move 5px to start a drag, otherwise it's a click.
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
);
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,26 +185,17 @@ 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>
{group && <NewTabButton />}
<NewTabButton />
</div>
);
}

View File

@@ -1,78 +1,41 @@
import { Activity, useEffect } from "react";
import { RouterProvider } from "react-router-dom";
import { useActiveGroup } from "@/stores/tab-store";
import { useTabStore } 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";
/**
* Inner wrapper rendered inside each tab's RouterProvider. The router
* reference is stable for a tab's lifetime, so passing it in directly
* (instead of re-deriving from the store) avoids needless re-renders.
*/
function TabRouterInner({ tab }: { tab: Tab }) {
useTabRouterSync(tab.id, tab.router);
/** Inner wrapper rendered inside each tab's RouterProvider. */
function TabRouterInner({ tabId }: { tabId: string }) {
const tab = useTabStore((s) => s.tabs.find((t) => t.id === tabId));
useTabRouterSync(tabId, tab!.router);
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.
* Renders all tabs using Activity for state preservation.
* Only the active tab is visible; hidden tabs keep their DOM and React state.
*
* When switching workspaces, the previous workspace's tabs unmount entirely
* and the new workspace's tabs mount fresh — cross-workspace state
* preservation is an explicit non-goal (keeping all workspaces' tabs warm
* simultaneously would bloat memory and make workspace switching feel
* anything but "switching").
*/
export function TabContent() {
const group = useActiveGroup();
const tabs = useTabStore((s) => s.tabs);
const activeTabId = useTabStore((s) => s.activeTabId);
// Sync document.title when switching tabs within the active workspace.
// Sync document.title when switching tabs
useEffect(() => {
if (!group) return;
const tab = group.tabs.find((t) => t.id === group.activeTabId);
const tab = tabs.find((t) => t.id === activeTabId);
if (tab) document.title = tab.title;
}, [group?.activeTabId, group?.tabs]);
if (!group) return null;
}, [activeTabId, tabs]);
return (
<>
{group.tabs.map((tab) => (
{tabs.map((tab) => (
<Activity
key={tab.id}
mode={tab.id === group.activeTabId ? "visible" : "hidden"}
mode={tab.id === 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 tabId={tab.id} />
</TabNavigationProvider>
</Activity>
))}
</>

View File

@@ -1,65 +1,124 @@
import { useEffect, useState } from "react";
import { RefreshCw, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
// Downloads run silently in the background (main process has
// autoDownload=true). The renderer only renders UI once the package is fully
// downloaded and waiting for a restart.
type UpdateState =
| { status: "idle" }
| { status: "ready"; version: string };
| { status: "available"; version: string }
| { status: "downloading"; percent: number }
| { status: "ready" };
export function UpdateNotification() {
const [state, setState] = useState<UpdateState>({ status: "idle" });
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const cleanup = window.updater.onUpdateDownloaded((info) => {
setState({ status: "ready", version: info.version });
setDismissed(false);
});
return cleanup;
const cleanups: (() => void)[] = [];
cleanups.push(
window.updater.onUpdateAvailable((info) => {
setState({ status: "available", version: info.version });
setDismissed(false);
}),
);
cleanups.push(
window.updater.onDownloadProgress((progress) => {
setState({ status: "downloading", percent: progress.percent });
}),
);
cleanups.push(
window.updater.onUpdateDownloaded(() => {
setState({ status: "ready" });
}),
);
return () => cleanups.forEach((fn) => fn());
}, []);
const handleDownload = useCallback(() => {
// Prevent double-click: immediately transition to downloading state
if (state.status !== "available") return;
setState({ status: "downloading", percent: 0 });
window.updater.downloadUpdate();
}, [state.status]);
const handleInstall = useCallback(() => {
window.updater.installUpdate();
}, []);
// Only allow dismiss when update is available (not during download or ready)
if (state.status === "idle") return null;
if (dismissed) return null;
if (dismissed && state.status === "available") return null;
return (
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
<button
type="button"
onClick={() => setDismissed(true)}
className="absolute top-2 right-2 rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
>
<X className="size-3.5" />
</button>
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} will be applied on next launch.
</p>
<div className="mt-2 flex items-center gap-1.5">
{state.status === "available" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">New version available</p>
<p className="text-xs text-muted-foreground mt-0.5">
v{state.version} is ready to download
</p>
<button
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"
onClick={handleDownload}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Later
Download update
</button>
</div>
</div>
)}
{state.status === "downloading" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Downloading update...</p>
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${Math.round(state.percent)}%` }}
/>
</div>
<p className="text-xs text-muted-foreground mt-1">
{Math.round(state.percent)}%
</p>
</div>
</div>
)}
{state.status === "ready" && (
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
<RefreshCw className="size-4 text-success" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">Update ready</p>
<p className="text-xs text-muted-foreground mt-0.5">
Restart to apply the update
</p>
<button
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"
onClick={handleInstall}
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Restart now
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,95 +0,0 @@
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" }
| { status: "checking" }
| { status: "up-to-date" }
| { status: "available"; latestVersion: string }
| { status: "error"; message: string };
export function UpdatesSettingsTab() {
const { t } = useT("settings");
const [state, setState] = useState<CheckState>({ status: "idle" });
const currentVersion = window.desktopAPI.appInfo.version;
const handleCheck = useCallback(async () => {
setState({ status: "checking" });
const result = await window.updater.checkForUpdates();
if (!result.ok) {
setState({ status: "error", message: result.error });
return;
}
setState(
result.available
? { status: "available", latestVersion: result.latestVersion }
: { status: "up-to-date" },
);
}, []);
return (
<div>
<h2 className="text-lg font-semibold">{t(($) => $.desktop.updates.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.desktop.updates.description)}
</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 text-muted-foreground mt-0.5 font-mono">
v{currentVersion}
</p>
</div>
</div>
<div className="flex items-start justify-between gap-6 py-4">
<div className="min-w-0">
<p className="text-sm font-medium">{t(($) => $.desktop.updates.check_section_title)}</p>
<p className="text-sm text-muted-foreground mt-0.5">
{t(($) => $.desktop.updates.check_section_description)}
</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)}
</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 })}
</p>
)}
{state.status === "error" && (
<p className="text-sm text-destructive mt-2 inline-flex items-center gap-1.5">
<AlertCircle className="size-3.5" />
{state.message}
</p>
)}
</div>
<div className="shrink-0">
<Button
variant="outline"
size="sm"
onClick={handleCheck}
disabled={state.status === "checking"}
>
{state.status === "checking" ? (
<>
<Loader2 className="size-3.5 animate-spin" />
{t(($) => $.desktop.updates.checking)}
</>
) : (
t(($) => $.desktop.updates.check_now)
)}
</Button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,88 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
import { InvitePage } from "@multica/views/invite";
import { InvitationsPage } from "@multica/views/invitations";
import { OnboardingFlow } from "@multica/views/onboarding";
import { useNavigation } from "@multica/views/navigation";
import { paths } from "@multica/core/paths";
import { workspaceListOptions } from "@multica/core/workspace/queries";
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
/**
* Window-level transition overlay: renders above the tab system when the
* user is in a pre-workspace flow (onboarding, create workspace, accept
* invite).
*
* This component is intentionally thin — just a fixed positioning shell
* that covers the tab system. It does NOT hide traffic lights or provide
* a drag strip: each contained view (OnboardingFlow, NewWorkspacePage,
* InvitePage) renders its own `<DragStrip />` as a flex-child at top so
* native macOS traffic lights stay visible and the page content can fill
* the window edge-to-edge. This matches the Linear/Notion/Arc pattern for
* pre-dashboard flows and keeps platform chrome consistent across every
* "not-in-dashboard" surface.
*
* All UX affordances (Back button, Log out button, welcome copy, invite
* card) live inside the shared view components under `packages/views/`,
* so web and desktop render identical content.
*/
export function WindowOverlay() {
const overlay = useWindowOverlayStore((s) => s.overlay);
if (!overlay) return null;
return <WindowOverlayInner />;
}
function WindowOverlayInner() {
const overlay = useWindowOverlayStore((s) => s.overlay);
const close = useWindowOverlayStore((s) => s.close);
const { push } = useNavigation();
const { data: wsList = [] } = useQuery(workspaceListOptions());
if (!overlay) return null;
// Back is only meaningful when there's somewhere to go — i.e. the user
// has at least one workspace. Zero-workspace users can only Log out or
// complete the flow.
const onBack = wsList.length > 0 ? close : undefined;
return (
<div className="fixed inset-0 z-50 flex flex-col overflow-auto bg-background">
{overlay.type === "new-workspace" && (
<NewWorkspacePage
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
onBack={onBack}
/>
)}
{overlay.type === "invite" && (
<InvitePage
invitationId={overlay.invitationId}
onBack={onBack}
/>
)}
{overlay.type === "invitations" && <InvitationsPage />}
{overlay.type === "onboarding" && (
<OnboardingFlow
onComplete={(ws, issueId) => {
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) {
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

@@ -2,18 +2,11 @@ import { useEffect } from "react";
import { Outlet, useNavigate, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
import {
workspaceBySlugOptions,
workspaceListOptions,
} from "@multica/core/workspace";
import { workspaceBySlugOptions } from "@multica/core/workspace";
import { setCurrentWorkspace } from "@multica/core/platform";
import { useAuthStore } from "@multica/core/auth";
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
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.
@@ -24,30 +17,20 @@ import { useWindowOverlayStore } from "@/stores/window-overlay-store";
* guaranteed non-null when called. Two industry-standard identities are
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
*
* Unlike web, desktop never renders a "workspace not available" page: the
* app has no URL bar and no clickable links from outside the session, so
* landing on an inaccessible slug can only mean stale state (a persisted
* tab group for a workspace the current user no longer has access to, or
* active eviction). Both cases resolve by dropping the stale tab group
* from the tab store — the TabBar then renders a different workspace or
* the WindowOverlay takes over (zero valid workspaces).
* If the slug doesn't resolve to any workspace the user has access to,
* we render NoAccessPage instead of silently redirecting — users get
* explicit feedback for stale bookmarks or revoked access.
*/
export function WorkspaceRouteLayout() {
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
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.
// Workspace routes require auth. If user is unauthenticated (token
// expired, logged out from another tab, etc.), bounce to /login.
// Without this, the layout renders null and the user sees a blank page
// stuck on /{slug}/...
useEffect(() => {
if (!isAuthLoading && !user) navigate(paths.login(), { replace: true });
}, [isAuthLoading, user, navigate]);
@@ -57,61 +40,40 @@ export function WorkspaceRouteLayout() {
enabled: !!user && !!workspaceSlug,
});
const { data: wsList } = useQuery({
...workspaceListOptions(),
enabled: !!user,
});
// Feed the URL slug into the platform singleton so the API client's
// X-Workspace-Slug header and persist namespace follow the active tab.
// setCurrentWorkspace self-dedupes on slug equality.
// setCurrentWorkspace self-dedupes on slug equality — safe to call on
// every render (matters on desktop, where N tabs each mount their own
// layout). Rehydrate is the singleton's internal side effect.
if (workspace && workspaceSlug) {
setCurrentWorkspace(workspaceSlug, workspace.id);
}
// Remember whether this slug has resolved before (see hook docs). Gates
// the NoAccessPage render below so active workspace removal doesn't
// flash "Workspace not available" before the navigate lands.
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
// Stale-slug auto-heal: when this tab's slug fails to resolve, drop the
// whole workspace group from the tab store. Per-workspace tab grouping
// means the cleanup is a single validator call — the TabContent will
// unmount this tab (and all siblings in the stale group) once the store
// updates. We don't navigate this tab's router because the tab's path
// is scoped to the stale slug; navigating to "/" would create an
// inconsistent "tab in group X with path /" state.
useEffect(() => {
if (!user) return;
if (!listFetched) return;
if (workspace) return;
if (hasBeenSeen) return; // active eviction in flight — let the other path win
if (!wsList) return;
const validSlugs = new Set(wsList.map((w) => w.slug));
useTabStore.getState().validateWorkspaceSlugs(validSlugs);
}, [user, listFetched, workspace, hasBeenSeen, wsList]);
if (isAuthLoading) return null;
if (!workspaceSlug) return null;
// Don't render children until workspace is resolved. useWorkspaceId()
// throws when the workspace list hasn't populated or the slug is
// unknown — gating here is the single point where that invariant is
// enforced, so every descendant can call useWorkspaceId() safely.
if (!listFetched) return null;
if (!workspace) return null; // auto-heal effect above handles the cleanup
if (!workspace) {
// Active workspace just removed (delete/leave/realtime eviction) —
// navigate is in flight; hold null briefly instead of flashing
// NoAccessPage.
if (hasBeenSeen) return null;
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
// link from a former teammate's workspace) → explicit feedback.
return <NoAccessPage />;
}
return (
<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,12 @@
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;
--font-serif: "Source Serif 4 Variable", "Source Serif 4", "Iowan Old Style",
"Apple Garamond", Baskerville, "Times New Roman", serif;
"Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC",
sans-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,6 +1,6 @@
import { useCallback } from "react";
import type { DataRouter } from "react-router-dom";
import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
import { useTabStore } from "@/stores/tab-store";
/**
* Shared hint map so useTabRouterSync can distinguish back vs forward POP.
@@ -9,32 +9,32 @@ import { useActiveTabRouter, useActiveTabHistory } from "@/stores/tab-store";
export const popDirectionHints = new Map<DataRouter, "back" | "forward">();
/**
* Per-tab back/forward navigation derived from the active workspace's
* active tab.
*
* Subscribed via primitive selectors so this hook only re-renders when
* the numeric history state actually changes — path ticks on the active
* tab (which don't shift historyIndex) don't churn the back/forward
* buttons.
* Per-tab back/forward navigation derived from the active tab's history state.
* Replaces the old global useNavigationHistory() hook.
*/
export function useTabHistory() {
const router = useActiveTabRouter();
const { historyIndex, historyLength } = useActiveTabHistory();
// Return the actual tab object from the store — stable reference.
// Do NOT create a new object in the selector (causes infinite re-renders).
const activeTab = useTabStore((s) =>
s.tabs.find((t) => t.id === s.activeTabId),
);
const canGoBack = historyIndex > 0;
const canGoForward = historyIndex < historyLength - 1;
const canGoBack = (activeTab?.historyIndex ?? 0) > 0;
const canGoForward =
(activeTab?.historyIndex ?? 0) < (activeTab?.historyLength ?? 1) - 1;
const goBack = useCallback(() => {
if (!router || historyIndex <= 0) return;
popDirectionHints.set(router, "back");
router.navigate(-1);
}, [router, historyIndex]);
if (!activeTab || activeTab.historyIndex <= 0) return;
popDirectionHints.set(activeTab.router, "back");
activeTab.router.navigate(-1);
}, [activeTab]);
const goForward = useCallback(() => {
if (!router || historyIndex >= historyLength - 1) return;
popDirectionHints.set(router, "forward");
router.navigate(1);
}, [router, historyIndex, historyLength]);
if (!activeTab || activeTab.historyIndex >= activeTab.historyLength - 1)
return;
popDirectionHints.set(activeTab.router, "forward");
activeTab.router.navigate(1);
}, [activeTab]);
return { canGoBack, canGoForward, goBack, goForward };
}

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

@@ -2,23 +2,20 @@ import { useEffect } from "react";
import { useTabStore } from "@/stores/tab-store";
/**
* Watches document.title via MutationObserver and updates the active tab's
* title. Pages set document.title via TitleSync (route handle.title) or
* useDocumentTitle(). This observer picks up the change and syncs it to
* the tab store.
* Watches document.title via MutationObserver and updates the active tab's title.
*
* Pages set document.title via TitleSync (route handle.title) or useDocumentTitle().
* This observer picks up the change and syncs it to the tab store.
*/
export function useActiveTitleSync() {
useEffect(() => {
const observer = new MutationObserver(() => {
const title = document.title;
if (!title) return;
const state = useTabStore.getState();
if (!state.activeWorkspaceSlug) return;
const group = state.byWorkspace[state.activeWorkspaceSlug];
if (!group) return;
const activeTab = group.tabs.find((t) => t.id === group.activeTabId);
const { tabs, activeTabId } = useTabStore.getState();
const activeTab = tabs.find((t) => t.id === activeTabId);
if (activeTab && activeTab.title !== title) {
state.updateTab(activeTab.id, { title });
useTabStore.getState().updateTab(activeTabId, { title });
}
});

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