Compare commits

..

1 Commits

Author SHA1 Message Date
Jiayuan Zhang
c2d8ef97f2 docs(runtimes): add install-agent-runtime page and link from onboarding empty state
New docs page covering install pointers, binary names the daemon scans
for, and basic auth notes for all 11 supported AI coding tools. EN +
zh-Hans, registered under "How agents run" in the docs sidebar.

The onboarding "no agent runtime found" empty state now shows an
"Install an agent runtime →" link that opens the new doc, so users have
a discoverable path beyond "skip" and "join waitlist".

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 01:57:49 +08:00
933 changed files with 12125 additions and 94596 deletions

View File

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

View File

@@ -21,23 +21,14 @@ APP_ENV=
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
PORT=8080
# Optional aliases for the local/self-host backend port. If one is set, it
# takes precedence over PORT in compose, Makefile, and installer helpers.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
JWT_SECRET=change-me-in-production
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when the app's public URL differs from local frontend.
# MULTICA_APP_URL=http://localhost:3000
MULTICA_SERVER_URL=ws://localhost:8080/ws
MULTICA_APP_URL=http://localhost:3000
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers. Leave unset behind a same-origin reverse proxy or for plain
@@ -100,9 +91,7 @@ SMTP_TLS_INSECURE=false
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when your OAuth callback URL differs from local frontend.
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
@@ -123,18 +112,9 @@ CLOUDFRONT_DOMAIN=
# attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
@@ -174,7 +154,7 @@ CORS_ALLOWED_ORIGINS=
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → GitHub "Connect GitHub")
# GitHub App integration (Settings → Integrations "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
@@ -183,11 +163,9 @@ GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
# FRONTEND_ORIGIN=http://localhost:3000
FRONTEND_ORIGIN=http://localhost:3000
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
# Only set explicitly if frontend and backend are on different domains.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=

View File

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

View File

@@ -29,9 +29,6 @@ jobs:
- name: Install dependencies
run: pnpm install
- name: Test self-host env derivation
run: bash scripts/selfhost-config.test.sh
- name: Verify reserved-slugs.ts is up to date
# Re-runs the generator and fails on any drift from the
# checked-in TypeScript output. The Go side embeds the JSON
@@ -94,20 +91,3 @@ jobs:
- name: Test
run: cd server && go test ./...
installer:
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
# backend job so installer regressions surface independently, and
# exercised on macOS too because the installer targets macOS/Homebrew
# and `tar` / `sed` / `mktemp` differ between BSD and GNU userlands.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Test shell installers
run: bash scripts/install.test.sh

8
.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

View File

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

View File

@@ -269,37 +269,21 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
## Workspaces
### Working with multiple workspaces
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
1. `--workspace-id <id>` flag on the command
2. `MULTICA_WORKSPACE_ID` environment variable
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
### List Workspaces
```bash
multica workspace list
multica workspace list --full-id
multica workspace list --output json
```
The current default workspace is marked with `*`. Table output shows short UUID prefixes — pass `--full-id` when you need the canonical UUIDs.
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
### Switch Default Workspace
### Watch / Unwatch
```bash
multica workspace switch <workspace-id>
multica workspace switch <slug>
multica workspace watch <workspace-id>
multica workspace unwatch <workspace-id>
```
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
### Get Details
```bash
@@ -307,12 +291,10 @@ multica workspace get <workspace-id>
multica workspace get <workspace-id> --output json
```
Passing no `<workspace-id>` resolves to the current default workspace, so `multica workspace get` doubles as "what workspace am I on?".
### List Members
```bash
multica workspace member list <workspace-id>
multica workspace members <workspace-id>
```
## Issues
@@ -328,14 +310,7 @@ multica issue list --full-id
multica issue list --limit 20 --output json
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--metadata`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
Use `--metadata key=value` (repeatable; combined with AND) to filter by per-issue metadata. The value is JSON-parsed: `true`/`false` become bool, numbers become numbers, anything else is a string. Wrap as `'"42"'` to force a string when the value would otherwise sniff as a number:
```bash
multica issue list --metadata pipeline_status=waiting_review
multica issue list --metadata pr_number=482 --metadata is_blocked=true
```
Table output shows a routable issue `KEY` such as `MUL-123`; copy that key into follow-up commands like `issue get`, `issue comment list`, `issue status`, or `--parent`. Add `--full-id` when you need canonical UUIDs. Available filters: `--status`, `--priority`, `--assignee` / `--assignee-id`, `--project`, `--limit`. Use `--assignee-id <uuid>` for unambiguous filtering when names overlap.
### Get Issue
@@ -351,7 +326,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace member list --output json` / `multica agent list --output json`.
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. Pass `--assignee-id <uuid>` (mutually exclusive with `--assignee`) when scripting against the IDs returned by `multica workspace members --output json` / `multica agent list --output json`.
### Update Issue
@@ -380,44 +355,9 @@ Valid statuses: `backlog`, `todo`, `in_progress`, `in_review`, `done`, `blocked`
### Comments
```bash
# List comments — flat timeline, chronological. Hard cap of 2000 rows; on
# long-running issues prefer one of the thread-aware reads below to keep
# context windows tight.
# List comments
multica issue comment list <issue-id>
# Single thread (root + every descendant). Anchor may be the root itself
# or any reply inside the thread — the server walks up to the root.
multica issue comment list <issue-id> --thread <comment-id>
# Single thread, capped to the N most recent replies. The thread root is
# always included (even with --tail 0), so an agent landing on a long
# thread keeps the "what is this about" context without dragging hundreds
# of replies into its prompt.
multica issue comment list <issue-id> --thread <comment-id> --tail 30
# Scroll older replies inside the same thread. --before / --before-id are
# the reply cursor that the previous response emitted on stderr as
# `Next reply cursor: --before <ts> --before-id <reply-id>`.
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--before <ts> --before-id <reply-id>
# Most recently active threads (root + every descendant), grouped by
# thread. Returns N complete conversational arcs, oldest-active first so
# the freshest thread sits closest to "now" in an agent prompt.
multica issue comment list <issue-id> --recent 20
# Scroll older threads. Under --recent, --before / --before-id are a
# THREAD cursor (thread last_activity_at + root id), emitted on stderr as
# `Next thread cursor: --before <ts> --before-id <root-id>`.
multica issue comment list <issue-id> --recent 20 \
--before <ts> --before-id <root-id>
# Incremental polling. Combines with --thread or --recent; filters out
# replies created on or before <ts> from the page (the thread root is
# exempt so the agent always gets context).
multica issue comment list <issue-id> --thread <comment-id> --tail 30 \
--since <RFC3339-timestamp>
# Add a comment
multica issue comment add <issue-id> --content "Looks good, merging now"
@@ -428,56 +368,6 @@ multica issue comment add <issue-id> --parent <comment-id> --content "Thanks!"
multica issue comment delete <comment-id>
```
**`--before` / `--before-id` semantics depend on the paging mode**, by
design — same flag, different scope:
| Mode | What the cursor walks | stderr label |
| --- | --- | --- |
| `--recent N` | Older *threads* (last_activity_at, root_id) | `Next thread cursor` |
| `--thread <id> --tail N` | Older *replies* inside that thread (created_at, id) | `Next reply cursor` |
Outside those two modes (`--thread` without `--tail`, or no `--thread`
and no `--recent`) the cursor flags are rejected so they cannot silently
no-op. The server emits the cursor headers (`X-Multica-Next-Before` /
`X-Multica-Next-Before-Id`) only when an older page actually exists —
exact-boundary pages (e.g. `--tail 3` on a thread with exactly 3
replies) intentionally return no cursor so callers stop paginating.
When `--since` is combined with `--recent` or `--thread --tail`, the
server additionally suppresses the cursor once the cursor target itself
is older than `since`. Older pages walk strictly older rows, so they
cannot satisfy `> since` either — emitting a cursor there would just
hand back root-only pages until the caller reaches the start of the
thread / issue. Incremental polling stops at the first page whose
cursor target falls before the watermark.
### Metadata
Per-issue metadata is a small KV map agents use to track pipeline state (PR number, pipeline status, waiting_on, ...). Keys match `^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$`, values are primitives (string / number / bool), max 50 keys per issue, blob capped at 8KB.
The bar for writing is high: pin a value only when it is materially important to the issue AND likely to be re-read by future runs on this same issue (the PR URL, the deploy URL, what we're blocked on). Most runs write zero new keys — that's the expected case. Don't pin runtime bookkeeping like `attempts`, single-run investigation notes, large logs, secrets/tokens, or description/comment copies — see the agent runtime prompt for the full anti-pattern list.
```bash
# List every key on an issue
multica issue metadata list <issue-id>
# Read a single key
multica issue metadata get <issue-id> --key pipeline_status
# Write a single key — value auto-typed (true/false → bool, numbers → number, else string)
multica issue metadata set <issue-id> --key pipeline_status --value waiting_review
multica issue metadata set <issue-id> --key pr_number --value 482
multica issue metadata set <issue-id> --key is_blocked --value true
# Force a specific type when sniffing would pick the wrong one
multica issue metadata set <issue-id> --key code --value 42 --type string
# Remove a key
multica issue metadata delete <issue-id> --key pipeline_status
```
All writes are single-key atomic — concurrent agents writing different keys do not lose each other's updates. To query, use `multica issue list --metadata key=value` (see *List Issues* above).
### Subscribers
```bash
@@ -618,8 +508,6 @@ multica config set app_url https://app.example.com
multica config set workspace_id <workspace-id>
```
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
## Autopilot Commands
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ POSTGRES_DB ?= multica
POSTGRES_USER ?= multica
POSTGRES_PASSWORD ?= multica
POSTGRES_PORT ?= 5432
PORT := $(or $(BACKEND_PORT),$(API_PORT),$(SERVER_PORT),$(PORT),8080)
PORT ?= 8080
FRONTEND_PORT ?= 3000
FRONTEND_ORIGIN ?= http://localhost:$(FRONTEND_PORT)
MULTICA_APP_URL ?= $(FRONTEND_ORIGIN)
@@ -21,7 +21,6 @@ NEXT_PUBLIC_API_URL ?= http://localhost:$(PORT)
NEXT_PUBLIC_WS_URL ?= ws://localhost:$(PORT)/ws
GOOGLE_REDIRECT_URI ?= $(FRONTEND_ORIGIN)/auth/callback
MULTICA_SERVER_URL ?= ws://localhost:$(PORT)/ws
LOCAL_UPLOAD_BASE_URL ?= http://localhost:$(PORT)
export

View File

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

View File

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

View File

@@ -135,234 +135,6 @@ multica daemon status
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent — it will pick up the task automatically
---
## Kubernetes Deployment (Alternative)
If you already run a Kubernetes cluster, you can deploy Multica there instead of Docker Compose using the Helm chart at [`deploy/helm/multica/`](deploy/helm/multica/). It targets a typical k3s / k8s setup with an Ingress controller and a default `ReadWriteOnce` StorageClass — authored against k3s + Traefik + `local-path`, and should work on any cluster with minor tweaks.
The chart creates the following resources in the target namespace:
- `multica-postgres``pgvector/pgvector:pg17` backed by a 10Gi PVC
- `multica-backend` — Go API/WS server backed by a 5Gi uploads PVC
- `multica-frontend` — Next.js standalone server
- Two `Ingress` resources: one for the web host, one for the backend host
- `multica-config` ConfigMap (rendered from `values.yaml`)
The `multica-secrets` Secret is **not** managed by the chart — you create it once with `kubectl` so real values never need to land in git.
> **One release per namespace:** the prebuilt `multica-web` image bakes `REMOTE_API_URL=http://backend:8080` at build time, so the chart ships an ExternalName Service literally named `backend`. Because that name is unprefixed, you can run only one Multica release per namespace, and `helm install` will fail if a `Service/backend` already exists there (pass `--take-ownership`, or use a dedicated namespace). If you build a web image with a patched `REMOTE_API_URL`, set `frontend.compatibility.backendAlias: false` to drop the alias.
> **Prerequisites:** `kubectl` and `helm` (v3.13+ for `--take-ownership`, or v4+) configured for the target cluster, an Ingress controller (Traefik / NGINX), and a default StorageClass.
### Step 1 — Point hostnames at the cluster
The chart defaults to `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Pick one of:
- **`/etc/hosts`** on every machine that needs access (developer laptops + the machine running the daemon):
```text
192.168.1.206 multica.dev.lan api.multica.dev.lan
```
Replace `192.168.1.206` with any node IP where your Ingress controller's Service is reachable.
- **Local DNS** (Pi-hole, Unbound, etc.): add A records for both hostnames pointing at the cluster Ingress IP.
To use different hostnames, override the matching values at install time (see [Step 4](#step-4--install-the-chart)) — `ingress.frontend.host`, `ingress.backend.host`, plus `backend.config.appUrl`, `backend.config.frontendOrigin`, `backend.config.localUploadBaseUrl`, and `backend.config.googleRedirectUri`.
### Step 2 — Create the namespace
```bash
kubectl create namespace multica
```
### Step 3 — Create the `multica-secrets` Secret
The chart references this Secret by name. Create it once with random values:
```bash
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
Leave optional values empty for now — you can fill them in later (see [Step 5 — Log In](#step-5--log-in)).
### Step 4 — Install the chart
```bash
helm install multica deploy/helm/multica -n multica
```
To override defaults, copy `deploy/helm/multica/values.yaml`, edit it, and pass it with `-f`:
```bash
cp deploy/helm/multica/values.yaml my-values.yaml
# edit my-values.yaml — e.g. change ingress hosts, image tags, resource limits
helm install multica deploy/helm/multica -n multica -f my-values.yaml
```
Watch the pods come up:
```bash
kubectl -n multica get pods -w
```
On a cold cluster the backend can sit `Running` but not `Ready` for a few minutes while it waits on PostgreSQL and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once the backend reports `Ready`, migrations have completed and `/healthz` returns OK:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
Then open http://multica.dev.lan in your browser.
### Step 5 — Log In
The chart defaults to `APP_ENV=production` (set in `values.yaml` under `backend.config.appEnv`), and there is no fixed verification code by default. Pick one of the following to log in — the same three options as the Docker setup:
- **Recommended (production):** patch the Secret with a real Resend key, then restart the backend:
```bash
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"RESEND_API_KEY":"re_xxx"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
Real verification codes will be sent to the email address you enter. See [Advanced Configuration → Email](SELF_HOSTING_ADVANCED.md#email-required-for-authentication).
- **Without email configured:** the verification code is generated server-side and printed to the backend pod logs (look for `[DEV] Verification code for ...:`). Useful for one-off testing.
```bash
kubectl -n multica logs -f deploy/multica-backend | grep "Verification code"
```
- **Deterministic local/private testing:** set `backend.config.appEnv: development` in your values file and `MULTICA_DEV_VERIFICATION_CODE=888888` in the Secret, then `helm upgrade` and restart. This fixed code is ignored when `APP_ENV=production`.
```bash
helm upgrade multica deploy/helm/multica -n multica \
-f my-values.yaml --set backend.config.appEnv=development
kubectl -n multica patch secret multica-secrets --type=merge \
-p '{"stringData":{"MULTICA_DEV_VERIFICATION_CODE":"888888"}}'
kubectl -n multica rollout restart deploy/multica-backend
```
`ALLOW_SIGNUP` and `GOOGLE_CLIENT_ID` likewise live under `backend.config.*` in `values.yaml`. After `helm upgrade`, the backend pod will roll automatically because the ConfigMap hash changes; the web UI reads both from `/api/config` at runtime, so no web rebuild is needed.
> **Warning:** do **not** set `MULTICA_DEV_VERIFICATION_CODE` on a publicly reachable instance — anyone who knows an email address can then log in with that fixed code.
### Step 6 — Install CLI & Start Daemon
The daemon runs on your local machine, not in the cluster. Install the CLI and an AI agent as in [Step 3](#step-3--install-cli--start-daemon) above, then point the CLI at your Ingress hostnames:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
Make sure the machine running the daemon has the same `/etc/hosts` (or DNS) entries from [Step 1](#step-1--point-hostnames-at-the-cluster).
### Updating
To pull the latest images without changing the chart version:
```bash
kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend
```
To pin a specific Multica release, set the image tags in your values file:
```yaml
images:
backend:
tag: v0.2.4
frontend:
tag: v0.2.4
```
Then upgrade:
```bash
helm upgrade multica deploy/helm/multica -n multica -f my-values.yaml
```
To roll back if an upgrade goes sideways:
```bash
helm -n multica rollback multica
```
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** Same migration guard as the Docker path — see [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule). Run the backfill against the same database the chart is using (`kubectl -n multica exec deploy/multica-backend -- ./backfill_task_usage_hourly --sleep-between-slices=2s`), then restart the backend deployment to re-apply migrations.
### Tearing down
```bash
# Remove the workloads but keep the PVCs and the Secret
helm -n multica uninstall multica
# Wipe everything, including PostgreSQL data and uploads
kubectl delete namespace multica
```
---
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table rather than directly from `task_usage`. Raw `task_usage` rows are written by the backend on every task, but the dashboard only sees data after `rollup_task_usage_hourly()` runs and aggregates them into `task_usage_hourly`.
**The bundled `pgvector/pgvector:pg17` image does NOT include `pg_cron`.** If nothing schedules the rollup, the dashboard will stay at zero forever even though `task_usage` is populated. You have three supported options — pick one before relying on the dashboard.
> **Upgrading from `v0.3.4` to `v0.3.5+`** with existing `task_usage` history: migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: …`. Run `backfill_task_usage_hourly` first (Option C below), then re-run the upgrade. **Fresh installs** are exempted by that guard and migrate cleanly — but the dashboard will still stay at zero until you pick Option A or Option B.
### Option A — External cron / systemd-timer (simplest)
Schedule a 5-minute job that calls `rollup_task_usage_hourly()`. It is idempotent and watermark-driven, so a missed tick catches up on the next run.
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Or as a systemd timer + service if you prefer that surface. The function returns the number of (upserted + deleted-empty) rows; it's safe to call concurrently with itself (an advisory lock makes overlapping runs no-op) and safe to call alongside `backfill_task_usage_hourly`.
### Option B — Swap Postgres for an image that ships `pg_cron`
If you'd rather have Postgres schedule itself, replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that bundles both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or your own build of `pgvector/pgvector` with `pg_cron` added and `shared_preload_libraries=pg_cron` set on the server). Then, once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`shared_preload_libraries` requires a Postgres restart to take effect — set it in `postgresql.conf` (or via the image's documented mechanism) before bringing the container up.
### Option C — Backfill history first, then schedule
If you're upgrading from `v0.3.4 → v0.3.5+` and already have `task_usage` rows (or you just want the dashboard to show historical data on a fresh install that you've been running for a while), run the bundled backfill command once before scheduling the rollup:
```bash
# Backfills task_usage_hourly from all historical task_usage rows and stamps
# the rollup watermark. Idempotent — safe to re-run.
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
On a database with years of data this can scan tens of millions of rows; `--sleep-between-slices=2s` throttles the read pressure. Use `--months-back N` (plus `--force-partial`) if you only want the last N months. Once it finishes, set up Option A or Option B so new buckets keep flowing.
After upgrading, re-run `migrate up` (or restart the backend container — migrations run automatically on startup) to apply migration `103` cleanly.
## Stopping Services
If you installed via the install script:
@@ -403,8 +175,6 @@ docker compose -f docker-compose.selfhost.yml up -d
Pin `MULTICA_IMAGE_TAG` in `.env` to an exact version like `v0.2.4` if you want to stay on a specific release. Migrations run automatically on backend startup.
If the selected GHCR tag has not been published yet, fall back to `make selfhost-build` or `docker compose -f docker-compose.selfhost.yml -f docker-compose.selfhost.build.yml up -d --build`.
> **Upgrading from `v0.3.4` to `v0.3.5+` fails with `refusing to drop legacy daily rollups: ...`?** That's migration `103`'s fail-closed guard: it requires `task_usage_hourly` to be seeded before the legacy daily rollups are dropped. Run `backfill_task_usage_hourly` first, then re-run the upgrade. Full instructions in [Usage Dashboard Rollup → Option C](#option-c--backfill-history-first-then-schedule).
---
## Manual Docker Compose Setup

View File

@@ -79,7 +79,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |
@@ -166,111 +166,6 @@ The Docker Compose setup runs migrations automatically. If you need to run them
cd server && go run ./cmd/migrate up
```
## Usage Dashboard Rollup
The Usage and Runtime dashboards read from `task_usage_hourly`, a derived table populated by `rollup_task_usage_hourly()`. The function is **not** scheduled out of the box on the default self-host stack: the bundled `pgvector/pgvector:pg17` image ships without `pg_cron`, and the backend does not run the rollup in-process either. Until something calls it on a schedule, raw `task_usage` rows will keep arriving while the dashboard stays at zero.
Pick one of the supported paths:
### Option A — External cron / systemd-timer
The simplest path. Schedule `SELECT rollup_task_usage_hourly()` every five minutes from any out-of-band timer (host crontab, systemd timer, sidecar container, Kubernetes CronJob). It is idempotent and watermark-driven — overlapping runs are no-ops on an internal advisory lock, and a missed tick catches up on the next run.
Docker Compose:
```bash
# /etc/cron.d/multica-rollup
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
Kubernetes (one-off `CronJob`):
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: multica-usage-rollup
spec:
schedule: "*/5 * * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: psql
image: postgres:17-alpine
command:
- psql
- "$(DATABASE_URL)"
- -c
- "SELECT rollup_task_usage_hourly();"
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: multica-secrets
key: DATABASE_URL
```
### Option B — Postgres with `pg_cron`
If you'd rather have Postgres schedule itself, swap the bundled image for one that ships both `pgvector` and `pg_cron` (e.g. `supabase/postgres`, or a custom build of `pgvector/pgvector` with `pg_cron` added). `pg_cron` requires `shared_preload_libraries=pg_cron` in `postgresql.conf`, which only takes effect on Postgres restart — set it before bringing the container up.
Then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
`pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, point `pg_cron` at it via that GUC or run `cron.schedule_in_database(...)` instead.
### Option C — Backfill historical data first
`rollup_task_usage_hourly()` only processes new buckets after it starts running. If you already have `task_usage` rows from before the rollup was scheduled — most commonly when upgrading from `v0.3.4` to `v0.3.5+`, or on a fresh install that has been collecting usage for a while — run `backfill_task_usage_hourly` once to seed historical buckets, then set up Option A or Option B for ongoing rollups.
```bash
# Docker Compose
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
# Kubernetes
kubectl -n multica exec deploy/multica-backend -- \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
The command walks `task_usage`'s full time range in monthly slices and calls the same idempotent primitive the cron path uses, so it's safe to re-run, to interrupt with Ctrl-C, and to run concurrently with an already-scheduled rollup. Flags:
| Flag | Description |
|---|---|
| `--sleep-between-slices` | Pause between monthly slices to throttle read pressure on busy databases (e.g. `2s`). Recommended on production DBs with years of history. |
| `--months-back N` | Only backfill the last N months. **Requires `--force-partial`** because the watermark still advances past the skipped older buckets — those are permanently abandoned. |
| `--dry-run` | Log slices that would be processed without writing anything. |
After backfill completes, the rollup-state watermark is stamped to `now() - 5 minutes`, so the first scheduled tick after backfill does not redo history.
### `v0.3.4 → v0.3.5+` upgrade order
Migration `103` adds a fail-closed guard that refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up. If you run `migrate up` straight through on a database with existing `task_usage` rows, it aborts with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
Recovery is straightforward: run `backfill_task_usage_hourly` (Option C above), then re-run `migrate up` (or restart the backend container — migrations run automatically on startup). **Fresh installs are exempt** — the guard short-circuits when `task_usage` is empty, and migrations succeed, but the dashboard will still stay at zero until you set up Option A or Option B.
## Manual Setup (Without Docker Compose)
If you prefer to build and run services manually:

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import { setupDaemonManager } from "./daemon-manager";
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";
@@ -201,59 +200,7 @@ function createWindow(): void {
}
});
// Dev-mode renderer diagnostics. When the renderer crashes hard enough
// that DevTools can't be opened (white screen with no clickable surface),
// the only way to recover the actual JS error is to forward it from the
// main process to the terminal running `make dev`. Without these, the
// user sees only the daemon-manager polling noise (`Render frame was
// disposed before WebFrameMain could be accessed`) which is a downstream
// symptom, not the cause.
//
// Gated by `is.dev` to keep production stderr clean — packaged builds
// don't have a terminal anyway, and we ship to crash-reporting separately.
if (is.dev) {
const log = (tag: string, ...args: unknown[]) =>
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
// Forward every renderer-side console.* call. The detail object also
// carries source URL + line — included so a thrown stack trace from
// window.onerror is traceable back to a file.
mainWindow.webContents.on("console-message", (details) => {
const { level, message, sourceId, lineNumber } = details;
log(level, `${message} (${sourceId}:${lineNumber})`);
});
// Fires when the renderer process dies for any reason (OOM, crash,
// killed). `details.reason` is the discriminator: "crashed", "oom",
// "killed", "abnormal-exit", "launch-failed", etc.
mainWindow.webContents.on("render-process-gone", (_event, details) => {
log("process-gone", JSON.stringify(details));
});
// Fires when loadURL / loadFile can't reach its target (dev server
// not up yet, network blip, file missing). errorCode is a Chromium
// net error number; -3 = ABORTED is normal during HMR and skipped.
mainWindow.webContents.on(
"did-fail-load",
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (errorCode === -3) return;
log(
"did-fail-load",
`code=${errorCode} desc=${errorDescription} url=${validatedURL} mainFrame=${isMainFrame}`,
);
},
);
// Fires when the preload script throws before the renderer can boot.
// This is the one error class that NEVER reaches DevTools (preload
// runs before any window) — without this listener it's invisible.
mainWindow.webContents.on("preload-error", (_event, preloadPath, error) => {
log("preload-error", `path=${preloadPath} err=${error?.stack ?? error}`);
});
}
installContextMenu(mainWindow.webContents);
installNavigationGestures(mainWindow);
if (is.dev && process.env["ELECTRON_RENDERER_URL"]) {
mainWindow.loadURL(process.env["ELECTRON_RENDERER_URL"]);

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,6 +1,5 @@
import { ElectronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import type { NavigationGesture } from "../shared/navigation-gestures";
interface DesktopAPI {
/** App version + normalized OS, captured synchronously at preload time. */
@@ -43,8 +42,6 @@ interface DesktopAPI {
issueKey: string;
}) => void,
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
}
interface DaemonStatus {

View File

@@ -1,11 +1,6 @@
import { contextBridge, ipcRenderer } from "electron";
import { electronAPI } from "@electron-toolkit/preload";
import type { RuntimeConfigResult } from "../shared/runtime-config";
import {
isNavigationGesture,
NAVIGATION_GESTURE_CHANNEL,
type NavigationGesture,
} from "../shared/navigation-gestures";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -146,16 +141,6 @@ const desktopAPI = {
ipcRenderer.removeListener("inbox:open", handler);
};
},
/** Listen for native macOS back/forward swipe gestures. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => {
const handler = (_event: Electron.IpcRendererEvent, gesture: unknown) => {
if (isNavigationGesture(gesture)) callback(gesture);
};
ipcRenderer.on(NAVIGATION_GESTURE_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
};
interface DaemonStatus {

View File

@@ -3,11 +3,9 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { CoreProvider } from "@multica/core/platform";
import { pickLocale } from "@multica/core/i18n";
import { useAuthStore } from "@multica/core/auth";
import { useWelcomeStore } from "@multica/core/onboarding";
import { workspaceKeys, workspaceListOptions } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import { useHasOnboarded } from "@multica/core/paths";
import { setCurrentWorkspace } from "@multica/core/platform";
import { ThemeProvider } from "@multica/ui/components/common/theme-provider";
import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
import { Toaster } from "@multica/ui/components/ui/sonner";
@@ -120,31 +118,25 @@ function AppContent() {
: undefined;
useDaemonIPCBridge(activeWsId);
// Pre-workspace overlay routing for desktop. Mirrors the web layout
// hard gate via overlays (desktop has no URL bar, so we open the
// onboarding overlay instead of router.replace):
// onboarded + has workspace → no overlay, dashboard
// un-onboarded (any wsCount):
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// onboarded + no workspace → /workspaces/new overlay
// Pre-workspace overlay routing for desktop. Mirrors the web entry-point
// judgment in callback / login:
// un-onboarded:
// pending invites on email → /invitations overlay
// no invites → /onboarding overlay
// already onboarded:
// zero workspaces → /workspaces/new overlay
// ≥1 workspaces → no overlay, fall through to dashboard
//
// V3 invariant: `onboarded_at != null` is the only path into the
// dashboard. CreateWorkspace does not mark onboarded; only Step 3's
// CompleteOnboarding (and AcceptInvitation) flip the flag. A user who
// somehow has a workspace but no onboarded mark must be sent back to
// /onboarding — we also clear the active workspace so the dashboard
// doesn't render under the overlay with stale workspace context.
// The "un-onboarded but in workspace" state is now physically impossible
// because backend transactions atomically set onboarded_at when a user
// joins the `member` table. Anyone with workspaces is by definition
// onboarded.
useEffect(() => {
if (!user || !workspaceListFetched) return undefined;
const { overlay, open } = useWindowOverlayStore.getState();
if (overlay) return undefined;
if (hasOnboarded && wsCount > 0) return undefined;
if (wsCount > 0) return undefined;
if (!hasOnboarded) {
// Stale workspace context (if any) would leak X-Workspace-Slug
// headers into onboarding-time API calls. Clear it before opening
// the overlay.
setCurrentWorkspace(null, null);
// Look up pending invitations by email. Network blip is non-fatal —
// fall through to onboarding so the user isn't stuck on a blank
// window. The sidebar's pending-invitations dropdown will surface
@@ -266,9 +258,6 @@ function BlockingRuntimeConfigError({ message }: { message: string }) {
async function handleDaemonLogout() {
useTabStore.getState().reset();
useWindowOverlayStore.getState().close();
// Drop any post-onboarding welcome signal so user B logging in next
// doesn't inherit user A's pending modal state.
useWelcomeStore.getState().reset();
try {
await window.daemonAPI.clearToken();
} catch {

View File

@@ -13,6 +13,7 @@ import { ModalRegistry } from "@multica/views/modals/registry";
import { AppSidebar } from "@multica/views/layout";
import { SearchCommand, SearchTrigger } from "@multica/views/search";
import { ChatFab, ChatWindow } from "@multica/views/chat";
import { StarterContentPrompt } from "@multica/views/onboarding";
import { WorkspaceSlugProvider, paths, useCurrentWorkspace } from "@multica/core/paths";
import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
import { useDesktopUnreadBadge } from "@multica/views/platform";
@@ -54,20 +55,6 @@ function SidebarTopBar() {
);
}
function useNativeNavigationGestures() {
const { goBack, goForward } = useTabHistory();
useEffect(() => {
return window.desktopAPI.onNavigationGesture((gesture) => {
if (gesture === "back") {
goBack();
} else {
goForward();
}
});
}, [goBack, goForward]);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
@@ -146,7 +133,6 @@ function DesktopInboxBridge() {
export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
useNativeNavigationGestures();
// Reactive read of current workspace slug from the platform singleton.
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab
@@ -183,6 +169,7 @@ export function DesktopShell() {
</div>
{slug && <ModalRegistry />}
{slug && <SearchCommand />}
{slug && <StarterContentPrompt />}
<WindowOverlay />
</WorkspaceSlugProvider>
</DesktopNavigationProvider>

View File

@@ -19,28 +19,10 @@ import type { DaemonStatus } from "../../../shared/daemon-types";
*/
export function DesktopRuntimesPage() {
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
// Remember the last known daemonId/deviceName. After the daemon is
// stopped, `status.daemonId` goes back to undefined — without this
// sticky cache the local row would either disappear or get reclassified
// as a remote machine (since `isCurrent` requires a daemonId match),
// taking the Start button with it.
const [lastIdentity, setLastIdentity] = useState<{
daemonId: string | null;
deviceName: string | null;
}>({ daemonId: null, deviceName: null });
useEffect(() => {
const apply = (s: DaemonStatus) => {
setStatus(s);
if (s.daemonId) {
setLastIdentity({
daemonId: s.daemonId,
deviceName: s.deviceName ?? null,
});
}
};
window.daemonAPI.getStatus().then(apply);
return window.daemonAPI.onStatusChange(apply);
window.daemonAPI.getStatus().then(setStatus);
return window.daemonAPI.onStatusChange(setStatus);
}, []);
const bootstrapping =
@@ -50,14 +32,9 @@ export function DesktopRuntimesPage() {
return (
<RuntimesPage
localDaemonId={status.daemonId ?? lastIdentity.daemonId}
localMachineName={status.deviceName ?? lastIdentity.deviceName}
localDaemonId={status.daemonId ?? null}
localMachineName={status.deviceName ?? null}
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,40 +25,12 @@ import { AgentsPage } from "@multica/views/agents";
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
import { InboxPage } from "@multica/views/inbox";
import { SettingsPage } from "@multica/views/settings";
import { useT } from "@multica/views/i18n";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
import { Download, Server } from "lucide-react";
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
import { UpdatesSettingsTab } from "./components/updates-settings-tab";
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
/**
* Wraps `SettingsPage` so the desktop-only extra tabs can pull their labels
* from i18n. The route element has to be a component (not a literal JSX
* value) for `useT` to run.
*/
function DesktopSettingsRoute() {
const { t } = useT("settings");
return (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: t(($) => $.desktop.tabs.updates),
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
);
}
/**
* Sets document.title from the deepest matched route's handle.title.
* The tab system observes document.title via MutationObserver.
@@ -201,7 +173,24 @@ export const appRoutes: RouteObject[] = [
},
{
path: "settings",
element: <DesktopSettingsRoute />,
element: (
<SettingsPage
extraAccountTabs={[
{
value: "daemon",
label: "Daemon",
icon: Server,
content: <DaemonSettingsTab />,
},
{
value: "updates",
label: "Updates",
icon: Download,
content: <UpdatesSettingsTab />,
},
]}
/>
),
handle: { title: "Settings" },
},
],

View File

@@ -17,7 +17,6 @@ vi.mock("../routes", () => ({
import {
sanitizeTabPath,
migrateV1ToV2,
migrateV2ToV3,
useTabStore,
} from "./tab-store";
@@ -278,155 +277,3 @@ describe("useTabStore actions", () => {
expect(useTabStore.getState().activeWorkspaceSlug).toBe("acme");
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(true);
store.togglePin(tabId);
expect(useTabStore.getState().byWorkspace.acme.tabs[0].pinned).toBe(false);
});
it("moves a newly-pinned tab to the start of the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme"); // creates default unpinned tab at index 0
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs[0].id).toBe(agentsId);
expect(tabs[0].pinned).toBe(true);
expect(tabs[1].pinned).toBe(false);
expect(tabs[2].pinned).toBe(false);
});
it("appends a second pinned tab after the first pinned tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(agentsId);
store.togglePin(projectsId);
// Both pinned, in the order they were pinned (agents first, projects
// second), then the unpinned default tab.
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([
agentsId,
projectsId,
tabs[2].id,
]);
expect(tabs.map((t) => t.pinned)).toEqual([true, true, false]);
});
it("returns an unpinned tab to the start of the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const projectsId = useTabStore.getState().byWorkspace.acme.tabs[1].id;
// Pin both, then unpin one.
store.togglePin(issuesId);
store.togglePin(projectsId);
store.togglePin(issuesId);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.id)).toEqual([projectsId, issuesId]);
expect(tabs.map((t) => t.pinned)).toEqual([true, false]);
});
});
describe("moveTab boundary clamp", () => {
it("clamps a pinned-tab move so it never crosses into the unpinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag the pinned tab to index 2 (unpinned zone end).
store.moveTab(0, 2);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// It should be clamped to index 0 — the only pinned slot — i.e. unchanged.
expect(tabs[0].id).toBe(issuesId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("clamps an unpinned-tab move so it never crosses into the pinned zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
const issuesId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
const agentsId = useTabStore.getState().byWorkspace.acme.tabs[2].id;
store.togglePin(issuesId); // [issues(pinned), projects, agents]
// User tries to drag agents (index 2) to index 0 (pinned zone).
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
// Clamped to index 1 — start of the unpinned zone.
expect(tabs[0].id).toBe(issuesId);
expect(tabs[1].id).toBe(agentsId);
expect(tabs.map((t) => t.pinned)).toEqual([true, false, false]);
});
it("reorders freely within the same zone", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
store.addTab("/acme/agents", "Agents", "Bot");
// All unpinned; move agents (2) to position 0.
store.moveTab(2, 0);
const tabs = useTabStore.getState().byWorkspace.acme.tabs;
expect(tabs.map((t) => t.path)).toEqual([
"/acme/agents",
"/acme/issues",
"/acme/projects",
]);
});
});
describe("migrateV2ToV3", () => {
it("adds pinned=false to every persisted tab", () => {
const v2 = {
activeWorkspaceSlug: "acme",
byWorkspace: {
acme: {
activeTabId: "t1",
tabs: [
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo" },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban" },
],
},
},
};
const v3 = migrateV2ToV3(v2);
expect(v3.activeWorkspaceSlug).toBe("acme");
expect(v3.byWorkspace.acme.tabs).toEqual([
{ id: "t1", path: "/acme/issues", title: "Issues", icon: "ListTodo", pinned: false },
{ id: "t2", path: "/acme/projects", title: "Projects", icon: "FolderKanban", pinned: false },
]);
});
it("handles missing byWorkspace gracefully", () => {
const v3 = migrateV2ToV3({ activeWorkspaceSlug: null } as Parameters<typeof migrateV2ToV3>[0]);
expect(v3.byWorkspace).toEqual({});
expect(v3.activeWorkspaceSlug).toBeNull();
});
});

View File

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

View File

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

View File

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

View File

@@ -64,9 +64,9 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
System-critical variables cannot be overridden: `PATH`, `HOME`, `USER`, `SHELL`, `TERM`, `CODEX_HOME`, and any key starting with `MULTICA_*` are silently ignored by the daemon (with a warn log — no error).
<Callout type="warning">
**Values in `custom_env` are stored in plaintext in Multica's server database.** Agent list/get responses no longer carry env values at all — only an opaque count. Reading values requires a workspace owner or admin to hit the dedicated, audited `GET /api/agents/{id}/env` endpoint (CLI: `multica agent env get <id>`). Agents running tasks can NOT use their host's owner credentials to reveal env on other agents — the endpoint denies agent-actor sessions.
**Values in `custom_env` are stored in plaintext in Multica's server database.** Non-creators and non-workspace-admins can't see the values (the API returns `****`), but they're still visible in database backups and DB audits.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly. Database backups and DB audits remain a meaningful exposure surface.
**Don't put high-value secrets in `custom_env`** (production database passwords, root-level tokens, etc.). Use **dedicated, limited-scope credentials** for agents (read-only API keys, single-scope PATs), and rotate them regularly.
</Callout>
## Custom CLI arguments (custom_args)
@@ -96,7 +96,7 @@ Arguments are passed as-is, not through a shell (no injection risk), but whether
New agents default to `private`.
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't read sensitive config (env values never appear in agent list/get responses; MCP config is masked for non-owners). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
**Private does not mean hidden** — every member sees a private agent's name and description in the list, they just can't see sensitive config fields (the values in `custom_env` and MCP config are masked). Full meaning in [Agents → Who can assign an agent](/agents#who-can-assign-an-agent).
## Concurrency limit

View File

@@ -64,7 +64,7 @@ ANTHROPIC_BASE_URL = https://my-proxy.example.com
系统关键变量不能被覆盖:`PATH`、`HOME`、`USER`、`SHELL`、`TERM`、`CODEX_HOME`,以及任何 `MULTICA_*` 开头的 key 都会被守护进程静默忽略(日志里有 warn不会报错
<Callout type="warning">
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** agent list/get 接口不再返回任何 env 值——只返回键的数量。读取真实值需要 workspace owner / admin 调用专用且会审计的 `GET /api/agents/{id}/env`CLI: `multica agent env get <id>`)。正在执行任务的智能体即便宿主是 owner也无法借此读取其它智能体的 env——这个接口拒绝 agent-actor 会话。数据库备份、DB 审计里仍然能看到真实值
**`custom_env` 的值在 Multica 服务器的数据库里是明文存储的。** 非智能体创建者 / 非 workspace admin 看不到值API 返回 `****`),但数据库备份、DB 审计里仍然能看到。
**不要把高价值 secret 放进 `custom_env`**生产数据库密码、root 级 token 等)。给智能体用**独立的、有限权限的凭证**(只读 API key、单 scope 的 PAT定期轮换。
</Callout>
@@ -96,7 +96,7 @@ claude --model <model> --max-turns 100 --append-system-prompt "always respond in
新建默认 `private`。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置env 值从不会出现在 agent list/get 响应里MCP 配置对非 owner 会被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
**私有不等于隐藏**——列表里所有成员都能看到私有智能体的名字和描述,只是看不到敏感配置字段(`custom_env`、MCP 配置的值被打码)。完整含义见 [智能体 → 谁能把智能体分配出去](/agents#谁能把智能体分配出去)。
## 并发上限

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,7 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|---|---|
| `multica workspace list` | List every workspace you can access |
| `multica workspace get <slug>` | Show details for one workspace |
| `multica workspace member list` | List members of the current workspace |
| `multica workspace members` | List members of the current workspace |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | Update workspace metadata (admin/owner). Long fields accept `--description-stdin` / `--context-stdin`. |
## Issues and projects

View File

@@ -39,7 +39,7 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|---|---|
| `multica workspace list` | 列出你有权访问的所有工作区 |
| `multica workspace get <slug>` | 查看一个工作区的详情 |
| `multica workspace member list` | 列出当前工作区的成员 |
| `multica workspace members` | 列出当前工作区的成员 |
| `multica workspace update <id> --name "..." [--description "..."] [--context "..."] [--issue-prefix "..."]` | 修改 workspace 元数据admin/owner 权限)。长文本可用 `--description-stdin` / `--context-stdin`。 |
## Issue 和 Project

View File

@@ -210,7 +210,7 @@ multica workspace get <workspace-id> --output json
### List Members
```bash
multica workspace member list <workspace-id>
multica workspace members <workspace-id>
```
### Update Workspace
@@ -267,7 +267,7 @@ multica issue create --title "Fix login bug" --description "..." --priority high
multica issue create --title "Fix login bug" --assignee-id 5fb87ac7-23b5-4a7a-81fa-ed295a54545d
```
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace member list --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
Flags: `--title` (required), `--description`, `--status`, `--priority`, `--assignee` / `--assignee-id`, `--parent`, `--project`, `--due-date`. 脚本里如果已经拿到了 UUID例如来自 `multica workspace members --output json`),传 `--assignee-id <uuid>`(与 `--assignee` 互斥)以精确锁定。
### Update Issue

View File

@@ -72,7 +72,7 @@ multica daemon status
In the web UI, go to **Settings → Runtimes**. The daemon you just started should appear as one or more active runtimes — one per AI coding tool installed locally.
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't connect to the server](/troubleshooting#daemon-cant-connect-to-the-server).
If it shows as offline, don't panic — see [Troubleshooting → Daemon can't reach the server](/troubleshooting#daemon-cant-reach-the-server).
## 5. Create an agent
@@ -99,7 +99,7 @@ Assign the issue to the agent you just created — click its avatar in the web U
multica issue assign MUL-1 --to my-agent-name
```
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace member list --output json`.
`--to` takes the **name** of an agent or member. A substring match works — if the agent is called `my-code-reviewer`, `reviewer` resolves to it. If your workspace has overlapping names, pass `--to-id <uuid>` instead (mutually exclusive with `--to`); look up the UUID via `multica agent list --output json` or `multica workspace members --output json`.
**What happens next from the daemon**:

View File

@@ -99,7 +99,7 @@ multica issue create --title "给 README 加一段 ASCII 架构图"
multica issue assign MUL-1 --to my-agent-name
```
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace member list --output json`。
`--to` 后面填智能体或成员的**名字**,子串就行——如果智能体叫 `my-code-reviewer`,填 `reviewer` 也能命中。如果工作区里名字相互重叠或冲突,改用 `--to-id <uuid>`(与 `--to` 互斥UUID 来自 `multica agent list --output json` 或 `multica workspace members --output json`。
**接下来守护进程会**

View File

@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
### Issue keys
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
### Comments in code

View File

@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
### Issue 编号
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改
### 代码注释

View File

@@ -180,12 +180,12 @@ The [GitHub PR ↔ issue integration](/github-integration) needs two variables.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → GitHub install button URL |
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
**Behavior when either is unset:**
- `Connect GitHub` in Settings → GitHub is **disabled** and shows a "not configured" hint to admins.
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.

View File

@@ -180,12 +180,12 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → GitHub 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
**任一变量未设时:**
- Settings → GitHub 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。

View File

@@ -133,18 +133,6 @@ Alternatively, configure step by step: `multica config set server_url http://loc
3. Go to **Settings → Agents** and create a new agent
4. Create an issue and assign it to your agent
## Usage Dashboard Rollup (Required)
Starting with `v0.3.5`, the Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` image does **not** include `pg_cron`, and the backend doesn't run the rollup in-process either — until you schedule it yourself, the dashboard will stay at zero even though `task_usage` is populated.
Pick one supported path before relying on the Usage / Runtime dashboard:
- **External cron / systemd-timer / Kubernetes `CronJob`**: schedule `SELECT rollup_task_usage_hourly()` every 5 minutes. Idempotent, watermark-driven — overlapping or skipped ticks are safe.
- **Postgres with `pg_cron`**: swap the bundled Postgres image for one that ships `pg_cron`, set `shared_preload_libraries=pg_cron`, then `SELECT cron.schedule('rollup_task_usage_hourly', '*/5 * * * *', 'SELECT rollup_task_usage_hourly()')` once.
- **Backfill historical data**: required on the `v0.3.4 → v0.3.5+` upgrade path when the database already has `task_usage` rows — migration `103` is fail-closed and will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run `./backfill_task_usage_hourly --sleep-between-slices=2s` inside the backend container, then re-run the upgrade and configure one of the schedules above.
Full reference (Compose + Kubernetes templates, flag descriptions, upgrade order) lives in [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Stopping Services
```bash
@@ -231,7 +219,7 @@ For file uploads and attachments, configure S3 and (optionally) CloudFront:
| `S3_BUCKET` | Bucket name only (e.g. `my-bucket`). Do **not** include the `.s3.<region>.amazonaws.com` suffix — the server constructs the public URL from `S3_BUCKET` + `S3_REGION` |
| `S3_REGION` | AWS region (default: `us-west-2`). Must match the bucket's actual region — used for both SDK signing and public URLs |
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Static credentials. When both are unset, the AWS SDK default credential chain is used |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches to path-style URLs |
| `AWS_ENDPOINT_URL` | Custom S3-compatible endpoint (e.g. MinIO, R2, B2). Setting this switches the public URL to path-style |
| `CLOUDFRONT_DOMAIN` | CloudFront distribution domain — when set, public URLs use this host instead of the S3 host |
| `CLOUDFRONT_KEY_PAIR_ID` | CloudFront key pair ID for signed URLs |
| `CLOUDFRONT_PRIVATE_KEY` | CloudFront private key (PEM format) |

View File

@@ -5,7 +5,7 @@ description: Connect a GitHub App once, then PRs whose branch, title, or body re
import { Callout } from "fumadocs-ui/components/callout";
Connect a GitHub account or organization once in **Settings → GitHub**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
There is no per-issue setup. The whole flow is identifier-driven.
@@ -13,7 +13,7 @@ There is no per-issue setup. The whole flow is identifier-driven.
| Surface | Behavior |
|---|---|
| **Settings → GitHub** | Workspace admins see the GitHub tab with a master toggle, **Connect GitHub** button, and feature switches (PR sidebar, Co-authored-by, auto-link). After install you bounce back to the GitHub tab. |
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
@@ -56,10 +56,10 @@ The action is attributed to the `system` actor on the timeline. Subscribers of t
## Disconnecting
In **Settings → GitHub** there is no installation list — you manage existing installations from GitHub directly:
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
- **Disconnect from inside Multica is admin-only** — the Disconnect control on the GitHub tab is hidden for non-admins. It stays available even when the master GitHub switch is off, so admins can still revoke a stale installation after one-click-disabling the feature.
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
@@ -121,7 +121,7 @@ Both variables are required. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings?tab=github` after install.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
Restart the API after setting the env vars.
@@ -139,10 +139,10 @@ Three tables get created: `github_installation`, `github_pull_request`, `issue_p
In Multica:
1. Open **Settings → GitHub** as an owner or admin.
1. Open **Settings → Integrations** as an owner or admin.
2. Click **Connect GitHub**. GitHub opens in a new tab.
3. Pick the repositories to grant access to and **Install**.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.

View File

@@ -5,7 +5,7 @@ description: 一次性连接 GitHub App之后 PR 的分支名、标题或正
import { Callout } from "fumadocs-ui/components/callout";
在 **Settings → GitHub** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
没有 per-issue 的配置,整个流程是「编号驱动」的。
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
| 出现位置 | 行为 |
|---|---|
| **Settings → GitHub** | 工作区 owner / admin 看到 GitHub 这个 tab里面有主开关、**Connect GitHub** 按钮以及功能开关PR 侧栏、Co-authored-by、auto-link。点 Connect 会打开 GitHub 的 App 安装页;装好后跳回 GitHub tab。 |
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR含标题、仓库、状态`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
| **Webhook后台** | 每次 `pull_request` 事件触发upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
@@ -56,10 +56,10 @@ PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`issue
## 断开连接
**Settings → GitHub** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
- **Multica 这边的断开是 admin only** —— GitHub tab 上的 Disconnect 控件对非 admin 不显示;主开关关掉时 Disconnect 仍然可用,方便 admin 一键关闭功能后再单独清理已连接的 installation
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR但来自这个 installation 的新 webhook 事件不再被接受。
@@ -121,7 +121,7 @@ GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
- Settings 里 `Connect GitHub` 按钮会被 **disable**并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings?tab=github`。
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
设完 env 重启 API。
@@ -139,10 +139,10 @@ make migrate-up
到 Multica
1. 以 owner 或 admin 身份打开 **Settings → GitHub**
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
2. 点 **Connect GitHub**GitHub 在新 tab 打开
3. 选择要授权的仓库,点 **Install**
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?tab=github&github_connected=1`
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。

View File

@@ -39,7 +39,6 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---Developers---",
"developers"
]

View File

@@ -37,7 +37,6 @@
"cli",
"auth-tokens",
"desktop-app",
"mobile-app",
"---开发者---",
"developers"
]

View File

@@ -1,82 +0,0 @@
---
title: Mobile app (iOS)
description: How to build the open-source Multica iOS app on your own iPhone — no App Store yet.
---
import { Callout } from "fumadocs-ui/components/callout";
Multica's iOS client is open-source and lives in the [main repo](https://github.com/multica-ai/multica) alongside web, desktop, and backend. It isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. The build takes about 1020 minutes the first time and ~2 minutes after that, and it talks to the same backend as [multica.ai](https://multica.ai) so your existing account just works.
<Callout type="info">
This page is for **personal use**. App developers should read [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) in the repo — it covers the dev / staging variants and the full script matrix.
</Callout>
## What you need
- A **Mac** with Xcode installed (free from the App Store).
- A free **Apple ID** added under Xcode → Settings → Accounts. A paid Apple Developer Program account is optional and only extends the 7-day signing window to 1 year — see [7-day limit](#7-day-signing-limit) below.
- An **iPhone** connected via USB cable, with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/) (Settings → Privacy & Security → Developer Mode).
- The Multica source code checked out:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
pnpm install
```
If anything in that list is missing, walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) — it's the canonical setup guide for everything except the repo checkout.
## Build it
One command:
```bash
pnpm ios:mobile:device:prod:release
```
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — this team is created silently the first time you sign into Xcode with any Apple ID, so it's there even if you don't remember setting anything up. This is a **Release build**: no Metro dependency, splash → app, exactly like an App Store install.
The first build downloads CocoaPods + compiles React Native from source — expect 1020 minutes. Subsequent builds reuse Xcode's cache.
That's it for the typical path. If signing fails, jump to [Troubleshooting](#troubleshooting).
## 7-day signing limit
A free Apple ID signs builds for **7 days**. After that, the app refuses to launch on your iPhone and shows an "untrusted developer" error. Plug back into your Mac and re-run the same command to re-sign — your data stays put because it lives on the backend, not in the app.
The only way to extend this is an **Apple Developer Program account** ($99/yr from [developer.apple.com](https://developer.apple.com)). Signing is then valid for 1 year between renewals, and you can also distribute to other devices via TestFlight.
## Updating
There is no auto-update yet. When the Multica codebase moves forward, pull and rebuild:
```bash
git pull
pnpm install
pnpm ios:mobile:device:prod:release
```
Subsequent builds are fast because Xcode caches the native compile.
## Why no App Store yet
The iOS app is still moving fast — the team prefers ship-and-iterate over App Store review cycles right now. A TestFlight beta is the most likely next step before a full App Store release. Until then, the self-build path above is the only way to use Multica on iOS.
If you'd like to be notified when TestFlight opens, watch the [GitHub repo](https://github.com/multica-ai/multica).
## Troubleshooting
**"No matching provisioning profiles found"** — Xcode refuses to sign the default bundle id `ai.multica.mobile` with your Apple ID. Rare, but happens if someone has registered that prefix on Apple's developer portal. Pick any reverse-domain you control (`com.yourname.multica` is fine), export it, and re-run:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
The id doesn't have to mean anything — Apple just needs it to be unclaimed by other teams.
**"Could not launch &lt;app&gt;" / "Untrusted Developer"** — either you've hit the 7-day limit (re-run the build) or you need to manually trust the developer profile on your iPhone: Settings → General → VPN & Device Management → tap your Apple ID → Trust.
**Build hangs on `Pod install` or compiles forever** — first build is genuinely 1020 minutes because CocoaPods downloads dependencies and Xcode compiles React Native from source. Subsequent builds are much faster.
**App can't reach the backend** — confirm `apps/mobile/.env.production` hasn't been modified (it ships with `EXPO_PUBLIC_API_URL=https://api.multica.ai`). If you changed it, restore with `git checkout apps/mobile/.env.production`.

View File

@@ -1,82 +0,0 @@
---
title: 移动 AppiOS
description: 在自己的 iPhone 上自助 build 开源版 Multica iOS app —— 暂未上 App Store。
---
import { Callout } from "fumadocs-ui/components/callout";
Multica iOS 客户端开源,跟 web、desktop、后端一起放在[主仓库](https://github.com/multica-ai/multica)里。目前没上 App Store —— 在那之前,想用的人自己从源码 build 一份。首次 build 约 1020 分钟,之后每次约 2 分钟,连接的是 [multica.ai](https://multica.ai) 同一个后端,所以你现有账号直接能登。
<Callout type="info">
本页是给**个人使用者**看的。如果你是要开发这个 app,请看仓库里的 [`apps/mobile/README.md`](https://github.com/multica-ai/multica/blob/main/apps/mobile/README.md) —— 那里覆盖 dev / staging 变体和完整脚本表。
</Callout>
## 你需要
- 一台装了 Xcode 的 **Mac**(Xcode 在 App Store 免费下载)。
- 一个免费的 **Apple ID**,在 Xcode → Settings → Accounts 里加进去。付费的 Apple Developer Program 账号是可选的 —— 只把 7 天签名期延到 1 年,见下方[7 天签名限制](#7-天签名限制)。
- 一台通过 USB 线连接的 **iPhone**,并打开 [Developer Mode](https://docs.expo.dev/guides/ios-developer-mode/)(设置 → 隐私与安全性 → 开发者模式)。
- Multica 源码已 clone:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
pnpm install
```
上面任何一项缺失,先走 Expo 的 [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/)(选 **Development build → iOS Device**)—— 它是除仓库拉取外所有环境准备的官方指引。
## Build
一条命令:
```bash
pnpm ios:mobile:device:prod:release
```
Xcode 会用你 Apple ID 自动持有的"Personal Team"来签名 —— 这个 team 是你第一次用任何 Apple ID 登 Xcode 时静默建的,所以即使你不记得"什么时候弄过",它都已经在那里了。这是个 **Release build**:不依赖 Metro,启动屏 → app,跟从 App Store 装的体验一样。
首次 build 会下载 CocoaPods + 从源码编译 React Native —— 大约 1020 分钟。之后 build 会快很多,Xcode 缓存了原生编译产物。
典型路径就这样。签名失败的话见下方[排错](#排错)。
## 7 天签名限制
免费 Apple ID 签的 build 只有 **7 天**有效期。过期后 app 在 iPhone 上拒绝启动,提示 "untrusted developer"。插回 Mac 重跑同一条命令重签即可 —— 数据不会丢,因为数据在后端,不在 app 里。
唯一的延期方式是 **Apple Developer Program 账号**($99/年,在 [developer.apple.com](https://developer.apple.com) 注册)。有了它签名一次有效 1 年(直到续费),还能通过 TestFlight 分发给其他设备。
## 更新
暂时没有自动更新。Multica 代码库前进时,你 pull 然后重 build:
```bash
git pull
pnpm install
pnpm ios:mobile:device:prod:release
```
后续 build 很快,因为 Xcode 缓存了原生编译产物。
## 为什么还没上 App Store
iOS app 还在快速迭代 —— 团队目前更倾向于"先发再改",而不是 App Store 审核周期。下一步比较可能是 TestFlight 内测,然后才是正式上架。在那之前,上面的自助 build 是 iOS 上用 Multica 的唯一方式。
想第一时间知道 TestFlight 开放的话,watch 一下 [GitHub 仓库](https://github.com/multica-ai/multica)。
## 排错
**"No matching provisioning profiles found"** —— Xcode 拒绝用你的 Apple ID 签默认的 `ai.multica.mobile`。比较罕见,如果有人在 Apple Developer Portal 抢注了这个前缀就会出现。换一个你控制的反向域名(`com.yourname.multica` 就够),export 后重跑:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
id 本身没意义,Apple 只要求它没被别的 team 抢注就行。
**"无法启动 &lt;app&gt;" / "未受信任的开发者"** —— 要么过了 7 天有效期(重跑 build),要么需要在 iPhone 上手动信任开发者证书:设置 → 通用 → VPN 与设备管理 → 点你的 Apple ID → 信任。
**Build 卡在 `Pod install` 或者编译很久不动** —— 首次 build 就是 1020 分钟,CocoaPods 要下载依赖、Xcode 要从源码编译 React Native。后续会快很多。
**App 连不上后端** —— 确认 `apps/mobile/.env.production` 没动过(默认值 `EXPO_PUBLIC_API_URL=https://api.multica.ai`)。如果你改过,用 `git checkout apps/mobile/.env.production` 还原。

View File

@@ -1,6 +1,6 @@
---
title: Self-host quickstart
description: Run Multica on your own server or machine with Docker (or Helm on Kubernetes). Takes about 10 minutes.
description: Run Multica on your own server or machine with Docker. Takes about 10 minutes.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -18,10 +18,6 @@ Agent **execution** still relies on the [daemon](/daemon-runtimes) you run local
## 1. Pull the project and start the backend
<Callout type="info">
**Already on Kubernetes?** Skip Docker and use the Helm chart instead — jump to [Kubernetes deployment](#kubernetes-deployment-alternative) below, then come back to [Step 4](#4-first-login--create-a-workspace) for first login.
</Callout>
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
@@ -159,99 +155,11 @@ After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` i
Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-create-an-agent).
## 7. Schedule the usage rollup (required for the Usage dashboard)
<Callout type="warning">
The Usage / Runtime dashboards read from a derived `task_usage_hourly` table populated by `rollup_task_usage_hourly()`. The bundled `pgvector/pgvector:pg17` Postgres image **does not include `pg_cron`**, and the backend does not run the rollup in-process either. If nothing schedules `rollup_task_usage_hourly()`, raw `task_usage` rows keep arriving while the dashboard stays at zero forever.
</Callout>
Pick one of the supported options — only one is needed.
**Option A — External cron / systemd-timer (simplest).** Run the rollup every 5 minutes from any out-of-band scheduler. It's idempotent and watermark-driven, so missed ticks catch up:
```bash
# /etc/cron.d/multica-rollup — every 5 minutes
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B — Swap Postgres for an image that ships `pg_cron`.** Replace `pgvector/pgvector:pg17` in `docker-compose.selfhost.yml` with an image that has both `pgvector` and `pg_cron` (`supabase/postgres`, or a custom build), set `shared_preload_libraries=pg_cron`, restart, then register the job once:
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
**Option C — Backfill history first (upgrade path).** If you're upgrading from `v0.3.4 → v0.3.5+` and have existing `task_usage` rows, migration `103` will abort `migrate up` with `refusing to drop legacy daily rollups: ...` until the hourly table is seeded. Run the bundled backfill once, then set up Option A or B:
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` throttles read pressure on a busy DB. After it finishes, restart the backend container (migrations run on startup) and the upgrade completes.
Full reference — including the Kubernetes `CronJob` template and the upgrade order — lives in the repo's [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup).
## Kubernetes deployment (alternative)
If you already run a Kubernetes cluster, the repo also ships a Helm chart at `deploy/helm/multica/`. It's the equivalent of `make selfhost` for k8s — same backend image, frontend image, and `pgvector/pgvector:pg17` Postgres, packaged as Deployments / Services / Ingresses with one `ConfigMap` rendered from `values.yaml`. Authored against k3s + Traefik + `local-path` and should work on any cluster with an Ingress controller and a default `ReadWriteOnce` StorageClass.
The chart **does not template secret values**. It references a Secret named `multica-secrets` by name, so real JWT / DB / Resend / Google keys never need to live in git or in `values.yaml`. Create the namespace + Secret once with kubectl:
```bash
kubectl create namespace multica
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
Then install the chart:
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
helm install multica deploy/helm/multica -n multica
```
Defaults assume the hostnames `multica.dev.lan` (web) and `api.multica.dev.lan` (backend). Add them to `/etc/hosts` (or local DNS) pointing at any node IP where your Ingress is reachable. To use different hostnames, copy `deploy/helm/multica/values.yaml`, edit `ingress.frontend.host` / `ingress.backend.host` and the matching `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri`, then install with `-f my-values.yaml`.
On a cold cluster the backend can stay `Running` but not `Ready` for a few minutes while it waits on Postgres and runs migrations — a startupProbe absorbs this, so the pod should not restart. Once it's `Ready`:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
Then open `http://multica.dev.lan` and continue at [Step 4 — First login](#4-first-login--create-a-workspace) above. Point the CLI at your Ingress hostnames:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
To pull the latest images without changing the chart, `kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`. To pin a specific Multica release, set `images.backend.tag` / `images.frontend.tag` in your values file and `helm upgrade`. `helm -n multica uninstall multica` removes the workloads but keeps the PVCs and Secret; `kubectl delete namespace multica` wipes everything.
The full reference — three login modes, the `backend` ExternalName workaround for the build-time-baked `REMOTE_API_URL` in the web image, resource limits, and TLS — lives in the repo's [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative).
## Common issues
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
- **Usage / Runtime dashboard stays at zero**: `rollup_task_usage_hourly()` isn't being scheduled — see [Step 7](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard) above and [Troubleshooting → Usage dashboard shows zero](/troubleshooting#usage-dashboard-stays-at-zero)
- **`migrate up` fails with `refusing to drop legacy daily rollups`**: upgrade-path guard from `v0.3.4 → v0.3.5+`. Run `backfill_task_usage_hourly` first — see [Step 7 → Option C](#7-schedule-the-usage-rollup-required-for-the-usage-dashboard)
## Next steps

View File

@@ -1,6 +1,6 @@
---
title: Self-Host 快速上手
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来(也可以在 Kubernetes 上用 Helm。约 10 分钟。
description: 在自己的服务器或本机用 Docker 把 Multica 跑起来。约 10 分钟。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -18,10 +18,6 @@ import { Callout } from "fumadocs-ui/components/callout";
## 1. 拉取项目 + 一键启动后端
<Callout type="info">
**已经有 Kubernetes 集群?** 不用走 Docker直接用 Helm chart——跳到下面的 [Kubernetes 部署(替代方案)](#kubernetes-部署替代方案),装完再回到 [第 4 步](#4-首次登录--创建工作区) 完成登录。
</Callout>
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
@@ -158,99 +154,11 @@ multica.example.com {
流程和 Cloud 一样——见 [Cloud 快速上手 → 5-6 步](/cloud-quickstart#5-创建智能体)。
## 7. 调度用量汇总任务Usage Dashboard 必需)
<Callout type="warning">
Usage / Runtime 看板读的是派生表 `task_usage_hourly`,需要 `rollup_task_usage_hourly()` 周期性运行才能填充。**默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`**,后端进程内部也不会跑这个 rollup——什么都没调度的话原始 `task_usage` 行会继续写入,但 dashboard 会一直停在 0不会报错。
</Callout>
三种支持路径,三选一即可。
**Option A —— 外部 cron / systemd-timer最简单。** 在任意外部调度器上每 5 分钟跑一次 rollup。函数是幂等的、按 watermark 推进,丢一两个 tick 下次能补上:
```bash
# /etc/cron.d/multica-rollup —— 每 5 分钟跑一次
*/5 * * * * root docker compose -f /path/to/multica/docker-compose.selfhost.yml \
exec -T postgres psql -U multica -d multica \
-c "SELECT rollup_task_usage_hourly();" >/dev/null
```
**Option B —— 换成自带 `pg_cron` 的 Postgres 镜像。** 把 `docker-compose.selfhost.yml` 里的 `pgvector/pgvector:pg17` 换成同时带 `pgvector` 和 `pg_cron` 的镜像(比如 `supabase/postgres`,或自己 build 一份),把 `shared_preload_libraries=pg_cron` 配上、重启 Postgres然后注册一次任务
```sql
CREATE EXTENSION IF NOT EXISTS pg_cron;
SELECT cron.schedule(
'rollup_task_usage_hourly',
'*/5 * * * *',
$$SELECT rollup_task_usage_hourly()$$
);
```
**Option C —— 先回填历史(升级路径)。** 如果你是从 `v0.3.4` 升级到 `v0.3.5+` 且数据库里已经有 `task_usage` 行migration `103` 会以 `refusing to drop legacy daily rollups: ...` 报错并中止 `migrate up`,直到 hourly 表被 seed 过。先跑一次内置的 backfill 命令,然后再配 Option A 或 Option B 让新数据持续流进来:
```bash
docker compose -f docker-compose.selfhost.yml exec backend \
./backfill_task_usage_hourly --sleep-between-slices=2s
```
`--sleep-between-slices=2s` 用来在繁忙的数据库上限制读压力。回填跑完后重启后端容器migration 在启动时自动跑),升级就能继续。
完整参考(含 Kubernetes `CronJob` 模板和升级顺序)见仓库的 [`SELF_HOSTING_ADVANCED.md → Usage Dashboard Rollup`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING_ADVANCED.md#usage-dashboard-rollup)。
## Kubernetes 部署(替代方案)
如果你已经在跑 Kubernetes 集群,仓库里也带了一个 Helm chart路径 `deploy/helm/multica/`。它就是 k8s 版的 `make selfhost`——一样的 backend 镜像、frontend 镜像、`pgvector/pgvector:pg17` Postgres封装成 Deployment / Service / Ingress再加上一个由 `values.yaml` 渲染出来的 `ConfigMap`。这套 chart 是按照 k3s + Traefik + `local-path` 写的,集群里只要有 Ingress controller 和默认的 `ReadWriteOnce` StorageClass 就能跑,其他类型的集群稍微改一改也能用。
这个 chart **不会模板化任何敏感值**。它通过 name 引用一个叫 `multica-secrets` 的 Secret所以真实的 JWT / DB / Resend / Google 密钥永远不用进 git也不用进 `values.yaml`。先用 kubectl 一次性把命名空间和 Secret 建好:
```bash
kubectl create namespace multica
kubectl -n multica create secret generic multica-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=POSTGRES_PASSWORD="$(openssl rand -hex 16)" \
--from-literal=RESEND_API_KEY="" \
--from-literal=GOOGLE_CLIENT_SECRET="" \
--from-literal=CLOUDFRONT_PRIVATE_KEY="" \
--from-literal=MULTICA_DEV_VERIFICATION_CODE=""
```
再装 chart
```bash
git clone https://github.com/multica-ai/multica.git
cd multica
helm install multica deploy/helm/multica -n multica
```
默认主机名是 `multica.dev.lan`web和 `api.multica.dev.lan`backend。把它们加进 `/etc/hosts`(或者本地 DNS指向任意一个 Ingress 可达的节点 IP 就行。要换主机名,就把 `deploy/helm/multica/values.yaml` 复制一份,改掉 `ingress.frontend.host` / `ingress.backend.host`,再把 `backend.config.appUrl` / `frontendOrigin` / `localUploadBaseUrl` / `googleRedirectUri` 改成相应的地址,然后 `helm install ... -f my-values.yaml`。
冷集群上 backend 可能会 `Running` 但 `Not Ready` 持续几分钟,等 Postgres 起来并跑完 migration——startupProbe 会兜住这一段pod 不会被 liveness 重启。等它 `Ready` 之后:
```bash
curl -H "Host: api.multica.dev.lan" http://<ingress-ip>/healthz
# {"status":"ok","checks":{"db":"ok","migrations":"ok"}}
```
然后浏览器打开 `http://multica.dev.lan`,回到上面的 [第 4 步——首次登录](#4-首次登录--创建工作区) 继续。命令行连到你的 Ingress 主机:
```bash
multica setup self-host \
--server-url http://api.multica.dev.lan \
--app-url http://multica.dev.lan
```
只想拉最新镜像、不动 chart`kubectl -n multica rollout restart deploy/multica-backend deploy/multica-frontend`。要锁到某个 Multica 版本,就在 values 文件里设 `images.backend.tag` / `images.frontend.tag`,再 `helm upgrade`。`helm -n multica uninstall multica` 只删工作负载PVC 和 Secret 都保留;`kubectl delete namespace multica` 才会全清。
完整参考——三种登录方式、为了绕过 web 镜像 build-time 写死的 `REMOTE_API_URL` 而加的 `backend` ExternalName 别名、资源限制、TLS——都在仓库的 [`SELF_HOSTING.md`](https://github.com/multica-ai/multica/blob/main/SELF_HOSTING.md#kubernetes-deployment-alternative)。
## 常见问题
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
- **验证码收不到**没配任何邮件后端Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
- **Usage / Runtime 看板一直是 0**:没人调度 `rollup_task_usage_hourly()` —— 见上面的 [第 7 步](#7-调度用量汇总任务usage-dashboard-必需) 和 [故障排查 → Usage 看板一直是 0](/troubleshooting#usage-看板一直是-0)
- **`migrate up` 报 `refusing to drop legacy daily rollups`**`v0.3.4 → v0.3.5+` 升级路径的 fail-closed guard。先跑 `backfill_task_usage_hourly` —— 见 [第 7 步 → Option C](#7-调度用量汇总任务usage-dashboard-必需)
## 下一步

View File

@@ -126,7 +126,7 @@ There is currently no unarchive command; create a new squad if you need the rout
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace member list --output json`, and `multica squad list --output json`.
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
## Next

View File

@@ -126,7 +126,7 @@ multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agen
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace member list --output json`、`multica squad list --output json` 拿。
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
## 下一步

View File

@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
Behavior:
- By default, targets the issue's **current agent assignee** — useful when you want the rerun to follow the current assignment regardless of who ran the prior task.
- The execution-log retry button on a specific row sends that row's task ID alongside, so the rerun targets **the agent that ran that exact task** — not the current assignee. This makes per-row retry meaningful for squad workers, parallel @-mention agents, or rows whose agent has since been displaced by a reassignment.
- **Cancels** the target agent's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Targets the issue's **current agent assignee** — not whoever ran the most recent task. If the assignee changed since the last run, rerun follows the current assignment. To rerun a specific agent that is no longer the assignee, reassign the issue first, then rerun.
- **Cancels** the assignee's queued or running task on this issue (if any). Tasks owned by other agents on the same issue (e.g. parallel @-mention runs) are left alone.
- Creates a **brand-new** task — attempt count resets to 1, even if the original task hit the attempt ceiling.
- Starts a **fresh agent session** — the prior session ID is **not** inherited. A manual rerun means you've judged the previous output bad, so resuming the same conversation would replay the same poisoned state. (Automatic retry, by contrast, does inherit the session — that path is for infrastructure failures, not bad output.)
@@ -90,7 +89,7 @@ Comparison:
| Trigger | System, based on failure reason | You, manually |
| Ceiling | 2 attempts | No limit |
| Applicable sources | Issues, chat | Issues with an agent assignee |
| Agent picked | Same agent as the failed task | Source task's agent (UI per-row retry) or issue's current assignee (CLI / no task_id) |
| Agent picked | Same agent as the failed task | Issue's current assignee |
| Session inheritance | Yes (resumes prior session) | No (fresh session) |
## How a failed task affects issue status

View File

@@ -77,9 +77,8 @@ multica issue rerun <issue-id>
行为:
- 默认跑的是 issue **当前的智能体分配人**——适用于希望 rerun 跟随当前分配人的场景
- 执行日志里某一行的 retry 按钮会把这一行的 task ID 一并发出rerun 会**针对那一行原本的 agent**,而不是当前分配人。这让 squad worker、并行的 @-mention agent、或者已经被新分配人替代的旧任务行的 retry 按钮都能符合直觉地工作
- **取消**目标 agent 在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消。
- 跑的是 issue **当前的智能体分配人**——不是上一次跑过的 agent。如果分配人在上次运行后改了rerun 会跟着新的分配人走。要重跑一个已经不再是分配人的智能体,先把 issue 改派回它,再 rerun
- **取消**该分配人在这条 issue 上 queued / running 的任务(如果有)。同 issue 上其它 agent 的任务(例如 @-mention 触发的并行任务)不会被一起取消
- 创建一个**全新**的执行任务——尝试次数重置为 1即使原任务已达最大尝试。
- 启动**全新的智能体会话**——**不**继承之前的会话 ID。手动重跑意味着你已经判定上一次的产出不行再继续之前的对话只会重放被污染的上下文。自动重试则相反会继承会话——那条路径处理的是基础设施层面的失败不是产出不好。
@@ -90,7 +89,7 @@ multica issue rerun <issue-id>
| 触发 | 系统基于失败原因自动执行 | 你主动发起 |
| 上限 | 2 次 | 无上限 |
| 适用来源 | issue、聊天 | 有智能体分配人的 issue |
| 跑哪个 agent | 失败任务原本的 agent | UI 单行 retry那一行任务的 agentCLI / 不带 task_idissue 当前的分配人 |
| 跑哪个 agent | 失败任务原本的 agent | issue 当前的分配人 |
| 会话继承 | 是(接着上次会话) | 否(全新会话) |
## 失败的任务对 issue 状态有什么影响

View File

@@ -1,6 +1,6 @@
---
title: Troubleshooting
description: Common issues when self-hosting Multica — symptoms, causes, how to diagnose, how to fix.
description: The top 7 common issues when self-hosting Multica — symptoms, causes, how to diagnose, how to fix.
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -132,75 +132,6 @@ Check your inbox (including spam) for the real verification code.
- In production, leave `MULTICA_DEV_VERIFICATION_CODE` empty — configure Resend and use real codes
- For local development or internal testing, either copy the generated code from server logs or set `APP_ENV=development` plus `MULTICA_DEV_VERIFICATION_CODE=888888` — never enable a fixed code on a public instance (see [Sign-in and signup configuration → Fixed local testing codes](/auth-setup#fixed-local-testing-codes))
## Usage dashboard stays at zero
**Symptom**: agents complete tasks, raw token usage is written to the database, but **Settings → Usage** and **Settings → Runtime** show 0 input / output / cost across the board. This is silent — there is no error in the backend logs.
**Likely causes**:
1. **`rollup_task_usage_hourly()` is never scheduled** — the Usage / Runtime dashboards read from the derived `task_usage_hourly` table, which is populated by that function. The bundled `pgvector/pgvector:pg17` image does not include `pg_cron`, and the backend does not run the rollup in-process either. On a fresh self-host install with no external scheduler, this is the default state.
2. **`pg_cron` is installed but pointing at the wrong database** — `pg_cron.database_name` defaults to `postgres`; if your Multica database has a different name, the scheduled job never sees `rollup_task_usage_hourly()`.
3. **The scheduler is running but the rollup is silently erroring** — e.g. wrong DB role / search_path inside the cron entry.
**How to diagnose**:
```sql
-- Confirm raw events exist but the hourly table is empty.
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- Confirm pg_cron is (or isn't) available.
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- If pg_cron is installed, check the schedule + last run.
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- Watermark — if this is 1970-01-01, the rollup has never run.
SELECT watermark_at FROM task_usage_hourly_rollup_state;
```
**How to fix**:
- Call the rollup once by hand to confirm it works: `SELECT rollup_task_usage_hourly();` — refresh the dashboard; if numbers appear, the only missing piece is a scheduler.
- Pick one of the supported paths from [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard): external cron / systemd-timer / Kubernetes CronJob, or swap Postgres for an image with `pg_cron`.
- If you already have history that pre-dates the schedule, run `backfill_task_usage_hourly` inside the backend container to seed buckets before the watermark.
## Migration `103` fails with `refusing to drop legacy daily rollups`
**Symptom**: upgrading from `v0.3.4` to `v0.3.5+`, the backend container fails to start (or `migrate up` aborts) with:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
**Likely cause**: this is migration `103`'s fail-closed guard. It refuses to drop the legacy daily rollups until `task_usage_hourly` has caught up with raw `task_usage`. The guard fires whenever existing rows are present and the rollup watermark still sits at the epoch — i.e. nothing has rolled history into the hourly table yet.
**How to fix**:
1. Run the backfill against the same database (idempotent, safe to interrupt, safe to re-run):
```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
```
2. Re-run the upgrade — restarting the backend container is enough, migrations run on startup. The guard now sees a current watermark and lets `103` apply.
3. Set up an ongoing rollup schedule (cron / `pg_cron`) so the watermark keeps advancing — see [Self-host quickstart → Schedule the usage rollup](/self-host-quickstart#7-schedule-the-usage-rollup-required-for-the-usage-dashboard).
`--sleep-between-slices=2s` is a polite default on production databases with years of history. Use `--months-back N --force-partial` if you only want to keep the last N months and are willing to permanently abandon older buckets.
## Port conflicts
**Symptom**: `multica server` or `multica daemon start` fails with `address already in use`.

View File

@@ -1,6 +1,6 @@
---
title: 故障排查
description: self-host Multica 常见问题——症状、原因、怎么查、怎么修。
description: self-host Multica 遇到的 Top 7 常见问题——症状、原因、怎么查、怎么修。
---
import { Callout } from "fumadocs-ui/components/callout";
@@ -132,75 +132,6 @@ docker exec <container> env | grep -E 'APP_ENV|MULTICA_DEV_VERIFICATION_CODE'
- 生产环境保持 `MULTICA_DEV_VERIFICATION_CODE` 为空,配好 Resend 后使用真实验证码
- 本地开发或内网测试可以从 server 日志抄生成的验证码;如果需要 `888888`,设置 `APP_ENV=development` 和 `MULTICA_DEV_VERIFICATION_CODE=888888`。不要在公网实例启用固定验证码(详见 [登录与注册配置 → 固定本地测试验证码](/auth-setup#固定本地测试验证码)
## Usage 看板一直是 0
**症状**agent 执行完任务、原始的 token 用量已经写入数据库,但 **Settings → Usage** 和 **Settings → Runtime** 上输入 / 输出 / 成本全部显示 0。**没有任何报错**——这是静默故障。
**可能原因**
1. **`rollup_task_usage_hourly()` 没人调度** —— Usage / Runtime 看板读的是派生表 `task_usage_hourly`,这张表必须靠 `rollup_task_usage_hourly()` 周期性填充。默认的 `pgvector/pgvector:pg17` 镜像不带 `pg_cron`,后端进程内部也不会跑 rollup。如果你是新装的自部署、没配过外部调度器默认就是这种状态。
2. **`pg_cron` 装了但指向了错的库** —— `pg_cron.database_name` 默认是 `postgres`;如果你的 Multica 数据库名不是 `postgres`,调度任务根本看不到 `rollup_task_usage_hourly()`。
3. **调度跑了,但 rollup 静默报错** —— 比如 cron entry 里 DB role / search_path 不对。
**怎么查**
```sql
-- 确认原始数据有、hourly 表是空的
SELECT count(*) AS raw_rows FROM task_usage;
SELECT count(*) AS hourly_rows FROM task_usage_hourly;
-- 看 pg_cron 装没装、有没有加载
SELECT * FROM pg_available_extensions WHERE name = 'pg_cron';
SHOW shared_preload_libraries;
-- 如果 pg_cron 装了,看调度和最近一次运行
SELECT jobname, schedule, database, active FROM cron.job;
SELECT jobname, status, return_message, start_time, end_time
FROM cron.job_run_details ORDER BY start_time DESC LIMIT 10;
-- watermark —— 如果还是 1970-01-01说明 rollup 从来没跑过
SELECT watermark_at FROM task_usage_hourly_rollup_state;
```
**怎么修**
- 手动跑一次确认函数本身没问题:`SELECT rollup_task_usage_hourly();` —— 刷新看板;如果数字出来了,缺的就只是调度器。
- 从 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需) 里挑一种调度方式:外部 cron / systemd-timer / Kubernetes CronJob或者换成带 `pg_cron` 的 Postgres 镜像。
- 如果调度配好之前数据库已经有一段历史,先在后端容器里跑 `backfill_task_usage_hourly` 把 watermark 之前的桶补出来。
## migration `103` 报 `refusing to drop legacy daily rollups`
**症状**:从 `v0.3.4` 升级到 `v0.3.5+` 时,后端容器起不来(或 `migrate up` 中止),错误信息:
```text
ERROR: refusing to drop legacy daily rollups:
task_usage_hourly_rollup_state.watermark_at (1970-01-01 ...) trails
task_usage latest event (...) by more than 01:00:00 — backfill is
incomplete or pg_cron is not running. Run cmd/backfill_task_usage_hourly
(and let pg_cron catch up) before re-running migrate
```
**可能原因**:这是 migration `103` 的 fail-closed guard。它要求 `task_usage_hourly` 已经追平了原始的 `task_usage` 之后,才允许丢掉旧的 daily rollup。只要数据库里有历史数据、且 rollup watermark 还停在 epoch说明还没把历史回填进 hourly 表),这条 guard 就会拦住。
**怎么修**
1. 对同一个数据库跑一次 backfill幂等可以打断可以重试
```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
```
2. 重新跑升级 —— 重启 backend 容器即可,启动时会自动跑 migration。Guard 看到新的 watermark`103` 就会通过。
3. 同时配上持续的 rollup 调度,保证 watermark 持续推进 —— 见 [Self-host 快速上手 → 调度用量汇总任务](/self-host-quickstart#7-调度用量汇总任务usage-dashboard-必需)。
`--sleep-between-slices=2s` 在有多年历史的生产库上是个比较克制的默认值。如果你只想保留最近 N 个月、可以接受永久丢掉更老的桶,用 `--months-back N --force-partial`。
## 端口冲突
**症状**`multica server` 或 `multica daemon start` 启动失败,报 `address already in use`。

View File

@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
<Callout type="warning">
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."

View File

@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
- **Slug短链标识符** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
<Callout type="warning">
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。

View File

@@ -1,34 +0,0 @@
# Mobile env template — copy this to one of:
# .env.development.local (used by `*:mobile` — local backend)
# .env.staging (used by `*:mobile:staging` — remote staging)
#
# All five mobile scripts read one of these two files, depending on suffix:
# dev:mobile / dev:mobile:staging — Metro only
# ios:mobile:device / ios:mobile:device:staging — Debug build to iPhone
# ios:mobile:device:staging:release — Release build to iPhone
#
# How EXPO_PUBLIC_* values reach the installed app:
# - Metro reads this file once at startup and inlines the values into every
# JS bundle it serves. Editing the file mid-session does NOT auto-refresh
# — restart Metro (Ctrl+C, then re-run `dev:mobile*`) to pick up changes.
# - For an installed Release build the value is baked into the embedded
# bundle at `ios:*:release` time; the only way to change it is to re-run
# the release build.
#
# Phone must be able to reach this URL. For local dev use your Mac's LAN IP
# (run `ipconfig getifaddr en0`), not `localhost` / `127.0.0.1`.
#
# Staging URL: see apps/desktop/.env.staging (`VITE_API_URL`) for the canonical
# value, or ask a teammate. Same backend across mobile / web / desktop.
EXPO_PUBLIC_API_URL=https://<api-host>
# Optional. Overrides the iOS bundleIdentifier for the DEV variant only so a
# dev whose Apple ID isn't on the Multica Apple Developer team yet can still
# sign local builds. Use a reverse-domain you own (e.g. com.<yourname>.multica).
# Leave unset to use the default ai.multica.mobile.dev.
#
# Only read in `.env.development.local` — staging / production bundle ids are
# never overridable (variants must stay on their canonical ids so the same
# device can hold all three side-by-side).
# EXPO_BUNDLE_IDENTIFIER_DEV=com.yourname.multica.dev

View File

@@ -1,5 +0,0 @@
# Mobile production env — committed so external users can build a personal
# iPhone copy of Multica against the same backend as multica.ai on web.
# Loaded by the `*:prod` scripts via dotenv-cli (see package.json).
EXPO_PUBLIC_API_URL=https://api.multica.ai
EXPO_PUBLIC_WEB_URL=https://multica.ai

View File

@@ -1,10 +0,0 @@
# Used by `pnpm dev:mobile:staging` and the `ios:device:staging[:release]`
# scripts. Loaded via `dotenv-cli` (see package.json), NOT by Expo's auto-
# loader — Expo only auto-loads .env.<NODE_ENV>.local files.
EXPO_PUBLIC_API_URL=https://multica-api.copilothub.ai
# Optional. Enables "Copy link" / "Open on web" actions in issue / project /
# comment menus. Without it those menu items just don't appear. Fill in the
# staging web host when you have it (canonical value lives in
# apps/desktop/.env.staging on a teammate's machine).
# EXPO_PUBLIC_WEB_URL=https://<staging-web-host>

View File

@@ -1,28 +0,0 @@
node_modules/
.expo/
dist/
web-build/
# macOS
.DS_Store
# Local env files only. `.env.staging` is committed — the override that
# rescues it from the repo-root `.env*` ignore rule lives in the root
# .gitignore (`!apps/mobile/.env.staging`).
.env*.local
# Native (Expo prebuild output)
ios/
android/
# Override the root .gitignore "data/" rule (intended for backend runtime
# dirs). apps/mobile/data/ is source — TanStack Query queries, mutations,
# stores, ApiClient — and MUST be tracked.
!data/
!data/**
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli

View File

@@ -1,575 +0,0 @@
# Mobile App Rules (apps/mobile/)
For cross-app sharing rules, see the root `CLAUDE.md` *Sharing Principles* section. This file documents the locked tech-stack baseline and the few mobile-specific rules — so AI doesn't suggest outdated alternatives.
## What mobile may import from `packages/`
- `import type` from `@multica/core/types/*` (zero runtime coupling)
- Pure functions from `@multica/core/`
Everything else, mobile writes its own.
## Pre-flight — before you write any code
For any new mobile feature / screen / interaction, complete the three steps below in order. **Skipping any step = no code yet** (read-only investigation and answering questions are exempt). This section overrides every other rule in this file.
### 1. Read the real web/desktop implementation
Until you can name the relevant code, don't reason from "general experience":
- `packages/views/<feature>/` — UI shape, information density
- `packages/core/<feature>/{queries,mutations,ws-updaters}.ts` — endpoints, cache key shapes, optimistic patches, WS event coverage
- Anything matching `*-display.ts` / `dedupe*` / `coalesce*` / `useMemo(() => transform(raw))` — preprocessing between backend and JSX
List the **must-agree points**: counts, enums, permissions, cross-cache side effects (e.g. a status change must also refresh inbox), navigation flow. Missing one of these is how the 2026-05-09 inbox duplicate-dot incident happened.
### 2. Show the user the interaction plan + parity points (≤30s to read)
Include:
- What you're about to build (one sentence)
- The container / interaction you propose (after walking the iOS-native > RNR > ask waterfall in §UI components)
- Mental-model parity points pulled from step 1 (example: "counts mirror `deduplicateInboxItems`")
- What UI **must differ** and why (example: "web has a sidebar workspace switcher; mobile puts it in Settings — same switching semantics")
- **Visual baseline check** (this is baseline, not polish): tab bar has icons, every screen has a title, multiple right-side row elements stack vertically, secondary text routes through a type-aware label; place a web screenshot next to a simulator screenshot
### 3. Wait for an explicit "do it / go / start" before writing code
"Yes / right / sounds good" ≠ permission to act. "How should we do X?" ≠ permission to act. Only an explicit imperative ("build X / change X / start") triggers code.
> Detailed rules live downstream: must-agree details in §Behavioral parity; component waterfall in §UI components; data / mirroring rules in §Data layer helpers and §Realtime. Pre-flight is the gate; those are the references.
## Behavioral parity with web/desktop
Mobile is allowed to differ in **UI and interaction** — it's a phone, not a port. It is NOT allowed to differ in **product semantics**. Users should not get a different mental model of "what's there" depending on which client they open.
**The four things that must agree:**
- **Counts / visibility** — same N for the same filter, under identical pagination / coalescing rules.
- **Permissions / access** — mirror the same logic web uses (from `packages/core`); don't re-derive from feel.
- **State enums / transitions** — render every status / priority / inbox type / comment type, with a sensible fallback for unknown values (per "API Response Compatibility" in the root CLAUDE.md). Never silently drop a category.
- **Data identity** — same `id`, same `slug`, same canonical fields. Don't invent ids or normalize differently.
**When UI must diverge**, write at the divergence point what rule it's mirroring (point at the source function in `packages/core` or `packages/views`) and why mobile renders it differently. A future reader should be able to tell in 30 seconds that the divergence is intentional and find the web-side source of truth.
### ⚠️ Incident (2026-05-09): inbox dedup missing — counts disagreed
**Symptom**: Web sidebar showed "Inbox 1" while mobile rendered 3+ unread dots on the same workspace, same user, same moment.
**Root cause**: Backend `GET /api/inbox` returns raw rows that include:
1. archived items, and
2. multiple inbox notifications per issue (a comment, a status change, and an assignment on the same issue each create one row).
Web/desktop run those raw rows through `deduplicateInboxItems` (`packages/core/inbox/queries.ts`) before rendering and before counting unread:
1. filter `archived = true` out
2. group by `issue_id`, keep the newest in each group
3. sort by `created_at` desc
Mobile's first cut rendered the raw list directly. So a single issue with 3 notifications showed as 3 rows with 3 unread dots, while web showed 1.
**Fix**: mirror `deduplicateInboxItems` into `apps/mobile/lib/inbox-display.ts`, run mobile's inbox tab through it before rendering and before any counting.
**Lesson — encode this into your reflexes when adding any new mobile screen that consumes a list endpoint**:
> Before rendering an API list response, grep `packages/core/<domain>/queries.ts` and `packages/views/<domain>/components/*.tsx` for any preprocessing — `dedupe*`, `coalesce*`, `filter*`, `*-display.ts`, `useMemo(() => transform(raw))`. Mirror everything that runs between `useQuery` and the JSX in web/desktop. **Do not assume the backend returns "what should be displayed"** — it usually returns the raw cache shape, and the client is responsible for shaping it.
This pattern repeats: timeline coalescing (`buildTimelineGroups`), inbox dedup, comment thread flattening, etc. Each one is a behavioral parity hazard if mobile skips it.
## Tech-stack baseline
Start minimal. Add to this list when actually adopted — do NOT pre-list libraries.
- **Expo SDK 55**
- **React Native 0.82**
- **React 19.1** — whatever Expo SDK 55 ships. Pinned in `apps/mobile/package.json` directly, NOT via root `catalog:`.
- **TypeScript** strict
- **Expo Router 55** (file-based routing — version aligns with Expo SDK)
- **NativeWind 4** + **Tailwind 3.4** — NativeWind 5 is unstable; stay on v4. (Note: web/desktop use Tailwind v4 — versions intentionally differ.)
- **react-native-reusables (RNR)** — the shadcn equivalent for React Native. Uses NativeWind + RN-Primitives + CVA. Component API mirrors shadcn. **Phased adoption in progress — see `apps/mobile/docs/rnr-migration.md` for the canonical plan, three-tier classification, and Phase 0/1/2/3 status.**
- **TanStack Query 5** — mobile owns its `QueryClient` with `AppState` focus listener + `NetInfo` online listener.
- **Zustand** — mobile-local state only.
- **expo-secure-store** — auth token persistence + theme preference (`light` / `dark` / `system`).
When upgrading any of these, update this list.
## UI components & theming
The full plan, file inventory, and migration phases live in `apps/mobile/docs/rnr-migration.md`. The rules below are the durable ones that must survive after the migration completes — read this section first when working on any UI.
### Hard rule — existing pattern first, defaults first, native waterfall
Three principles govern every UI decision on mobile. They exist to fight the temptation to recreate things that already exist — which is exactly the trap that produced the current 21 hand-written components and 18 hand-rolled sheets.
**Principle 1 — existing pattern first.** Before reaching for ANY new component (RNR add, hand-written primitive, new sheet container), grep the mobile codebase for an already-shipped pattern that does the same thing.
- Building a row → grep `components/inbox/`, `components/issue/`, `components/project/` for an analogous list-row first.
- Building a picker / sheet → check `components/issue/pickers/`, `components/project/pickers/` — there are 8+ pickers; one of them is probably the shape you need.
- Building a status / priority / actor visual → `components/ui/status-icon.tsx`, `priority-icon.tsx`, `actor-avatar.tsx` already exist. Re-use, don't re-skin.
- Composer / form / detail screen layout → `app/(app)/[workspace]/issue/[id]/`, `chat/`, `new-issue.tsx` — copy the structure, don't reinvent.
If a working pattern exists, **import or copy-adapt it**. If it almost-fits but needs a small extension, extend the existing one (one PR) rather than fork a second variant. Only when no existing pattern fits, proceed to Principle 2.
Why: every "I'll just write a fresh one" produced one of the 21 legacy components. The codebase already paid the cost of figuring out the iOS-correct shape for inbox rows, picker sheets, status icons — don't re-pay it.
**Principle 2 — defaults first.** When you use any RNR component, accept its default variant, default size, default spacing, default palette. Do NOT add wrapper layers, "improved" defaults, or `variant="multicaCustom"` styles unless a concrete product need demands it. Reaching for shadcn defaults is correct; reaching for a hand-tuned version of them is the failure mode.
**Principle 3 — iOS native > RNR > discuss.** When you need a new interaction, walk this waterfall in order, stop at the first hit:
1. **iOS / RN ships a native API?** Use it directly. Don't wrap a `Modal` to mimic it.
- Text input prompt → `Alert.prompt`
- Confirm / destructive prompt → `Alert.alert`
- Action sheet (one-of-N) → `ActionSheetIOS.showActionSheetWithOptions`
- Date / time → `@react-native-community/datetimepicker` (already installed)
- Image / camera → `expo-image-picker` (already installed)
- Documents → `expo-document-picker` (already installed)
- Share → `Share.share` from `react-native`
- Haptics → `expo-haptics` (already installed)
2. **RNR ships a matching component?** `npx @react-native-reusables/cli@latest add <name>`. Use the default variant/size/palette.
3. **Neither.** **Stop and ask the user.** Don't silently hand-roll a replacement — that's exactly how the pre-migration legacy accumulated.
### Component placement
After deciding via the waterfall:
- **Generic UI primitives** → `components/ui/`. Either RNR `add` output or hand-written with `cva` + `cn()` + semantic tokens + `@rn-primitives/*` building blocks.
- **Domain UI** (anything mentioning issues, priorities, statuses, actors, agents, presence, projects, runs) → `components/<domain>/`. Composes primitives but isn't generic.
Never copy the visual shape of an existing hand-written `components/ui/` component as a template if its RNR equivalent exists — most of them are pre-migration legacy. The migration doc tracks which files are legacy and which have been replaced.
### Theming model — CSS variables + class-based dark mode
- Source of truth for colors is `global.css` — CSS variables defined under `:root` (light) and `.dark:root` (dark). `tailwind.config.js` maps utilities like `bg-background` to `hsl(var(--background))`, so the same class name resolves to the right color in either mode automatically.
- `darkMode: 'class'` (NOT media-query). We control the mode explicitly so the in-app Settings → Appearance picker (`light` / `dark` / `system`) can override the OS preference.
- The mode is switched by NativeWind's `useColorScheme().setColorScheme(mode)`. Calling it sets the root class; every `bg-foo` / `text-foo` reactively rebinds to the new variable values. No manual className toggling, no re-render dance.
- React Navigation (`expo-router`'s `Stack` headers, modal chrome, drawer) is themed separately by passing `NAV_THEME[isDarkColorScheme ? 'dark' : 'light']` into `ThemeProvider`. Source of `NAV_THEME` is `lib/theme.ts`, which mirrors `global.css` in TypeScript.
- Persistence: the user's choice goes into `expo-secure-store` under the key `theme-preference` (values: `light` / `dark` / `system`). Loaded synchronously at app startup in `app/_layout.tsx` before the first paint; missing key defaults to `system`.
- **When you change a CSS variable in `global.css`, also update `lib/theme.ts`.** They mirror each other. The RNR docs include a prompt template for this sync.
### What this replaces (and what stays)
- The old "Visual tokens" approach — hand-transcribed hex values in `tailwind.config.js` — is being **replaced** by the CSS-variable system above. Web tokens are still inspiration only; we do NOT import `packages/ui/styles/tokens.css` (Tailwind v3.4 vs v4 mismatch makes file sharing impractical; isolation is intentional).
- The `cn()` helper at `lib/utils.ts` stays — RNR uses the same one.
- The sheet rule from Lesson 6 below still applies. RNR ships `Dialog` and other modal primitives; use them for **new** sheets. The legacy `sheet-shell.tsx` (RN `<Modal presentationStyle="pageSheet">`) has been deleted — every long-list / search / form sheet now uses an Expo Router `presentation: "formSheet"` route, which instantiates iOS' `UISheetPresentationController` for native grabber, detents, and spring drag physics.
## Build & release
- **Main CI** (`.github/workflows/ci.yml`) excludes mobile via `--filter='!@multica/mobile'`. Mobile failures do NOT block web/desktop PRs.
- **Mobile verify** (`.github/workflows/mobile-verify.yml`): triggered on `apps/mobile/**` or `packages/core/types/**` changes — runs typecheck/lint/test only, no IPA build.
- **Mobile release** (`.github/workflows/mobile-release.yml`): triggered by `mobile-v*.*.*` tag → `eas build` + `eas submit`.
- **OTA** — EAS Update for JS-only fixes that don't change the runtime version. Manual / on-demand push to preview/production channels.
Mobile release cadence is decoupled from main `v*.*.*` tags (server / CLI / desktop).
## Realtime / WebSocket strategy
Mobile uses the same WS server protocol as web/desktop, but mounts subscriptions differently. The rules below exist because mobile-specific constraints (cellular data cost, AppState lifecycle, per-screen unmount cleanup, smaller cache surface) make a direct port of web's pattern wrong.
### Three-layer stack
```
Layer 1 ws-client.ts — single socket, no React. Exponential
backoff with full jitter. Three-state
lifecycle (idle / active / paused) so
the provider can pause on background
and resume on foreground without
racing the auto-reconnect timer.
Layer 2 realtime-provider.tsx — owns the WSClient. Mounts/unmounts on
auth + workspace + AppState + NetInfo
changes. Exposes useWSClient().
Layer 3 use-<feature>-realtime.ts — per-feature subscriptions. Translate
events → cache mutations.
```
Layer 3 is what changes per feature; layers 1 and 2 are infrastructure and shouldn't be edited when adding event coverage.
### Mount strategy: list-level global, per-record per-screen
Mobile **does NOT use a single centralized `useRealtimeSync` hook** like `packages/core/realtime/use-realtime-sync.ts`. That pattern is fine on web (one tab = one mount, lives forever) but on mobile it gets in the way: most events care about a single record (one issue's comments, one chat session's messages), and the hook needs to know which record without prop-drilling.
Two mount tiers:
- **Listing-level (always-on for the workspace session)** — mount inside the `<RealtimeSubscriptions />` component in `app/(app)/[workspace]/_layout.tsx`. These don't take parameters; they patch caches keyed only on `wsId`. Examples: `useInboxRealtime`, `useMyIssuesRealtime`. Both run from the moment the user enters a workspace until they leave it, regardless of which tab is foregrounded.
- **Per-record (mounted with id, cleans up on unmount)** — mount inside the screen that owns the record, parameterized by the id from the route. Example: `useIssueRealtime(id, () => router.back())` in `issue/[id].tsx`. The hook filters every event by `payload.issue_id === id` and only patches the current issue's caches. When the user navigates away the `useEffect` cleanup unsubscribes all listeners, so a backgrounded screen doesn't keep mutating caches it no longer owns.
Don't mount a per-record hook globally to "just be safe" — every filter call on every event then runs N times where N is the number of issues a user has ever opened in this session.
### Patch over invalidate (cellular-data rule)
When a WS payload contains the full updated object, **patch** the cache (`setQueryData` / `setQueriesData`). Only fall back to **invalidate** when:
1. The payload is just an id (we don't know the full new shape — e.g., `issue:created` with no scope context).
2. The cache shape doesn't match what we can patch (e.g., multi-key scope-filtered lists where we'd have to predict membership).
3. The event is rare enough that the extra refetch isn't a real cost (e.g., `issue:deleted` on a list that was about to invalidate anyway).
4. After a reconnect, where we may have missed events while disconnected.
Web is fine to invalidate generously because most users are on broadband; mobile users on cellular pay for each refetch. A `setQueryData` is free; an `invalidateQueries` is a network roundtrip per affected query key.
### Mobile-owned updaters (don't import `packages/core/issues/ws-updaters.ts`)
Mobile has its own `apps/mobile/data/realtime/issue-ws-updaters.ts` even though web has a near-identical file. **Do not import web's updaters into mobile.** Two reasons:
1. **Key-factory binding.** Web's updaters reference `issueKeys` from `packages/core/issues/queries.ts` — a different runtime instance from mobile's `apps/mobile/data/queries/issue-keys.ts`. TanStack Query compares keys structurally so it *appears* to work, but binding cache mutation to a foreign key factory invites silent drift the moment either side adjusts its key shape (renames a segment, adds a discriminator).
2. **Cache-shape divergence.** Mobile has simpler caches: flat `Issue[]` for my-issues (web has status-bucketed); no children subtree (web does); no label-byIssue cache (web does). Web's updaters carry conditional dead-code for paths mobile doesn't have, and mobile would silently no-op on web shapes that don't exist locally.
When the same logic needs to exist on both sides, copy the design — not the import. Document the mirror at the top of the mobile file (see `issue-ws-updaters.ts` for the pattern).
### Event-always-wins (optimistic conflict policy)
Mutations like `useUpdateIssue` apply an optimistic patch to the detail cache, then the server processes the request and broadcasts `issue:updated`. If a separate WS event (from another client / another user / an agent) arrives between the optimistic patch and the mutation response, the WS handler overwrites the optimistic state with the server's authoritative state. Brief UI flicker is acceptable; correctness wins.
**Do not** add timestamp-comparison logic to "protect" the optimistic state — the server is the truth and the user benefits from seeing real changes immediately. If a specific event proves problematic in practice, add the gate at that point, not by default.
### Reconnect handling
Each hook registers a single `ws.onReconnect(cb)` that invalidates **only the queries it owns**:
| Hook | Invalidates on reconnect |
|---|---|
| `useInboxRealtime` | `inboxKeys.list(wsId)` |
| `useMyIssuesRealtime` | `issueKeys.myAll(wsId)` |
| `useIssueRealtime(id)` | `issueKeys.detail(wsId, id)` + `issueKeys.timeline(wsId, id)` |
No global "invalidate everything on reconnect" sweep. The fanout would be every screen the user has ever visited in this session refetching simultaneously — wasteful on cellular and prone to rate-limiting the server in low-signal areas where reconnects happen frequently.
### Cross-cutting cache patches across features
Some events legitimately need to mutate a foreign feature's cache. The
canonical example: `issue:updated` changing an issue's status must also
update the StatusIcon shown on the matching inbox row, and `issue:deleted`
must strip every inbox row pointing at the dead issue.
The pattern:
1. **The feature whose cache is being patched owns the updater.** Example:
`apps/mobile/data/realtime/inbox-ws-updaters.ts` exports
`patchInboxIssueStatus` and `dropInboxItemsByIssue` — they live with
inbox, not with issues, because they read `inboxKeys.list(wsId)`.
2. **That feature's realtime hook subscribes to the foreign event.**
`use-inbox-realtime.ts` subscribes to `issue:updated` and `issue:deleted`
alongside the `inbox:*` events. The issue-realtime hook does NOT know
that inbox cares.
3. **Mirror web's wiring.** Web's `packages/core/inbox/ws-updaters.ts` has
the same handlers; mobile copies the design. Behavioral parity hazard:
without these the mobile inbox row keeps showing the prior status (or
404s on tap if the issue is gone) while web users see the change live.
If you find yourself reaching across features in `use-issues-realtime` to
patch something else, you have the inversion: move the updater to the
patched feature and subscribe there.
### Adding new event coverage — recipe
1. **Read the payload.** Find the event in `@multica/core/types/events.ts`. Note the fields; decide if patch is possible (full object) or invalidate is required (just an id).
2. **Mirror, don't import.** If web has an updater for this event in `packages/core/<feature>/ws-updaters.ts`, copy the design into `apps/mobile/data/realtime/<feature>-ws-updaters.ts`. Adapt to mobile's actual cache shapes — don't carry web's bucket/children/childProgress dead-code if mobile doesn't have those caches.
3. **Subscribe in a hook.** Either extend an existing `use-<feature>-realtime.ts` or create a new one. Filter by id at the top of each handler so per-record hooks ignore unrelated events.
4. **Mount it.** Listing-level → add to `<RealtimeSubscriptions />` in workspace `_layout.tsx`. Per-record → add to the owning screen's body, parameterized by the route id.
5. **Add reconnect invalidate.** Single `ws.onReconnect()` call scoped to the hook's own keys.
6. **Verify cross-client.** Open the affected screen on mobile, change the same record from a second client (web or another device), confirm mobile updates within ~500ms without pull-to-refresh.
If a new event has no consumer on mobile (e.g., `subscriber:added` when mobile doesn't render subscriber lists yet), **don't subscribe**. Mounting a listener with no UI consumer adds CPU on every fire for zero user benefit.
## Data layer helpers (use these — don't recreate them)
Common boilerplate is wrapped. New code that reinvents these helpers is a
review-block, both because it makes the codebase inconsistent AND because
the helpers encode subtle correctness rules (signal forwarding, schema
fallback, sync-before-await ordering, type-safe payloads).
### Three rails that every feature must follow
1. **Logic mirrors web/desktop.** See §Pre-flight step 1 at the top of
this file. Restating the data-contract half here: endpoints, request
bodies, response schemas, optimistic patches, and cache key prefixes
all match web verbatim. UI / interaction can diverge freely per
§Behavioral parity.
2. **Use the existing components — no new primitives.** Walk the
`iOS native > RNR > discuss` waterfall in §UI components. If RNR ships
it, `npx @react-native-reusables/cli@latest add <name>`. If iOS ships
it (Alert / ActionSheetIOS / Haptics / share / picker), use it directly.
If neither has it AND it's a single-screen need, inline compose with
`<Pressable>` + `<Text>` + tokens. **Do NOT create a new generic
primitive in `components/ui/` for one or two callers** — the migration
doc lists "21 hand-written components" as exactly the trap we're
escaping. Threshold for a new primitive is three callers AND no
RNR/iOS-native alternative.
3. **Use the wrapped request / WS layer.** See the helper map below.
### API client: `fetchValidated` + `fetchValidatedWith`
`apps/mobile/data/api.ts` exposes two private helpers on `ApiClient` that
collapse the fetch + parseWithFallback envelope. **Every new read-side
method that returns a typed body must use them.**
| Helper | When to use | Shape |
|---|---|---|
| `this.fetchValidated(path, schema, fallback, opts?)` | GET endpoints | One-liner method body — see `getMe`, `listInbox`, `getNotificationPreferences` |
| `this.fetchValidatedWith(path, schema, fallback, init, opts?)` | Any HTTP method (PATCH / PUT / POST) whose response is consumed | Carries the body via `init.body` + method; signal forwarding handled |
| `this.fetch<T>(path, init?)` directly | Writes whose response is `{ count }` / `void` / not consumed by UI logic | Only here is a raw `as T` acceptable, because the value never reaches a render path |
Rules:
- The fallback object MUST match the success type exactly so downstream
code never has a partial value (see `EMPTY_USER` / `EMPTY_INBOX_LIST`
pattern in `apps/mobile/data/schemas.ts`).
- The `endpoint` label is for telemetry — defaults to the path; override
only when the path has dynamic segments and you want stable groupings
(`GET /api/issues/:id` not `GET /api/issues/abc-123`).
- Migration is progressive: not every legacy method is converted yet.
Adding a new method? Use the helpers. Touching an old method that
isn't using them? Convert it as part of the same PR.
### Query / mutation factory pattern
Every workspace-scoped feature exposes a key factory in
`apps/mobile/data/queries/<feature>.ts`:
```ts
export const inboxKeys = {
all: (wsId: string | null) => ["inbox", wsId] as const,
list: (wsId: string | null) => [...inboxKeys.all(wsId), "list"] as const,
};
```
Three-segment shape matches web (`packages/core/inbox/queries.ts`).
Reasons:
- TQ does prefix matching by default — `invalidateQueries({ queryKey:
inboxKeys.all(wsId) })` invalidates the list AND any future sub-keys
(e.g. a `detail(id)`) under the same prefix. Use `.all` to clear a
workspace cleanly, `.list` to target the list specifically.
- Cross-platform mental-model parity: a reader switching between mobile
and web finds the same key shape.
- Stops bare `["inbox", wsId]` strings from spreading. Grep
`\["inbox"` in this codebase should only hit the factory file.
Mutations import the factory and use `inboxKeys.list(wsId)` everywhere —
never inline strings.
### WS layer: `ws.on<E>()` + `useWSSubscriptions`
Two helpers replace ~20 lines of boilerplate per realtime hook:
1. **`ws.on<E extends WSEventType>(event, handler)`** — the handler's
`payload` parameter is auto-typed to `WSEventPayload<E>`. **Do not
add `as XxxPayload` casts at handler bodies** — they're redundant
and (worse) silently hide drift if `WSEventPayloadMap` shifts.
The cast is only acceptable when one handler covers multiple events
that don't share a typed common ancestor (see `onTaskEvent` in
`use-issue-realtime.ts` — `task:progress` has no formal payload).
2. **`useWSSubscriptions(setup, deps)`** in
`apps/mobile/lib/use-ws-subscriptions.ts` — wraps the
`if (!ws || !wsId) return; useEffect + cleanup` template. Setup
callback receives `(ws, wsId)`, returns the unsub array (or
`undefined` to short-circuit, e.g. when a per-record id is missing).
Adding a new event type? Extend `packages/core/types/events.ts`:
1. Add the event to the `WSEventType` union.
2. Add the payload interface.
3. Add the `WSEventType → payload` entry in `WSEventPayloadMap`.
Forgetting step 3 means callers get `unknown` (loud — they have to
narrow), not `any` (silent unsafe access). That's the safety net.
### Synchronous setQueryData before `await cancelQueries`
Optimistic mutations that flip state read by a UI element that's about
to be in a navigation snapshot (the classic case: marking an inbox row
read, then `router.push` to the issue) MUST call `setQueryData` in
`onMutate` **before** `await qc.cancelQueries(...)`. The await yields
one microtask; iOS captures the source-view snapshot during that gap and
freezes the row in its unread style inside the slide-in transition.
Lives inside the mutation, not the caller. See `useMarkInboxRead.onMutate`
in `apps/mobile/data/mutations/inbox.ts` for the canonical example.
### Checklist for a new feature
Before opening a PR for a new screen / mutation / realtime hook:
1. Grep `packages/core/<feature>/` for the web equivalent — endpoints,
key shape, optimistic patch shape. Mirror, don't invent.
2. API methods → `fetchValidated` / `fetchValidatedWith` (or raw
`this.fetch` only for writes with no consumed response).
3. Query key → factory in `data/queries/<feature>.ts`, 3-segment shape.
4. Mutations → optimistic three-step (snapshot → patch → rollback) +
settle invalidate, all keys via factory.
5. Realtime → `useWSSubscriptions(setup, deps)`, typed `ws.on<E>()`,
per-event patching (no global invalidate) when payload carries the
full object.
6. UI → waterfall (iOS native > RNR > inline compose). No new
`components/ui/` primitive unless three callers + RNR doesn't ship.
7. Verify cross-client: change the same record from web and confirm
mobile updates within ~500ms without pull-to-refresh.
## Lessons learned (encode into reflexes)
These are real mistakes that have been made building the mobile shell. Each one cost time to find. Treat as enforceable rules, not suggestions.
### 1. Install/upgrade any dependency: check `dist-tags` first
Do NOT hardcode version numbers from memory. Run `pnpm view <pkg> dist-tags` to see `latest / sdk-XX / canary` and decide which tag to lock. For Expo packages (`expo-*` / `react-native-*` that Expo aligns), use `pnpm exec expo install <pkg>` — it queries Expo's dependency manifest and picks the SDK-compatible version. `pnpm add <pkg>` will silently install the npm `latest`, which often outpaces the SDK and breaks at runtime. Past mistakes: hardcoded `expo@~54.0.0` (latest was already `55.x`); installed `lucide-react-native@0.468` without checking React 19 peer compatibility.
### 2. New source subdirectory: verify git tracking
Every time you create a new source subdirectory under `apps/mobile/` (e.g. `data/`, `lib/foo/`, `components/inbox/`):
1. Run `git check-ignore -v <dir>/<file>` immediately. The repo-root `.gitignore` has generic rules (`data/`, `build/`, `bin/`, `*.app`, `*.dmg`) that are intended for backend runtime/output dirs but will silently swallow mobile source.
2. If a rule matches, add `!<dir>/` and `!<dir>/**` to `apps/mobile/.gitignore` (subtree override beats parent rule).
3. After the commit lands, run `git ls-files <dir>` to confirm every file is tracked.
This rule exists because `apps/mobile/data/` was once committed-but-not-tracked — 14 source files (ApiClient, all queries, all stores) were missing from the git tree even though `git status` was clean. Local builds worked because Metro reads the filesystem; CI / clones would have died.
### 3. ApiClient capability list (4 must-haves)
Mobile's fetch wrapper (`apps/mobile/data/api.ts`) MUST implement all four. Missing any of them is a bug, not a deferred polish item.
1. **Zod `parseWithFallback` for response validation.** Strictly enforced by the root CLAUDE.md "API Response Compatibility" section and the "Type drift defense" section above. **Any new endpoint method that does `as T` on the response body is a bug.** Reuse schemas from `packages/core/api/schemas.ts` (pure Zod exports, on the mobile sharing whitelist); define mobile-side fallbacks for new endpoints in `apps/mobile/data/`.
2. **`onUnauthorized` 401 callback.** The `ApiClientOptions.onUnauthorized` hook fires on every 401 and must be wired in `app/_layout.tsx` to: clear auth token, clear workspace store, clear TanStack Query cache, navigate to `/login`. Without it a session that expired server-side puts every subsequent request into a 401 loop and the user sees opaque "API error: 401" toasts on every screen. Use a `signingOutRef` to make the callback idempotent — multiple in-flight requests will all 401 simultaneously when a session expires.
3. **`X-Request-ID` per request.** Generate a short random ID (`createRequestId()` in `apps/mobile/lib/request-id.ts`), send as `X-Request-ID` header. The same ID goes into client-side log lines so backend telemetry can be cross-referenced (server picks it up via the same header).
4. **Structured request logger.** Two log lines per request: `[api] → METHOD path` (start, with `rid`) and `[api] ← STATUS path` (end, with `rid` + `duration`). Use `console.error` for 5xx, `console.warn` for 404s, `console.log` for success. Without this, debugging mobile API issues means staring at the React Native Network panel; with it, the dev console is self-explanatory and prod telemetry already comes structured.
**What mobile correctly does NOT need (don't add these):** CSRF token (`X-CSRF-Token`), `credentials: "include"`, cookie reading. Mobile is Bearer-token auth, not cookie auth — the cookie attack surface that requires CSRF protection on web doesn't exist on mobile.
### 4. Every read query must pass `signal` to fetch; api.ts always has a hard timeout
**Symptom that triggered the rule (2026-05-11)**: Inbox screen sometimes returned to the foreground showing the FlatList pull-to-refresh spinner stuck indefinitely. List items were rendered underneath, but `isRefetching` never flipped back to `false`. Pull-to-refresh, navigating away, and re-opening the tab did not clear it.
**Root cause**: `apps/mobile/data/api.ts`'s `fetch()` had no timeout, no `AbortController`, and no caller-`signal` plumbing. iOS suspends backgrounded apps within ~30 seconds and can silently kill in-flight network tasks (facebook/react-native#35384 — "iOS fetch() POST fails if called too soon, with app running in background"; facebook/react-native#38711 — "JS Timers don't fire when app is launched in background"). When the app foregrounded, the suspended fetch's Promise neither resolved nor rejected. TanStack Query saw an existing query still in `fetching` state and did NOT start a new fetch on invalidate — it just waited on the dead Promise forever. `isRefetching` stayed `true`, the FlatList spinner stayed spinning.
**Rule, three parts (every one is required — partial fixes leave a footgun)**:
**1. `api.ts` `fetch()` MUST have a hard timeout** (currently 30s; the `FETCH_TIMEOUT_MS` constant). Without this, a single suspended request can wedge a query indefinitely. Use a manual `AbortController` + `setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)` — **DO NOT** use `AbortSignal.timeout()`: Hermes throws `TypeError: AbortSignal.timeout is not a function` (facebook/react-native#42042). Same for `AbortSignal.any()` — Hermes does not implement it (livekit/livekit#4014). To combine the timeout signal with a caller-supplied signal, attach an `"abort"` event listener manually and forward to the inner controller.
**2. Every read-side `api.ts` method MUST accept `opts?: { signal?: AbortSignal }` and pass it to `fetch()`**. Mutations don't need this (TanStack Query doesn't pass a signal to `mutationFn`). The pattern:
```ts
async listInbox(opts?: { signal?: AbortSignal }): Promise<InboxItem[]> {
return this.fetch<InboxItem[]>("/api/inbox", { signal: opts?.signal });
}
```
Adding a new query-bound method without `opts` is a bug — the next person who writes a `queryFn` will silently drop the signal.
**3. Every `queryFn` MUST forward the signal it receives from TanStack Query**. The official TanStack guide (tanstack.com/query/v5/docs/framework/react/guides/query-cancellation) states: "When a query becomes out-of-date or inactive, this `signal` will become aborted." The pattern:
```ts
queryOptions({
queryKey: [...],
queryFn: ({ signal }) => api.listInbox({ signal }),
});
```
Forgetting the destructure (writing `() => api.listInbox()`) defeats every benefit of (1) and (2): TQ can't cancel hung requests when the user navigates away, and on workspace switch every stale request lives until its 30s timeout.
**Verification**: After any change to `api.ts` or a new query addition, `grep -n "queryFn: () =>" apps/mobile/data/queries/` should return zero matches. Every `queryFn` should destructure `{ signal }`.
**Why the wiring already in `data/query-client.ts` (focusManager + AppState, onlineManager + NetInfo) is not enough on its own**: focusManager triggers a *refetch attempt* when the app comes back to the foreground, but if the prior fetch promise is hanging, TQ won't start a new request — it'll keep waiting on the dead one. Only timeout + signal cancellation actually unwedges the query. The three pieces work together: signal lets TQ proactively cancel on staleness, timeout is the safety net when nothing else fires, focusManager is the "user came back, let's recheck" trigger.
### 5. Modal container selection: match container to content, don't copy the first sheet
The mobile codebase started with ~15 Modal sheets. They almost all copied the same shape (`Modal transparent fade` + hand-drawn `bg-black/40` backdrop + centered/bottom card with `maxHeight`). That shape is correct for **short action menus** (the earliest sheets), wrong for **everything else**. Once the pattern was established as "the mobile sheet style," subsequent sheets inherited it regardless of content — and inherited a different bug each time: keyboard squashing the card, `maxHeight: 380` clipping FlatLists on tall phones, `useSafeAreaInsets` returning 0 inside Modal so bottom content collides with the Home Indicator, etc.
**Choose the container by content type, not by "what the last sheet did":**
| Content shape | Container | Why |
|---|---|---|
| < 5 fixed actions, 1-2s stay, no keyboard | `Modal transparent` + bottom action card | Short, light, dim-backdrop tap-to-dismiss is correct here |
| Yes/No or one-tap confirm | `Alert.alert` | Native, accessible, no custom UI |
| One-of-N from a server-driven short list | `ActionSheetIOS.showActionSheetWithOptions` | Native iOS action sheet, no custom UI |
| < 7 fixed picker options, no search | `Modal transparent` + small centered card | Same as action card, just centered |
| Long list / search box / content view / form / anything with a keyboard | **Expo Router `presentation: "formSheet"` route** | Instantiates iOS `UISheetPresentationController`: native grabber, drag-dismiss with spring physics, stacked-card backdrop, detents — all UIKit-managed |
| Multi-screen flow / route-level full modal | Expo Router `presentation: "modal"` | Full-page slide-up, has back-stack, swipe-dismiss, deep-linkable |
**`SheetShell` is deleted.** It was a wrapper around RN core `<Modal presentationStyle="pageSheet">` which does NOT instantiate `UISheetPresentationController` — so it never had native grabber, stacked-card backdrop, or real spring physics. Every former SheetShell call site is now an Expo Router formSheet route.
**Rules for adding a new formSheet route:**
1. **File goes under the parent context** so the URL reads sensibly — issue-detail pickers under `app/(app)/[workspace]/issue/[id]/picker/<field>.tsx`; project pickers under `project/[id]/picker/<field>.tsx`; transient action sheets under `<context>/<noun>/actions.tsx`. The new-issue draft flow has its own `new-issue-picker/<field>.tsx` directory because routes can't share state with the modal that opened them — see the draft-store discussion below.
2. **Register the Stack.Screen in `app/(app)/[workspace]/_layout.tsx`** using the shared `SHEET_OPTIONS` constant. Do NOT inline the config per screen — every picker-row sheet must look and feel identical (grabber, detents, corner radius). Isolated sheets that have no neighbour to be consistent with may override `sheetAllowedDetents` only (e.g. the `menu` sheet uses `"fitToContents"` because it's ≤ 5 fixed actions and the two-snap default would leave 60% blank).
3. **Self-contained route bodies.** A picker route reads the record it needs from the TanStack Query cache (issue / project / timeline are already cached when the user gets there), calls its own mutation on submit, and `router.back()`s. No callbacks back up to a parent. The only legitimate exception is the new-issue draft flow, which uses `useNewIssueDraftStore` because the issue doesn't exist yet — there's nothing in cache to read.
4. **Header is drawn inside the body**, not by the Stack. SHEET_OPTIONS sets `headerShown: false`; the body renders its own `<View>` with title + optional right action. The native Stack header on a formSheet creates a layout dance with the grabber that doesn't match iOS sheets.
**SHEET_OPTIONS rationale (every value exists for a known bug or platform behavior):**
- `presentation: "formSheet"` — the magic that hands the screen to `UISheetPresentationController`.
- `sheetGrabberVisible: true` — the iOS native drag handle. Users don't discover the gesture without it.
- `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55 (expo/expo#42904 padding inconsistency, #42965 zero-size). Predictable two-snap presentation across every picker-row sheet is more important than shrink-wrapping; every formSheet that lives in a chip row (issue-detail / project-detail AttributeRow) uses these explicit detents so muscle memory carries across the row. Isolated sheets (no chip-row neighbour) override with `"fitToContents"` — see the workspace `menu` sheet for the canonical example.
- `sheetCornerRadius: 20` — matches RNR card radius. Without this iOS uses a larger system default that's slightly out of sync with the rest of the app.
- `contentStyle: { height: "100%" }` — safety net against the zero-size class of bugs above. Ensures the sheet body fills the allotted detent height.
**Caveats that still apply:**
- **Android falls back to a regular modal** — no rounded corners, no native drag. mobile/CLAUDE.md treats iOS as the primary target so this is acceptable, but document inline at the call site if a particular feature must work identically on both.
- **A formSheet pushed from inside a `presentation: "modal"` route is supported** by Expo Router 55 / RN Screens 4, but the back gesture from the formSheet returns to the modal, not the underlying tab. This is the right UX for the new-issue draft flow (sheet dismisses back to the form), but check the navigation graph if you're adding a sheet under a non-obvious parent.
**Carve-out — picker-row consistency wins over per-container optimisation:**
The table above says "< 7 fixed picker options → centered card". That rule
applies in isolation, but **breaks down when multiple pickers coexist in
the same chip row** (issue-detail AttributeRow is the canonical case:
status / priority / assignee / label / project / due-date all sit next
to each other). Mixing centered cards (for status/priority, short
fixed lists) with formSheet routes (for assignee/label/project, long
lists) means the user gets two different gestures depending on which
chip they tap — there's no muscle-memory carry-over.
When you find yourself building a row like this, **use the formSheet
route for every picker in the row**, even the ones a standalone
centered card would handle fine. The cost is some empty space below
57 short rows; the gain is uniform tap → slide-up-sheet +
drag-down-to-dismiss behaviour across the whole row. Linear iOS /
Things 3 / Apple Reminders all do this for the same reason.
The centered-card pattern stays correct for **isolated short menus**
(e.g. the chat-composer's "More" popover, the timeline's coalesce-
expand) where there's no neighbour to be consistent with.
### 6. Destructive swipe: reveal only, no auto-fire — always pair with haptic
iOS Mail / Linear iOS / Things: leftward swipe reveals a red Archive
button; the user **must tap it** to commit. The earlier mobile inbox
swipe auto-fired on full drag past the threshold and "felt wrong" — no
peek, easy to trigger by accident on a fast vertical scroll that
catches some horizontal motion. There is no native UX that auto-commits
a destructive action on swipe — match the platform standard.
The rule:
- `ReanimatedSwipeable` with `renderRightActions={<Pressable onPress={fireArchive} />}`.
- **No `onSwipeableOpen` auto-fire.** Drag → reveals the action; release
past threshold → action stays revealed; tap action → commit; tap
outside or drag back → cancel.
- One-shot `Haptics.impactAsync('medium')` when the drag crosses the
action width. Wire via `useAnimatedReaction(() => drag.value <= -ACTION_WIDTH, ...)`
+ `runOnJS(Haptics.impactAsync)`. The shared-value reaction runs on
the UI thread; `runOnJS` bridges to the JS-only Haptics call.
See `apps/mobile/components/inbox/swipeable-inbox-row.tsx` for the
reference implementation. When adding a new swipe-to-action row
elsewhere, copy that pattern; do not reinvent.
### 7. Tier C domain components: opportunistic upgrade only — no silent rewrites
Tier C in `apps/mobile/docs/rnr-migration.md` §4 names the domain UI
files that stay where they are but need foundation upgrades
(`ActorAvatar`, `StatusIcon`, `PriorityIcon`, `PresenceDot`, etc.).
**You don't rewrite a Tier C file just because you're rendering it in
your new feature.** That spreads scope and stalls feature PRs.
Two rules:
1. **Touch only what your PR needs to touch.** If `ActorAvatar` has
hardcoded `#71717a` and you're building an inbox feature that
*uses* `<ActorAvatar>`, leave the hex alone. Note it for a future
doc / cleanup PR.
2. **Upgrade Tier C only when you're modifying that file for a
different real reason.** E.g. adding presence to chat header → you
were going to touch `<ActorAvatar>` anyway → fold the RNR-Avatar
migration + hex → token cleanup into the same PR.
The pre-migration legacy persists because someone "while I'm in
here…"-style touched 21 files in one PR; we don't do that anymore.
Document any Tier C smells you spotted in the PR description as
follow-ups; surface for a future grouped Tier C cleanup PR.

View File

@@ -1,104 +0,0 @@
# Multica Mobile (iOS)
Expo + React Native iOS client for Multica. Independent from web/desktop — shares only types from `@multica/core/`. See [`CLAUDE.md`](./CLAUDE.md) for the locked tech-stack baseline and import rules.
## Just want to use it on your phone? (no development)
Multica isn't on the App Store yet — until that changes, anyone who wants it on their iPhone builds from source. One command:
```bash
pnpm ios:mobile:device:prod:release
```
This connects to the same backend as `multica.ai`, so your existing account just works.
**Prerequisites**: Mac with Xcode, a free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with [Developer Mode enabled](https://docs.expo.dev/guides/ios-developer-mode/). Walk through Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) (pick **Development build → iOS Device**) if any of that is missing.
Xcode signs the build with the "Personal Team" your Apple ID automatically owns — created silently the first time you signed into Xcode, no setup needed. The first build downloads CocoaPods + compiles React Native from source — expect 1020 minutes. Subsequent builds reuse Xcode's cache.
**If Xcode rejects signing with "No matching provisioning profiles found"** — rare, happens if someone has claimed the default bundle id `ai.multica.mobile` on Apple's developer portal. Pick any reverse-domain you own and re-run:
```bash
export EXPO_BUNDLE_IDENTIFIER_PROD=com.yourname.multica
pnpm ios:mobile:device:prod:release
```
**7-day signing limit**: a free Apple ID signs builds for 7 days. After that, plug back into the Mac and re-run the command to re-sign. An Apple Developer Program account ($99/yr) extends this to 1 year.
Everything below is for app developers — you can ignore the rest if you only wanted a personal install.
## Scripts
| Command | What it does | Backend |
|---|---|---|
| `pnpm dev:mobile` | Metro only (reuse existing install) | local (`.env.development.local`) |
| `pnpm dev:mobile:staging` | Metro only (reuse existing install) | staging (`.env.staging`) |
| `pnpm dev:mobile:prod` | Metro only (reuse existing install) | production (`.env.production`) |
| `pnpm ios:mobile` | Full rebuild + install on **iOS Simulator**, Debug | local |
| `pnpm ios:mobile:staging` | Full rebuild + install on **iOS Simulator**, Debug | staging |
| `pnpm ios:mobile:prod` | Full rebuild + install on **iOS Simulator**, Debug | production |
| `pnpm ios:mobile:device` | Full rebuild + install on **USB iPhone**, Debug | local |
| `pnpm ios:mobile:device:staging` | Full rebuild + install on **USB iPhone**, Debug | staging |
| `pnpm ios:mobile:device:staging:release` | Full rebuild + install on **USB iPhone**, Release (standalone) | staging |
| `pnpm ios:mobile:device:prod` | Full rebuild + install on **USB iPhone**, Debug | production |
| `pnpm ios:mobile:device:prod:release` | Full rebuild + install on **USB iPhone**, Release (standalone) | production |
`dev:*` runs Metro only — assumes the matching variant is already installed. `ios:mobile*` does a full native rebuild + install.
Bundle id and display name switch on `APP_ENV` (see `app.config.ts`), so Dev / Staging / Production variants can coexist on the same device or simulator.
## First-time setup
`.env.staging` is committed (public staging URL). `.env.development.local` is gitignored — copy the template once:
```bash
cp apps/mobile/.env.example apps/mobile/.env.development.local
# then edit EXPO_PUBLIC_API_URL inside it to your Mac's LAN IP, e.g. http://192.168.1.42:8080
```
If your Apple ID isn't on the Multica Apple Developer team yet, also uncomment and set `EXPO_BUNDLE_IDENTIFIER_DEV` to a reverse-domain you own (e.g. `com.yourname.multica.dev`). This **only** overrides the dev variant — staging / production bundle ids are intentionally not overridable so variants can coexist.
## Build it onto your iPhone
Two paths, depending on what you want to do:
### Day-to-day development (Mac in front of you)
```bash
pnpm ios:mobile:device:staging
```
Produces a **Debug build** with `expo-dev-launcher` embedded. Every launch the app probes Metro on your Mac and pulls fresh JS — perfect for hot-reload, painful when the Mac is asleep or you're on a different WiFi.
### Standalone / "just use it" (walk away from the Mac)
```bash
pnpm ios:mobile:device:staging:release
```
Produces a **Release build**. No `expo-dev-launcher`, no Metro probe, no "Downloading…" screen. Splash → app, exactly like an App Store install. Trade-off: every JS change requires re-running this command.
Both paths share the same prerequisites: Mac with Xcode, free Apple ID added under Xcode → Settings → Accounts, iPhone connected via USB with Developer Mode enabled. Follow Expo's [Set up your environment](https://docs.expo.dev/get-started/set-up-your-environment/) — pick **Development build → iOS Device** — if any of that is missing.
First build of either variant downloads CocoaPods + compiles React Native from source — expect 10-20 minutes. Subsequent builds reuse Xcode's DerivedData cache.
## Try it in the iOS Simulator (no iPhone needed)
```bash
pnpm ios:mobile:staging
```
Boots the simulator, builds, installs the dev-client. Faster to iterate than a device build because no signing / provisioning step. Same `dev:mobile:staging` Metro flow afterward.
## 7-day signing limit (device only)
A free Apple ID signs builds for **7 days only**, Debug and Release both. After that the app refuses to launch on the iPhone. Plug back into the Mac and re-run the corresponding `ios:mobile:device*` script to re-sign. Simulator builds are unaffected. The only workaround for the device limit is an Apple Developer Program account ($99/yr), which extends to 1 year.
## Pointing at a different backend
Edit `EXPO_PUBLIC_API_URL` in `.env.staging`, `.env.production`, or `.env.development.local` (whichever variant you're running). Then:
- For an installed **Debug build**: restart Metro (`pnpm dev:mobile:staging`) so the next JS bundle picks up the new value.
- For an installed **Release build**: re-run the `ios:mobile:device:staging:release` command — the value is baked into the embedded bundle at build time.
For local backend testing, use your Mac's LAN IP (`ipconfig getifaddr en0`), not `localhost`.

View File

@@ -1,79 +0,0 @@
import type { ExpoConfig, ConfigContext } from "expo/config";
/**
* Dynamic Expo config — replaces app.json so we can read APP_ENV at runtime
* and switch bundleIdentifier / display name for dev / staging / production.
*
* APP_ENV is set by package.json scripts:
* - dev → APP_ENV unset (treated as "development")
* - dev:staging → APP_ENV=staging
* - dev:prod → APP_ENV=production (rare; usually only for EAS build)
*/
export default ({ config }: ConfigContext): ExpoConfig => {
const env = process.env.APP_ENV ?? "development";
const isProd = env === "production";
const isStaging = env === "staging";
return {
...config,
name: isProd
? "Multica"
: isStaging
? "Multica (Staging)"
: "Multica (Dev)",
slug: "multica-mobile",
version: "0.1.0",
orientation: "portrait",
userInterfaceStyle: "automatic",
scheme: "multica",
// 1024x1024 source shared with the desktop client
// (apps/desktop/build/icon.png). Expo prebuild generates every required
// iOS icon size from this single PNG.
icon: "./assets/icon.png",
ios: {
supportsTablet: false,
// Per-variant bundle id overrides exist for one reason: an Apple ID
// can only sign bundle prefixes it owns, so contributors not on the
// Multica Apple Developer team (and external users self-building a
// personal copy against production) need to swap to a reverse-domain
// they control. Each variant has its own `_<VARIANT>` suffix and is
// only read inside that variant's branch — a generic
// `EXPO_BUNDLE_IDENTIFIER` would leak across variants (Expo CLI
// auto-loads `.env.<mode>.local` regardless of APP_ENV) and collapse
// dev / staging / prod onto a single id.
bundleIdentifier: isProd
? (process.env.EXPO_BUNDLE_IDENTIFIER_PROD ?? "ai.multica.mobile")
: isStaging
? "ai.multica.mobile.staging"
: (process.env.EXPO_BUNDLE_IDENTIFIER_DEV ?? "ai.multica.mobile.dev"),
},
plugins: [
"expo-router",
"expo-secure-store",
"@react-native-community/datetimepicker",
"react-native-enriched-markdown",
[
"expo-image-picker",
{
// iOS NSPhotoLibraryUsageDescription. Without this string in
// Info.plist, calling launchImageLibraryAsync hard-crashes on
// iOS 14+. Camera + microphone are disabled — we only ever read
// from the existing photo library.
photosPermission:
"Allow Multica to access your photos to attach images to issues and comments.",
cameraPermission: false,
microphonePermission: false,
},
],
[
"expo-build-properties",
{
ios: {
buildReactNativeFromSource: true,
},
},
],
],
extra: { APP_ENV: env },
};
};

View File

@@ -1,149 +0,0 @@
/**
* Bottom tab bar — JS `<Tabs>` from expo-router (react-navigation under the
* hood). We tried NativeTabs first but its `canPreventDefault: false`
* constraint makes "tap More → open something" impossible. JS Tabs
* supports `listeners.tabPress + e.preventDefault()`, the canonical RN
* pattern for tab-as-action.
*
* The "More" tab is **not a navigation target** — its press opens a
* DropdownMenu popover anchored above the tab. The popover is rendered
* by `<MoreTabDropdownAnchor />` as a sibling of `<Tabs>`, NOT as a
* `tabBarButton` replacement: keeping the real tab button intact means
* the icon + "More" label render identically to the other three tabs.
* We just open the dropdown imperatively from `listeners.tabPress` via
* the exposed `TriggerRef.open()`.
*
* The stub (tabs)/more.tsx file still exists only because expo-router
* requires every Tabs.Screen to have a backing route file — the press
* is preventDefault'd so we never actually navigate to it.
*
* Active / inactive tint colors are derived from the current colour
* scheme via THEME so dark mode picks contrasting values automatically.
*/
import { useRef } from "react";
import { Tabs } from "expo-router";
import { Image } from "expo-image";
import { View } from "react-native";
import type { TriggerRef } from "@rn-primitives/dropdown-menu";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import {
useInboxUnreadCount,
useChatUnreadSessionCount,
} from "@/lib/unread-counts";
import { MoreTabDropdownAnchor } from "@/components/nav/more-tab-dropdown";
// Only override backgroundColor — @react-navigation/elements Badge internally
// sets borderRadius = size/2, height = size, minWidth = size, so a single
// character renders as a perfect circle. Overriding minWidth/fontSize here
// breaks that geometry. Text color is auto-derived from backgroundColor
// luminance by Badge itself (white on brand blue).
const BADGE_STYLE = {
backgroundColor: THEME.light.brand,
};
export default function TabsLayout() {
const { colorScheme } = useColorScheme();
const t = THEME[colorScheme];
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const inboxUnread = useInboxUnreadCount(wsId);
const chatUnread = useChatUnreadSessionCount(wsId);
// Truncation aligned with web: inbox 99+, chat 9+ (matches sidebar +
// ChatFab respectively). `undefined` makes React Navigation hide the
// badge, so zero-count is a free no-op.
const inboxBadge =
inboxUnread > 0 ? (inboxUnread > 99 ? "99+" : String(inboxUnread)) : undefined;
const chatBadge =
chatUnread > 0 ? (chatUnread > 9 ? "9+" : String(chatUnread)) : undefined;
// Imperative handle into the More tab's dropdown — listeners.tabPress
// calls .open(); the @rn-primitives Trigger measures itself inside
// open() so the popover anchors to MoreTabDropdownAnchor's rect.
const moreTriggerRef = useRef<TriggerRef>(null);
return (
<View style={{ flex: 1 }}>
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: t.foreground,
tabBarInactiveTintColor: t.mutedForeground,
tabBarStyle: { backgroundColor: t.background },
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="inbox"
options={{
title: "Inbox",
tabBarBadge: inboxBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:tray.fill" : "sf:tray"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="my-issues"
options={{
title: "My Issues",
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:checklist" : "sf:checklist.unchecked"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="chat"
options={{
title: "Chat",
tabBarBadge: chatBadge,
tabBarBadgeStyle: BADGE_STYLE,
tabBarIcon: ({ color, size, focused }) => (
<Image
source={focused ? "sf:bubble.left.fill" : "sf:bubble.left"}
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
/>
<Tabs.Screen
name="more"
options={{
title: "More",
tabBarIcon: ({ color, size }) => (
<Image
source="sf:ellipsis"
tintColor={color}
style={{ width: size, height: size }}
/>
),
}}
listeners={() => ({
tabPress: (e) => {
// Don't navigate to the (stub) /more screen — open the
// dropdown popover instead. The trigger is invisible and
// mounted in MoreTabDropdownAnchor below; ref.open() also
// measures its rect so the popover anchors correctly.
e.preventDefault();
moreTriggerRef.current?.open();
},
})}
/>
</Tabs>
<MoreTabDropdownAnchor triggerRef={moreTriggerRef} />
</View>
);
}

View File

@@ -1,428 +0,0 @@
/**
* Chat tab — single-screen IA.
*
* Layout:
* View ─ Header(center: ChatTitleButton, right: ChatSessionActions)
* ─ (NoAgentBanner?)
* ─ KeyboardAvoidingView ─ ChatMessageList (includes live status
* + timeline in its
* ListFooterComponent)
* ─ OfflineBanner
* ─ ChatComposer
*
* Session switching, agent selection, and session deletion all happen
* inside this screen via Modal sheets — there is no `/chat/[id]` sub-route.
*
* State (all local, none in Zustand):
* - activeSessionId — which session is being viewed (null = new chat blank)
* - selectedAgentId — overrides currentSession.agent_id when set (used
* when starting a new chat with a freshly-picked agent)
* - sessionSheetOpen — bottom modal visibility
* - agentPickerOpen — bottom modal visibility
*
* Side effects:
* - useChatSessionRealtime(activeSessionId) for per-record WS events
* - auto markRead when entering a session with has_unread
* - ensureSession dedupe ref for concurrent first-message sends
*
* Optimistic send burst mirrors web's chat-window.tsx send sequence
* (packages/views/chat/components/chat-window.tsx ~262-345):
* seed messages → seed pendingTask → flip activeSessionId → POST →
* patch pendingTask with server task_id + created_at.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
View,
} from "react-native";
import { router } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import type {
Agent,
ChatMessage,
ChatPendingTask,
} from "@multica/core/types";
import { api } from "@/data/api";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { agentListOptions } from "@/data/queries/agents";
import { memberListOptions } from "@/data/queries/members";
import {
chatKeys,
chatMessagesOptions,
chatSessionsOptions,
pendingChatTaskOptions,
taskMessagesOptions,
} from "@/data/queries/chat";
import {
useCreateChatSession,
useDeleteChatSession,
useMarkChatSessionRead,
} from "@/data/mutations/chat";
import {
DRAFT_NEW_SESSION,
useChatDraftsStore,
} from "@/data/stores/chat-drafts-store";
import { useChatSessionPickerStore } from "@/data/stores/chat-session-picker-store";
import { useChatSessionRealtime } from "@/data/realtime/use-chat-session-realtime";
import { canAssignAgent } from "@/lib/can-assign-agent";
import { useWorkspaceAgentAvailability } from "@/lib/workspace-agent-availability";
import { useAgentPresence } from "@/lib/use-agent-presence";
import { Header } from "@/components/ui/header";
import { ChatTitleButton } from "@/components/chat/chat-title-button";
import { ChatSessionActions } from "@/components/chat/chat-session-actions";
import { ChatMessageList } from "@/components/chat/chat-message-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { AgentPickerSheet } from "@/components/chat/agent-picker-sheet";
import { NoAgentBanner } from "@/components/chat/no-agent-banner";
import { OfflineBanner } from "@/components/chat/offline-banner";
import { useChatSelectStore } from "@/data/chat-select-store";
export default function ChatTab() {
const qc = useQueryClient();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const userId = useAuthStore((s) => s.user?.id);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [agentPickerOpen, setAgentPickerOpen] = useState(false);
// Bridge to the chat-sessions formSheet route. Mirror local
// activeSessionId into the store so the picker can render the current
// selection's check mark; consume the picker's one-shot select request
// via useEffect.
const setStoreActiveSessionId = useChatSessionPickerStore(
(s) => s.setActiveSessionId,
);
const selectRequest = useChatSessionPickerStore((s) => s.selectRequest);
const consumeSelect = useChatSessionPickerStore((s) => s.consumeSelect);
useEffect(() => {
setStoreActiveSessionId(activeSessionId);
}, [activeSessionId, setStoreActiveSessionId]);
// ── Server state ───────────────────────────────────────────────────────
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: members = [] } = useQuery(memberListOptions(wsId));
// ── Auto-hydrate active session on first Chat tab entry ────────────────
// Mobile-only deviation from web: web's chat-window opens to an empty
// state when no `activeSessionId` is persisted; on a phone, picking
// a session is 4 taps, so jump straight to the most recent session.
// Hydration is one-shot per workspace.
const hydratedWsRef = useRef<string | null>(null);
useEffect(() => {
if (!wsId) return;
if (hydratedWsRef.current === wsId) return;
if (sessions.length === 0) {
hydratedWsRef.current = wsId;
return;
}
hydratedWsRef.current = wsId;
setActiveSessionId(sessions[0].id);
}, [wsId, sessions]);
const { data: messages = [], isLoading: messagesLoading } = useQuery(
chatMessagesOptions(activeSessionId),
);
const { data: pendingTask } = useQuery(
pendingChatTaskOptions(activeSessionId),
);
// Live execution trace for the in-flight task. `task:message` WS events
// append rows to this same cache key via `appendTaskMessage`, so the
// list/pill stay in sync without a polling fetch. `enabled` is gated by
// `isTaskMessageTaskId` inside taskMessagesOptions — optimistic ids
// never hit the network.
const { data: liveTaskMessages = [] } = useQuery(
taskMessagesOptions(pendingTask?.task_id),
);
// ── Derived ────────────────────────────────────────────────────────────
const memberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const availableAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, memberRole),
),
[agents, userId, memberRole],
);
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
// Active agent: explicit selection wins; otherwise inherit from the
// active session; otherwise pick the first available agent.
const currentAgent: Agent | null = useMemo(() => {
if (selectedAgentId) {
return availableAgents.find((a) => a.id === selectedAgentId) ?? null;
}
if (activeSession) {
return agents.find((a) => a.id === activeSession.agent_id) ?? null;
}
return availableAgents[0] ?? null;
}, [selectedAgentId, availableAgents, activeSession, agents]);
const availability = useWorkspaceAgentAvailability();
const presenceDetail = useAgentPresence(wsId, currentAgent?.id);
const presenceAvailability =
presenceDetail === "loading" ? undefined : presenceDetail.availability;
const isArchived = activeSession?.status === "archived";
const sending = !!pendingTask?.task_id;
// ── Drafts ─────────────────────────────────────────────────────────────
const draftKey = activeSessionId ?? DRAFT_NEW_SESSION;
const draft = useChatDraftsStore((s) => s.drafts[draftKey] ?? "");
const setDraft = useChatDraftsStore((s) => s.setDraft);
const clearDraft = useChatDraftsStore((s) => s.clearDraft);
const promoteNewDraft = useChatDraftsStore((s) => s.promoteNewDraft);
// ── Realtime ───────────────────────────────────────────────────────────
useChatSessionRealtime(activeSessionId, () => {
setActiveSessionId(null);
});
// Exit text-selection mode whenever the chat tab loses focus. Expo
// Router bottom tabs stay mounted across tab switches, so a plain
// useEffect cleanup wouldn't fire — useFocusEffect is the navigation-
// aware equivalent.
useFocusEffect(
useCallback(() => () => useChatSelectStore.getState().clear(), []),
);
// ── Auto markRead while viewing a session with unread state ──────────
const isFocused = useIsFocused();
const markRead = useMarkChatSessionRead();
useEffect(() => {
if (!isFocused) return;
if (!activeSessionId) return;
if (!activeSession?.has_unread) return;
markRead.mutate(activeSessionId);
}, [isFocused, activeSessionId, activeSession?.has_unread, markRead]);
// ── Mutations ──────────────────────────────────────────────────────────
const createSession = useCreateChatSession();
const deleteSession = useDeleteChatSession();
// ── Send burst ─────────────────────────────────────────────────────────
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
const ensureSession = useCallback(
async (titleSeed: string): Promise<string | null> => {
if (activeSessionId) return activeSessionId;
if (!currentAgent) return null;
if (sessionPromiseRef.current) return sessionPromiseRef.current;
const promise = (async () => {
try {
const session = await createSession.mutateAsync({
agent_id: currentAgent.id,
title: titleSeed.slice(0, 50),
});
return session.id;
} finally {
sessionPromiseRef.current = null;
}
})();
sessionPromiseRef.current = promise;
return promise;
},
[activeSessionId, currentAgent, createSession],
);
const handleSend = useCallback(
async (content: string, attachmentIds: string[] = []) => {
if (!currentAgent) return;
const isNewSession = !activeSessionId;
const sessionId = await ensureSession(content);
if (!sessionId) return;
const sentAt = new Date().toISOString();
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content,
task_id: null,
created_at: sentAt,
};
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? [...old, optimistic] : [optimistic],
);
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: `optimistic-${optimistic.id}`,
status: "queued",
created_at: sentAt,
});
if (isNewSession) {
promoteNewDraft(sessionId);
setActiveSessionId(sessionId);
}
try {
const result = await api.sendChatMessage(sessionId, content, {
attachmentIds: attachmentIds.length > 0 ? attachmentIds : undefined,
});
qc.setQueryData<ChatPendingTask>(chatKeys.pendingTask(sessionId), {
task_id: result.task_id,
status: "queued",
created_at: result.created_at,
});
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
clearDraft(sessionId);
} catch (err) {
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? old.filter((m) => m.id !== optimistic.id) : old,
);
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
throw err;
}
},
[
activeSessionId,
currentAgent,
ensureSession,
qc,
promoteNewDraft,
clearDraft,
],
);
// ── Cancel in-flight ───────────────────────────────────────────────────
const handleStop = useCallback(() => {
if (!pendingTask?.task_id || !activeSessionId) return;
qc.setQueryData(chatKeys.pendingTask(activeSessionId), {});
void api.cancelTaskById(pendingTask.task_id).catch(() => {
// Silent — task may have already terminated server-side.
});
}, [pendingTask?.task_id, activeSessionId, qc]);
// ── Header / sheet actions ─────────────────────────────────────────────
const handleNewChat = useCallback(() => {
if (availableAgents.length > 1) {
setAgentPickerOpen(true);
return;
}
setSelectedAgentId(null);
setActiveSessionId(null);
}, [availableAgents.length]);
const handlePickAgent = useCallback((agent: Agent) => {
setSelectedAgentId(agent.id);
setActiveSessionId(null);
}, []);
// Apply the user's pick from the chat-sessions route (or "no session"
// when they delete the active one in the sheet).
useEffect(() => {
if (!selectRequest) return;
setSelectedAgentId(null);
setActiveSessionId(selectRequest.id);
consumeSelect();
}, [selectRequest, consumeSelect]);
const handleDeleteActive = useCallback(() => {
if (!activeSession) return;
Alert.alert(
"Delete this chat?",
activeSession.title || "Untitled chat",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
const id = activeSession.id;
setActiveSessionId(null);
deleteSession.mutate(id);
},
},
],
{ cancelable: true },
);
}, [activeSession, deleteSession]);
// ── Composer disabled-state ────────────────────────────────────────────
const disabled =
!currentAgent || availability === "none" || isArchived === true;
const disabledReason = !currentAgent
? "No agent selected"
: availability === "none"
? "No agents in this workspace"
: isArchived
? "This chat is archived"
: undefined;
return (
<View className="flex-1 bg-background">
<Header
center={
<ChatTitleButton
currentSession={activeSession}
currentAgent={currentAgent}
onPress={() => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/chat-sessions",
params: { workspace: wsSlug },
});
}}
/>
}
right={
<ChatSessionActions
showMore={!!activeSession}
onMorePress={handleDeleteActive}
onNewPress={handleNewChat}
/>
}
/>
{availability === "none" ? <NoAgentBanner /> : null}
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : undefined}
className="flex-1"
>
<ChatMessageList
messages={messages}
loading={messagesLoading}
hasSessions={sessions.length > 0}
agentName={currentAgent?.name}
onPickPrompt={(text) => setDraft(draftKey, text)}
pendingTask={pendingTask}
liveTaskMessages={liveTaskMessages}
availability={presenceAvailability}
/>
<OfflineBanner
agentName={currentAgent?.name}
availability={presenceAvailability}
/>
<ChatComposer
value={draft}
onChangeText={(next) => setDraft(draftKey, next)}
onSend={handleSend}
onStop={handleStop}
sending={sending}
disabled={disabled}
disabledReason={disabledReason}
/>
</KeyboardAvoidingView>
<AgentPickerSheet
visible={agentPickerOpen}
agents={availableAgents}
currentAgentId={currentAgent?.id ?? null}
onPick={handlePickAgent}
onClose={() => setAgentPickerOpen(false)}
/>
</View>
);
}

View File

@@ -1,199 +0,0 @@
import { useMemo } from "react";
import {
ActionSheetIOS,
Alert,
FlatList,
View,
} from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { InboxItem } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Header } from "@/components/ui/header";
import { IconButton } from "@/components/ui/icon-button";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { SwipeableInboxRow } from "@/components/inbox/swipeable-inbox-row";
import { inboxListOptions } from "@/data/queries/inbox";
import {
useArchiveAllInbox,
useArchiveAllReadInbox,
useArchiveCompletedInbox,
useArchiveInbox,
useMarkAllInboxRead,
useMarkInboxRead,
} from "@/data/mutations/inbox";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
import { deduplicateInboxItems } from "@/lib/inbox-display";
export default function Inbox() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const { colorScheme } = useColorScheme();
const { data: rawItems, isLoading, error, refetch, isRefetching } = useQuery(
inboxListOptions(wsId),
);
// Dedup + drop archived to match web/desktop. See CLAUDE.md
// "Behavioral parity" → inbox dedup incident.
const data = useMemo(
() => deduplicateInboxItems(rawItems ?? []),
[rawItems],
);
const markRead = useMarkInboxRead();
const markAllRead = useMarkAllInboxRead();
const archive = useArchiveInbox();
const archiveAll = useArchiveAllInbox();
const archiveAllRead = useArchiveAllReadInbox();
const archiveCompleted = useArchiveCompletedInbox();
const onPressItem = (item: InboxItem) => {
if (!item.read) {
// Optimistic read flip lives in useMarkInboxRead.onMutate — fires
// setQueryData synchronously before the cancelQueries await, so the
// row is already styled "read" by the time iOS captures the source
// snapshot for the native stack push transition.
markRead.mutate(item.id);
}
if (item.issue_id && wsSlug) {
router.push({
pathname: "/[workspace]/issue/[id]",
params: {
workspace: wsSlug,
id: item.issue_id,
highlight: item.details?.comment_id,
h: String(Date.now()),
},
});
}
};
// Trailing batch menu — mirrors web's dropdown
// (packages/views/inbox/components/inbox-page.tsx). "Mark all read" is
// first (most common batch op); "Archive all" is destructive so it gets
// the iOS red treatment + Alert confirm.
const onPressMenu = () => {
const options = [
"Cancel",
"Mark all read",
"Archive all read",
"Archive completed",
"Archive all",
];
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: 4,
title: "Inbox",
},
(i) => {
if (i === 1) markAllRead.mutate();
else if (i === 2) archiveAllRead.mutate();
else if (i === 3) archiveCompleted.mutate();
else if (i === 4) {
Alert.alert(
"Archive all?",
"This archives every inbox item, read or unread. You can still find them via the issue pages.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Archive all",
style: "destructive",
onPress: () => archiveAll.mutate(),
},
],
);
}
},
);
};
return (
<View className="flex-1 bg-background">
<Header
title="Inbox"
right={
<>
<IconButton
name="ellipsis-horizontal"
onPress={onPressMenu}
accessibilityLabel="Inbox actions"
/>
<HeaderActions />
</>
}
/>
{isLoading ? (
<InboxLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load inbox:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : !data || data.length === 0 ? (
<InboxEmpty iconColor={THEME[colorScheme].mutedForeground} />
) : (
<FlatList
data={data}
keyExtractor={(item) => item.id}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-16" />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<SwipeableInboxRow
item={item}
onPress={() => onPressItem(item)}
onArchive={() => archive.mutate(item.id)}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
// Loading state — 6 row-shaped Skeletons matching InboxRow's layout
// (avatar circle + two text lines). Perceived perf wins over a centered
// spinner because the eye immediately sees the list-like structure.
function InboxLoading() {
return (
<View className="px-4 pt-4 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<View key={i} className="flex-row gap-3">
<Skeleton className="size-9 rounded-full" />
<View className="flex-1 gap-2 pt-1">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</View>
</View>
))}
</View>
);
}
function InboxEmpty({ iconColor }: { iconColor: string }) {
return (
<View className="flex-1 items-center justify-center px-8 gap-3">
<Ionicons name="mail-open-outline" size={42} color={iconColor} />
<Text className="text-base font-medium text-foreground text-center">
Inbox zero
</Text>
<Text className="text-sm text-muted-foreground text-center">
When someone @mentions you, assigns an issue, or an agent finishes a
task, it shows up here.
</Text>
</View>
);
}

View File

@@ -1,16 +0,0 @@
/**
* Stub route. The "More" tab in (tabs)/_layout.tsx intercepts tabPress and
* pushes /[workspace]/menu (formSheet route) instead of navigating here,
* so this screen is never rendered through normal use. expo-router still
* requires a file to exist at this path to register the Tabs.Screen entry.
*
* If a deep link or stale tab state somehow lands the user here, bounce
* to inbox so they don't see a blank screen.
*/
import { Redirect } from "expo-router";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function MoreStub() {
const slug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
return <Redirect href={slug ? `/${slug}/inbox` : "/select-workspace"} />;
}

View File

@@ -1,373 +0,0 @@
/**
* "My Issues" tab. Three scopes — assigned / created / agents — mirroring
* web's `packages/views/my-issues/components/my-issues-page.tsx:48-65`. The
* `agents` scope label is "Agents and Squads" because the backend predicate
* (`involves_user_id`, MUL-2397) surfaces both the user's owned agents and
* squads they're involved in (member / leader / has an owned agent inside).
*
* Issues are grouped by status using SectionList in `BOARD_STATUSES` order;
* empty status sections are filtered out so the screen doesn't fill with
* "(0)" headers. Section grouping uses `BOARD_STATUSES` (cancelled excluded)
* to match web — same source `packages/views/my-issues/components/my-issues-page.tsx:117-125`.
*
* Status + Priority filters mirror web's MyIssuesHeader filter sub-menus.
* Filter state lives in `useMyIssuesViewStore` and is cleared on workspace
* change via the shared `useClearFiltersOnWorkspaceChange` hook.
*/
import { useMemo } from "react";
import { Pressable, SectionList, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { Issue, IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { Header } from "@/components/ui/header";
import { HeaderActions } from "@/components/ui/app-header-actions";
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssuesLoading } from "@/components/issue/issues-loading";
import {
buildMyIssuesFilter,
myIssueListOptions,
} from "@/data/queries/my-issues";
import type { MyIssuesScope } from "@/data/queries/issue-keys";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useMyIssuesViewStore } from "@/data/stores/my-issues-view-store";
import { useClearFiltersOnWorkspaceChange } from "@/lib/use-clear-filters-on-workspace-change";
import {
BOARD_STATUSES,
PRIORITY_LABEL,
STATUS_LABEL,
} from "@/lib/issue-status";
import { filterIssues } from "@/lib/filter-issues";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
// Mobile pill row has tight width on SE3 (375pt). Three pills + Filter icon
// must fit in 343pt usable space, so the agents scope renders "Agents" — the
// full "Agents and Squads" label (~135pt) blows past safe limits and breaks
// under Dynamic Type. Semantics unchanged: same backend predicate
// (`involves_user_id`, MUL-2397) covers owned agents + related squads; the
// empty state copy still says "agents or squads".
const SCOPES: { value: MyIssuesScope; label: string }[] = [
{ value: "assigned", label: "Assigned" },
{ value: "created", label: "Created" },
{ value: "agents", label: "Agents" },
];
type IssueSection = { status: IssueStatus; data: Issue[] };
export default function MyIssues() {
const userId = useAuthStore((s) => s.user?.id ?? null);
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const scope = useMyIssuesViewStore((s) => s.scope);
const setScope = useMyIssuesViewStore((s) => s.setScope);
const statusFilters = useMyIssuesViewStore((s) => s.statusFilters);
const priorityFilters = useMyIssuesViewStore((s) => s.priorityFilters);
const openFilter = () => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issues-filter",
params: { workspace: wsSlug, scope: "my" },
});
};
useClearFiltersOnWorkspaceChange(
useMyIssuesViewStore.getState().clearFilters,
wsId,
);
const filter = useMemo(
() => (userId ? buildMyIssuesFilter(scope, userId) : { assignee_id: "" }),
[scope, userId],
);
const { data, isLoading, error, refetch, isRefetching } = useQuery({
...myIssueListOptions(wsId, scope, filter),
enabled: !!wsId && !!userId,
});
// Apply client-side status + priority filter. Mirrors the predicate at
// packages/views/issues/utils/filter.ts:30-34 via filterIssues().
const filtered = useMemo(
() => filterIssues(data ?? [], statusFilters, priorityFilters),
[data, statusFilters, priorityFilters],
);
// When statusFilters is non-empty, intersect visible status order with it
// so hidden statuses don't render an empty section header. Uses
// BOARD_STATUSES (cancelled excluded) to match web.
const sections = useMemo<IssueSection[]>(() => {
if (filtered.length === 0) return [];
const byStatus = new Map<IssueStatus, Issue[]>();
for (const issue of filtered) {
const list = byStatus.get(issue.status);
if (list) list.push(issue);
else byStatus.set(issue.status, [issue]);
}
const visibleStatuses = statusFilters.length > 0
? BOARD_STATUSES.filter((s) => statusFilters.includes(s))
: BOARD_STATUSES;
return visibleStatuses
.map((status) => ({ status, data: byStatus.get(status) ?? [] }))
.filter((s) => s.data.length > 0);
}, [filtered, statusFilters]);
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
const showEmptyState =
!isLoading && !error && filtered.length === 0;
return (
<View className="flex-1 bg-background">
<Header title="My Issues" right={<HeaderActions />} />
<ScopeToolbar
scopes={SCOPES}
scope={scope}
onChange={(v) => setScope(v)}
onOpenFilter={openFilter}
hasActiveFilters={hasActiveFilters}
/>
{hasActiveFilters ? (
<ActiveFilterChips
statusFilters={statusFilters}
priorityFilters={priorityFilters}
onClearStatus={(s) =>
useMyIssuesViewStore.getState().toggleStatusFilter(s)
}
onClearPriority={(p) =>
useMyIssuesViewStore.getState().togglePriorityFilter(p)
}
/>
) : null}
{isLoading ? (
<IssuesLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : showEmptyState ? (
<EmptyState
message={
hasActiveFilters
? "No issues match the current filters."
: emptyMessageForScope(scope)
}
/>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled={false}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderSectionHeader={({ section }) => (
<SectionHeader
status={section.status}
count={section.data.length}
/>
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<IssueRow
issue={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/issue/${item.id}`);
}}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
/**
* Outline icon button matching the pill height so the toolbar row reads as
* one visual group. Mirrors web `IssuesHeader` / `MyIssuesHeader` filter
* trigger (`packages/views/my-issues/components/my-issues-header.tsx:174`),
* which is also `variant="outline"` + icon-sized — NOT the ghost-style we'd
* get from <IconButton>. Square (`w-9`) with `px-0` to suppress the sm
* default `px-3`.
*/
function FilterButton({
onPress,
hasActiveFilters,
}: {
onPress: () => void;
hasActiveFilters: boolean;
}) {
const { colorScheme } = useColorScheme();
return (
<View style={{ position: "relative" }} className="ml-2">
<Button
variant="outline"
size="sm"
onPress={onPress}
accessibilityLabel="Filter"
className="w-9 px-0"
>
<Ionicons
name="options-outline"
size={16}
color={THEME[colorScheme].mutedForeground}
/>
</Button>
{hasActiveFilters ? (
<View
pointerEvents="none"
className="absolute top-1 right-1 size-1.5 rounded-full bg-brand"
/>
) : null}
</View>
);
}
/**
* Toolbar row mirroring web `MyIssuesHeader` / `IssuesHeader`
* (`packages/views/my-issues/components/my-issues-header.tsx:138-163`):
* left-aligned scope pill group + right-side Filter icon (red dot when
* filters are active). Replaces the previous full-width segmented tabs +
* Filter-in-title-bar split — keeps scope and the filter affordance in the
* same row, because they both control the list directly below.
*/
function ScopeToolbar<S extends string>({
scopes,
scope,
onChange,
onOpenFilter,
hasActiveFilters,
}: {
scopes: { value: S; label: string }[];
scope: S;
onChange: (value: S) => void;
onOpenFilter: () => void;
hasActiveFilters: boolean;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-2 pb-2">
<View className="flex-row items-center gap-1 flex-shrink min-w-0">
{scopes.map((s) => {
const active = scope === s.value;
return (
<Button
key={s.value}
variant="outline"
size="sm"
onPress={() => onChange(s.value)}
className={active ? "bg-accent" : ""}
accessibilityState={{ selected: active }}
>
<Text
numberOfLines={1}
className={active ? "text-accent-foreground" : "text-muted-foreground"}
>
{s.label}
</Text>
</Button>
);
})}
</View>
<FilterButton
onPress={onOpenFilter}
hasActiveFilters={hasActiveFilters}
/>
</View>
);
}
function ActiveFilterChips({
statusFilters,
priorityFilters,
onClearStatus,
onClearPriority,
}: {
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
onClearStatus: (s: IssueStatus) => void;
onClearPriority: (p: IssuePriority) => void;
}) {
return (
<View className="flex-row flex-wrap gap-1.5 px-4 pb-2">
{statusFilters.map((s) => (
<Chip key={`s-${s}`} label={STATUS_LABEL[s]} onClear={() => onClearStatus(s)} />
))}
{priorityFilters.map((p) => (
<Chip key={`p-${p}`} label={PRIORITY_LABEL[p]} onClear={() => onClearPriority(p)} />
))}
</View>
);
}
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
const { colorScheme } = useColorScheme();
return (
<Pressable
onPress={onClear}
className="flex-row items-center gap-1 pl-2.5 pr-2 py-1 rounded-full border border-border bg-secondary/40 active:bg-secondary"
>
<Text className="text-xs text-foreground">{label}</Text>
<Ionicons
name="close"
size={12}
color={THEME[colorScheme].mutedForeground}
/>
</Pressable>
);
}
function SectionHeader({
status,
count,
}: {
status: IssueStatus;
count: number;
}) {
return (
<View className="flex-row items-center gap-2 px-4 py-2 bg-background">
<StatusIcon status={status} size={14} />
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{STATUS_LABEL[status]}
</Text>
<Text className="text-xs text-muted-foreground/60">{count}</Text>
</View>
);
}
function EmptyState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
{message}
</Text>
</View>
);
}
function emptyMessageForScope(scope: MyIssuesScope): string {
switch (scope) {
case "assigned":
return "No issues assigned to you.";
case "created":
return "You haven't created any issues.";
case "agents":
return "No issues assigned to your agents or squads yet.";
}
}

View File

@@ -1,339 +0,0 @@
import { useEffect } from "react";
import type { ComponentProps } from "react";
import { Redirect, Stack, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { workspaceListOptions } from "@/data/queries/workspaces";
import { useWorkspaceStore } from "@/data/workspace-store";
import { RealtimeProvider } from "@/data/realtime/realtime-provider";
import { useInboxRealtime } from "@/data/realtime/use-inbox-realtime";
import { useIssuesRealtime } from "@/data/realtime/use-issues-realtime";
import { useMyIssuesRealtime } from "@/data/realtime/use-my-issues-realtime";
import { useChatSessionsRealtime } from "@/data/realtime/use-chat-sessions-realtime";
import { useProjectsRealtime } from "@/data/realtime/use-projects-realtime";
import { usePinsRealtime } from "@/data/realtime/use-pins-realtime";
import { usePresenceRealtime } from "@/data/realtime/use-presence-realtime";
import { useWorkspacePresencePrefetch } from "@/lib/use-workspace-presence-prefetch";
import { ModalCloseButton } from "@/components/ui/modal-close-button";
import { useNewIssueDraftResetOnWorkspaceChange } from "@/data/stores/new-issue-draft-store";
import { useNewProjectDraftResetOnWorkspaceChange } from "@/data/stores/new-project-draft-store";
import { useChatSessionPickerResetOnWorkspaceChange } from "@/data/stores/chat-session-picker-store";
/**
* Shared Stack.Screen options for every iOS formSheet-presented sheet route.
*
* Why these specific values:
* - `presentation: "formSheet"` instantiates iOS
* UISheetPresentationController — native grabber, stacked-card backdrop,
* drag-to-dismiss spring physics, detents.
* - `sheetAllowedDetents: [0.6, 0.95]` — explicit numeric detents. The
* ergonomic `"fitToContents"` is broken on iOS 26 + Expo 55
* (expo/expo#42904 padding inconsistency, expo/expo#42965 zero-size).
* Predictable two-snap presentation across every picker-row sheet >
* shrink-wrap; this is the right default for sheets that sit next to
* other sheets in the same chip row (issue / project AttributeRow) so
* the user gets the same gesture regardless of which chip they tap.
* Isolated sheets that have no neighbour to be consistent with (e.g.
* the workspace `menu` sheet) override this with `"fitToContents"`
* to avoid the large blank area below their content.
* - `sheetGrabberVisible: true` — surfaces the iOS native drag handle
* so users discover the gesture.
* - `contentStyle.height: "100%"` — safety net against the same
* zero-size class of bugs above; ensures the sheet body fills the
* allotted detent.
* - `headerShown: false` — every sheet body draws its own header (title
* + optional right action). The native Stack header would double up.
*/
const SHEET_OPTIONS: ComponentProps<typeof Stack.Screen>["options"] = {
presentation: "formSheet",
sheetGrabberVisible: true,
sheetAllowedDetents: [0.6, 0.95],
sheetCornerRadius: 20,
contentStyle: { flex: 1 },
headerShown: false,
};
/**
* Cold-start deep-link anchor. Expo Router otherwise treats whatever
* route resolves the URL as the root of the stack — if the user opens a
* notification that targets `issue/[id]/picker/status` directly, they
* land on the formSheet with NO parent under it, no way to go back to
* the tabs. `anchor: "(tabs)"` tells the router to mount the tab UI as
* the implicit underlying screen so back/swipe-dismiss returns the user
* to a sensible base state.
*/
export const unstable_settings = { anchor: "(tabs)" } as const;
/**
* Mounts every per-feature realtime subscription. Lives inside
* RealtimeProvider so the WSClient context is available, and stays alive
* for the whole workspace session — the inbox unread count must keep
* refreshing even while the user is on an issue page or settings, not
* just when the inbox tab is foregrounded.
*
* Add new realtime feature hooks here as they land (issue, chat, etc).
*/
function RealtimeSubscriptions() {
useInboxRealtime();
useIssuesRealtime();
useMyIssuesRealtime();
useChatSessionsRealtime();
useProjectsRealtime();
usePinsRealtime();
// Presence: warm the three queries up front so avatars don't flash a
// dotless first render, and listen for daemon/agent/task events to keep
// the runtime + snapshot caches fresh. See use-presence-realtime.ts for
// the deliberately-skipped high-frequency events.
useWorkspacePresencePrefetch();
usePresenceRealtime();
return null;
}
/**
* Workspace context layout. Reads the slug from the URL (the route is the
* source of truth — see apps/mobile/CLAUDE.md "Behavioral parity"), validates
* membership against the workspaces list, then syncs id+slug into the
* Zustand store so ApiClient.fetch can read the slug synchronously when
* injecting the X-Workspace-Slug header.
*
* If the slug doesn't match any workspace the user belongs to, redirect to
* /select-workspace (covers stale persisted slugs after the user lost
* membership, deep links to wrong slugs, etc.).
*/
export default function WorkspaceLayout() {
const { workspace: slug } = useLocalSearchParams<{ workspace: string }>();
const { data: workspaces, isLoading } = useQuery(workspaceListOptions());
const setCurrentWorkspace = useWorkspaceStore((s) => s.setCurrentWorkspace);
const matched = workspaces?.find((w) => w.slug === slug);
useEffect(() => {
if (matched) {
setCurrentWorkspace(matched.id, matched.slug);
}
}, [matched, setCurrentWorkspace]);
// Wipe cross-route Zustand draft stores whenever the active workspace
// changes — a draft picked under workspace A (assignee id, draft
// session id, etc.) is invalid in workspace B and must not leak.
useNewIssueDraftResetOnWorkspaceChange(matched?.id ?? null);
useNewProjectDraftResetOnWorkspaceChange(matched?.id ?? null);
useChatSessionPickerResetOnWorkspaceChange(matched?.id ?? null);
// Wait for the workspaces list before deciding membership — otherwise a
// valid deep link would briefly redirect away on cold start.
if (isLoading) return null;
if (!matched) return <Redirect href="/select-workspace" />;
// Tabs hide their own header; pushed screens (issue/[id]) get a native
// iOS Stack header with the standard back button + swipe-to-dismiss.
return (
<RealtimeProvider>
<RealtimeSubscriptions />
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen
name="issue/[id]"
options={{
title: "Issue",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="project/[id]"
options={{
title: "Project",
headerBackTitle: "Back",
}}
/>
<Stack.Screen
name="project/[id]/edit"
options={{
title: "Edit Project",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="issue/[id]/edit"
options={{
title: "Edit Issue",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="project/new"
options={{
title: "New Project",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
{/* Issue-detail formSheet pickers. All share the same sheet config:
explicit numeric detents to dodge expo/expo#42904+#42965 (the
`fitToContents` zero-size / padding bugs on iOS 26 + Expo 55),
iOS native grabber, and contentStyle.height=100% as a safety
net against the same zero-size class of bugs. */}
<Stack.Screen
name="issue/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="issue/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
{/* Experiment: assignee uses iOS-native nav header + UISearchController
instead of the body-rendered header pattern in SHEET_OPTIONS.
Eliminates the #3634 overlap class of bugs and the focus-loss
footgun of a custom TextInput inside ListHeaderComponent. The
route file wires `headerSearchBarOptions` via setOptions. If this
proves out, propagate to label / project / other search pickers
and update CLAUDE.md Lesson 6 with a carve-out. */}
<Stack.Screen
name="issue/[id]/picker/assignee"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="issue/[id]/picker/label"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="mention-picker"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Mention",
}}
/>
<Stack.Screen
name="issue/[id]/picker/project"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="issue/[id]/picker/due-date"
options={SHEET_OPTIONS}
/>
<Stack.Screen name="issue/[id]/runs" options={SHEET_OPTIONS} />
{/* Full emoji picker for a comment reaction. Pushed from the "+"
button inside the comment long-press tapback row — see
components/issue/comment-context-menu.tsx. */}
<Stack.Screen
name="issue/[id]/comment/[commentId]/emoji-picker"
options={SHEET_OPTIONS}
/>
{/* Project-detail formSheet pickers. */}
<Stack.Screen
name="project/[id]/picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/picker/lead"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="project/[id]/add-resource"
options={SHEET_OPTIONS}
/>
{/* New-issue draft formSheet pickers — stacked on top of the
new-issue.tsx Stack.Screen (which is itself a `modal`).
Expo Router 55 / RN Screens 4 support a formSheet pushed on top
of a modal in the same Stack. */}
<Stack.Screen
name="new-issue-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/priority"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/assignee"
options={{
...SHEET_OPTIONS,
headerShown: true,
title: "Assignee",
}}
/>
<Stack.Screen
name="new-issue-picker/project"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-issue-picker/due-date"
options={SHEET_OPTIONS}
/>
{/* New-project draft formSheet pickers — same pattern as
new-issue-picker/*. Stacked on top of `project/new` (a modal). */}
<Stack.Screen
name="new-project-picker/status"
options={SHEET_OPTIONS}
/>
<Stack.Screen
name="new-project-picker/priority"
options={SHEET_OPTIONS}
/>
{/* Shared filter sheet for My Issues and the workspace Issues page —
chooses the right view-store via `?scope=my|all` URL param. */}
<Stack.Screen name="issues-filter" options={SHEET_OPTIONS} />
{/* Chat session-switch sheet. */}
<Stack.Screen name="chat-sessions" options={SHEET_OPTIONS} />
{/* Workspace switcher — reached from the More popover's collapsed
WorkspaceCard. Two-step (pick → iOS Alert confirm → switch). */}
<Stack.Screen name="switch-workspace" options={SHEET_OPTIONS} />
<Stack.Screen
name="more/issues"
options={{ title: "Issues", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/projects"
options={{ title: "Projects", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/agents"
options={{ title: "Agents", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/pins"
options={{ title: "Pinned", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/settings"
options={{ title: "Settings", headerBackTitle: "Back" }}
/>
<Stack.Screen
name="more/settings/profile"
options={{ title: "Profile", headerBackTitle: "Settings" }}
/>
<Stack.Screen
name="more/settings/notifications"
options={{ title: "Notifications", headerBackTitle: "Settings" }}
/>
<Stack.Screen
name="new-issue"
options={{
title: "New Issue",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
<Stack.Screen
name="search"
options={{
title: "Search",
presentation: "modal",
headerLeft: () => <ModalCloseButton />,
}}
/>
</Stack>
</RealtimeProvider>
);
}

View File

@@ -1,121 +0,0 @@
/**
* Chat session-switch sheet — presented as a formSheet by the parent Stack.
* Reads the session list from the chat cache and writes the user's pick
* through a shared "active session" store so the chat tab picks it up on
* dismiss.
*
* Why a tiny dedicated store: the chat tab's `activeSessionId` used to live
* as a `useState` inside `chat.tsx`, but now that session picking happens
* on a separate route screen, we need a cross-screen channel. Same minimum
* pattern as `useNewIssueDraftStore` for the new-issue form.
*/
import { Alert, Pressable, ScrollView, View } from "react-native";
import { router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { ChatSession } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { ActorAvatar } from "@/components/ui/actor-avatar";
import { chatSessionsOptions } from "@/data/queries/chat";
import { useDeleteChatSession } from "@/data/mutations/chat";
import { useChatSessionPickerStore } from "@/data/stores/chat-session-picker-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { cn } from "@/lib/utils";
export default function ChatSessionsRoute() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: sessions = [] } = useQuery(chatSessionsOptions(wsId));
const activeSessionId = useChatSessionPickerStore((s) => s.activeSessionId);
const requestSelect = useChatSessionPickerStore((s) => s.requestSelect);
const deleteSession = useDeleteChatSession();
const confirmDelete = (session: ChatSession) => {
Alert.alert(
"Delete this chat?",
session.title || "Untitled chat",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => {
deleteSession.mutate(session.id);
// If we just deleted the active one, the chat tab clears its
// local activeSessionId via the picker-store request.
if (session.id === activeSessionId) {
requestSelect(null);
}
},
},
],
{ cancelable: true },
);
};
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">Chats</Text>
</View>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
{sessions.length === 0 ? (
<View className="px-4 py-8">
<Text className="text-sm text-muted-foreground text-center">
No chats yet.
</Text>
</View>
) : (
sessions.map((session) => {
const selected = session.id === activeSessionId;
const archived = session.status === "archived";
return (
<Pressable
key={session.id}
onPress={() => {
requestSelect(session.id);
router.back();
}}
onLongPress={() => confirmDelete(session)}
className={cn(
"flex-row items-center gap-3 px-4 py-3 active:bg-secondary",
selected && "bg-secondary/60",
)}
>
<View
className={cn(
"h-2 w-2 rounded-full",
session.has_unread ? "bg-primary" : "bg-transparent",
)}
/>
<ActorAvatar
type="agent"
id={session.agent_id}
size={32}
showPresence
/>
<View className="flex-1">
<Text
className={cn(
"text-sm text-foreground",
session.has_unread && "font-semibold",
)}
numberOfLines={1}
>
{session.title || "Untitled chat"}
</Text>
{archived ? (
<Text className="text-xs text-muted-foreground mt-0.5">
archived
</Text>
) : null}
</View>
{selected ? (
<Text className="text-sm text-primary font-semibold"></Text>
) : null}
</Pressable>
);
})
)}
</ScrollView>
</View>
);
}

View File

@@ -1,222 +0,0 @@
/**
* Issue detail screen.
*
* Read-mostly timeline with an inline comment composer pinned to the
* bottom (`<InlineCommentComposer>`). The composer is a single
* `<TextInput>` + mention suggestion bar — no modal route, no toolbar,
* no draft persistence. Sticks to the keyboard via `KeyboardStickyView`.
*
* Header note: the parent _layout.tsx already declares the `issue/[id]`
* Stack.Screen with title "Issue". We override that here once the data
* lands so the navigation bar shows `MUL-123` (Linear-style).
*/
import { useCallback, useEffect } from "react";
import {
ActionSheetIOS,
ActivityIndicator,
Alert,
Linking,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import * as Clipboard from "expo-clipboard";
import type { Issue } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { TimelineList } from "@/components/issue/timeline-list";
import { AgentHeaderBadge } from "@/components/issue/agent-header-badge";
import { InlineCommentComposer } from "@/components/issue/inline-comment-composer";
import {
issueDetailOptions,
issueKeys,
issueTimelineOptions,
} from "@/data/queries/issues";
import { useDeleteIssue } from "@/data/mutations/issues";
import { pinListOptions } from "@/data/queries/pins";
import { useCreatePin, useDeletePin } from "@/data/mutations/pins";
import { useAuthStore } from "@/data/auth-store";
import { useIssueRealtime } from "@/data/realtime/use-issue-realtime";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useViewedIssuesStore } from "@/data/viewed-issues-store";
import { useCommentSelectStore } from "@/data/comment-select-store";
import { useReplyTargetStore } from "@/data/stores/reply-target-store";
export default function IssueDetail() {
// `highlight` + `h` come from inbox deep-link (apps/mobile/app/(app)/
// [workspace]/(tabs)/inbox.tsx). `highlight` is the target comment id;
// `h` is a per-tap nonce so re-tapping the same row re-fires the
// scroll-and-flash effect.
const { id, workspace: wsSlug, highlight, h } = useLocalSearchParams<{
id: string;
workspace: string;
highlight?: string;
h?: string;
}>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const qc = useQueryClient();
const detail = useQuery(issueDetailOptions(wsId, id));
const timeline = useQuery(issueTimelineOptions(wsId, id));
// Subscribe to per-issue WS events: status/priority/assignee/label
// changes, comments, activity, reactions, agent task progress.
// Mounted with `id` — cleans up automatically on navigate-away.
// If another client deletes the issue we're viewing, pop back so the
// user isn't stranded on a 404 detail page.
useIssueRealtime(id, () => router.back());
// Track viewed issues so the chat composer's `@` suggestion bar can
// surface "Recent" — the user just looked at MUL-123, likely wants to
// ask the agent about it next. Workspace-scoped + in-memory; see
// data/viewed-issues-store.ts.
useEffect(() => {
if (wsId && id) {
useViewedIssuesStore.getState().push(wsId, id);
}
}, [wsId, id]);
// Screen-scoped composer state — clear on unmount so re-entering the
// issue starts from a clean slate (no stale text-selection comment id,
// no stale "Replying to X" target). Both stores are singletons used by
// the long-press action sheet.
useEffect(() => {
return () => {
useCommentSelectStore.getState().clear();
useReplyTargetStore.getState().clear();
};
}, []);
const onRefresh = useCallback(async () => {
await Promise.all([
detail.refetch(),
qc.invalidateQueries({ queryKey: issueKeys.timeline(wsId, id) }),
]);
}, [detail, qc, wsId, id]);
const issue = detail.data;
const deleteIssue = useDeleteIssue();
const userId = useAuthStore((s) => s.user?.id ?? null);
const { data: pins } = useQuery(pinListOptions(wsId, userId));
const isPinned =
!!issue &&
!!pins?.some((p) => p.item_type === "issue" && p.item_id === issue.id);
const createPin = useCreatePin();
const deletePin = useDeletePin();
// Three-dot menu: Pin/Unpin / Copy link / Open on web (if web URL set) /
// Delete. Mirrors apps/mobile/app/(app)/[workspace]/project/[id].tsx — same
// ActionSheetIOS + Alert.alert confirm pattern. Property edits (status,
// priority, assignee, due_date) live on the IssueHeaderCard chips inside
// the timeline list, not in this menu — one entry per action.
const onPressMore = useCallback(() => {
if (!issue || !wsSlug) return;
const webUrl = process.env.EXPO_PUBLIC_WEB_URL;
const issueLink = webUrl
? `${webUrl}/${wsSlug}/issue/${issue.identifier}`
: null;
const options: string[] = ["Cancel"];
options.push(isPinned ? "Unpin" : "Pin");
options.push("Edit details");
if (issueLink) options.push("Copy link");
if (issueLink) options.push("Open on web");
options.push("Delete issue");
const destructiveIndex = options.length - 1;
ActionSheetIOS.showActionSheetWithOptions(
{
options,
cancelButtonIndex: 0,
destructiveButtonIndex: destructiveIndex,
title: issue.identifier,
},
(i) => {
const label = options[i];
if (label === "Pin") {
createPin.mutate({ item_type: "issue", item_id: issue.id });
} else if (label === "Unpin") {
deletePin.mutate({ itemType: "issue", itemId: issue.id });
} else if (label === "Edit details") {
if (wsSlug) router.push(`/${wsSlug}/issue/${issue.id}/edit`);
} else if (label === "Copy link" && issueLink) {
Clipboard.setStringAsync(issueLink);
} else if (label === "Open on web" && issueLink) {
Linking.openURL(issueLink);
} else if (label === "Delete issue") {
confirmDelete(issue, () =>
deleteIssue.mutate(issue.id, {
onSuccess: () => router.back(),
}),
);
}
},
);
}, [issue, wsSlug, deleteIssue, isPinned, createPin, deletePin]);
return (
<View className="flex-1 bg-background">
<Stack.Screen
options={{
title: issue?.identifier ?? "Issue",
headerBackTitle: "Back",
headerRight: issue
? () => (
<View className="flex-row items-center gap-2">
{/* Ambient agent-working badge — renders null when no
* active tasks, so it doesn't crowd the header in the
* common case. See agent-header-badge.tsx. */}
<AgentHeaderBadge issueId={id} />
<IconButton
name="ellipsis-horizontal"
onPress={onPressMore}
accessibilityLabel="Issue actions"
/>
</View>
)
: undefined,
}}
/>
{detail.isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
) : detail.error || !issue ? (
<View className="flex-1 items-center justify-center px-6 gap-3">
<Text className="text-sm text-destructive text-center">
Failed to load issue:{" "}
{detail.error instanceof Error
? detail.error.message
: "not found"}
</Text>
<Button variant="outline" onPress={() => detail.refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : (
<View className="flex-1">
<TimelineList
issue={issue}
entries={timeline.data}
timelineLoading={timeline.isLoading}
refreshing={detail.isRefetching || timeline.isRefetching}
onRefresh={onRefresh}
highlightCommentId={highlight}
highlightNonce={h}
/>
<InlineCommentComposer issueId={id} />
</View>
)}
</View>
);
}
function confirmDelete(issue: Issue, onConfirm: () => void) {
Alert.alert(
"Delete issue?",
`${issue.identifier} and its comments, reactions, and attachments will be permanently deleted. This cannot be undone.`,
[
{ text: "Cancel", style: "cancel" },
{ text: "Delete", style: "destructive", onPress: onConfirm },
],
);
}

View File

@@ -1,114 +0,0 @@
/**
* Full emoji picker for a comment reaction — opened from the per-comment
* long-press menu's "+" tapback button. Mirrors web's emoji-mart picker
* that sits behind QuickEmojiPicker's overflow button: same product
* semantics (mobile must offer the full emoji set, not only the 8 quick
* picks).
*
* Reads the comment from the timeline cache to detect an already-applied
* reaction by the current user, then fires `useToggleCommentReaction` with
* the right `existing` value so re-tapping an active emoji removes it
* (matches web behaviour and the inline ReactionBar toggle semantics).
*
* Library: `rn-emoji-keyboard` (TheWidlarzGroup/rn-emoji-keyboard). We
* embed the `EmojiKeyboard` component (no built-in modal) inside the
* Expo Router formSheet route body, so the iOS UISheetPresentationController
* still owns the chrome (grabber, detents, drag-to-dismiss).
*/
import { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { EmojiKeyboard, type EmojiType } from "rn-emoji-keyboard";
import type { Reaction } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { issueTimelineOptions } from "@/data/queries/issues";
import { useToggleCommentReaction } from "@/data/mutations/issues";
import { useAuthStore } from "@/data/auth-store";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
export default function CommentEmojiPickerRoute() {
const { id, commentId } = useLocalSearchParams<{
id: string;
commentId: string;
}>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const userId = useAuthStore((s) => s.user?.id);
const toggle = useToggleCommentReaction(id);
const { colorScheme } = useColorScheme();
const { data: timeline = [] } = useQuery(issueTimelineOptions(wsId, id));
const entry = useMemo(
() => timeline.find((e) => e.id === commentId) ?? null,
[timeline, commentId],
);
const reactions = useMemo<Reaction[]>(
() => (entry?.reactions ?? []) as Reaction[],
[entry?.reactions],
);
const onSelect = useCallback(
(picked: EmojiType) => {
const existing = reactions.find(
(r) =>
r.emoji === picked.emoji &&
r.actor_type === "member" &&
r.actor_id === userId,
);
toggle.mutate({ commentId, emoji: picked.emoji, existing });
router.back();
},
[reactions, userId, toggle, commentId],
);
const theme = THEME[colorScheme];
return (
<View className="flex-1">
<View className="px-4 pt-3 pb-2">
<Text className="text-lg font-semibold text-foreground">
Add Reaction
</Text>
</View>
<View className="flex-1">
<EmojiKeyboard
onEmojiSelected={onSelect}
enableSearchBar
enableRecentlyUsed
categoryPosition="top"
theme={{
backdrop: theme.background,
knob: theme.mutedForeground,
container: theme.popover,
header: theme.foreground,
skinTonesContainer: theme.secondary,
category: {
icon: theme.mutedForeground,
iconActive: theme.foreground,
container: theme.popover,
containerActive: theme.secondary,
},
search: {
background: theme.secondary,
text: theme.foreground,
placeholder: theme.mutedForeground,
icon: theme.mutedForeground,
},
customButton: {
icon: theme.mutedForeground,
iconPressed: theme.foreground,
background: theme.secondary,
backgroundPressed: theme.muted,
},
emoji: {
selected: theme.secondary,
},
}}
/>
</View>
</View>
);
}

View File

@@ -1,203 +0,0 @@
/**
* Edit issue title / description. Modal presentation, configured in
* `[workspace]/_layout.tsx`. Save runs the optimistic `useUpdateIssue`
* mutation; modal dismisses on success.
*
* Mirrors `project/[id]/edit.tsx` so users get the same gesture on both
* record types (cancel/save in header, dirty Alert on dismiss-while-dirty).
*
* Description uses `useMentionInput` + `<DescriptionField>` so the @-mention
* pipeline matches `new-issue.tsx`. v1 note: existing mentions in the
* server-side description render as raw markdown text while editing because
* there's no markdown-to-marker deserializer yet — `serialize()` still
* produces a valid round-trip since unparsed `[@name](mention://...)` literals
* pass through unchanged. New @-mentions added during the edit get serialized
* normally via the marker pipeline.
*
* Properties (status / priority / assignee / labels / project / due_date)
* are NOT edited here — they have dedicated chip pickers on the detail page.
* This screen only owns the two free-text fields.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
TextInput,
View,
} from "react-native";
import { Stack, router, useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import { DescriptionField } from "@/components/issue/description-field";
import { MentionSuggestionBar } from "@/components/issue/mention-suggestion-bar";
import { MOBILE_PLACEHOLDER_COLOR } from "@/components/ui/input-tokens";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useMentionInput } from "@/lib/use-mention-input";
export default function EditIssue() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const detail = useQuery(issueDetailOptions(wsId, id));
const update = useUpdateIssue(id);
const [title, setTitle] = useState("");
const description = useMentionInput();
const [seeded, setSeeded] = useState(false);
// `useMentionInput` returns `setText` from `useState`, which is a stable
// identity across renders. Pulling it out of the hook return lets us list
// it explicitly in the seeding effect's dep array without the whole
// `description` object (which changes every render) re-triggering the
// seed and overwriting in-progress edits.
const setDescriptionText = description.setText;
useEffect(() => {
if (!detail.data || seeded) return;
setTitle(detail.data.title);
setDescriptionText(detail.data.description ?? "");
setSeeded(true);
}, [detail.data, seeded, setDescriptionText]);
const initialDescription = detail.data?.description ?? "";
const currentDescription = description.serialize();
const dirty = useMemo(() => {
if (!detail.data || !seeded) return false;
return (
title.trim() !== detail.data.title ||
currentDescription.trim() !== initialDescription
);
}, [detail.data, seeded, title, currentDescription, initialDescription]);
const canSave =
seeded && title.trim().length > 0 && dirty && !update.isPending;
const onCancel = useCallback(() => {
if (!dirty) {
router.back();
return;
}
Alert.alert(
"Discard changes?",
"Your edits to this issue will be lost.",
[
{ text: "Keep editing", style: "cancel" },
{
text: "Discard",
style: "destructive",
onPress: () => router.back(),
},
],
);
}, [dirty]);
const onSave = useCallback(() => {
if (!canSave) return;
// `UpdateIssueRequest.description` is `string | undefined` — server
// treats empty string as "clear the description", which is what we
// want when the user wipes the field.
const patch = {
title: title.trim(),
description: currentDescription.trim(),
};
update.mutate(patch, {
onSuccess: () => router.back(),
onError: (err) => {
Alert.alert(
"Failed to save",
err instanceof Error ? err.message : "Unknown error",
);
},
});
}, [canSave, title, currentDescription, update]);
const headerLeft = useCallback(
() => (
<Pressable onPress={onCancel} className="px-1 py-1">
<Text className="text-base text-brand">Cancel</Text>
</Pressable>
),
[onCancel],
);
const headerRight = useCallback(
() => (
<Pressable
onPress={onSave}
disabled={!canSave}
className={canSave ? "px-1 py-1" : "px-1 py-1 opacity-40"}
>
<Text className="text-base text-brand font-semibold">
{update.isPending ? "Saving…" : "Save"}
</Text>
</Pressable>
),
[canSave, onSave, update.isPending],
);
return (
<>
<Stack.Screen options={{ headerLeft, headerRight }} />
<KeyboardAvoidingView
className="flex-1 bg-background"
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
className="flex-1"
contentContainerClassName="px-4 pt-4 pb-6 gap-4"
keyboardShouldPersistTaps="handled"
>
{!detail.data ? (
<Text className="text-sm text-muted-foreground">Loading</Text>
) : (
<>
<Field label="Title">
<TextInput
value={title}
onChangeText={setTitle}
placeholder="Issue title"
placeholderTextColor={MOBILE_PLACEHOLDER_COLOR}
className="text-base text-foreground bg-secondary/50 rounded-md px-3 py-2"
returnKeyType="next"
editable={!update.isPending}
/>
</Field>
<Field label="Description">
<DescriptionField
description={description}
disabled={update.isPending}
/>
</Field>
</>
)}
</ScrollView>
{/* Mention suggestion bar floats above the keyboard while the user
is mid-@. Outside the ScrollView so it doesn't scroll with the
form body. */}
<MentionSuggestionBar {...description.suggestionBar} />
</KeyboardAvoidingView>
</>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</Text>
{children}
</View>
);
}

View File

@@ -1,44 +0,0 @@
/**
* Assignee picker route for an existing issue. Uses the native iOS Stack
* header + UISearchController (registered in ../_layout.tsx with
* `headerShown: true` + title); the search bar wiring is encapsulated in
* `useNativeSearchBar`.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { AssigneePickerBody } from "@/components/issue/pickers/assignee-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueAssigneePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
const query = useNativeSearchBar("Search people", { autoFocus: true });
const value =
issue?.assignee_type && issue?.assignee_id
? { type: issue.assignee_type, id: issue.assignee_id }
: null;
return (
<AssigneePickerBody
value={value}
query={query}
onChange={(next) => {
if (next === null) {
updateIssue.mutate({ assignee_type: null, assignee_id: null });
} else {
updateIssue.mutate({
assignee_type: next.type,
assignee_id: next.id,
});
}
router.back();
}}
/>
);
}

View File

@@ -1,84 +0,0 @@
/**
* Due-date picker route for an existing issue.
*
* Diverges from the other single-select pickers because the native
* UIDatePicker needs a confirmation step — the user spins to a date but
* doesn't auto-commit on every onChange. Done / Clear buttons live in a
* mini header row inside the route body (the parent Stack hides its own
* header per the formSheet config), and on submit we fire the mutation +
* router.back().
*/
import { useRef } from "react";
import { Pressable, View } from "react-native";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { Text } from "@/components/ui/text";
import {
DueDatePickerBody,
type DueDatePickerBodyHandle,
} from "@/components/issue/pickers/due-date-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssueDueDatePickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
const ref = useRef<DueDatePickerBodyHandle>(null);
const value = issue?.due_date ?? null;
return (
<View className="flex-1">
<DueDateHeader
hasValue={!!value}
onDone={() => {
const iso = ref.current?.getIso();
if (iso) updateIssue.mutate({ due_date: iso });
router.back();
}}
onClear={() => {
updateIssue.mutate({ due_date: null });
router.back();
}}
/>
<DueDatePickerBody ref={ref} value={value} />
</View>
);
}
function DueDateHeader({
hasValue,
onDone,
onClear,
}: {
hasValue: boolean;
onDone: () => void;
onClear: () => void;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-4 pb-2">
<Text className="text-base font-semibold text-foreground">Due date</Text>
<View className="flex-row items-center gap-1">
{hasValue ? (
<Pressable
onPress={onClear}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm text-destructive">Clear</Text>
</Pressable>
) : null}
<Pressable
onPress={onDone}
hitSlop={6}
className="px-2 py-1 rounded-md active:bg-secondary"
>
<Text className="text-sm font-medium text-primary">Done</Text>
</Pressable>
</View>
</View>
);
}

View File

@@ -1,59 +0,0 @@
/**
* Label picker route for an existing issue — multi-select with inline
* create. Uses native iOS Stack header + UISearchController via
* `useNativeSearchBar` (sheet stays open across toggles; the user
* dismisses via the sheet grabber or the Back button).
*/
import { useRef } from "react";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { LabelPickerBody } from "@/components/issue/pickers/label-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import {
useAttachLabel,
useDetachLabel,
} from "@/data/mutations/issues";
import { useCreateLabel } from "@/data/mutations/labels";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueLabelPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const attachLabel = useAttachLabel(id);
const detachLabel = useDetachLabel(id);
const createLabel = useCreateLabel();
const query = useNativeSearchBar("Search labels", { autoFocus: true });
// Synchronous lock to prevent double-submit on rapid taps on the Create
// row before React state updates — mirrors web's `creatingRef` pattern in
// `packages/views/issues/components/pickers/label-picker.tsx`.
const creatingRef = useRef(false);
const attached = issue?.labels ?? [];
return (
<LabelPickerBody
attached={attached}
query={query}
onAttach={(label) => attachLabel.mutate({ label })}
onDetach={(labelId) => detachLabel.mutate({ labelId })}
onCreate={(name, color) => {
if (creatingRef.current) return;
creatingRef.current = true;
createLabel.mutate(
{ name, color },
{
onSuccess: (label) => {
attachLabel.mutate({ label });
},
onSettled: () => {
creatingRef.current = false;
},
},
);
}}
/>
);
}

View File

@@ -1,27 +0,0 @@
/**
* Priority picker route for an existing issue. See ./status.tsx for the
* self-contained-route rationale.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { PriorityPickerBody } from "@/components/issue/pickers/priority-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssuePriorityPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
return (
<PriorityPickerBody
value={issue?.priority ?? "none"}
onChange={(next) => {
updateIssue.mutate({ priority: next });
router.back();
}}
/>
);
}

View File

@@ -1,39 +0,0 @@
/**
* Project picker route for an existing issue. Uses native iOS Stack header
* + UISearchController via `useNativeSearchBar` (search bar registered in
* ../_layout.tsx).
*/
import { useMemo } from "react";
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { ProjectPickerBody } from "@/components/issue/pickers/project-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { findProject, projectListOptions } from "@/data/queries/projects";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
export default function IssueProjectPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const { data: projects = [] } = useQuery(projectListOptions(wsId));
const updateIssue = useUpdateIssue(id);
const query = useNativeSearchBar("Search projects", { autoFocus: true });
const project = useMemo(
() => findProject(projects, issue?.project_id ?? null),
[projects, issue?.project_id],
);
return (
<ProjectPickerBody
value={project ?? null}
query={query}
onChange={(next) => {
updateIssue.mutate({ project_id: next?.id ?? null });
router.back();
}}
/>
);
}

View File

@@ -1,36 +0,0 @@
/**
* Status picker route for an existing issue — presented as a formSheet
* (UISheetPresentationController) by the parent Stack.
*
* Self-contained: reads the issue from the TanStack Query detail cache,
* calls `useUpdateIssue` directly on selection, then `router.back()`s. No
* onChange callback to a parent.
*
* If the cache is cold (rare — the user reaches this screen by tapping
* a chip on the issue-detail page that already populated it), the picker
* still renders against the current value of `todo` and the optimistic
* mutation patches the cache when the user picks.
*/
import { useLocalSearchParams, router } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import { StatusPickerBody } from "@/components/issue/pickers/status-picker-body";
import { issueDetailOptions } from "@/data/queries/issues";
import { useUpdateIssue } from "@/data/mutations/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
export default function IssueStatusPickerRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: issue } = useQuery(issueDetailOptions(wsId, id));
const updateIssue = useUpdateIssue(id);
return (
<StatusPickerBody
value={issue?.status ?? "todo"}
onChange={(next) => {
updateIssue.mutate({ status: next });
router.back();
}}
/>
);
}

View File

@@ -1,110 +0,0 @@
/**
* Agent Runs sheet — presented as a formSheet by the parent Stack. Two
* sections: Active (queued/dispatched/running, created_at desc) and Past
* (failed → cancelled → completed, completed_at desc within each). Empty
* sections hide entirely.
*
* Both entry points (the in-card AgentActivityRow and the Stack-header
* AgentHeaderBadge) now `router.push("/[workspace]/issue/[id]/runs")` —
* the legacy `useRunsSheetStore` is gone since the route system is the
* single source of truth for what's open.
*
* Past-row tap is a no-op in v1 — transcript drilldown is deferred.
*/
import { useMemo } from "react";
import { ScrollView, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import { useQuery } from "@tanstack/react-query";
import type { AgentTask } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { RunRow } from "@/components/issue/run-row";
import {
issueActiveTasksOptions,
issueTasksOptions,
} from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
const PAST_STATUS_ORDER: Record<AgentTask["status"], number> = {
failed: 0,
cancelled: 1,
completed: 2,
queued: 99,
dispatched: 99,
running: 99,
};
export default function IssueRunsRoute() {
const { id } = useLocalSearchParams<{ id: string }>();
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const { data: activeTasks = [] } = useQuery(
issueActiveTasksOptions(wsId, id),
);
const { data: allTasks = [] } = useQuery(issueTasksOptions(wsId, id));
const active = useMemo(
() =>
[...activeTasks].sort((a, b) =>
(b.created_at ?? "").localeCompare(a.created_at ?? ""),
),
[activeTasks],
);
const past = useMemo(() => {
const filtered = allTasks.filter(
(t) =>
t.status === "completed" ||
t.status === "failed" ||
t.status === "cancelled",
);
return filtered.sort((a, b) => {
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
if (ord !== 0) return ord;
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
});
}, [allTasks]);
return (
<View className="flex-1">
<View className="px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">
Agent Runs
</Text>
</View>
<ScrollView showsVerticalScrollIndicator={false}>
<View className="px-4 gap-3 pb-4">
{active.length > 0 ? (
<Section title="Active">
{active.map((task) => (
<RunRow key={task.id} task={task} issueId={id} />
))}
</Section>
) : null}
{past.length > 0 ? (
<Section title="Past">
{past.map((task) => (
<RunRow key={task.id} task={task} issueId={id} />
))}
</Section>
) : null}
</View>
</ScrollView>
</View>
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<View className="gap-1">
<Text className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
{title}
</Text>
<View>{children}</View>
</View>
);
}

View File

@@ -1,173 +0,0 @@
/**
* Status + Priority filter sheet — presented as a formSheet by the parent
* Stack. Shared by My Issues and the workspace-wide Issues page; which
* view-store to read/write is selected by the `scope` URL param.
*
* Routes that open this sheet:
* - /[workspace]/issues-filter?scope=my → useMyIssuesViewStore
* - /[workspace]/issues-filter?scope=all → useIssuesViewStore
*
* Self-contained: reads/writes the store directly, no callback passing.
*/
import { Pressable, ScrollView, View } from "react-native";
import { useLocalSearchParams } from "expo-router";
import type { IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { StatusIcon } from "@/components/ui/status-icon";
import { PriorityIcon } from "@/components/ui/priority-icon";
import { useIssuesViewStore } from "@/data/stores/issues-view-store";
import { useMyIssuesViewStore } from "@/data/stores/my-issues-view-store";
import { BOARD_STATUSES, STATUS_LABEL } from "@/lib/issue-status";
import { cn } from "@/lib/utils";
const ALL_STATUSES: IssueStatus[] = [...BOARD_STATUSES, "cancelled"];
// Mirrors PRIORITY_ORDER in packages/core/issues/config/priority.ts.
const PRIORITY_ORDER: IssuePriority[] = [
"urgent",
"high",
"medium",
"low",
"none",
];
// Label map duplicated across several mobile files — out of scope to
// consolidate per the SheetShell migration plan.
const PRIORITY_LABEL: Record<IssuePriority, string> = {
urgent: "Urgent",
high: "High",
medium: "Medium",
low: "Low",
none: "No priority",
};
type Scope = "my" | "all";
export default function IssuesFilterRoute() {
const { scope } = useLocalSearchParams<{ scope?: string }>();
const resolvedScope: Scope = scope === "all" ? "all" : "my";
const statusFilters = useScopedFilters(resolvedScope, "status");
const priorityFilters = useScopedFilters(resolvedScope, "priority");
const onToggleStatus = (s: IssueStatus) => {
if (resolvedScope === "all") {
useIssuesViewStore.getState().toggleStatusFilter(s);
} else {
useMyIssuesViewStore.getState().toggleStatusFilter(s);
}
};
const onTogglePriority = (p: IssuePriority) => {
if (resolvedScope === "all") {
useIssuesViewStore.getState().togglePriorityFilter(p);
} else {
useMyIssuesViewStore.getState().togglePriorityFilter(p);
}
};
const onClearFilters = () => {
if (resolvedScope === "all") {
useIssuesViewStore.getState().clearFilters();
} else {
useMyIssuesViewStore.getState().clearFilters();
}
};
const hasActive = statusFilters.length > 0 || priorityFilters.length > 0;
return (
<View className="flex-1">
<View className="flex-row items-center justify-between px-4 pt-4 pb-3">
<Text className="text-base font-semibold text-foreground">Filter</Text>
{hasActive ? (
<Pressable
onPress={onClearFilters}
hitSlop={8}
className="px-2 py-1 active:opacity-60"
>
<Text className="text-sm text-primary font-medium">Reset</Text>
</Pressable>
) : null}
</View>
<ScrollView className="flex-1" showsVerticalScrollIndicator={false}>
<SectionLabel>Status</SectionLabel>
{ALL_STATUSES.map((status) => {
const checked = statusFilters.includes(status);
return (
<Pressable
key={status}
onPress={() => onToggleStatus(status)}
className={cn(
"flex-row items-center gap-3 px-4 py-2.5 active:bg-secondary",
checked && "bg-secondary/60",
)}
>
<StatusIcon status={status} size={16} />
<Text className="flex-1 text-sm text-foreground">
{STATUS_LABEL[status]}
</Text>
<CheckMark checked={checked} />
</Pressable>
);
})}
<SectionLabel>Priority</SectionLabel>
{PRIORITY_ORDER.map((priority) => {
const checked = priorityFilters.includes(priority);
return (
<Pressable
key={priority}
onPress={() => onTogglePriority(priority)}
className={cn(
"flex-row items-center gap-3 px-4 py-2.5 active:bg-secondary",
checked && "bg-secondary/60",
)}
>
<PriorityIcon priority={priority} />
<Text className="flex-1 text-sm text-foreground">
{PRIORITY_LABEL[priority]}
</Text>
<CheckMark checked={checked} />
</Pressable>
);
})}
</ScrollView>
</View>
);
}
function useScopedFilters(
scope: Scope,
kind: "status",
): IssueStatus[];
function useScopedFilters(
scope: Scope,
kind: "priority",
): IssuePriority[];
function useScopedFilters(
scope: Scope,
kind: "status" | "priority",
): IssueStatus[] | IssuePriority[] {
const allStatus = useIssuesViewStore((s) => s.statusFilters);
const allPriority = useIssuesViewStore((s) => s.priorityFilters);
const myStatus = useMyIssuesViewStore((s) => s.statusFilters);
const myPriority = useMyIssuesViewStore((s) => s.priorityFilters);
if (scope === "all") {
return kind === "status" ? allStatus : allPriority;
}
return kind === "status" ? myStatus : myPriority;
}
function SectionLabel({ children }: { children: string }) {
return (
<View className="px-4 pt-3 pb-1.5">
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{children}
</Text>
</View>
);
}
function CheckMark({ checked }: { checked: boolean }) {
if (!checked) return null;
return <Text className="text-sm text-primary font-semibold"></Text>;
}

View File

@@ -1,32 +0,0 @@
/**
* Workspace-level mention picker route — formSheet, opened from any
* composer that has an `@` button (currently the issue-comment composer
* and the chat composer).
*
* `?mode=` controls which sections render:
* - "comment" (default) — @all + People + Agents + Squads + Issues.
* The comment composer offers the full surface; mentions notify the
* mentioned actor.
* - "chat" — Issues only. Chat is user ↔ single agent, so member /
* agent / squad / @all mentions are noise (and would generate
* unintended notifications). Issues remain useful as "reference this
* ticket for the agent's context".
*
* Lives at workspace level (not nested under issue/[id]) because the chat
* tab has no per-session route to nest under; making it workspace-level
* keeps a single route file serving both contexts.
*/
import { useLocalSearchParams } from "expo-router";
import { MentionPickerBody } from "@/components/issue/pickers/mention-picker-body";
import { useNativeSearchBar } from "@/lib/use-native-search-bar";
type Mode = "comment" | "chat";
export default function MentionPickerRoute() {
const { mode: rawMode } = useLocalSearchParams<{ mode?: string }>();
const mode: Mode = rawMode === "chat" ? "chat" : "comment";
const placeholder =
mode === "chat" ? "Reference an issue" : "Search people or issues";
const query = useNativeSearchBar(placeholder, { autoFocus: true });
return <MentionPickerBody mode={mode} query={query} />;
}

View File

@@ -1,12 +0,0 @@
import { View } from "react-native";
import { Text } from "@/components/ui/text";
export default function AgentsPage() {
return (
<View className="flex-1 items-center justify-center bg-background px-6">
<Text className="text-sm text-muted-foreground text-center">
Agents coming soon.
</Text>
</View>
);
}

View File

@@ -1,383 +0,0 @@
/**
* Workspace-wide Issues page. Mirrors web `packages/views/issues/components/
* issues-page.tsx:32-94`: fetch every issue in the workspace, expose
* `all / members / agents` scope tabs, group by status, allow status +
* priority filtering.
*
* Scope is a **client-side** filter on `assignee_type` — matches web
* `issues-page.tsx:90-94`. This keeps `issueListOptions(wsId)` workspace-
* scoped (no scope param on the wire), so `issueKeys.list(wsId)` and
* `useIssuesRealtime` need no changes.
*
* Differences vs My Issues (`(tabs)/my-issues.tsx`):
* - Workspace-wide list (all issues), not user-scoped.
* - Three scopes are `all / members / agents` (assignee_type pre-filter),
* not `assigned / created / agents` (per-user predicates).
* - Independent filter store (`useIssuesViewStore`) so workspace-level
* filters don't bleed into the per-user view.
*
* Filters beyond status/priority (assignee / project / label / creator)
* are deferred — power-user features with non-trivial picker cost; ship
* after the parity-critical scope tabs land.
*/
import { useMemo } from "react";
import { Pressable, SectionList, View } from "react-native";
import { useQuery } from "@tanstack/react-query";
import { router } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import type { Issue, IssuePriority, IssueStatus } from "@multica/core/types";
import { Text } from "@/components/ui/text";
import { Button } from "@/components/ui/button";
// Header chrome (back + "Issues" title) comes from the parent Stack
// (`apps/mobile/app/(app)/[workspace]/_layout.tsx:269`). The Filter
// affordance now lives in <ScopeToolbar> below, matching web's
// IssuesHeader pattern (scope + filter share a row).
import { StatusIcon } from "@/components/ui/status-icon";
import { IssueRow } from "@/components/issue/issue-row";
import { IssuesLoading } from "@/components/issue/issues-loading";
import { issueListOptions } from "@/data/queries/issues";
import { useWorkspaceStore } from "@/data/workspace-store";
import {
useIssuesViewStore,
type IssuesScope,
} from "@/data/stores/issues-view-store";
import { useClearFiltersOnWorkspaceChange } from "@/lib/use-clear-filters-on-workspace-change";
import {
BOARD_STATUSES,
PRIORITY_LABEL,
STATUS_LABEL,
} from "@/lib/issue-status";
import { filterIssues } from "@/lib/filter-issues";
import { useColorScheme } from "@/lib/use-color-scheme";
import { THEME } from "@/lib/theme";
type IssueSection = { status: IssueStatus; data: Issue[] };
// Scope tab definitions. Mirrors web `issuesScopeStore`. Counts are NOT
// rendered on the pill labels — web's `IssuesHeader` doesn't show them
// either, and on SE3 (375pt) "(123)" appended to each label pushes the
// row past the safe width when filter icon shares the row. Per-status
// counts still appear on the SectionList headers below.
const SCOPES: { value: IssuesScope; label: string }[] = [
{ value: "all", label: "All" },
{ value: "members", label: "Members" },
{ value: "agents", label: "Agents" },
];
export default function IssuesPage() {
const wsId = useWorkspaceStore((s) => s.currentWorkspaceId);
const wsSlug = useWorkspaceStore((s) => s.currentWorkspaceSlug);
const scope = useIssuesViewStore((s) => s.scope);
const setScope = useIssuesViewStore((s) => s.setScope);
const statusFilters = useIssuesViewStore((s) => s.statusFilters);
const priorityFilters = useIssuesViewStore((s) => s.priorityFilters);
const openFilter = () => {
if (!wsSlug) return;
router.push({
pathname: "/[workspace]/issues-filter",
params: { workspace: wsSlug, scope: "all" },
});
};
useClearFiltersOnWorkspaceChange(
useIssuesViewStore.getState().clearFilters,
wsId,
);
const { data, isLoading, error, refetch, isRefetching } = useQuery(
issueListOptions(wsId),
);
const allIssues = data ?? [];
// Scope pre-filter — mirrors web `issues-page.tsx:90-94`. Applied before
// status/priority filtering so chip filters operate on the visible slice.
const scopedIssues = useMemo(() => {
if (scope === "members") {
return allIssues.filter((i) => i.assignee_type === "member");
}
if (scope === "agents") {
return allIssues.filter(
(i) => i.assignee_type === "agent" || i.assignee_type === "squad",
);
}
return allIssues;
}, [allIssues, scope]);
const filtered = useMemo(
() => filterIssues(scopedIssues, statusFilters, priorityFilters),
[scopedIssues, statusFilters, priorityFilters],
);
// Section grouping uses BOARD_STATUSES (cancelled excluded) — matches web
// `issues-page.tsx:117-125`.
const sections = useMemo<IssueSection[]>(() => {
if (filtered.length === 0) return [];
const byStatus = new Map<IssueStatus, Issue[]>();
for (const issue of filtered) {
const list = byStatus.get(issue.status);
if (list) list.push(issue);
else byStatus.set(issue.status, [issue]);
}
const visibleStatuses =
statusFilters.length > 0
? BOARD_STATUSES.filter((s) => statusFilters.includes(s))
: BOARD_STATUSES;
return visibleStatuses
.map((status) => ({ status, data: byStatus.get(status) ?? [] }))
.filter((s) => s.data.length > 0);
}, [filtered, statusFilters]);
const hasActiveFilters =
statusFilters.length > 0 || priorityFilters.length > 0;
const showEmptyState = !isLoading && !error && filtered.length === 0;
return (
<View className="flex-1 bg-background">
<ScopeToolbar
scopes={SCOPES}
scope={scope}
onChange={(v) => setScope(v)}
onOpenFilter={openFilter}
hasActiveFilters={hasActiveFilters}
/>
{hasActiveFilters ? (
<ActiveFilterChips
statusFilters={statusFilters}
priorityFilters={priorityFilters}
onClearStatus={(s) =>
useIssuesViewStore.getState().toggleStatusFilter(s)
}
onClearPriority={(p) =>
useIssuesViewStore.getState().togglePriorityFilter(p)
}
/>
) : null}
{isLoading ? (
<IssuesLoading />
) : error ? (
<View className="px-4 gap-3 pt-4">
<Text className="text-sm text-destructive">
Failed to load issues:{" "}
{error instanceof Error ? error.message : "unknown error"}
</Text>
<Button variant="outline" onPress={() => refetch()}>
<Text>Retry</Text>
</Button>
</View>
) : showEmptyState ? (
<EmptyState
message={
hasActiveFilters
? "No issues match the current filters."
: emptyMessageForScope(scope)
}
/>
) : (
<SectionList
sections={sections}
keyExtractor={(item) => item.id}
stickySectionHeadersEnabled={false}
ItemSeparatorComponent={() => (
<View className="h-px bg-border ml-4" />
)}
renderSectionHeader={({ section }) => (
<SectionHeader status={section.status} count={section.data.length} />
)}
contentContainerClassName="pb-6"
renderItem={({ item }) => (
<IssueRow
issue={item}
onPress={() => {
if (wsSlug) router.push(`/${wsSlug}/issue/${item.id}`);
}}
/>
)}
refreshing={isRefetching}
onRefresh={refetch}
/>
)}
</View>
);
}
/**
* Outline icon button matching the pill height. Identical to the helper in
* `(tabs)/my-issues.tsx` for the same reason ScopeToolbar is duplicated:
* two callers don't justify a shared primitive yet.
*/
function FilterButton({
onPress,
hasActiveFilters,
}: {
onPress: () => void;
hasActiveFilters: boolean;
}) {
const { colorScheme } = useColorScheme();
return (
<View style={{ position: "relative" }} className="ml-2">
<Button
variant="outline"
size="sm"
onPress={onPress}
accessibilityLabel="Filter"
className="w-9 px-0"
>
<Ionicons
name="options-outline"
size={16}
color={THEME[colorScheme].mutedForeground}
/>
</Button>
{hasActiveFilters ? (
<View
pointerEvents="none"
className="absolute top-1 right-1 size-1.5 rounded-full bg-brand"
/>
) : null}
</View>
);
}
/**
* Toolbar row mirroring web `IssuesHeader`
* (`packages/views/issues/components/issues-header.tsx:516-543`): left-aligned
* scope pill group + right-side Filter icon (red dot on active filters).
* Identical to the equivalent in `(tabs)/my-issues.tsx` — kept duplicated
* because the threshold for a shared `components/ui/` primitive is 3 callers,
* and two callers don't justify the abstraction yet.
*/
function ScopeToolbar<S extends string>({
scopes,
scope,
onChange,
onOpenFilter,
hasActiveFilters,
}: {
scopes: { value: S; label: string }[];
scope: S;
onChange: (value: S) => void;
onOpenFilter: () => void;
hasActiveFilters: boolean;
}) {
return (
<View className="flex-row items-center justify-between px-4 pt-2 pb-2">
<View className="flex-row items-center gap-1 flex-shrink min-w-0">
{scopes.map((s) => {
const active = scope === s.value;
return (
<Button
key={s.value}
variant="outline"
size="sm"
onPress={() => onChange(s.value)}
className={active ? "bg-accent" : ""}
accessibilityState={{ selected: active }}
>
<Text
numberOfLines={1}
className={active ? "text-accent-foreground" : "text-muted-foreground"}
>
{s.label}
</Text>
</Button>
);
})}
</View>
<FilterButton
onPress={onOpenFilter}
hasActiveFilters={hasActiveFilters}
/>
</View>
);
}
function ActiveFilterChips({
statusFilters,
priorityFilters,
onClearStatus,
onClearPriority,
}: {
statusFilters: IssueStatus[];
priorityFilters: IssuePriority[];
onClearStatus: (s: IssueStatus) => void;
onClearPriority: (p: IssuePriority) => void;
}) {
return (
<View className="flex-row flex-wrap gap-1.5 px-4 pb-2">
{statusFilters.map((s) => (
<Chip
key={`s-${s}`}
label={STATUS_LABEL[s]}
onClear={() => onClearStatus(s)}
/>
))}
{priorityFilters.map((p) => (
<Chip
key={`p-${p}`}
label={PRIORITY_LABEL[p]}
onClear={() => onClearPriority(p)}
/>
))}
</View>
);
}
function Chip({ label, onClear }: { label: string; onClear: () => void }) {
const { colorScheme } = useColorScheme();
return (
<Pressable
onPress={onClear}
className="flex-row items-center gap-1 pl-2.5 pr-2 py-1 rounded-full border border-border bg-secondary/40 active:bg-secondary"
>
<Text className="text-xs text-foreground">{label}</Text>
<Ionicons
name="close"
size={12}
color={THEME[colorScheme].mutedForeground}
/>
</Pressable>
);
}
function SectionHeader({
status,
count,
}: {
status: IssueStatus;
count: number;
}) {
return (
<View className="flex-row items-center gap-2 px-4 py-2 bg-background">
<StatusIcon status={status} size={14} />
<Text className="text-xs uppercase tracking-wider text-muted-foreground font-medium">
{STATUS_LABEL[status]}
</Text>
<Text className="text-xs text-muted-foreground/60">{count}</Text>
</View>
);
}
function EmptyState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-sm text-muted-foreground text-center">
{message}
</Text>
</View>
);
}
function emptyMessageForScope(scope: IssuesScope): string {
switch (scope) {
case "all":
return "No issues in this workspace.";
case "members":
return "No issues assigned to a member.";
case "agents":
return "No issues assigned to agents or squads.";
}
}

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