mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 15:39:25 +02:00
Compare commits
152 Commits
fix/commen
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0610f277 | ||
|
|
9d053c57f9 | ||
|
|
8a9f15dbc9 | ||
|
|
5556f4570b | ||
|
|
b13e1808a4 | ||
|
|
0aa3b53c25 | ||
|
|
39ab355585 | ||
|
|
81bde585ba | ||
|
|
91e6c779d6 | ||
|
|
9d7060caf1 | ||
|
|
6d0e875dbb | ||
|
|
737c976b0d | ||
|
|
31d942d010 | ||
|
|
745832b536 | ||
|
|
140a113c38 | ||
|
|
4bbaf5363c | ||
|
|
c0c41fa0b4 | ||
|
|
06ae9b2a0c | ||
|
|
49e1c2a8af | ||
|
|
27fcbb015f | ||
|
|
ba7be23f7c | ||
|
|
e1c6754304 | ||
|
|
990e7a6d74 | ||
|
|
9eaea31892 | ||
|
|
8b3b054d17 | ||
|
|
b4c9e4423c | ||
|
|
0d0edac32f | ||
|
|
e4b53eb5c0 | ||
|
|
dd9e7bf19d | ||
|
|
4c7fefd143 | ||
|
|
2d1313c5a4 | ||
|
|
e3a829f05e | ||
|
|
02c0298c18 | ||
|
|
e7daf876bd | ||
|
|
6f29a4c0a6 | ||
|
|
da4f278330 | ||
|
|
1279f22d1c | ||
|
|
e9e97ee6d2 | ||
|
|
d74a0cb34c | ||
|
|
b7857a6aa3 | ||
|
|
63b9b10df5 | ||
|
|
2d71872daa | ||
|
|
2f398c36ad | ||
|
|
c5eb778532 | ||
|
|
c27d35b7fe | ||
|
|
5a3324e886 | ||
|
|
3077810049 | ||
|
|
a08281a1b2 | ||
|
|
23eba24076 | ||
|
|
0f36c88855 | ||
|
|
77ac17ef49 | ||
|
|
af146b6dc7 | ||
|
|
41586f1499 | ||
|
|
59263df748 | ||
|
|
d26cac0008 | ||
|
|
6f2e9aa7a8 | ||
|
|
acb1c3fb64 | ||
|
|
114a1ffb8f | ||
|
|
eb6dffdbc6 | ||
|
|
6e010320f8 | ||
|
|
3030c803bf | ||
|
|
6bb8cac9ea | ||
|
|
64ce459e30 | ||
|
|
1f5cb51d4e | ||
|
|
52e76e7b23 | ||
|
|
32dac3dd57 | ||
|
|
1f8f3e8037 | ||
|
|
f46b929ebc | ||
|
|
89ada0ee81 | ||
|
|
1272311ebe | ||
|
|
18a58e80c0 | ||
|
|
2c0f6edca8 | ||
|
|
3aaca155e7 | ||
|
|
4f1797598e | ||
|
|
8ba1ef2dce | ||
|
|
097064ed0e | ||
|
|
089832d6ec | ||
|
|
c222088262 | ||
|
|
79394ee057 | ||
|
|
241a3582cf | ||
|
|
7c71007e6e | ||
|
|
2f24057bc2 | ||
|
|
1afa493165 | ||
|
|
f2e72577b2 | ||
|
|
12c2d58e18 | ||
|
|
7d30ef1c67 | ||
|
|
3ce4cf6f2f | ||
|
|
93541be975 | ||
|
|
76c687d39a | ||
|
|
f9c193e06b | ||
|
|
0e31a9ca58 | ||
|
|
71eb938a67 | ||
|
|
4df6c1468d | ||
|
|
8ea8048005 | ||
|
|
ea4f816ce2 | ||
|
|
7bd99c3c87 | ||
|
|
40b318e3e0 | ||
|
|
90fafab33a | ||
|
|
2ab7b5b7af | ||
|
|
63cf0ed308 | ||
|
|
9a7eebb194 | ||
|
|
a4fb84d5ac | ||
|
|
6c17771cce | ||
|
|
34d4cd3a28 | ||
|
|
5b7eb9ad20 | ||
|
|
04a0677704 | ||
|
|
f415099c4a | ||
|
|
ef08d8584c | ||
|
|
70b90d287c | ||
|
|
fa15041864 | ||
|
|
7db3e507d1 | ||
|
|
7d28b5a040 | ||
|
|
be00801acf | ||
|
|
c8ab73d38d | ||
|
|
99afb82c50 | ||
|
|
d2a03b8edc | ||
|
|
4594c776e1 | ||
|
|
9439a85aa6 | ||
|
|
f37d71a443 | ||
|
|
9f720a401c | ||
|
|
c510515da7 | ||
|
|
21ff178ac0 | ||
|
|
5c136f8557 | ||
|
|
5957454dd9 | ||
|
|
0985bad9fd | ||
|
|
6acca84c28 | ||
|
|
0cbb834f96 | ||
|
|
8151f60c6c | ||
|
|
e4ec9dc425 | ||
|
|
5480c69c9e | ||
|
|
7d719cfbbe | ||
|
|
a0b63462d0 | ||
|
|
d66730ecdb | ||
|
|
2754b7d7d8 | ||
|
|
f2ba3c8f1a | ||
|
|
dc129b1178 | ||
|
|
619c4c4953 | ||
|
|
906f70a3e2 | ||
|
|
abf99eb700 | ||
|
|
9455310c0c | ||
|
|
34c68e1e4c | ||
|
|
e15df22e98 | ||
|
|
b1c8eb5f11 | ||
|
|
ac75c97797 | ||
|
|
72179d1145 | ||
|
|
2e0b0bb776 | ||
|
|
9f21d0b634 | ||
|
|
6d646db577 | ||
|
|
d9347f0715 | ||
|
|
b542c40936 | ||
|
|
2bdc8344dd | ||
|
|
a2ef95445b |
25
.env.example
25
.env.example
@@ -21,11 +21,16 @@ 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
|
||||
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
|
||||
# override the backend host port.
|
||||
BACKEND_PORT=8080
|
||||
# Optional aliases for local/self-host backend port helpers outside compose.
|
||||
# API_PORT=8080
|
||||
# SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
@@ -35,9 +40,9 @@ 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.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Set explicitly only when the app's public URL differs from local frontend.
|
||||
# MULTICA_APP_URL=http://localhost:3000
|
||||
MULTICA_APP_URL=${FRONTEND_ORIGIN}
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers and to show correct daemon setup commands in the web UI. Leave
|
||||
@@ -112,9 +117,9 @@ SMTP_EHLO_NAME=
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Set explicitly only when your OAuth callback URL differs from local frontend.
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
@@ -122,6 +127,8 @@ GOOGLE_CLIENT_SECRET=
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
|
||||
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
|
||||
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
|
||||
@@ -228,10 +235,6 @@ MULTICA_LARK_HTTP_BASE_URL=
|
||||
MULTICA_LARK_CALLBACK_BASE_URL=
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -98,7 +98,7 @@ jobs:
|
||||
run: cd server && go run ./cmd/migrate up
|
||||
|
||||
- name: Test
|
||||
run: cd server && go test ./...
|
||||
run: cd server && go test -race ./...
|
||||
|
||||
installer:
|
||||
# Stub-driven shell tests for scripts/install.sh. Kept off the heavy
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
cache-dependency-path: server/go.sum
|
||||
|
||||
- name: Run tests
|
||||
run: cd server && go test ./...
|
||||
run: cd server && go test -race ./...
|
||||
|
||||
release:
|
||||
needs: verify
|
||||
|
||||
32
AGENTS.md
32
AGENTS.md
@@ -3,8 +3,10 @@
|
||||
This file provides guidance to AI agents when working with code in this repository.
|
||||
|
||||
> **Single source of truth:** This file is a concise pointer document.
|
||||
> All authoritative architecture, coding rules, commands, and conventions
|
||||
> All authoritative architecture, coding rules, and conventions
|
||||
> live in **CLAUDE.md** at the project root. Read that file first.
|
||||
> Use `Makefile`, `package.json`, and `pnpm-workspace.yaml` as the
|
||||
> source of truth for the full command list.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -12,27 +14,27 @@ This file provides guidance to AI agents when working with code in this reposito
|
||||
|
||||
Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.
|
||||
|
||||
- `server/` — Go backend (Chi router, sqlc, gorilla/websocket)
|
||||
- `apps/web/` — Next.js frontend (App Router)
|
||||
- `apps/desktop/` — Electron desktop app
|
||||
- `packages/core/` — Headless business logic (Zustand stores, React Query hooks, API client)
|
||||
- `packages/ui/` — Atomic UI components (shadcn/Base UI, zero business logic)
|
||||
- `packages/views/` — Shared business pages/components
|
||||
- `packages/tsconfig/` — Shared TypeScript config
|
||||
- `server/` - Go backend (Chi router, sqlc, gorilla/websocket)
|
||||
- `apps/web/` - Next.js frontend (App Router)
|
||||
- `apps/desktop/` - Electron desktop app
|
||||
- `packages/core/` - Headless business logic (Zustand stores, React Query hooks, API client)
|
||||
- `packages/ui/` - Atomic UI components (shadcn/Base UI, zero business logic)
|
||||
- `packages/views/` - Shared business pages/components
|
||||
- `packages/tsconfig/` - Shared TypeScript config
|
||||
|
||||
### State Management (critical)
|
||||
|
||||
- **React Query** owns all server state (issues, members, agents, inbox, workspace list)
|
||||
- **Zustand** owns all client state (current workspace selection, view filters, drafts, modals)
|
||||
- All Zustand stores live in `packages/core/` — never in `packages/views/` or app directories
|
||||
- WS events invalidate React Query — never write directly to stores
|
||||
- All Zustand stores live in `packages/core/` - never in `packages/views/` or app directories
|
||||
- WS events invalidate React Query - never write directly to stores
|
||||
|
||||
### Package Boundaries (hard rules)
|
||||
|
||||
- `packages/core/` — zero react-dom, zero localStorage, zero process.env
|
||||
- `packages/ui/` — zero `@multica/core` imports
|
||||
- `packages/views/` — zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
|
||||
- `apps/web/platform/` — only place for Next.js APIs
|
||||
- `packages/core/` - zero react-dom, zero localStorage, zero process.env
|
||||
- `packages/ui/` - zero `@multica/core` imports
|
||||
- `packages/views/` - zero `next/*`, zero `react-router-dom`, use `NavigationAdapter` for routing
|
||||
- `apps/web/platform/` - only place for Next.js APIs
|
||||
|
||||
### Commands
|
||||
|
||||
@@ -44,4 +46,4 @@ make test # Go tests
|
||||
make check # Full verification pipeline
|
||||
```
|
||||
|
||||
See CLAUDE.md for the complete command reference.
|
||||
See CLAUDE.md for the authoritative rules and common commands.
|
||||
|
||||
507
CLAUDE.md
507
CLAUDE.md
@@ -1,427 +1,226 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
Guidance for Claude Code when working in this repository. Keep this file short and authoritative: rules here should be hard to infer from code or easy to get wrong.
|
||||
|
||||
## Conventions reference
|
||||
## Conventions
|
||||
|
||||
The single source of truth for **code naming, the i18n translation glossary, and the Chinese voice guide** is the docs site:
|
||||
The source of truth for code naming, i18n glossary, and Chinese product voice is:
|
||||
|
||||
- **`apps/docs/content/docs/developers/conventions.mdx`** (English)
|
||||
- **`apps/docs/content/docs/developers/conventions.zh.mdx`** (Chinese)
|
||||
- `apps/docs/content/docs/developers/conventions.mdx`
|
||||
- `apps/docs/content/docs/developers/conventions.zh.mdx`
|
||||
|
||||
Read that page before:
|
||||
Read it before editing translations in `packages/views/locales/`, naming routes/packages/files/DB columns/types, or writing Chinese UI/docs copy. Do not rely on `packages/views/locales/glossary.md`; it is only a redirect stub.
|
||||
|
||||
- Writing or editing translations (`packages/views/locales/`)
|
||||
- Naming a new route, package, file, DB column, or TS type
|
||||
- Writing Chinese product copy (UI strings, error messages, docs)
|
||||
## Project Shape
|
||||
|
||||
The legacy `packages/views/locales/glossary.md` is now a stub redirecting to the docs page; do not rely on it.
|
||||
Multica is an AI-native task management platform for small teams, with agents as first-class assignees that can own issues, comment, and change status.
|
||||
|
||||
## Project Context
|
||||
- `server/`: Go backend, Chi router, sqlc, gorilla/websocket.
|
||||
- `apps/web/`: Next.js App Router.
|
||||
- `apps/desktop/`: Electron desktop app.
|
||||
- `apps/mobile/`: Expo / React Native iOS app. Read `apps/mobile/CLAUDE.md` before touching it.
|
||||
- `packages/core/`: headless business logic, API client, React Query hooks, Zustand stores.
|
||||
- `packages/ui/`: atomic UI components only.
|
||||
- `packages/views/`: shared business pages/components for web and desktop.
|
||||
- `packages/tsconfig/`: shared TypeScript config.
|
||||
|
||||
Multica is an AI-native task management platform — like Linear, but with AI agents as first-class citizens.
|
||||
Shared packages export raw `.ts` / `.tsx` and are compiled by consuming apps. Dependency direction is `views -> core + ui`; `core` and `ui` must stay independent.
|
||||
|
||||
- Agents can be assigned issues, create issues, comment, and change status
|
||||
- Supports local (daemon) and cloud agent runtimes
|
||||
- Built for 2-10 person AI-native teams
|
||||
## State Rules
|
||||
|
||||
## Architecture
|
||||
Keep server state and client state separate.
|
||||
|
||||
**Go backend + monorepo frontend (pnpm workspaces + Turborepo) with shared packages.**
|
||||
- TanStack Query owns server state: issues, users, workspaces, inbox, agents, members, and anything fetched from the API.
|
||||
- Zustand owns client state: selected workspace, filters, drafts, modals, tab layout, and navigation history.
|
||||
- Shared Zustand stores live in `packages/core/`, never in `packages/views/` or app directories.
|
||||
- React Context is for platform plumbing only, such as `WorkspaceIdProvider` and `NavigationProvider`.
|
||||
- Only auth/workspace stores may call `api.*` directly. Other server interaction belongs in queries/mutations.
|
||||
- Workspace-scoped query keys must include `wsId`.
|
||||
- Mutations should be optimistic by default: patch locally, send request, roll back on failure, invalidate on settle.
|
||||
- WebSocket events invalidate or patch Query cache; they never write directly to Zustand stores.
|
||||
- Persist durable preferences/drafts/layout. Do not persist server data or ephemeral UI state.
|
||||
- Zustand selectors must return stable references. Do not return freshly allocated objects/arrays from selectors without shallow comparison.
|
||||
- Hooks that need workspace context should accept `wsId`; do not call `useWorkspaceId()` internally unless the hook is guaranteed to run under the provider.
|
||||
|
||||
- `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/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
|
||||
## Package Boundaries
|
||||
|
||||
What lives where for sharing purposes is documented in *Sharing Principles* below — read it once.
|
||||
These are hard constraints:
|
||||
|
||||
### Key Architectural Decisions
|
||||
- `packages/core/`: no `react-dom`, `localStorage` (use `StorageAdapter`), `process.env`, or UI libraries.
|
||||
- `packages/ui/`: no `@multica/core` imports and no business logic.
|
||||
- `packages/views/`: no `next/*`, no `react-router-dom`, no stores. Use `NavigationAdapter`, `useNavigation()`, and `<AppLink>`.
|
||||
- `apps/web/platform/`: only place for Next.js navigation/platform APIs.
|
||||
- `apps/desktop/src/renderer/src/platform/`: only place for `react-router-dom` navigation wiring.
|
||||
- Every workspace under `apps/` and `packages/` must declare directly imported external packages in its own `package.json`.
|
||||
- Shared dependencies use `catalog:` from `pnpm-workspace.yaml`; `apps/mobile/` pins Expo/React Native related versions directly.
|
||||
|
||||
**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.
|
||||
## Sharing Rules
|
||||
|
||||
**Dependency direction:** `views/ → core/ + ui/`. Core and UI are independent of each other. No package imports from `next/*`, `react-router-dom`, or app-specific code.
|
||||
Web and desktop share business logic, hooks, stores, components, and views through `packages/core/`, `packages/ui/`, and `packages/views/`.
|
||||
|
||||
**Platform bridge:** `packages/core/platform/` provides `CoreProvider` — initializes API client, auth/workspace stores, WS connection, and QueryClient. Each app wraps its root with `<CoreProvider>` and provides its own `NavigationAdapter` for routing.
|
||||
If the same logic exists in both web and desktop, extract it unless it depends on platform APIs:
|
||||
|
||||
**pnpm catalog** — `pnpm-workspace.yaml` defines `catalog:` for version pinning. All shared deps use `catalog:` references to guarantee a single version across all packages. When adding new shared deps (including test deps), add to catalog first.
|
||||
1. Next.js, Electron, or router APIs stay in the app/platform layer.
|
||||
2. Headless logic belongs in `packages/core/`.
|
||||
3. Shared UI or business views belong in `packages/views/`.
|
||||
4. Shared primitives belong in `packages/ui/`.
|
||||
|
||||
### State Management
|
||||
|
||||
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.
|
||||
- **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.
|
||||
|
||||
**Hard rules — these are how the architecture stays coherent:**
|
||||
|
||||
- **Never duplicate server data into Zustand.** If it came from the API, it belongs in the Query cache. Copying it into a store creates two sources of truth and they will drift.
|
||||
- **Workspace-scoped queries must key on `wsId`.** This is what makes workspace switching automatic — the cache key changes, the right data appears, no manual invalidation needed.
|
||||
- **Mutations are optimistic by default.** Apply the change locally, send the request, roll back on failure, invalidate on settle. The user shouldn't wait for the server.
|
||||
- **WS events invalidate queries — they never write to stores directly.** This keeps the cache as the single source of truth and avoids race conditions.
|
||||
- **Persist what's worth preserving across restarts** (user preferences, drafts, tab layout). **Don't persist ephemeral UI state** (modal open/close, transient selections) or server data.
|
||||
|
||||
**Common Zustand footguns to avoid:**
|
||||
|
||||
- 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.
|
||||
Mobile is independent. It may import types and pure functions from `@multica/core`, with `import type` for types, but owns its UI, state, hooks, providers, i18n, React version, build pipeline, and release cadence.
|
||||
|
||||
## Commands
|
||||
|
||||
Use the repo scripts as the source of truth. Common commands:
|
||||
|
||||
```bash
|
||||
# One-command dev (auto-setup + start everything)
|
||||
make dev # Auto-creates env, installs deps, starts DB, migrates, launches app
|
||||
|
||||
# Explicit setup & run (if you prefer separate steps)
|
||||
make setup # First-time: ensure shared DB, create app DB, migrate
|
||||
make start # Start backend + frontend together
|
||||
make stop # Stop app processes for the current checkout
|
||||
make db-down # Stop the shared PostgreSQL container
|
||||
|
||||
# Frontend (all commands go through Turborepo)
|
||||
pnpm install
|
||||
pnpm dev:web # Next.js dev server (port 3000)
|
||||
pnpm dev:desktop # Electron dev (electron-vite, HMR)
|
||||
pnpm build # Build all frontend apps
|
||||
pnpm typecheck # TypeScript check (all packages + apps via turbo)
|
||||
pnpm lint # ESLint
|
||||
pnpm test # TS tests (Vitest, all packages + apps via turbo)
|
||||
|
||||
# Backend (Go)
|
||||
make server # Run Go server only (port 8080)
|
||||
make daemon # Run local daemon
|
||||
make build # Build server + CLI binaries to server/bin/
|
||||
make cli ARGS="..." # Run multica CLI (e.g. make cli ARGS="config")
|
||||
make dev # auto-setup and start the app
|
||||
make start # start backend + frontend
|
||||
make stop # stop app processes for this checkout
|
||||
make server # run Go server only
|
||||
make daemon # run local daemon
|
||||
make test # Go tests
|
||||
make sqlc # Regenerate sqlc code after editing SQL in server/pkg/db/queries/
|
||||
make migrate-up # Run database migrations
|
||||
make migrate-down # Rollback migrations
|
||||
|
||||
# Run a single TS test (works for any package with a test script)
|
||||
pnpm --filter @multica/views exec vitest run auth/login-page.test.tsx
|
||||
pnpm --filter @multica/core exec vitest run runtimes/version.test.ts
|
||||
pnpm --filter @multica/web exec vitest run app/\(auth\)/login/page.test.tsx
|
||||
|
||||
# Run a single Go test
|
||||
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)
|
||||
|
||||
# shadcn — config lives in packages/ui/components.json (Base UI variant, base-nova style)
|
||||
pnpm ui:add badge # Adds component to packages/ui/components/ui/
|
||||
|
||||
# Infrastructure
|
||||
make db-up # Start shared PostgreSQL (pgvector/pg17 image)
|
||||
make db-down # Stop shared PostgreSQL
|
||||
make db-reset # Drop + recreate current env's DB, then re-run migrations (local only; stop backend first)
|
||||
make sqlc # regenerate sqlc code after SQL changes
|
||||
pnpm install
|
||||
pnpm dev:web
|
||||
pnpm dev:desktop
|
||||
pnpm build
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm test # TS/Vitest tests through Turborepo
|
||||
pnpm exec playwright test
|
||||
pnpm ui:add badge # shadcn/Base UI component into packages/ui
|
||||
```
|
||||
|
||||
### CI Requirements
|
||||
Worktrees share one PostgreSQL container and get isolated DB names/ports via `.env.worktree`. `make dev` auto-detects this. For manual setup use `make worktree-env`, `make setup-worktree`, and `make start-worktree`.
|
||||
|
||||
CI runs on Node 22 and Go 1.26.1 with a `pgvector/pgvector:pg17` PostgreSQL service. See `.github/workflows/ci.yml`.
|
||||
|
||||
### Worktree Support
|
||||
|
||||
All checkouts share one PostgreSQL container. Isolation is at the database level — each worktree gets its own DB name and unique ports via `.env.worktree`. Main checkouts use `.env`.
|
||||
|
||||
`make dev` auto-detects worktrees and handles everything. For explicit control:
|
||||
|
||||
```bash
|
||||
make worktree-env # Generate .env.worktree with unique DB/ports
|
||||
make setup-worktree # Setup using .env.worktree
|
||||
make start-worktree # Start using .env.worktree
|
||||
```
|
||||
CI runs Node 22, Go 1.26.1, and a `pgvector/pgvector:pg17` PostgreSQL service.
|
||||
|
||||
## Coding Rules
|
||||
|
||||
- TypeScript strict mode is enabled; keep types explicit.
|
||||
- Go code follows standard Go conventions (gofmt, go vet).
|
||||
- Keep comments in code **English only**.
|
||||
- Prefer existing patterns/components over introducing parallel abstractions.
|
||||
- Unless the user explicitly asks for backwards compatibility, do **not** add compatibility layers, fallback paths, dual-write logic, legacy adapters, or temporary shims **for internal, non-boundary code** (a function calling another function in the same package, a component reading its own state, a store helper, etc.).
|
||||
- This rule does **not** apply at API boundaries: the desktop app cannot assume the backend it talks to has the same shape as the one it was built against (older desktop installs will outlive any given server build). API response handling must follow the rules in **API Response Compatibility** below — that is a defensive boundary, not a legacy shim.
|
||||
- If a flow or API is being replaced and the product is not yet live, prefer removing the old path instead of preserving both old and new behavior.
|
||||
- Go follows standard conventions: `gofmt`, `go vet`, checked errors.
|
||||
- Code comments must be English.
|
||||
- Prefer existing patterns/components over new parallel abstractions.
|
||||
- Avoid broad refactors unless required by the task.
|
||||
- New global (pre-workspace) routes MUST use a single word (`/login`, `/inbox`) or a `/{noun}/{verb}` pair (`/workspaces/new`). NEVER add hyphenated word-group root routes (`/new-workspace`, `/create-team`) — they collide with common user workspace names and force endless reserved-slug audits. Reserving the noun (`workspaces`) automatically protects the entire `/workspaces/*` subtree.
|
||||
- The reserved-slug list lives in **one** place: `server/internal/handler/reserved_slugs.json`. The Go side embeds the JSON; `packages/core/paths/reserved-slugs.ts` is generated from it by `pnpm generate:reserved-slugs`. Edit the JSON, run the generator, commit both. CI re-runs the generator and fails on any drift, so a stale TS file cannot land.
|
||||
- When you change a CLI command or flag, an API request/response field, or product behavior that a built-in skill documents (`server/internal/service/builtin_skills/*`), update that skill's `SKILL.md` **and** its `references/*-source-map.md` in the same PR. The built-in skills are source-traced contracts shipped to agents — if the code moves and the skill doesn't, it silently teaches stale behavior.
|
||||
- For internal, non-boundary code, do not add compatibility layers, fallback paths, dual writes, legacy adapters, or temporary shims unless explicitly requested.
|
||||
- API boundaries are different: installed desktop clients can talk to newer backends, so response parsing must follow the API compatibility rules below.
|
||||
- If a flow or API is being replaced and the product is not live, prefer removing the old path instead of preserving both.
|
||||
- New global pre-workspace routes must be a single word (`/login`, `/inbox`) or `/{noun}/{verb}` (`/workspaces/new`). Do not add hyphenated root routes like `/new-workspace`.
|
||||
- Reserved slugs live in `server/internal/handler/reserved_slugs.json`. Edit it, run `pnpm generate:reserved-slugs`, and commit the generated `packages/core/paths/reserved-slugs.ts`.
|
||||
- When changing CLI commands/flags, API fields, or product behavior documented by built-in skills under `server/internal/service/builtin_skills/*`, update the relevant `SKILL.md` and `references/*-source-map.md` in the same PR.
|
||||
|
||||
### API Response Compatibility
|
||||
## API Compatibility
|
||||
|
||||
The desktop app installed on a user's machine is older than any backend it talks to: a user on 0.2.26 will hit a server running 0.3.x, then 0.4.x, then beyond. Every response shape is a contract that **will** drift, and the frontend must survive drift without white-screening. Three concrete incidents already happened from violating this — #2143, #2147, #2192.
|
||||
Frontend code must survive backend response drift, especially in installed desktop builds.
|
||||
|
||||
When writing code that consumes an API response, follow these rules:
|
||||
- Parse API JSON with `parseWithFallback` in `packages/core/api/schema.ts` and a zod schema. Do not cast network JSON to `T`.
|
||||
- Endpoint responses consumed by UI logic must pass through a schema before returning.
|
||||
- Downstream UI should optional-chain and default fields defensively.
|
||||
- Prefer explicit boolean checks (`=== true`) over truthy/falsy checks on server fields.
|
||||
- Do not pin critical affordances to one backend boolean; combine signals when possible.
|
||||
- Server-driven enum switches need a `default` branch.
|
||||
- When adding or changing an endpoint, add/update the schema and include a malformed-response test.
|
||||
|
||||
- **Parse, don't cast.** Untyped JSON crossing the network is not `T`. Use `parseWithFallback` in `packages/core/api/schema.ts` with a `zod` schema and an explicit fallback. On validation failure it logs a warning and returns the fallback; it never throws into the UI.
|
||||
- **No bare `as` casts on response bodies.** Every endpoint method whose response is consumed by UI logic must run through a schema before returning.
|
||||
- **Optional-chain and default everywhere downstream.** Treat every field as possibly missing. Use explicit boolean checks (`=== true`) over truthy/falsy negation, which silently treats `undefined` and `null` as `false`.
|
||||
- **Don't pin a UI affordance to a single backend field.** If a button or indicator depends on exactly one boolean from the server, a backend bug deletes it. Combine signals (cursor presence, page length, etc.) so the affordance stays available in the worst case.
|
||||
- **Enum drift downgrades, not crashes.** A new server-side enum value should render a generic fallback. `switch` statements on server-driven strings must have a `default` branch.
|
||||
- **When you add or change an endpoint:** add the schema in the same PR, and write at least one test that feeds a malformed response through it (missing field, wrong type, `null` array). The test fails closed if a future change breaks the contract.
|
||||
## Backend UUID Rules
|
||||
|
||||
This is not premature defense — it is the *only* defense for an installed-app architecture. CSR-only browser apps can ship a fix in minutes; an Electron build sitting on a developer's laptop cannot.
|
||||
In `server/internal/handler/`, always know where a UUID came from before using it in write queries.
|
||||
|
||||
### Backend Handler UUID Parsing Convention
|
||||
- Resource path params that may be UUIDs or human-readable IDs must be resolved through loaders such as `loadIssueForUser`, `loadSkillForUser`, `loadAgentForUser`, or `requireDaemonRuntimeAccess`; subsequent writes use the resolved `entity.ID`.
|
||||
- Pure UUID inputs from request boundaries use `parseUUIDOrBadRequest(w, s, fieldName)` and return immediately on `ok=false`.
|
||||
- Trusted UUID round-trips from sqlc results or test fixtures use `parseUUID(s)`, which panics on invalid input.
|
||||
- Outside handlers, `util.ParseUUID(s) (pgtype.UUID, error)` is the safe variant; always check the error.
|
||||
|
||||
Every Go handler in `server/internal/handler/` follows these rules. The convention exists because `util.ParseUUID` used to silently return a zero UUID on invalid input, which caused #1661 — a `DELETE` returning 204 success while the SQL `DELETE` matched zero rows.
|
||||
## Web/Desktop Features
|
||||
|
||||
- **Resource path params that accept either a UUID or a human-readable identifier** (e.g. `chi.URLParam(r, "id")` for an issue, which accepts both `MUL-123` and a UUID) MUST be resolved through the dedicated loader (`loadIssueForUser` / `loadSkillForUser` / `loadAgentForUser` / `requireDaemonRuntimeAccess`). After resolution, all subsequent DB calls — especially `Queries.Delete*` / `Queries.Update*` — MUST use `entity.ID` from the resolved object. Never round-trip the raw URL string through `parseUUID` for a write query.
|
||||
- **Pure-UUID inputs from request boundaries** (URL params that are always UUIDs, request body fields, query params, headers) MUST be validated with `parseUUIDOrBadRequest(w, s, fieldName)`. On invalid input it writes a 400 and returns `ok=false` — return immediately.
|
||||
- **Trusted UUID round-trips** (sqlc-returned UUIDs being passed back into queries, test fixtures) use `parseUUID(s)` which calls `util.MustParseUUID` and panics on invalid input. A panic here means an unguarded user-input string slipped in — that is a real bug. `chi`'s `middleware.Recoverer` translates the panic into a 500 so the process keeps running.
|
||||
- **`util.ParseUUID(s) (pgtype.UUID, error)`** is the only safe variant outside the handler package. Always check the error.
|
||||
When adding a shared page or feature for web and desktop:
|
||||
|
||||
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.
|
||||
1. Put the page/component in `packages/views/<domain>/`.
|
||||
2. Add platform wiring in both `apps/web/app/` and the desktop router, unless the desktop flow is a transition overlay.
|
||||
3. Use `useNavigation().push()` or `<AppLink>` in shared code.
|
||||
4. Use shared guards/providers such as `DashboardGuard` from `packages/views/layout/`.
|
||||
5. Keep platform-only UI in the app or inject it through props/slots.
|
||||
6. Hooks that need workspace context should accept `wsId`.
|
||||
|
||||
### Dependency Declaration Rule
|
||||
CSS for web/desktop is shared from `packages/ui/styles/`. Use semantic tokens such as `bg-background` and `text-muted-foreground`; avoid hardcoded Tailwind colors and duplicated base styles.
|
||||
|
||||
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.
|
||||
## Desktop Rules
|
||||
|
||||
- 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.
|
||||
Desktop routing has three categories:
|
||||
|
||||
### Package Boundary Rules
|
||||
- Session routes: workspace-scoped tab destinations such as `/:slug/issues`.
|
||||
- Transition flows: pre-workspace one-shot actions such as create workspace or accept invite. These are `WindowOverlay` state, not routes.
|
||||
- Error/stale states: stale workspace tabs should auto-heal by dropping stale tab groups, not render desktop error pages.
|
||||
|
||||
These are hard constraints. Violating them breaks the cross-platform architecture:
|
||||
More desktop constraints:
|
||||
|
||||
- `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/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.
|
||||
- New pre-workspace desktop flows register a `WindowOverlay` type in `stores/window-overlay-store.ts`; do not add them to `routes.tsx`.
|
||||
- `setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the active workspace source of truth.
|
||||
- Code that leaves workspace context must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
- Leave/delete workspace flow order: read cached destination, clear current workspace, navigate, then run the mutation.
|
||||
- Cross-workspace navigation must go through the navigation adapter so it can call `switchWorkspace(slug, targetPath)`.
|
||||
- Full-window desktop views outside the dashboard shell must mount `<DragStrip />` from `@multica/views/platform` as the first flex child. Interactive controls in the top 48px need `WebkitAppRegion: "no-drag"`.
|
||||
|
||||
### The No-Duplication Rule (web + desktop)
|
||||
## Mobile Rules
|
||||
|
||||
**If the same logic exists in both web and desktop, it must be extracted to a shared package.**
|
||||
Read `apps/mobile/CLAUDE.md` before touching `apps/mobile/`. It contains the mandatory pre-flight process, import limits, parity rules, tech stack, UI rules, data helpers, realtime strategy, and mobile release flow.
|
||||
|
||||
This applies to everything between web and desktop: components, hooks, guards, providers, utility functions. The decision process:
|
||||
Root-level reminders:
|
||||
|
||||
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.
|
||||
3. Everything else → belongs in `packages/core/` (headless logic) or `packages/views/` (UI components).
|
||||
- Mobile shares only `@multica/core` types and pure functions.
|
||||
- Mobile must match web/desktop product semantics: counts, permissions, enums/transitions, and data identity.
|
||||
- Mobile may differ in UI/interaction when the phone context requires it.
|
||||
|
||||
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.
|
||||
## UI Rules
|
||||
|
||||
### Cross-Platform Development Rules (web + desktop)
|
||||
- Prefer shadcn/Base UI components over custom implementations. Add them with `pnpm ui:add <component>` from the repo root.
|
||||
- Use design tokens and semantic classes; avoid hardcoded colors.
|
||||
- Do not introduce extra local state unless the design requires it.
|
||||
- Handle overflow, long text, scrolling, alignment, and spacing deliberately.
|
||||
- If a component is identical between web and desktop, it belongs in a shared package.
|
||||
|
||||
When adding a new page or feature for web/desktop:
|
||||
## Testing
|
||||
|
||||
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*.
|
||||
3. **Navigation** → use `useNavigation().push()` or `<AppLink>`. Never use framework-specific link/router APIs in shared code.
|
||||
4. **Shared guards/providers** → use `DashboardGuard` from `packages/views/layout/`. Don't create separate guard logic per app.
|
||||
5. **Platform-specific UI** → if a feature is web-only or desktop-only, keep it in the respective app. Use props slots (`extra`, `topSlot`) on shared layout components to inject platform-specific UI.
|
||||
6. **New hooks that need workspace context** → accept `wsId` as parameter instead of reading from `useWorkspaceId()` Context, so they work both inside and outside `WorkspaceIdProvider`.
|
||||
Tests follow the code:
|
||||
|
||||
### CSS Architecture (web + desktop)
|
||||
| What is tested | Location |
|
||||
| --- | --- |
|
||||
| Shared business logic, stores, queries, hooks | `packages/core/*.test.ts` |
|
||||
| Shared UI components, pages, forms, modals | `packages/views/*.test.tsx` |
|
||||
| Platform wiring such as cookies, redirects, search params | `apps/web/*.test.tsx` or `apps/desktop/` |
|
||||
| End-to-end flows | `e2e/*.spec.ts` |
|
||||
| Backend | `server/` Go tests |
|
||||
|
||||
Web and desktop share the same CSS foundation from `packages/ui/styles/`.
|
||||
Rules:
|
||||
|
||||
- **Design tokens** → use semantic tokens (`bg-background`, `text-muted-foreground`). Never use hardcoded Tailwind colors (`text-red-500`, `bg-gray-100`).
|
||||
- **Shared styles** → `packages/ui/styles/`. Never duplicate scrollbar styling, keyframes, or base layer rules in app CSS.
|
||||
- **`@source` directives** → both apps scan shared packages so Tailwind sees all class names.
|
||||
|
||||
## Mobile-specific Rules
|
||||
|
||||
Rules for `apps/mobile/` live in `apps/mobile/CLAUDE.md`. Read it before touching anything in `apps/mobile/` — it covers what may be imported from `@multica/core/`, the React version policy, the build/release pipeline, and the locked tech-stack baseline.
|
||||
|
||||
## Desktop-specific Rules
|
||||
|
||||
These rules apply to `apps/desktop/` only. Web has different constraints (URL bar, SSR, no tabs) and doesn't share these concerns. Every rule in this section was added after a concrete bug — treat them as enforced, not suggestions.
|
||||
|
||||
### Route categories
|
||||
|
||||
Every path in the desktop app falls into exactly one category. Choosing the wrong one reproduces bugs we've already fixed.
|
||||
|
||||
- **Session routes** — workspace-scoped pages (`/:slug/issues`, `/:slug/settings`). Rendered by the per-tab memory router under `WorkspaceRouteLayout`. These are legitimate tab destinations.
|
||||
- **Transition flows** — pre-workspace / one-shot actions (create workspace, accept invite). **NOT routes.** They live as `WindowOverlay` state, dispatched when the navigation adapter sees `push('/workspaces/new')` or `push('/invite/<id>')`. The shared view (`NewWorkspacePage`, `InvitePage`) is the content; the overlay wrapper supplies platform chrome.
|
||||
- **Error / stale states** — "workspace not available", tabs pointing at a revoked workspace. **NOT pages.** `WorkspaceRouteLayout` auto-heals by dropping the stale tab group from the store; the user never lands on an explicit error screen. Web keeps `NoAccessPage` (shareable URL makes the error state meaningful); desktop has no URL bar so stale = heal silently.
|
||||
|
||||
**Adding a new pre-workspace flow on desktop**: register a new `WindowOverlay` type in `stores/window-overlay-store.ts`. Do NOT add it to `routes.tsx`. If a shared view needs the flow on both platforms, add the route on web (`apps/web/app/(auth)/...`) AND the overlay type on desktop — the shared view component is identical.
|
||||
|
||||
### Workspace context
|
||||
|
||||
`setCurrentWorkspace(slug, uuid)` from `@multica/core/platform` is the single source of truth for the active workspace. `WorkspaceRouteLayout` sets it on mount; unmount does NOT clear it. Code that leaves workspace context (leave/delete workspace, force-navigate to overlay) must call `setCurrentWorkspace(null, null)` explicitly.
|
||||
|
||||
### Workspace destructive operations
|
||||
|
||||
Leave / Delete workspace flows must follow this order, otherwise concurrent refetches race and the renderer hard-reloads:
|
||||
|
||||
1. Read destination from cached workspace list.
|
||||
2. `setCurrentWorkspace(null, null)`.
|
||||
3. `navigation.push(destination)`.
|
||||
4. THEN `await mutation.mutateAsync(workspaceId)`.
|
||||
|
||||
### Tab isolation
|
||||
|
||||
Tabs are grouped per workspace in `stores/tab-store.ts`. The TabBar shows only the active workspace's tabs; cross-workspace tab leakage is impossible by construction (no flat global tabs array).
|
||||
|
||||
Cross-workspace `push(path)` is detected by the navigation adapter (`platform/navigation.tsx`) and translated into `switchWorkspace(slug, targetPath)` — NOT a navigation within the current tab's router. Don't bypass the adapter; always go through `useNavigation()` from shared code.
|
||||
|
||||
### Drag region (macOS)
|
||||
|
||||
Every full-window desktop view (anything outside the dashboard shell) must mount `<DragStrip />` from `@multica/views/platform` as the first flex child of the page root, otherwise users can't drag the window. Interactive UI inside the top 48px needs `WebkitAppRegion: "no-drag"` to stay clickable.
|
||||
|
||||
## UI/UX Rules
|
||||
|
||||
- Prefer shadcn components over custom implementations. Install via `pnpm ui:add <component>` from project root — adds to `packages/ui/components/ui/`. All components use Base UI primitives (`@base-ui/react`), not Radix.
|
||||
- Use shadcn design tokens for styling. Avoid hardcoded color values.
|
||||
- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design.
|
||||
- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency.
|
||||
- **If a component is identical between web and desktop, it belongs in a shared package.** Do not copy-paste between apps.
|
||||
|
||||
## Testing Rules
|
||||
|
||||
### Where to write tests
|
||||
|
||||
Tests follow the code, not the app. This is the most important testing principle in this monorepo:
|
||||
|
||||
| What you're testing | Where the test lives | Why |
|
||||
|---|---|---|
|
||||
| Shared business logic (stores, queries, hooks) | `packages/core/*.test.ts` | No DOM needed, pure logic |
|
||||
| Shared UI components (pages, forms, modals) | `packages/views/*.test.tsx` | jsdom, no framework mocks |
|
||||
| Platform-specific wiring (cookies, redirects, searchParams) | `apps/web/*.test.tsx` or `apps/desktop/` | Needs framework-specific mocks |
|
||||
| End-to-end user flows | `e2e/*.spec.ts` | Real browser, real backend |
|
||||
|
||||
**Never test shared component behavior in an app's test file.** If a test requires mocking `next/navigation` or `react-router-dom` to test a component from `@multica/views`, the test is in the wrong place — move it to `packages/views/` and mock `@multica/core` instead.
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- `packages/core/` — Vitest, Node environment (no DOM)
|
||||
- `packages/views/` — Vitest, jsdom environment, `@testing-library/react`
|
||||
- `apps/web/` — Vitest, jsdom environment, framework-specific mocks
|
||||
- `e2e/` — Playwright
|
||||
- `server/` — Go standard `go test`
|
||||
|
||||
All test deps are in the pnpm catalog for unified versioning.
|
||||
|
||||
### Mocking conventions
|
||||
|
||||
- Mock `@multica/core` stores with `vi.hoisted()` + `Object.assign(selectorFn, { getState })` pattern (Zustand stores are both callable and have `.getState()`).
|
||||
- Never test shared component behavior in an app test file.
|
||||
- `packages/views/` tests must not mock `next/*` or `react-router-dom`.
|
||||
- Mock `@multica/core` stores with the Zustand callable-store shape (`selectorFn` plus `getState`).
|
||||
- Mock `@multica/core/api` for API calls.
|
||||
- In `packages/views/` tests: never mock `next/*` or `react-router-dom` — those don't exist here.
|
||||
- In `apps/web/` tests: mock framework-specific APIs only for platform-specific behavior.
|
||||
- E2E tests should use `TestApiClient` for setup/teardown.
|
||||
- Prefer writing the failing test in the correct package before implementation when the change is behavioral.
|
||||
|
||||
### TDD workflow
|
||||
## Verification
|
||||
|
||||
1. Write failing test in the **correct package** first.
|
||||
2. Write implementation.
|
||||
3. Run `pnpm test` (Turborepo discovers all packages).
|
||||
4. Green → done.
|
||||
For code changes, run the narrowest useful checks while iterating, then run broader verification when risk justifies it or when asked.
|
||||
|
||||
### Go tests
|
||||
|
||||
Standard `go test`. Tests should create their own fixture data in a test database.
|
||||
|
||||
### E2E tests
|
||||
|
||||
E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown:
|
||||
|
||||
```typescript
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
let api: TestApiClient;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi();
|
||||
await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
});
|
||||
|
||||
test("example", async ({ page }) => {
|
||||
const issue = await api.createIssue("Test Issue");
|
||||
await page.goto(`/issues/${issue.id}`);
|
||||
});
|
||||
```
|
||||
|
||||
## Commit Rules
|
||||
|
||||
- Use atomic commits grouped by logical intent.
|
||||
- Conventional format: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
|
||||
## Minimum Pre-Push Checks
|
||||
|
||||
```bash
|
||||
make check # Runs all checks: typecheck, unit tests, Go tests, E2E
|
||||
```
|
||||
|
||||
Run verification only when the user explicitly asks for it.
|
||||
|
||||
For targeted checks when requested:
|
||||
```bash
|
||||
pnpm typecheck # TypeScript type errors only
|
||||
pnpm test # TS unit tests only (Vitest, all packages)
|
||||
make test # Go tests only
|
||||
pnpm exec playwright test # E2E only (requires backend + frontend running)
|
||||
```
|
||||
|
||||
## AI Agent Verification Loop
|
||||
|
||||
After writing or modifying code, always run the full verification pipeline:
|
||||
Useful checks:
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
make test
|
||||
pnpm exec playwright test
|
||||
make check
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
- Write code to satisfy the requirement
|
||||
- Run `make check`
|
||||
- If any step fails, read the error output, fix the code, and re-run
|
||||
- Repeat until all checks pass
|
||||
- Only then consider the task complete
|
||||
Do not claim verification passed unless you ran it. If you skip checks because the change is docs-only or the user asked not to run them, say so.
|
||||
|
||||
**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete.
|
||||
## Commits and Releases
|
||||
|
||||
## CLI Release
|
||||
- Commits should be atomic and use conventional prefixes: `feat(scope)`, `fix(scope)`, `refactor(scope)`, `docs`, `test(scope)`, `chore(scope)`.
|
||||
- A production deployment requires a CLI release tag on `main`: create `v0.x.x`, push it, and let `release.yml` publish binaries and the Homebrew tap.
|
||||
- Bump patch by default unless the user specifies a version.
|
||||
|
||||
**Prerequisite:** A CLI release must accompany every Production deployment.
|
||||
## Domain Reminders
|
||||
|
||||
1. Create a tag on the `main` branch: `git tag v0.x.x`
|
||||
2. Push the tag: `git push origin v0.x.x`
|
||||
3. GitHub Actions automatically triggers `release.yml`: runs Go tests → GoReleaser builds multi-platform binaries → publishes to GitHub Releases + Homebrew tap
|
||||
|
||||
By default, bump the patch version each release (e.g. `v0.1.12` → `v0.1.13`), unless the user specifies a specific version.
|
||||
|
||||
## Multi-tenancy
|
||||
|
||||
All queries filter by `workspace_id`. Membership checks gate access. `X-Workspace-ID` header routes requests to the correct workspace.
|
||||
|
||||
## Agent Assignees
|
||||
|
||||
Assignees are polymorphic — can be a member or an agent. `assignee_type` + `assignee_id` on issues. Agents render with distinct styling (purple background, robot icon).
|
||||
- All queries filter by `workspace_id`; membership gates access; `X-Workspace-ID` selects the workspace.
|
||||
- Issue assignees are polymorphic: `assignee_type` plus `assignee_id` can reference a member or an agent.
|
||||
|
||||
@@ -20,6 +20,7 @@ RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSIO
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${DATE}" -o bin/multica ./cmd/multica
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/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
|
||||
RUN cd server && CGO_ENABLED=0 go build -ldflags "-s -w" -o bin/backfill_codex_usage_cache ./cmd/backfill_codex_usage_cache
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM alpine:3.21
|
||||
@@ -32,6 +33,7 @@ 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 --from=builder /src/server/bin/backfill_codex_usage_cache .
|
||||
COPY server/migrations/ ./migrations/
|
||||
COPY docker/entrypoint.sh .
|
||||
RUN sed -i 's/\r$//' entrypoint.sh && chmod +x entrypoint.sh
|
||||
|
||||
2
Makefile
2
Makefile
@@ -296,7 +296,7 @@ test: ## Run Go tests after ensuring the target DB exists and migrations are app
|
||||
$(REQUIRE_ENV)
|
||||
@bash scripts/ensure-postgres.sh "$(ENV_FILE)"
|
||||
cd server && go run ./cmd/migrate up
|
||||
cd server && go test ./...
|
||||
cd server && go test -race ./...
|
||||
|
||||
# Database
|
||||
##@ Database
|
||||
|
||||
@@ -19,8 +19,9 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
[](https://discord.gg/W8gYBn226t)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@
|
||||
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
[](https://discord.gg/W8gYBn226t)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [Discord](https://discord.gg/W8gYBn226t) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"electron-updater": "^6.8.3",
|
||||
"fix-path": "^5.0.0",
|
||||
"lucide-react": "catalog:",
|
||||
"motion": "^12.38.0",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-router-dom": "^7.6.0",
|
||||
|
||||
@@ -98,16 +98,29 @@ export function stripLeadingSeparator(argv) {
|
||||
* - "v0.1.36" → "0.1.36"
|
||||
* - "v0.1.35-14-gf1415e96" → "0.1.35-14-gf1415e96" (semver prerelease)
|
||||
* - "v0.1.35-…-dirty" → same, dirty suffix preserved
|
||||
* - "f1415e96" (no tag) → "0.0.0-f1415e96" (fallback)
|
||||
* - "f1415e96" (no tag) → "0.0.0-gf1415e96" (fallback)
|
||||
* - "2f24057b" (no tag, hash begins with a digit) → "0.0.0-g2f24057b"
|
||||
* - "0123456" (no tag, all-digit hash w/ leading zero) → "0.0.0-g0123456"
|
||||
*
|
||||
* Leading `v` is stripped so the result is valid semver for package.json.
|
||||
* The fallback matters because a bare commit hash is never valid semver —
|
||||
* even one that happens to start with a digit (e.g. "2f24057b") — and
|
||||
* electron-updater throws on launch if package.json carries such a version.
|
||||
* The hash is prefixed with `g` so the pre-release identifier is always
|
||||
* alphanumeric; a bare all-digit hash with a leading zero (e.g. "0123456")
|
||||
* would otherwise form `0.0.0-0123456`, which is invalid semver.
|
||||
*/
|
||||
export function normalizeGitVersion(raw) {
|
||||
if (!raw) return null;
|
||||
const stripped = raw.replace(/^v/, "");
|
||||
if (!/^\d/.test(stripped)) {
|
||||
// No reachable tag — `git describe` fell back to just the commit hash.
|
||||
return `0.0.0-${stripped}`;
|
||||
// A real version begins with major.minor.patch. The bare commit hash
|
||||
// that `git describe --always` falls back to (no reachable tag) does not,
|
||||
// so coerce it to a 0.0.0 prerelease rather than passing it through.
|
||||
// Prefix the hash with `g` (mirroring `git describe`'s own `g<hash>`
|
||||
// shorthand) so a hash like "0123456" yields "0.0.0-g0123456" — a single
|
||||
// alphanumeric identifier — instead of the invalid "0.0.0-0123456".
|
||||
if (!/^\d+\.\d+\.\d+/.test(stripped)) {
|
||||
return `0.0.0-g${stripped}`;
|
||||
}
|
||||
return stripped;
|
||||
}
|
||||
|
||||
@@ -38,11 +38,27 @@ describe("normalizeGitVersion", () => {
|
||||
expect(normalizeGitVersion("v1.0.0-rc.2")).toBe("1.0.0-rc.2");
|
||||
});
|
||||
|
||||
it("falls back to 0.0.0-<hash> when no tags are reachable", () => {
|
||||
it("falls back to 0.0.0-g<hash> when no tags are reachable", () => {
|
||||
// `git describe --tags --always` returns just the short commit hash
|
||||
// when there are no tags in the history at all.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-f1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-abc1234");
|
||||
// when there are no tags in the history at all. A hash that begins with
|
||||
// a digit (e.g. "2f24057b") is still not valid semver and must fall
|
||||
// through — otherwise electron-updater rejects it on launch. The `g`
|
||||
// prefix mirrors git describe's own `g<hash>` shorthand and keeps the
|
||||
// pre-release identifier a single alphanumeric token.
|
||||
expect(normalizeGitVersion("f1415e96")).toBe("0.0.0-gf1415e96");
|
||||
expect(normalizeGitVersion("abc1234")).toBe("0.0.0-gabc1234");
|
||||
expect(normalizeGitVersion("2f24057b")).toBe("0.0.0-g2f24057b");
|
||||
});
|
||||
|
||||
it("prefixes an all-digit hash so the pre-release is valid semver", () => {
|
||||
// A short hash that is all decimal digits with a leading zero would
|
||||
// produce `0.0.0-0123456` — a numeric pre-release identifier must not
|
||||
// have a leading zero, so that value is invalid semver and
|
||||
// electron-updater would throw on the no-tag builds this fallback
|
||||
// exists to protect. The `g` prefix makes it a single alphanumeric
|
||||
// identifier, which is always valid.
|
||||
expect(normalizeGitVersion("0123456")).toBe("0.0.0-g0123456");
|
||||
expect(normalizeGitVersion("04567")).toBe("0.0.0-g04567");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ import type { DaemonStatus, DaemonPrefs } from "../shared/daemon-types";
|
||||
import { daemonStatusAlive } from "../shared/daemon-types";
|
||||
import { ensureManagedCli, managedCliPath } from "./cli-bootstrap";
|
||||
import { decideVersionAction } from "./version-decision";
|
||||
import {
|
||||
daemonLifecycleUnreachable,
|
||||
isDaemonExternallyManaged,
|
||||
normalizeHostOS,
|
||||
} from "./daemon-os";
|
||||
import {
|
||||
classifyAuthProbe,
|
||||
isAuthStatusError,
|
||||
@@ -161,6 +166,8 @@ function sendStatus(status: DaemonStatus): void {
|
||||
interface HealthPayload {
|
||||
status?: string;
|
||||
pid?: number;
|
||||
/** Daemon's runtime.GOOS. Absent on daemons older than the #3916 fix. */
|
||||
os?: string;
|
||||
uptime?: string;
|
||||
daemon_id?: string;
|
||||
device_name?: string;
|
||||
@@ -347,6 +354,16 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
authExpired = false;
|
||||
startingSince = null;
|
||||
|
||||
// A running daemon whose OS differs from this host's is one we can't drive
|
||||
// via the native lifecycle CLI (e.g. Linux-in-WSL2 behind a Windows desktop,
|
||||
// reachable only over localhost forwarding). Surface it so the UI disables
|
||||
// the auto-start/auto-stop toggles instead of letting them silently no-op,
|
||||
// and so before-quit skips a stop that would never land. See #3916.
|
||||
const externallyManaged = isDaemonExternallyManaged(
|
||||
data.os,
|
||||
normalizeHostOS(process.platform),
|
||||
);
|
||||
|
||||
// Safety: if we have a target URL and the daemon on our port reports a
|
||||
// different server_url, it's not "our" daemon — drop it and re-resolve.
|
||||
if (
|
||||
@@ -370,6 +387,7 @@ async function fetchHealth(): Promise<DaemonStatus> {
|
||||
: 0,
|
||||
profile: active.name,
|
||||
serverUrl: data.server_url,
|
||||
externallyManaged,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -556,6 +574,15 @@ async function ensureRunningDaemonVersionMatches(): Promise<
|
||||
> {
|
||||
const active = await ensureActiveProfile();
|
||||
const running = await fetchHealthAtPort(active.port);
|
||||
|
||||
// Don't try to version-match a daemon we can't restart (e.g. WSL2). Treat it
|
||||
// as up-to-date — restartDaemon would no-op anyway, and skipping here avoids
|
||||
// a misleading "restarting daemon" log on every auto-start. #3916.
|
||||
if (isDaemonExternallyManaged(running?.os, normalizeHostOS(process.platform))) {
|
||||
pendingVersionRestart = false;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
const bundled = await getCliBinaryVersion();
|
||||
const action = decideVersionAction(bundled, running);
|
||||
|
||||
@@ -837,7 +864,32 @@ async function startDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh boundary preflight for stop/restart: read the active profile's CURRENT
|
||||
* /health and decide whether the daemon runs somewhere the app can't drive
|
||||
* (WSL2 etc.). Done per call rather than off the poll cache, so a lifecycle op
|
||||
* never shells out to a CLI that can't reach the daemon's process — even on
|
||||
* paths that didn't just poll (e.g. restart-on-user-switch in syncToken, which
|
||||
* calls restartDaemon directly). See #3916.
|
||||
*/
|
||||
async function lifecycleBlockedByForeignDaemon(): Promise<boolean> {
|
||||
const active = await ensureActiveProfile();
|
||||
return daemonLifecycleUnreachable(
|
||||
async () => (await fetchHealthAtPort(active.port))?.os,
|
||||
normalizeHostOS(process.platform),
|
||||
);
|
||||
}
|
||||
|
||||
async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
// Central lifecycle guard: a daemon running in an environment we can't drive
|
||||
// (e.g. Linux in WSL2 behind a Windows desktop) can't be stopped by the
|
||||
// native CLI — it would act on the host process namespace and no-op, while
|
||||
// still flipping our state to "stopped". Bail as a successful no-op so every
|
||||
// caller (logout, quit, restart, the Runtime card) is covered in one place
|
||||
// rather than each remembering to check. Preflighted against live /health so
|
||||
// it holds even when no poll ran first. #3916.
|
||||
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
|
||||
|
||||
const bin = await resolveCliBinary();
|
||||
if (!bin) return { success: false, error: "multica CLI is not installed" };
|
||||
|
||||
@@ -864,6 +916,11 @@ async function stopDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
}
|
||||
|
||||
async function restartDaemon(): Promise<{ success: boolean; error?: string }> {
|
||||
// Same central, live-preflighted guard as stopDaemon: we can neither stop nor
|
||||
// start a daemon we don't manage, so don't try (user-switch, reauth,
|
||||
// first-workspace, and any future restart caller all route through here).
|
||||
// #3916.
|
||||
if (await lifecycleBlockedByForeignDaemon()) return { success: true };
|
||||
const stopResult = await stopDaemon();
|
||||
if (!stopResult.success) return stopResult;
|
||||
return startDaemon();
|
||||
@@ -1111,6 +1168,8 @@ export function setupDaemonManager(
|
||||
isQuitting = true;
|
||||
event.preventDefault();
|
||||
try {
|
||||
// stopDaemon no-ops for an externally-managed daemon (WSL2 etc.), so
|
||||
// this is safe and instant in that case — the guard lives there. #3916
|
||||
await stopDaemon();
|
||||
} catch {
|
||||
// Best-effort stop on quit
|
||||
|
||||
80
apps/desktop/src/main/daemon-os.test.ts
Normal file
80
apps/desktop/src/main/daemon-os.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
daemonLifecycleUnreachable,
|
||||
isDaemonExternallyManaged,
|
||||
normalizeHostOS,
|
||||
} from "./daemon-os";
|
||||
|
||||
describe("normalizeHostOS", () => {
|
||||
it("maps win32 to the GOOS spelling 'windows'", () => {
|
||||
expect(normalizeHostOS("win32")).toBe("windows");
|
||||
});
|
||||
|
||||
it("passes darwin and linux through unchanged (already GOOS spellings)", () => {
|
||||
expect(normalizeHostOS("darwin")).toBe("darwin");
|
||||
expect(normalizeHostOS("linux")).toBe("linux");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDaemonExternallyManaged", () => {
|
||||
it("flags a Linux (WSL2) daemon behind a Windows desktop — the #3916 case", () => {
|
||||
expect(isDaemonExternallyManaged("linux", normalizeHostOS("win32"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
// These three are the "不误伤" guarantees: a native daemon on each platform
|
||||
// must keep its auto-start/auto-stop toggles.
|
||||
it("does NOT flag a native Windows daemon under a Windows desktop", () => {
|
||||
expect(isDaemonExternallyManaged("windows", normalizeHostOS("win32"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT flag a native macOS daemon under a macOS desktop", () => {
|
||||
expect(isDaemonExternallyManaged("darwin", normalizeHostOS("darwin"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT flag a native Linux daemon under a Linux desktop", () => {
|
||||
expect(isDaemonExternallyManaged("linux", normalizeHostOS("linux"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// Fail safe: an older daemon that predates the `os` field reports nothing.
|
||||
// Hiding a toggle on a guess would 误伤, so unknown OS = treat as manageable.
|
||||
it("fails safe to false when the daemon reports no OS", () => {
|
||||
expect(isDaemonExternallyManaged(undefined, "windows")).toBe(false);
|
||||
expect(isDaemonExternallyManaged("", "windows")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// The stop/restart lifecycle boundary funnels through this. It must read the
|
||||
// daemon's LIVE OS (not a cached poll value), so a restart on a path that
|
||||
// didn't just poll — e.g. user-switch — still can't shell out at a WSL2 daemon.
|
||||
describe("daemonLifecycleUnreachable", () => {
|
||||
it("consults the live OS reader and blocks a foreign-OS (WSL2) daemon", async () => {
|
||||
const readDaemonOS = vi.fn().mockResolvedValue("linux");
|
||||
expect(await daemonLifecycleUnreachable(readDaemonOS, "windows")).toBe(true);
|
||||
// Proves the decision came from a fresh read, not a stale cache.
|
||||
expect(readDaemonOS).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows a native daemon whose live OS matches the host", async () => {
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => "windows", "windows"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => "darwin", "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails safe to false when the live OS is unknown (older daemon / none running)", async () => {
|
||||
expect(
|
||||
await daemonLifecycleUnreachable(async () => undefined, "windows"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
67
apps/desktop/src/main/daemon-os.ts
Normal file
67
apps/desktop/src/main/daemon-os.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Detecting a daemon the desktop app can't manage.
|
||||
*
|
||||
* The app reads daemon liveness over HTTP at 127.0.0.1:{port}/health, but it
|
||||
* starts/stops the daemon by shelling out to the bundled native CLI, which acts
|
||||
* on the *host* OS process namespace. On Windows with the daemon running inside
|
||||
* WSL2, /health is reachable via localhost forwarding (so status looks fine) but
|
||||
* the daemon's process lives in a separate Linux namespace the Windows CLI can't
|
||||
* touch — so auto-start / auto-stop silently do nothing (#3916).
|
||||
*
|
||||
* The reliable, low-false-positive signal is the daemon's own OS (reported as
|
||||
* `os` on /health, = runtime.GOOS) vs the desktop host OS. They only disagree
|
||||
* when the daemon runs in a foreign environment we can't drive. This module is
|
||||
* the single source of truth for that comparison so it stays unit-tested — the
|
||||
* cost of a false positive is hiding a working toggle from a native user, so the
|
||||
* logic must fail safe (treat unknown / matching as manageable).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize a Node `process.platform` value to the daemon's `runtime.GOOS`
|
||||
* vocabulary so the two are directly comparable. Only `win32` -> `windows`
|
||||
* actually differs across the platforms we ship (darwin/linux already match);
|
||||
* any other value passes through unchanged.
|
||||
*/
|
||||
export function normalizeHostOS(platform: NodeJS.Platform): string {
|
||||
return platform === "win32" ? "windows" : platform;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a running daemon is in an environment the desktop app can't control.
|
||||
*
|
||||
* Returns true ONLY when the daemon reports a concrete OS that differs from the
|
||||
* host's. Fails safe to false when:
|
||||
* - `daemonOS` is missing/empty (older daemon that predates the `os` field, or
|
||||
* a malformed response) — we can't prove it's foreign, so keep toggles live.
|
||||
* - the OSes match — a normally-managed native daemon.
|
||||
*
|
||||
* Callers must only invoke this for a daemon that is actually running; a stopped
|
||||
* daemon has no OS to compare and its toggles must stay enabled.
|
||||
*/
|
||||
export function isDaemonExternallyManaged(
|
||||
daemonOS: string | undefined,
|
||||
hostOS: string,
|
||||
): boolean {
|
||||
if (typeof daemonOS !== "string" || daemonOS.length === 0) return false;
|
||||
return daemonOS !== hostOS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary preflight for daemon lifecycle ops (stop / restart): resolve the
|
||||
* daemon's CURRENT OS via `readDaemonOS` and return true when it's running
|
||||
* somewhere the app can't drive.
|
||||
*
|
||||
* `readDaemonOS` is a live `/health` read performed at the call site — never a
|
||||
* cached poll value. That is the whole point: a stale "manageable" cache would
|
||||
* let a lifecycle op shell out to a native CLI that can't reach a WSL2 daemon
|
||||
* (the PID lives in another namespace), which is exactly the bug. Taking the
|
||||
* reader as a parameter keeps this unit-testable without the electron-coupled
|
||||
* daemon-manager module, and lets the test prove the live value — not a cache —
|
||||
* drives the decision. See #3916.
|
||||
*/
|
||||
export async function daemonLifecycleUnreachable(
|
||||
readDaemonOS: () => Promise<string | undefined>,
|
||||
hostOS: string,
|
||||
): Promise<boolean> {
|
||||
return isDaemonExternallyManaged(await readDaemonOS(), hostOS);
|
||||
}
|
||||
90
apps/desktop/src/main/freeze-breadcrumb.test.ts
Normal file
90
apps/desktop/src/main/freeze-breadcrumb.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
type FreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
|
||||
const dirs: string[] = [];
|
||||
function tempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "last-client-failure.json");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sample: FreezeBreadcrumb = {
|
||||
kind: "unresponsive",
|
||||
context: { desktopRoute: { path: "/acme/issues" } },
|
||||
ts: 1_700_000_000_000,
|
||||
version: "0.3.1",
|
||||
};
|
||||
|
||||
describe("freeze breadcrumb round-trip", () => {
|
||||
it("writes then reads back the breadcrumb", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
});
|
||||
|
||||
it("read clears the file so a failure reports exactly once", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
clearFreezeBreadcrumb(file);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
|
||||
// IPC) and lives across app versions — a future write shape or a corrupt file
|
||||
// must never throw into boot. CLAUDE.md "API Response Compatibility".
|
||||
describe("freeze breadcrumb defends against malformed input", () => {
|
||||
it("returns null when no file exists", () => {
|
||||
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on corrupt JSON", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "{ not valid json", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is missing", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is the wrong type", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a JSON null payload", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "null", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing a non-existent file is a no-op, never throws", () => {
|
||||
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
|
||||
});
|
||||
});
|
||||
76
apps/desktop/src/main/freeze-breadcrumb.ts
Normal file
76
apps/desktop/src/main/freeze-breadcrumb.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
// When the renderer truly hangs or its process dies, it can't send telemetry
|
||||
// itself — the thread is blocked or gone. The main process (always alive) is
|
||||
// the only watcher that can react, but during the hang it can't reach the
|
||||
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
|
||||
// time a renderer boots, it reads + clears the file and reports the event.
|
||||
// This survives even a force-quit, which is the whole point.
|
||||
|
||||
export type { FreezeBreadcrumb };
|
||||
|
||||
/**
|
||||
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
|
||||
*
|
||||
* Known limitation: this is a single slot — last write wins. Multiple failures
|
||||
* within one session collapse to the last one, so per-session failure counts
|
||||
* are undercounted. Acceptable for now: telemetry aggregates presence and
|
||||
* frequency across users, not exhaustive per-session sequences. Upgrade to an
|
||||
* append/ring buffer if per-session failure chains become a question.
|
||||
*/
|
||||
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
|
||||
} catch {
|
||||
// Disk full / permissions — drop silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
|
||||
* (a `responsive` event after `unresponsive`): the breadcrumb was written
|
||||
* pre-emptively while the thread was stuck, but since it came back, the
|
||||
* in-thread long-task watchdog already reports it — keeping the breadcrumb
|
||||
* would double-count it AND mislabel a recovered window as `recovered: false`.
|
||||
* Best-effort; a stale breadcrumb only costs one duplicate report.
|
||||
*/
|
||||
export function clearFreezeBreadcrumb(filePath: string): void {
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// Nothing to clear / permissions — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the breadcrumb and delete it in the same call, so a failure is reported
|
||||
* exactly once. Returns null when there's no breadcrumb (the normal case) or
|
||||
* when the file is unreadable / corrupt.
|
||||
*/
|
||||
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// If we can't delete it we'd re-report next launch; acceptable over throwing.
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof (parsed as FreezeBreadcrumb).kind === "string"
|
||||
) {
|
||||
return parsed as FreezeBreadcrumb;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON — drop.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,11 +13,21 @@ import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
sanitizeRendererRouteContext,
|
||||
type RendererRouteContext,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
createElectronReloadPrompt,
|
||||
installRendererRecoveryHandlers,
|
||||
type RendererRecoveryWindow,
|
||||
} from "./renderer-recovery";
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
@@ -61,7 +71,15 @@ if (process.platform !== "win32") {
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
// Where the main process parks a freeze/crash breadcrumb until the next
|
||||
// renderer boot flushes it to telemetry. Lives in userData so it survives a
|
||||
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
|
||||
function freezeBreadcrumbPath(): string {
|
||||
return join(app.getPath("userData"), "last-client-failure.json");
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let latestRendererRouteContext: RendererRouteContext | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
@@ -126,7 +144,7 @@ function createWindow(): void {
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
trafficLightPosition: { x: 16, y: 17 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option.
|
||||
@@ -166,9 +184,13 @@ function createWindow(): void {
|
||||
},
|
||||
});
|
||||
const window = mainWindow;
|
||||
latestRendererRouteContext = null;
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) mainWindow = null;
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
latestRendererRouteContext = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Strip Origin header from WebSocket upgrade requests so the server's
|
||||
@@ -204,10 +226,14 @@ function createWindow(): void {
|
||||
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
// anything we own here (reload-block, zoom, tab-close) is the sole handler
|
||||
// for that combination — no double-fire with the macOS default View menu.
|
||||
window.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, window.webContents)) {
|
||||
const result = handleAppShortcut(input, window.webContents);
|
||||
if (result === "close-tab") {
|
||||
event.preventDefault();
|
||||
window.webContents.send("tab:close-active");
|
||||
} else if (result) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -255,6 +281,27 @@ function createWindow(): void {
|
||||
showReloadPrompt: createElectronReloadPrompt((options) =>
|
||||
dialog.showMessageBox(window, options),
|
||||
),
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl: window.webContents.getURL(),
|
||||
...(latestRendererRouteContext
|
||||
? { desktopRoute: latestRendererRouteContext }
|
||||
: {}),
|
||||
}),
|
||||
// Only persist in production: a true hang/crash can't report itself, so we
|
||||
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
|
||||
// is excluded to keep field telemetry clean.
|
||||
persistBreadcrumb: is.dev
|
||||
? undefined
|
||||
: (payload) =>
|
||||
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
|
||||
kind: payload.kind,
|
||||
context: payload.context,
|
||||
ts: Date.now(),
|
||||
version: getAppVersion(),
|
||||
}),
|
||||
clearBreadcrumb: is.dev
|
||||
? undefined
|
||||
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
|
||||
});
|
||||
|
||||
installContextMenu(window.webContents);
|
||||
@@ -370,6 +417,11 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Renderer requests window close (e.g. Cmd+W on last tab).
|
||||
ipcMain.on("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
@@ -388,6 +440,14 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
|
||||
// session. The renderer flushes it to telemetry on boot (it couldn't be
|
||||
// reported when it happened — the renderer was hung or gone). Read-and-
|
||||
// clear so a failure reports exactly once.
|
||||
ipcMain.on("freeze:get-last", (event) => {
|
||||
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
@@ -395,6 +455,13 @@ if (!gotTheLock) {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
|
||||
if (!mainWindow || event.sender !== mainWindow.webContents) return;
|
||||
const sanitized = sanitizeRendererRouteContext(context);
|
||||
if (!sanitized) return;
|
||||
latestRendererRouteContext = sanitized;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -14,13 +14,14 @@ function makeWc(initialLevel = 0) {
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta" | "shift">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
@@ -150,3 +151,36 @@ describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — close tab (Cmd/Ctrl+W)", () => {
|
||||
it('returns "close-tab" on Cmd+W (macOS)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Cmd+W uppercase', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Ctrl+W (Linux/Windows)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "linux")).toBe("close-tab");
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "win32")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it("does not trigger without Cmd/Ctrl modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w"), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Cmd+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true, shift: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Ctrl+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { control: true, shift: true }), wc, "linux")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ShortcutInput = {
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
@@ -34,11 +35,19 @@ const ZOOM_MAX = 4.5;
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
/**
|
||||
* Result of handleAppShortcut:
|
||||
* - `false`: not handled, let Electron continue
|
||||
* - `true`: handled (preventDefault), no further action
|
||||
* - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer
|
||||
*/
|
||||
export type ShortcutResult = boolean | "close-tab";
|
||||
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
): ShortcutResult {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
@@ -70,5 +79,12 @@ export function handleAppShortcut(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W → close active tab (or window if last tab).
|
||||
// Cmd/Ctrl + Shift + W is reserved for "close window" — do not intercept.
|
||||
// Return a signal so the caller can send IPC to the renderer.
|
||||
if (input.key.toLowerCase() === "w" && !input.shift) {
|
||||
return "close-tab";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
@@ -83,10 +83,50 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
const desktopRoute = {
|
||||
surface: "tab",
|
||||
path: "/acme/issues/MUL-3239",
|
||||
workspaceSlug: "acme",
|
||||
tabId: "tab-1",
|
||||
reportedAt: "2026-06-15T00:00:00.000Z",
|
||||
};
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
}),
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({
|
||||
kind: "unresponsive",
|
||||
context: {
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
},
|
||||
});
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps prompting when diagnostic context collection fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => {
|
||||
throw new Error("diagnostics unavailable");
|
||||
},
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
@@ -94,7 +134,6 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps dev diagnostics non-prompting", async () => {
|
||||
@@ -109,4 +148,124 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows actionable recovery guidance before diagnostic details", async () => {
|
||||
let detail = "";
|
||||
const showMessageBox = vi.fn(
|
||||
async (options: { title: string; message: string; detail: string }) => {
|
||||
detail = options.detail;
|
||||
return { response: 1 };
|
||||
},
|
||||
);
|
||||
const showReloadPrompt = createElectronReloadPrompt(showMessageBox);
|
||||
|
||||
await showReloadPrompt({ kind: "unresponsive", context: {} });
|
||||
|
||||
expect(showMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Multica needs to reload",
|
||||
message: "The desktop window has been stuck for a few seconds.",
|
||||
detail: expect.stringContaining(
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(detail).toContain("what you were doing right before this message appeared");
|
||||
expect(detail).toContain("Activity Monitor sample");
|
||||
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/crash breadcrumb state machine", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function install(fixture: ReturnType<typeof makeWindow>) {
|
||||
const persistBreadcrumb = vi.fn();
|
||||
const clearBreadcrumb = vi.fn();
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt: vi.fn(async () => "dismiss" as const),
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
return { persistBreadcrumb, clearBreadcrumb };
|
||||
}
|
||||
|
||||
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "unresponsive" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// No "responsive" ever fires — the breadcrumb must survive uncleared.
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "render-process-gone" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,22 @@ type ReloadPromptResult = "reload" | "dismiss";
|
||||
type RendererRecoveryOptions = {
|
||||
isDev: boolean;
|
||||
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
|
||||
getDiagnosticContext?: () => Record<string, unknown>;
|
||||
/**
|
||||
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
|
||||
* true hang or process death itself (blocked / gone), so the main process
|
||||
* writes it here and the next renderer boot flushes it to telemetry. Omit
|
||||
* in dev to keep field telemetry clean.
|
||||
*/
|
||||
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
|
||||
/**
|
||||
* Delete a previously-persisted unresponsive breadcrumb. Called when the
|
||||
* renderer recovers (`responsive` after `unresponsive`): the window came
|
||||
* back, so the in-thread watchdog reports the freeze and the breadcrumb
|
||||
* would only double-count it. Crash breadcrumbs are never cleared — a dead
|
||||
* process never recovers.
|
||||
*/
|
||||
clearBreadcrumb?: () => void;
|
||||
log?: (tag: string, ...args: unknown[]) => void;
|
||||
unresponsivePromptDelayMs?: number;
|
||||
};
|
||||
@@ -26,11 +42,21 @@ export function installRendererRecoveryHandlers(
|
||||
{
|
||||
isDev,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext,
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
log = defaultDevLog,
|
||||
unresponsivePromptDelayMs = 1500,
|
||||
}: RendererRecoveryOptions,
|
||||
) {
|
||||
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// True once a breadcrumb has been written for the current hang. A later
|
||||
// `responsive` clears it; only a hang that never returns survives to report.
|
||||
let unresponsiveBreadcrumbWritten = false;
|
||||
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
|
||||
...readDiagnosticContext(getDiagnosticContext),
|
||||
...context,
|
||||
});
|
||||
const maybePromptReload = (payload: ReloadPromptPayload) => {
|
||||
if (isDev) return;
|
||||
void showReloadPrompt(payload).then((result) => {
|
||||
@@ -43,14 +69,23 @@ export function installRendererRecoveryHandlers(
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (isDev) log("process-gone", JSON.stringify(details));
|
||||
if (!isRecoverableRendererExit(details)) return;
|
||||
maybePromptReload({ kind: "render-process-gone", context: { details } });
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "render-process-gone",
|
||||
context: mergeDiagnosticContext({ details }),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
maybePromptReload(payload);
|
||||
});
|
||||
|
||||
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
|
||||
// failure of the preload script itself, and the breadcrumb-flush path depends
|
||||
// on that same preload exposing `getLastFreeze` — if preload is broken, the
|
||||
// next boot couldn't read it back anyway. We only prompt for reload here.
|
||||
window.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
|
||||
maybePromptReload({
|
||||
kind: "preload-error",
|
||||
context: { preloadPath, error: formatError(error) },
|
||||
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,14 +93,27 @@ export function installRendererRecoveryHandlers(
|
||||
if (isDev || unresponsivePromptTimer) return;
|
||||
unresponsivePromptTimer = setTimeout(() => {
|
||||
unresponsivePromptTimer = null;
|
||||
maybePromptReload({ kind: "unresponsive", context: {} });
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "unresponsive",
|
||||
context: mergeDiagnosticContext({}),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
unresponsiveBreadcrumbWritten = true;
|
||||
maybePromptReload(payload);
|
||||
}, unresponsivePromptDelayMs);
|
||||
});
|
||||
|
||||
window.on("responsive", () => {
|
||||
if (!unresponsivePromptTimer) return;
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
if (unresponsivePromptTimer) {
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
}
|
||||
// The window came back: drop any breadcrumb written during this hang so it
|
||||
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
|
||||
if (unresponsiveBreadcrumbWritten) {
|
||||
clearBreadcrumb?.();
|
||||
unresponsiveBreadcrumbWritten = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,18 +157,30 @@ function isRecoverableRendererExit(details: unknown) {
|
||||
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
|
||||
switch (kind) {
|
||||
case "render-process-gone":
|
||||
return "The desktop renderer process stopped responding or crashed.";
|
||||
return "The desktop window stopped unexpectedly.";
|
||||
case "preload-error":
|
||||
return "The desktop preload script failed before the app could start.";
|
||||
return "The desktop window could not finish starting.";
|
||||
case "unresponsive":
|
||||
return "The desktop window is not responding.";
|
||||
return "The desktop window has been stuck for a few seconds.";
|
||||
}
|
||||
}
|
||||
|
||||
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
|
||||
const guidance = [
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
"If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.",
|
||||
];
|
||||
|
||||
if (payload.kind === "unresponsive") {
|
||||
guidance.push(
|
||||
"For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.",
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
"Reloading is the safest recovery path for this window.",
|
||||
...guidance,
|
||||
"",
|
||||
"Diagnostic details:",
|
||||
`kind: ${payload.kind}`,
|
||||
`context: ${JSON.stringify(payload.context)}`,
|
||||
].join("\n");
|
||||
@@ -130,6 +190,17 @@ function defaultDevLog(tag: string, ...args: unknown[]) {
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
}
|
||||
|
||||
function readDiagnosticContext(
|
||||
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
|
||||
) {
|
||||
if (!getDiagnosticContext) return {};
|
||||
try {
|
||||
return getDiagnosticContext();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/desktop/src/preload/index.d.ts
vendored
12
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,6 +1,8 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -14,6 +16,9 @@ interface DesktopAPI {
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
|
||||
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
|
||||
getLastFreeze: () => FreezeBreadcrumb | null;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -45,6 +50,8 @@ interface DesktopAPI {
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) => void;
|
||||
/** Open the OS folder picker and return the chosen absolute path.
|
||||
* Used by the Project settings "Add local directory" flow. */
|
||||
pickDirectory: (
|
||||
@@ -71,6 +78,11 @@ interface DesktopAPI {
|
||||
| "error";
|
||||
error?: string;
|
||||
}>;
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* Returns an unsubscribe function. */
|
||||
onCloseActiveTab: (callback: () => void) => () => void;
|
||||
/** Ask the main process to close the window. */
|
||||
closeWindow: () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
type RendererRouteContextInput,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
@@ -74,6 +79,16 @@ const desktopAPI = {
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
|
||||
* the renderer can flush it to telemetry on boot. Returns null when there's
|
||||
* nothing pending (the normal case). */
|
||||
getLastFreeze: (): FreezeBreadcrumb | null => {
|
||||
try {
|
||||
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -156,12 +171,27 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) =>
|
||||
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
|
||||
/** Open the OS folder picker and return the chosen absolute path. */
|
||||
pickDirectory: (defaultPath?: string) =>
|
||||
ipcRenderer.invoke("local-directory:pick", defaultPath),
|
||||
/** Validate that a path is an existing readable+writable directory. */
|
||||
validateLocalDirectory: (path: string) =>
|
||||
ipcRenderer.invoke("local-directory:validate", path),
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* The renderer should close the active tab; if it was the last tab,
|
||||
* call `closeWindow()` to dismiss the window. Returns an unsubscribe fn. */
|
||||
onCloseActiveTab: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("tab:close-active", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("tab:close-active", handler);
|
||||
};
|
||||
},
|
||||
/** Ask the main process to close the window (used after closing the last tab). */
|
||||
closeWindow: () => ipcRenderer.send("window:close"),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { captureEvent } from "@multica/core/analytics";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
// BCP-47 region tags for the <html lang> attribute, mirroring
|
||||
@@ -34,10 +35,42 @@ const HTML_LANG: Record<SupportedLocale, string> = {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+W: close the active tab. When the last real tab is closed
|
||||
* (or no tabs/workspace exist — e.g. login page), close the window.
|
||||
*
|
||||
* Mounted at the App root so every renderer state — including login,
|
||||
* loading, onboarding, and runtime-config errors — has a working Cmd+W
|
||||
* handler. Without this, states outside the tab shell would swallow the
|
||||
* shortcut and do nothing.
|
||||
*/
|
||||
function useCmdWCloseTab() {
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onCloseActiveTab(() => {
|
||||
const store = useTabStore.getState();
|
||||
const { activeWorkspaceSlug, byWorkspace } = store;
|
||||
if (!activeWorkspaceSlug) {
|
||||
// No workspace — nothing to close, dismiss the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group || group.tabs.length <= 1) {
|
||||
// Last tab (or no tabs) — close the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
// Multiple tabs — close the active one.
|
||||
store.closeActiveTab();
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
@@ -298,6 +331,28 @@ export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
useCmdWCloseTab();
|
||||
|
||||
// Flush a freeze/crash breadcrumb the main process parked from a previous
|
||||
// session. A true hang or process death can't report itself when it happens
|
||||
// (the renderer is blocked or gone), so the main process persists it and we
|
||||
// emit it here on the next boot. The in-thread, recoverable freeze tier is
|
||||
// handled separately by the shared watchdog in CoreProvider.
|
||||
useEffect(() => {
|
||||
const last = window.desktopAPI.getLastFreeze();
|
||||
if (!last) return;
|
||||
const crashed = last.kind === "render-process-gone";
|
||||
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
|
||||
// Spread context FIRST so our explicit fields below always win — a
|
||||
// future context key (e.g. its own `source`) must not silently override.
|
||||
...last.context,
|
||||
source: crashed ? "render-process-gone" : "main-unresponsive",
|
||||
recovered: false,
|
||||
breadcrumb_ts: last.ts,
|
||||
crashed_version: last.version,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
// The component only needs these to render; stub them so the test focuses on
|
||||
// the externally-managed branching, not data fetching.
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: () => ({ data: [] }),
|
||||
}));
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
vi.mock("@multica/core/runtimes", () => ({
|
||||
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
|
||||
}));
|
||||
vi.mock("@multica/core/agents", () => ({
|
||||
agentTaskSnapshotOptions: () => ({ queryKey: ["snapshot"] }),
|
||||
}));
|
||||
vi.mock("./daemon-panel", () => ({ DaemonPanel: () => null }));
|
||||
vi.mock("../platform/daemon-reauth", () => ({
|
||||
reauthenticateDaemon: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
|
||||
function stubDaemonAPI(status: DaemonStatus) {
|
||||
Object.defineProperty(window, "daemonAPI", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getStatus: vi.fn().mockResolvedValue(status),
|
||||
onStatusChange: vi.fn(() => () => {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("DaemonRuntimeActions — externally managed daemon (#3916)", () => {
|
||||
it("hides Stop/Restart and shows the managed-outside hint for a daemon the app can't control", async () => {
|
||||
stubDaemonAPI({ state: "running", daemonId: "d1", externallyManaged: true });
|
||||
render(<DaemonRuntimeActions />);
|
||||
|
||||
// View logs still renders, confirming the running branch mounted.
|
||||
expect(await screen.findByText("View logs")).toBeInTheDocument();
|
||||
expect(screen.getByText("Managed outside the app")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Restart")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Stop")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Stop/Restart for a normally-managed running daemon (no 误伤)", async () => {
|
||||
stubDaemonAPI({
|
||||
state: "running",
|
||||
daemonId: "d1",
|
||||
externallyManaged: false,
|
||||
});
|
||||
render(<DaemonRuntimeActions />);
|
||||
|
||||
expect(await screen.findByText("Restart")).toBeInTheDocument();
|
||||
expect(screen.getByText("Stop")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Managed outside the app"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Activity,
|
||||
ScrollText,
|
||||
LogIn,
|
||||
Info,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -126,6 +127,12 @@ export function DaemonRuntimeActions() {
|
||||
}, []);
|
||||
|
||||
const isRunning = status.state === "running";
|
||||
// The daemon runs somewhere the app can't drive (e.g. inside WSL2): the
|
||||
// lifecycle CLI acts on the host process namespace and can't reach it. Hide
|
||||
// Stop/Restart so they don't silently no-op, mirroring the Settings tab. The
|
||||
// real guard is in the main process (stopDaemon/restartDaemon); this is the
|
||||
// matching UX. See #3916.
|
||||
const externallyManaged = status.externallyManaged === true;
|
||||
const isStopped = status.state === "stopped";
|
||||
const isCliMissing = status.state === "cli_not_found";
|
||||
const isAuthExpired = status.state === "auth_expired";
|
||||
@@ -142,24 +149,33 @@ export function DaemonRuntimeActions() {
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
{externallyManaged ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Info className="size-3.5 shrink-0" />
|
||||
Managed outside the app
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import { AlertCircle, LogIn } from "lucide-react";
|
||||
import { AlertCircle, Info, LogIn } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
@@ -88,6 +88,12 @@ export function DaemonSettingsTab() {
|
||||
[],
|
||||
);
|
||||
|
||||
// The daemon runs somewhere the app can't drive (e.g. inside WSL2 behind a
|
||||
// Windows desktop): /health is reachable but the lifecycle CLI can't reach
|
||||
// its process. Auto-start/auto-stop can't work, so disable them and say why
|
||||
// rather than letting the toggles silently no-op. See #3916.
|
||||
const externallyManaged = status.externallyManaged === true;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Daemon</h2>
|
||||
@@ -119,6 +125,19 @@ export function DaemonSettingsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externallyManaged && (
|
||||
<div className="mt-4 flex items-start gap-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="min-w-0 text-sm text-muted-foreground">
|
||||
This device's daemon runs outside the app — for example inside
|
||||
WSL2 — so the app can't start or stop it. Start or stop it from
|
||||
that environment with{" "}
|
||||
<code className="font-mono text-xs">multica daemon start</code> /{" "}
|
||||
<code className="font-mono text-xs">multica daemon stop</code>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
<SettingRow
|
||||
label="Auto-start on launch"
|
||||
@@ -127,7 +146,7 @@ export function DaemonSettingsTab() {
|
||||
<Switch
|
||||
checked={prefs.autoStart}
|
||||
onCheckedChange={(checked) => updatePref("autoStart", checked)}
|
||||
disabled={saving}
|
||||
disabled={saving || externallyManaged}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
@@ -138,7 +157,7 @@ export function DaemonSettingsTab() {
|
||||
<Switch
|
||||
checked={prefs.autoStop}
|
||||
onCheckedChange={(checked) => updatePref("autoStop", checked)}
|
||||
disabled={saving}
|
||||
disabled={saving || externallyManaged}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useSyncExternalStore } from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useTabHistory } from "@/hooks/use-tab-history";
|
||||
import { useActiveTitleSync } from "@/hooks/use-tab-sync";
|
||||
@@ -22,41 +23,69 @@ import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const TOP_BAR_HEIGHT_CLASS = "h-12";
|
||||
const WINDOW_TOOLBAR_CLEARANCE = 184;
|
||||
const toolbarMotion = {
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 38,
|
||||
mass: 0.8,
|
||||
} as const;
|
||||
|
||||
function WindowToolbar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
const navButtonClassName =
|
||||
"flex size-7 items-center justify-center rounded-md text-muted-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-30";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-12 shrink-0 flex items-center justify-end px-2"
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-30 flex w-[184px] shrink-0 items-center px-3",
|
||||
TOP_BAR_HEIGHT_CLASS,
|
||||
)}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-0.5"
|
||||
className="flex items-center gap-1 pl-[70px]"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-30 disabled:pointer-events-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
<SidebarTrigger
|
||||
className="size-7 text-muted-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
aria-label="Go back"
|
||||
title="Go back"
|
||||
className={navButtonClassName}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goForward}
|
||||
disabled={!canGoForward}
|
||||
aria-label="Go forward"
|
||||
title="Go forward"
|
||||
className={navButtonClassName}
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTopSpacer() {
|
||||
return <div className={cn("shrink-0", TOP_BAR_HEIGHT_CLASS)} />;
|
||||
}
|
||||
|
||||
function useNativeNavigationGestures() {
|
||||
const { goBack, goForward } = useTabHistory();
|
||||
|
||||
@@ -72,30 +101,31 @@ function useNativeNavigationGestures() {
|
||||
}
|
||||
|
||||
// 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
|
||||
// left side so tabs don't land under the macOS traffic lights (which live at
|
||||
// roughly x=16..68 and always hit-test above HTML), and surface a trigger so
|
||||
// the sidebar can be brought back without keyboard shortcut.
|
||||
// is not occupying main-flow width, leave room for the fixed window toolbar
|
||||
// so tabs do not land beneath the traffic lights / navigation controls.
|
||||
function MainTopBar() {
|
||||
const { state, isMobile } = useSidebar();
|
||||
const sidebarHidden = state === "collapsed" || isMobile;
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"h-12 shrink-0 flex items-center gap-2",
|
||||
sidebarHidden && "pl-20",
|
||||
)}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
<motion.header
|
||||
animate={{ paddingLeft: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
|
||||
className={cn("relative shrink-0 flex items-center gap-2", TOP_BAR_HEIGHT_CLASS)}
|
||||
initial={false}
|
||||
transition={toolbarMotion}
|
||||
>
|
||||
{sidebarHidden && (
|
||||
<SidebarTrigger
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
/>
|
||||
)}
|
||||
<TabBar />
|
||||
</header>
|
||||
<motion.div
|
||||
aria-hidden
|
||||
animate={{ left: sidebarHidden ? WINDOW_TOOLBAR_CLEARANCE : 0 }}
|
||||
className="absolute inset-y-0 right-0"
|
||||
initial={false}
|
||||
transition={toolbarMotion}
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full items-center">
|
||||
<TabBar />
|
||||
</div>
|
||||
</motion.header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -183,9 +213,10 @@ export function DesktopShell() {
|
||||
<DesktopInboxBridge />
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
{slug && <AppSidebar topSlot={<SidebarTopBar />} searchSlot={<SearchTrigger />} />}
|
||||
{slug && <WindowToolbar />}
|
||||
{slug && <AppSidebar topSlot={<SidebarTopSpacer />} searchSlot={<SearchTrigger />} />}
|
||||
{/* Right side: header + content container */}
|
||||
<div className="flex flex-1 min-w-0 flex-col">
|
||||
<motion.div layout transition={toolbarMotion} className="flex flex-1 min-w-0 flex-col">
|
||||
<MainTopBar />
|
||||
{/* Content area with inset styling — relative so ChatWindow/ChatFab are constrained here */}
|
||||
<div className="relative flex flex-1 min-h-0 flex-col overflow-hidden mr-2 mb-2 ml-0.5 rounded-xl shadow-sm bg-background">
|
||||
@@ -193,7 +224,7 @@ export function DesktopShell() {
|
||||
{slug && <ChatWindow />}
|
||||
{slug && <ChatFab />}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</SidebarProvider>
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
@@ -90,6 +91,16 @@ export function PageviewTracker() {
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
const routeContext: RendererRouteContextInput = {
|
||||
surface: kind,
|
||||
path,
|
||||
};
|
||||
if (kind === "tab") {
|
||||
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
|
||||
routeContext.tabId = activeTabId ?? undefined;
|
||||
}
|
||||
reportRendererRouteContext(routeContext);
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
@@ -112,6 +123,13 @@ export function PageviewTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function reportRendererRouteContext(context: RendererRouteContextInput) {
|
||||
const desktopAPI = window.desktopAPI as
|
||||
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
|
||||
| undefined;
|
||||
desktopAPI?.setRendererRouteContext?.(context);
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
|
||||
@@ -13,4 +13,18 @@ import "@fontsource/geist-mono/400.css";
|
||||
import "@fontsource/geist-mono/700.css";
|
||||
import "./globals.css";
|
||||
|
||||
// react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click any
|
||||
// element to copy its source path + line + component stack for pasting to an AI.
|
||||
// Opt-in per developer: only loads when VITE_REACT_GRAB is set in a local,
|
||||
// gitignored apps/desktop/.env.development.local — it never activates for anyone
|
||||
// else, and the whole branch is tree-shaken out of production builds. The web app
|
||||
// wires the same tool via next/script in apps/web/app/layout.tsx.
|
||||
// See https://www.react-grab.com/
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_REACT_GRAB) {
|
||||
const grab = document.createElement("script");
|
||||
grab.src = "//unpkg.com/react-grab/dist/index.global.js";
|
||||
grab.crossOrigin = "anonymous";
|
||||
document.head.appendChild(grab);
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -22,6 +22,16 @@ export interface DaemonStatus {
|
||||
profile?: string;
|
||||
/** Backend URL the daemon connects to. */
|
||||
serverUrl?: string;
|
||||
/**
|
||||
* True when a daemon is running but in an environment the app can't control
|
||||
* — its reported OS differs from the desktop host's (e.g. a Linux daemon
|
||||
* inside WSL2 behind a Windows desktop, reachable only via localhost
|
||||
* forwarding). The app's start/stop CLI acts on the host process namespace,
|
||||
* so auto-start/auto-stop can't reach it; the UI disables those toggles
|
||||
* instead of silently no-op'ing. Only ever set on a running daemon, so it
|
||||
* never disables the toggles for a normally-managed native daemon. See #3916.
|
||||
*/
|
||||
externallyManaged?: boolean;
|
||||
}
|
||||
|
||||
export interface DaemonPrefs {
|
||||
|
||||
16
apps/desktop/src/shared/freeze-breadcrumb.ts
Normal file
16
apps/desktop/src/shared/freeze-breadcrumb.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A freeze/crash breadcrumb persisted by the main process and flushed to
|
||||
* telemetry by the next renderer boot. Shared across main, preload, and
|
||||
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
|
||||
* read/write logic and the rationale.
|
||||
*/
|
||||
export interface FreezeBreadcrumb {
|
||||
/** "unresponsive" (hang) or "render-process-gone" (crash). */
|
||||
kind: string;
|
||||
/** Diagnostic context captured at failure time (route, window url, …). */
|
||||
context: Record<string, unknown>;
|
||||
/** Epoch ms when the failure was recorded. */
|
||||
ts: number;
|
||||
/** App version at failure time. */
|
||||
version: string;
|
||||
}
|
||||
51
apps/desktop/src/shared/renderer-route-context.ts
Normal file
51
apps/desktop/src/shared/renderer-route-context.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
|
||||
|
||||
export type RendererRouteSurface = "login" | "overlay" | "tab";
|
||||
|
||||
export type RendererRouteContextInput = {
|
||||
surface: RendererRouteSurface;
|
||||
path: string;
|
||||
workspaceSlug?: string;
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
export type RendererRouteContext = RendererRouteContextInput & {
|
||||
reportedAt: string;
|
||||
};
|
||||
|
||||
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
|
||||
|
||||
export function sanitizeRendererRouteContext(
|
||||
value: unknown,
|
||||
reportedAt = new Date(),
|
||||
): RendererRouteContext | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!isRendererRouteSurface(input.surface)) return null;
|
||||
|
||||
const path = sanitizeString(input.path);
|
||||
if (!path) return null;
|
||||
|
||||
const workspaceSlug = sanitizeString(input.workspaceSlug);
|
||||
const tabId = sanitizeString(input.tabId);
|
||||
|
||||
return {
|
||||
surface: input.surface,
|
||||
path,
|
||||
...(workspaceSlug ? { workspaceSlug } : {}),
|
||||
...(tabId ? { tabId } : {}),
|
||||
reportedAt: reportedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
|
||||
return value === "login" || value === "overlay" || value === "tab";
|
||||
}
|
||||
|
||||
function sanitizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
|
||||
}
|
||||
@@ -79,6 +79,19 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
|
||||
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
|
||||
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
|
||||
|
||||
### スキルインポートの競合
|
||||
|
||||
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
|
||||
|
||||
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## スクワッド
|
||||
|
||||
| コマンド | 用途 |
|
||||
|
||||
@@ -79,6 +79,19 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
|
||||
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
|
||||
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
|
||||
|
||||
### 스킬 가져오기 충돌
|
||||
|
||||
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
|
||||
|
||||
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 스쿼드
|
||||
|
||||
| 명령어 | 용도 |
|
||||
|
||||
@@ -79,6 +79,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
### Skill import conflicts
|
||||
|
||||
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
|
||||
with the same name already exists, the command exits with a structured
|
||||
`conflict` result and does not change the workspace.
|
||||
|
||||
Use `--on-conflict overwrite` when you created the existing skill and want to
|
||||
replace its content while preserving its ID and agent bindings. Use
|
||||
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
|
||||
Use `--on-conflict skip` to leave the existing skill untouched and report
|
||||
`skipped`.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
@@ -79,6 +79,19 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
### Skill 导入冲突
|
||||
|
||||
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill,命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
|
||||
|
||||
如果你是已有 Skill 的 creator,并且想用新导入内容覆盖它,同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份,用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## イシューを参照する
|
||||
|
||||
別のイシューをリンクするには、`MUL-123` のようにそのイシューキーを入力してください。Multica はコメント内で実在するイシューキーを解決し、内部的に `mention://issue/<uuid>` リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
別のイシューをリンクするには、コメントの mention ピッカーからそのイシューを選択してください。Multica はイシューリンクを明示的な `[MUL-123](mention://issue/<uuid>)` mention リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
|
||||
通常は `[MUL-123](mention://issue/<uuid>)` を手で書く必要はありません。その形式は、Multica がキーを解決した後に使う標準的な内部表現です。
|
||||
`MUL-123` のような裸のイシューキーを入力しても、通常のテキストのまま残ります。そのため、`feature/MUL-123` のようなコメント内のブランチ名やパスも書き換えられません。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 이슈 참조하기
|
||||
|
||||
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
다른 이슈를 링크하려면 댓글 mention 선택기에서 해당 이슈를 선택하세요. Multica는 이슈 링크를 명시적인 `[MUL-123](mention://issue/<uuid>)` mention 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
|
||||
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
|
||||
`MUL-123` 같은 bare 이슈 키를 입력하면 일반 텍스트로 유지됩니다. 따라서 `feature/MUL-123` 같은 댓글 안의 브랜치 이름과 경로도 다시 작성되지 않습니다.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
|
||||
|
||||
@@ -39,9 +39,9 @@ Mentioning the same person multiple times in one comment still produces **only o
|
||||
|
||||
## Referencing issues
|
||||
|
||||
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
To link another issue, choose it from the comment mention picker. Multica stores issue links as an explicit `[MUL-123](mention://issue/<uuid>)` mention link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
|
||||
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
|
||||
Typing a bare issue key, such as `MUL-123`, keeps it as plain text. This also keeps branch names and paths, such as `feature/MUL-123`, from being rewritten inside comments.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 引用 issue
|
||||
|
||||
要链接另一个 issue,直接输入它的 issue key,例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key,并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
要链接另一个 issue,请在评论的 mention 选择器里选择它。Multica 会把 issue 链接存成显式的 `[MUL-123](mention://issue/<uuid>)` mention 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
|
||||
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式。
|
||||
直接输入裸 issue key,例如 `MUL-123`,会保持为普通文本。这样评论里的分支名和路径,例如 `feature/MUL-123`,也不会被改写。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要な場合は Claude Code か ACP 系列のいずれかを選んでください。
|
||||
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開し、古いまたは存在しない thread では新しい thread にフォールバックします。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ multica daemon restart
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
Cursor エディタに対応する CLI です。**セッション再開は動作しません** — Cursor の CLI がセッション id を返さないため、再開時に渡す値は常に無効です。
|
||||
Cursor エディタに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent は stream-json イベントで `session_id` を返し、Multica は次回実行時に `--resume <id>` でそれを渡します。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code 또는 ACP 계열 중 하나를 선택하세요.
|
||||
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개하며, 오래되었거나 없는 thread는 새 thread로 폴백합니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ multica daemon restart
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 작동하지 않습니다** — Cursor의 CLI가 세션 id를 반환하지 않으므로 재개 시 전달하는 값은 항상 유효하지 않습니다.
|
||||
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent는 stream-json 이벤트에서 `session_id`를 반환하고, Multica는 다음 실행 때 이를 `--resume <id>`로 전달합니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ The most complete integration. Session resumption works, MCP works, and it consu
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; stale or missing threads fall back to a fresh thread.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
|
||||
The CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: Multica reads `session_id` from the stream-json events and passes it back with `--resume <id>`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话续接可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;thread 过期或不存在时会回退到新 thread。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接可用**——当前 Cursor Agent 会在 stream-json 事件里返回 `session_id`,Multica 会在下一次运行时用 `--resume <id>` 传回去。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -14,16 +14,16 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
|
||||
| ツール | ベンダー | セッション再開 | MCP | スキル注入パス | モデル選択 |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | **✅(実際に使用する唯一のツール)** | `.claude/skills/` | 静的 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ コードは存在するが到達不可 | ❌ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静的 + flag |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
|
||||
| **Cursor** | Anysphere | ⚠️ コードは存在するが使用不可 | ❌ | `.cursor/skills/` | 動的探索 |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 動的探索 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静的 |
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/`(フォールバック) | 動的探索 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 動的探索 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 動的探索 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 動的探索 |
|
||||
| **OpenClaw** | オープンソース | ✅ | ❌ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | 動的探索 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 動的探索 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 動的探索 |
|
||||
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 動的探索 + variant |
|
||||
| **OpenClaw** | オープンソース | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Pi** | Inflection AI | ✅(セッションがファイルパス) | ❌ | `.pi/skills/` | 動的探索 |
|
||||
|
||||
## 各ツールの用途
|
||||
@@ -34,11 +34,11 @@ Google が提供します。CLI バイナリ名は `agy` です。Google の Ant
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、**11 個の中で MCP 構成を本当に読み取る唯一のツール**であり、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、MCP 構成を読み取り、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要なら、Claude Code または ACP 系のいずれかを選んでください。
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。MCP 構成はタスクごとの `$CODEX_HOME/config.toml` に書き込まれます。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開します。保存済み thread が見つからない、または古い場合は、新しい thread にフォールバックしてタスクを続行します。
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub が提供します。モデルルーティングは GitHub アカウン
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開のコードは存在しますが、実際には動作しません** — Cursor CLI のイベントストリームがセッション ID を返さないため、渡す再開値は常に無効です。再開が必要なら、別のものを選んでください。
|
||||
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent の stream-json イベントには `session_id` が含まれ、Multica は次回実行時に `--resume <id>` でそれを渡します。MCP 構成はタスクワークスペースの `.cursor/mcp.json` に書き込まれ、Cursor のプロジェクト approval ファイルはタスクごとの `CURSOR_DATA_DIR` 配下に置かれるため、管理対象 MCP server はユーザーのグローバル Cursor approvals に依存しません。
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -54,23 +54,23 @@ Google が提供し、Gemini 2.5 および 3 シリーズをサポートしま
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作します。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
|
||||
### Kimi
|
||||
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有しますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有し、MCP 構成も ACP `mcpServers` として渡されますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
|
||||
### Kiro CLI
|
||||
|
||||
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
|
||||
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
|
||||
|
||||
### OpenCode
|
||||
|
||||
SST が提供するオープンソースです。利用可能なモデルを動的に探索します(CLI の構成ファイルをスキャン)。セッション再開が動作します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
|
||||
SST が提供するオープンソースです。利用可能なモデルと model variant を動的に探索します(CLI の構成ファイルをスキャン)。セッション再開が動作し、エージェントの `mcp_config` フィールドを消費します。Multica は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン注入するため、エージェントの MCP server はタスク workdir の `opencode.json`(エージェントまたはユーザーが所有するファイル)を書き換えずに OpenCode に届きます。モデルが variant を公開している場合、Multica はそれをエージェントの thinking selector として表示し、選択値を `opencode run --variant` で OpenCode に渡します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
|
||||
|
||||
### OpenClaw
|
||||
|
||||
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。**モデルはエージェント層にバインドされます**(`openclaw agents add --model`) — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
|
||||
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。MCP 構成は Multica のタスクごとの config wrapper 経由で書き込まれます。**モデルはエージェント層にバインドされます**(`openclaw agents add --model`) — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
|
||||
|
||||
### Pi
|
||||
|
||||
@@ -82,18 +82,19 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
|
||||
| 状態 | ツール | 意味 |
|
||||
|---|---|---|
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ⚠️ コードは存在するが到達不可 | Codex, Cursor | コードに再開パスがありますが、実際には到達しません(Codex は静かにフォールバックし、Cursor はセッション id を返しません) — **未サポートとみなしてください** |
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ❌ なし | Gemini | CLI に再開メカニズムがありません |
|
||||
|
||||
**意思決定のために**: ワークフローでエージェントがタスク間でコンテキストを保持する必要がある場合(失敗時のリトライ、手動の再実行、対話的な反復)、✅ の行にあるツールだけを選んでください。
|
||||
|
||||
## MCP 構成: Claude Code だけが実際に読み取る
|
||||
## MCP 構成: ツールごとの対応
|
||||
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは Claude Code だけです**。残りの 11 個はこのフィールドを受け取りますが、**完全に無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
|
||||
接続方式はツールごとに異なります: Claude Code は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `mcp_servers` ブロックをタスクごとの `$CODEX_HOME/config.toml` に書き込み、Cursor は `.cursor/mcp.json` とタスクごとの `CURSOR_DATA_DIR` 配下のプロジェクト approval を書き込みます。Hermes、Kimi、Kiro CLI は ACP `mcpServers` で受け取ります。OpenCode は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン構成を受け取り、OpenClaw は Multica のタスクごとの config wrapper 経由で `mcp.servers` を受け取ります。OpenCode の経路はプロジェクトの `opencode.json` を書き換えません。
|
||||
|
||||
<Callout type="warning">
|
||||
エージェント構成で `mcp_config` を設定しても、Claude Code 以外のツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。現在、MCP 連携は Claude Code のみをカバーしています。
|
||||
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
|
||||
</Callout>
|
||||
|
||||
## スキルファイルが置かれる場所
|
||||
|
||||
@@ -15,9 +15,9 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ 코드는 존재하지만 도달 불가 | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
|
||||
| **Cursor** | Anysphere | ⚠️ 코드는 존재하지만 사용 불가 | ❌ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 정적 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | 동적 탐색 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 동적 탐색 |
|
||||
@@ -38,7 +38,7 @@ Anthropic에서 제공합니다. **신규 사용자에게 첫 번째 선택지**
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code나 ACP 계열 중 하나를 선택하세요.
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개합니다. 저장된 thread가 없거나 오래된 경우에는 새 thread로 폴백해 작업을 계속 실행합니다.
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub에서 제공합니다. 모델 라우팅은 GitHub 계정 권한을 거칩
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개 코드는 존재하지만 실제로는 동작하지 않습니다** — Cursor CLI 이벤트 스트림이 세션 ID를 반환하지 않으므로, 전달하는 재개 값은 항상 무효입니다. 재개가 필요하다면 다른 것을 선택하세요.
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent의 stream-json 이벤트에는 `session_id`가 포함되며, Multica는 다음 실행 때 이를 `--resume <id>`로 다시 전달합니다. MCP 구성은 작업 워크스페이스의 `.cursor/mcp.json`에 기록되고, Cursor의 프로젝트 approval 파일은 작업별 `CURSOR_DATA_DIR` 아래에 기록되므로, 관리되는 MCP 서버는 사용자의 전역 Cursor approval에 의존하지 않습니다.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,17 +82,16 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
|
||||
|
||||
| 상태 | 도구 | 의미 |
|
||||
|---|---|---|
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ⚠️ 코드는 존재하지만 도달 불가 | Codex, Cursor | 코드에 재개 경로가 있지만 실제로는 도달하지 않습니다(Codex는 조용히 폴백하고, Cursor는 세션 id를 반환하지 않습니다) — **미지원으로 간주하세요** |
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ❌ 없음 | Gemini | CLI에 재개 메커니즘이 없습니다 |
|
||||
|
||||
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
|
||||
|
||||
## MCP 구성: 도구별 지원
|
||||
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 7개입니다: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 5개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
|
||||
<Callout type="warning">
|
||||
에이전트 구성에서 `mcp_config`를 설정했더라도 MCP 열에 ✅가 없는 도구를 선택하면, MCP 서버가 해당 에이전트에 **아무런 효과**도 미치지 않습니다. MCP 연동은 도구별로 구현됩니다.
|
||||
|
||||
@@ -15,9 +15,9 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
|
||||
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
|
||||
| **Cursor** | Anysphere | ⚠️ Code exists but unusable | ❌ | `.cursor/skills/` | Dynamic discovery |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | Dynamic discovery |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | Dynamic discovery |
|
||||
@@ -38,7 +38,7 @@ From Anthropic. **First choice for new users** — the most complete feature set
|
||||
|
||||
### Codex
|
||||
|
||||
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — if you need resume, pick Claude Code or one of the ACP family.
|
||||
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; if the saved thread is missing or stale, Multica falls back to a fresh thread so the task can still run.
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ From GitHub. Model routing goes through your GitHub account entitlement — the
|
||||
|
||||
### Cursor
|
||||
|
||||
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption code exists but doesn't actually work** — the Cursor CLI event stream doesn't return a session ID, so any resume value you pass is always invalid. If you need resume, pick something else.
|
||||
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: the stream-json event includes a `session_id`, and Multica passes it back with `--resume <id>` on the next run. MCP config is materialized into the task workspace's `.cursor/mcp.json`, with Cursor's project approval file written under a per-task `CURSOR_DATA_DIR` so managed MCP servers do not depend on the user's global Cursor approvals.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,17 +82,16 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
| Status | Tools | Meaning |
|
||||
|---|---|---|
|
||||
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
|
||||
| ✅ Really works | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ❌ None | Gemini | The CLI has no resume mechanism |
|
||||
|
||||
**For your decision**: if your workflow needs agents to preserve context across tasks (failure retries, manual reruns, conversational iteration), pick only from the ✅ row.
|
||||
|
||||
## MCP configuration: provider-specific support
|
||||
|
||||
**Of the 12 tools, seven consume `mcp_config`: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other five accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 12 tools, eight consume `mcp_config`: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other four accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
|
||||
<Callout type="warning">
|
||||
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
|
||||
|
||||
@@ -15,9 +15,9 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅(`--conversation <id>`)| ❌ | `.agents/skills/` | 动态发现(`agy models`)|
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
|
||||
| **Cursor** | Anysphere | ⚠️ 代码存在但不可用 | ❌ | `.cursor/skills/` | 动态发现 |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 动态发现 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 动态发现 |
|
||||
@@ -38,7 +38,7 @@ Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复代码存在但当前不可达**——如果你需要 resume,选 Claude Code 或 ACP 系列。
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;如果已保存的 thread 不存在或过期,会回退到新 thread,让任务继续执行。
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub 出品。模型路由走你的 GitHub 账号权益——工具自己不
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复代码存在但实际不工作**——Cursor CLI 的事件流里不回传 session ID,所以你传的 resume 值永远无效。如果要 resume,选别的。
|
||||
Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复可用**——当前 Cursor Agent 的 stream-json 事件会返回 `session_id`,Multica 会在下一次运行时通过 `--resume <id>` 传回去。MCP 配置会写入任务工作区的 `.cursor/mcp.json`,Cursor 的项目 approval 文件写在单次任务的 `CURSOR_DATA_DIR` 下,因此托管的 MCP server 不依赖用户全局 Cursor approvals。
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,17 +82,16 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
| 状态 | 工具 | 含义 |
|
||||
|---|---|---|
|
||||
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
|
||||
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ❌ 无 | Gemini | CLI 无 resume 机制 |
|
||||
|
||||
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
|
||||
|
||||
## MCP 配置:按工具不同
|
||||
|
||||
**12 款工具里有 7 款实际消费 `mcp_config`:Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 5 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
**12 款工具里有 8 款实际消费 `mcp_config`:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`;Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
|
||||
<Callout type="warning">
|
||||
如果你在智能体配置里设置了 `mcp_config`,但选了矩阵 MCP 列没有标 ✅ 的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的。
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
Multica は 2 つのスキルソースに対応しています。
|
||||
|
||||
- **ワークスペーススキル** — Multica のクラウドに保存されます。エージェントに取り付けると、タスク実行時にあなたのデーモンへ同期されます。これが**チーム全体でスキルを共有する標準的な方法**です。
|
||||
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。
|
||||
- **ローカルスキル** — あなたのマシン上のディレクトリに存在します。あなたが要求すると、[デーモン](/daemon-runtimes)がマシンをスキャンし、どれをワークスペースに取り込むかを手動で選びます。デーモンは**2 つのルートを優先順位順に**確認します。まずランタイム自身のスキルディレクトリ(各 AI コーディングツールには慣例的なデフォルトパスがあります。例: Claude Code の `~/.claude/skills/`)、次にツール横断の汎用ディレクトリ `~/.agents/skills/`(Codex や Gemini CLI などのエコシステムが共有する場所)です。同じスキル名が両方に存在する場合は**プロバイダー専用ディレクトリが優先**されるため、汎用ルートは*追加の*スキルを表示するだけで、既存スキルの解決結果を変えることはありません。
|
||||
|
||||
ほとんどの場合は**ワークスペーススキル**が望ましいでしょう。一度インポートすれば、すべてのチームメイトのエージェントが使えるからです。ローカルスキルは、まずローカルでテストしたい場合や、内容に機密性の高いローカル資料が含まれる場合に適しています。
|
||||
|
||||
@@ -54,7 +54,7 @@ GitHub や ClawHub からインポートしたスキルには、スクリプト
|
||||
- **スキル** = 構造化された**ナレッジパック**(静的なコンテンツ + 指示)。エージェントはスキルを読んで「問題 X を見たら、こう考えてこう行動する」を学びます。
|
||||
- **MCP**(Model Context Protocol)= **ツールチャネル**。エージェントは MCP を使って外部サービス(データベース、ファイルシステム、サードパーティ API)に接続し、それらを**呼び出します**。
|
||||
|
||||
この 2 つは相互補完的です。現在の Multica では、MCP のサポートを**実際に使うのは Claude Code だけ**です — 他のツールは MCP 設定を受け取りはしますが、実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
|
||||
この 2 つは相互補完的です。現在の Multica では、MCP サポートは**ツールごとに実装されています**: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw は `mcp_config` を使用し、他のツールはこのフィールドを受け取っても実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
Multica는 두 가지 스킬 소스를 지원합니다.
|
||||
|
||||
- **워크스페이스 스킬** — Multica 클라우드에 저장됩니다. 에이전트에 연결되면 작업 실행 시점에 여러분의 데몬으로 동기화됩니다. 이것이 **팀 전체에서 스킬을 공유하는 표준 방식**입니다.
|
||||
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`). 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다.
|
||||
- **로컬 스킬** — 여러분의 기기에 있는 디렉터리에 존재합니다. 여러분이 요청하면 [데몬](/daemon-runtimes)이 기기를 스캔하고, 어떤 스킬을 워크스페이스로 가져올지 직접 고릅니다. 데몬은 **두 개의 루트를 우선순위 순서로** 확인합니다. 먼저 런타임 자체의 스킬 디렉터리(각 AI 코딩 도구마다 관례적인 기본 경로가 있습니다. 예: Claude Code의 `~/.claude/skills/`), 그다음 도구 간 공용 디렉터리 `~/.agents/skills/`(Codex, Gemini CLI 등 생태계가 공유하는 위치)입니다. 동일한 스킬 이름이 양쪽에 모두 있으면 **프로바이더 전용 디렉터리가 우선**하므로, 공용 루트는 *추가* 스킬만 노출할 뿐 기존 스킬의 해석 결과를 절대 바꾸지 않습니다.
|
||||
|
||||
대부분의 경우 **워크스페이스 스킬**을 원하게 됩니다. 한 번만 가져오면 모든 팀원의 에이전트가 사용할 수 있기 때문입니다. 로컬 스킬은 먼저 로컬에서 테스트하고 싶거나, 콘텐츠에 민감한 로컬 자료가 포함된 경우에 적합합니다.
|
||||
|
||||
@@ -54,7 +54,7 @@ GitHub나 ClawHub에서 가져온 스킬에는 스크립트와 실행 가능한
|
||||
- **스킬** = 구조화된 **지식 팩**(정적 콘텐츠 + 지침). 에이전트는 스킬을 읽어 "문제 X를 만나면 이렇게 생각하고 이렇게 처리하라"를 학습합니다.
|
||||
- **MCP**(Model Context Protocol) = **도구 채널**. 에이전트는 MCP를 사용해 외부 서비스(데이터베이스, 파일 시스템, 서드파티 API)에 연결하고 이를 **호출**합니다.
|
||||
|
||||
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
|
||||
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ A Skill is a **knowledge pack** for an [agent](/agents) — a `SKILL.md` plus op
|
||||
Multica supports two skill sources:
|
||||
|
||||
- **Workspace skill** — stored in Multica's cloud. Once attached to an agent, it's synced down to your daemon at task execution time. This is the **standard way to share skills across a team**.
|
||||
- **Local skill** — lives in a directory on your machine (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`). On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace.
|
||||
- **Local skill** — lives in a directory on your machine. On your request, the [daemon](/daemon-runtimes) scans your machine, and you manually pick which ones to bring into the workspace. The daemon checks **two roots, in priority order**: first the runtime's own skill directory (each AI coding tool has a conventional default path, e.g. Claude Code's `~/.claude/skills/`), then the cross-tool universal directory `~/.agents/skills/` — a shared location used by ecosystems like Codex and Gemini CLI. When the same skill name exists in both, the **provider-specific directory wins**, so the universal root only ever surfaces *additional* skills and never changes what an existing skill resolves to.
|
||||
|
||||
Most of the time you want **workspace skills**: import once, every teammate's agent can use it. Local skills are a fit when you want to test locally first, or when the content involves sensitive local material.
|
||||
|
||||
@@ -54,7 +54,7 @@ Both augment what an agent can do, but in different directions:
|
||||
- **Skill** = a structured **knowledge pack** (static content + instructions). The agent reads a skill to learn "when I see problem X, here's how to think and what to do."
|
||||
- **MCP** (Model Context Protocol) = a **tool channel**. The agent uses MCP to connect to external services (databases, filesystems, third-party APIs) and **invoke** them.
|
||||
|
||||
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
|
||||
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Skill 是给 [智能体](/agents) 的**专业知识包**——一个 `SKILL.md`
|
||||
Multica 支持两种 Skill 来源:
|
||||
|
||||
- **工作区 Skill(workspace skill)** —— 存在 Multica 云端。挂到智能体后,任务执行时自动同步到你本机的守护进程。这是**团队共享 Skill 的标准方式**。
|
||||
- **本机 Skill(local skill)** —— 直接存在你本机的某个目录里(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`)。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。
|
||||
- **本机 Skill(local skill)** —— 直接存在你本机的某个目录里。[守护进程](/daemon-runtimes) 按你的请求扫描本机,发现后由你手动选入工作区。守护进程会**按优先级扫描两个根目录**:先扫该运行时自己的 skill 目录(每款 AI 编程工具有约定的默认路径,比如 Claude Code 的 `~/.claude/skills/`),再扫跨工具通用目录 `~/.agents/skills/`——这是 Codex、Gemini CLI 等生态共用的位置。当同名 skill 在两处都存在时,**provider 专用目录优先**,所以通用根目录只会*额外*带出新的 skill,绝不会改变已有 skill 的解析结果。
|
||||
|
||||
大多数情况用**工作区 Skill**:导入一次,团队所有成员的智能体都能用。本机 Skill 适合先在本地测试、或涉及敏感本地内容的场景。
|
||||
|
||||
@@ -54,7 +54,7 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
|
||||
- **Skill** = 结构化的**知识包**(静态内容 + 指令)。智能体读 Skill 来学"遇到 X 类问题该怎么想、怎么做"。
|
||||
- **MCP**(Model Context Protocol)= **工具通道**。智能体通过 MCP 连外部服务(数据库、文件系统、第三方 API)并**调用**它们。
|
||||
|
||||
两者可以同时用。目前 Multica 的 MCP 支持是**按工具实现**的:Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw 会消费 `mcp_config`;其他工具会接收到这个字段但不会实际用。MCP 的专题会在后续版本展开。
|
||||
两者可以同时用。目前 Multica 的 MCP 支持是**按工具实现**的:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw 会消费 `mcp_config`;其他工具会接收到这个字段但不会实际用。MCP 的专题会在后续版本展开。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -105,8 +105,7 @@ Multica はタスク中にセッション ID を**2 回**固定します: 開始
|
||||
|
||||
ただし、**実際にどの AI コーディングツールがこれをサポートするか**は大きく異なります。
|
||||
|
||||
- ✅ **実際にサポート** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **コードはあるが使用不可** — Codex, Cursor
|
||||
- ✅ **実際にサポート** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ❌ **サポートなし** — Gemini
|
||||
|
||||
[プロバイダー対応表 → セッション再開](/providers#session-resumption-who-really-supports-it)を参照してください。
|
||||
|
||||
@@ -105,8 +105,7 @@ Multica는 작업 중에 세션 ID를 **두 번** 고정합니다: 시작 시
|
||||
|
||||
하지만 **실제로 어떤 AI 코딩 도구가 이를 지원하는지**는 크게 다릅니다:
|
||||
|
||||
- ✅ **실제 지원** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **코드는 있지만 사용 불가** — Codex, Cursor
|
||||
- ✅ **실제 지원** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ❌ **지원 안 함** — Gemini
|
||||
|
||||
[제공자 매트릭스 → 세션 재개](/providers#session-resumption-who-really-supports-it)를 참고하세요.
|
||||
|
||||
@@ -105,8 +105,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **Code exists but unusable** — Codex, Cursor
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ❌ **No support** — Gemini
|
||||
|
||||
See [Providers Matrix → Session resumption](/providers#session-resumption-who-really-supports-it).
|
||||
|
||||
@@ -107,8 +107,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ❌ **不支持**——Gemini
|
||||
|
||||
详见 [Providers Matrix → 会话恢复](/providers#会话恢复谁真的支持)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 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
|
||||
* (completed_at desc, status rank as tiebreaker). Empty
|
||||
* sections hide entirely.
|
||||
*
|
||||
* Both entry points (the in-card AgentActivityRow and the Stack-header
|
||||
@@ -58,9 +58,9 @@ export default function IssueRunsRoute() {
|
||||
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 ?? "");
|
||||
const timeDiff = (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
|
||||
});
|
||||
}, [allTasks]);
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ const PRIORITY_LABEL: Record<IssuePriority, string> = {
|
||||
// Mirrors useTypeLabels in packages/views/inbox/components/inbox-detail-label.tsx
|
||||
const TYPE_LABEL: Record<InboxItemType, string> = {
|
||||
issue_assigned: "Assigned",
|
||||
issue_subscribed: "Subscribed",
|
||||
unassigned: "Unassigned",
|
||||
assignee_changed: "Reassigned",
|
||||
status_changed: "Status changed",
|
||||
|
||||
@@ -366,5 +366,6 @@ export function commentToTimelineEntry(comment: Comment): TimelineEntry {
|
||||
resolved_at: comment.resolved_at,
|
||||
resolved_by_type: comment.resolved_by_type,
|
||||
resolved_by_id: comment.resolved_by_id,
|
||||
source_task_id: comment.source_task_id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export const AttachmentSchema: z.ZodType<Attachment> = z.object({
|
||||
filename: z.string(),
|
||||
url: z.string(),
|
||||
download_url: z.string().default(""),
|
||||
markdown_url: z.string().default(""),
|
||||
content_type: z.string().default(""),
|
||||
size_bytes: z.number().default(0),
|
||||
created_at: z.string().default(""),
|
||||
@@ -88,6 +89,7 @@ export const CommentSchema = z.object({
|
||||
resolved_at: z.string().nullable().default(null),
|
||||
resolved_by_type: z.string().nullable().default(null),
|
||||
resolved_by_id: z.string().nullable().default(null),
|
||||
source_task_id: z.string().nullable().optional(),
|
||||
}).loose() as unknown as z.ZodType<Comment>;
|
||||
|
||||
export const EMPTY_COMMENT: Comment = {
|
||||
@@ -306,6 +308,7 @@ export const TaskMessagePayloadSchema: z.ZodType<TaskMessagePayload> = z.object(
|
||||
content: z.string().optional(),
|
||||
input: z.record(z.string(), z.unknown()).optional(),
|
||||
output: z.string().optional(),
|
||||
created_at: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
export const TaskMessageListSchema = z.array(TaskMessagePayloadSchema).default([]);
|
||||
|
||||
54
apps/mobile/lib/inbox-display.test.ts
Normal file
54
apps/mobile/lib/inbox-display.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { deduplicateInboxItems } from "./inbox-display";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
id: "inbox-1",
|
||||
workspace_id: "workspace-1",
|
||||
recipient_type: "member",
|
||||
recipient_id: "member-1",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
type: "new_comment",
|
||||
severity: "info",
|
||||
issue_id: "issue-1",
|
||||
title: "Issue title",
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deduplicateInboxItems", () => {
|
||||
it("keeps the newest issue row while preserving an older comment anchor", () => {
|
||||
const merged = deduplicateInboxItems([
|
||||
item({
|
||||
id: "comment-notification",
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: { comment_id: "comment-1" },
|
||||
}),
|
||||
item({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
created_at: "2026-06-15T08:01:00Z",
|
||||
details: { from: "in_progress", to: "in_review" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
details: {
|
||||
from: "in_progress",
|
||||
to: "in_review",
|
||||
comment_id: "comment-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,9 @@ export function getInboxDisplayTitle(item: InboxItem): string {
|
||||
* 2. Group by `issue_id` (fall back to `id` for items with no issue
|
||||
* attached — e.g. quick_create_failed).
|
||||
* 3. In each group, keep the newest by `created_at`.
|
||||
* 4. Sort the result newest-first.
|
||||
* 4. Preserve the newest grouped `comment_id` anchor when the newest row
|
||||
* is a later status/metadata event for the same issue.
|
||||
* 5. Sort the result newest-first.
|
||||
*/
|
||||
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
const active = items.filter((i) => !i.archived);
|
||||
@@ -79,7 +81,22 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (group[0]) merged.push(group[0]);
|
||||
const newest = group[0];
|
||||
if (!newest) continue;
|
||||
|
||||
const commentId =
|
||||
newest.details?.comment_id ??
|
||||
group.find((item) => item.details?.comment_id)?.details?.comment_id;
|
||||
|
||||
if (commentId && newest.details?.comment_id !== commentId) {
|
||||
merged.push({
|
||||
...newest,
|
||||
details: { ...(newest.details ?? {}), comment_id: commentId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(newest);
|
||||
}
|
||||
return merged.sort(
|
||||
(a, b) =>
|
||||
|
||||
@@ -125,11 +125,18 @@ function LoginPageContent() {
|
||||
router.push(await resolveLoggedInDestination(qc, onboarded, list));
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
// Build Google OAuth state: encode platform, next URL, and CLI callback
|
||||
// params so the callback can redirect to the right place after login.
|
||||
// CLI callback/state must survive the Google OAuth round-trip so the
|
||||
// post-login callback page can redirect the JWT back to the CLI's local
|
||||
// HTTP listener (critical for headless / WSL2 environments).
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl ? `next:${nextUrl}` : "",
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? `cli_callback:${encodeURIComponent(cliCallbackRaw)}`
|
||||
: "",
|
||||
cliState ? `cli_state:${encodeURIComponent(cliState)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
@@ -197,6 +197,104 @@ describe("CallbackPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to CLI callback with token when state contains valid cli_callback", async () => {
|
||||
const { api: mockedApi } = await import("@multica/core/api");
|
||||
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
mockSearchParams.set(
|
||||
"state",
|
||||
"cli_callback:http://127.0.0.1:46233/callback,cli_state:abc123",
|
||||
);
|
||||
mockGoogleLogin.mockResolvedValue({ token: "cli-jwt-token" });
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoogleLogin).toHaveBeenCalledWith(
|
||||
"test-code",
|
||||
expect.stringContaining("/auth/callback"),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:46233/callback?token=cli-jwt-token&state=abc123",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("falls through to normal web flow when state contains invalid cli_callback", async () => {
|
||||
mockSearchParams.set("state", "cli_callback:https://evil.com/callback");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
mockListMyInvitations.mockResolvedValue([]);
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Normal web flow: loginWithGoogle is called (not googleLogin)
|
||||
expect(mockLoginWithGoogle).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to CLI callback even when state also contains platform:desktop", async () => {
|
||||
// cli_callback takes precedence over platform:desktop — the CLI flow
|
||||
// is a specific user intent that should not be derailed by desktop flag.
|
||||
const { api: mockedApi } = await import("@multica/core/api");
|
||||
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
mockSearchParams.set(
|
||||
"state",
|
||||
"platform:desktop,cli_callback:http://localhost:12345/callback,cli_state:mystate",
|
||||
);
|
||||
mockGoogleLogin.mockResolvedValue({ token: "mixed-jwt" });
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoogleLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"http://localhost:12345/callback?token=mixed-jwt&state=mystate",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("onboarded users with missing source land in the workspace; the source-backfill modal is mounted there", async () => {
|
||||
// Source attribution backfill is now an in-workspace modal — see
|
||||
// `<SourceBackfillModal />` mounted inside `DashboardLayout`. The
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import { validateCliCallback, redirectToCliCallback } from "@multica/views/auth";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -46,9 +47,39 @@ function CallbackContent() {
|
||||
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
|
||||
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
|
||||
|
||||
// CLI callback params — carried across the Google OAuth round-trip so
|
||||
// headless/WSL2 `multica login` can receive the JWT after browser-based
|
||||
// Google auth completes.
|
||||
const cliCallbackPart = stateParts.find((p) => p.startsWith("cli_callback:"));
|
||||
const cliStatePart = stateParts.find((p) => p.startsWith("cli_state:"));
|
||||
const cliCallbackRaw = cliCallbackPart
|
||||
? decodeURIComponent(cliCallbackPart.slice("cli_callback:".length))
|
||||
: null;
|
||||
const cliState = cliStatePart
|
||||
? decodeURIComponent(cliStatePart.slice("cli_state:".length))
|
||||
: "";
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
if (isDesktop) {
|
||||
// Validate the CLI callback URL before redirecting — the state parameter
|
||||
// passes through Google OAuth and must be treated as attacker-controlled.
|
||||
const cliCallback =
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? cliCallbackRaw
|
||||
: null;
|
||||
|
||||
if (cliCallback) {
|
||||
// CLI login flow: exchange the Google code for a JWT, then redirect the
|
||||
// token back to the CLI's local HTTP listener (e.g. WSL2 host).
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
.then(({ token }) => {
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
} else if (isDesktop) {
|
||||
// Desktop flow: exchange code for token, then redirect via deep link
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
|
||||
58
apps/web/app/global-error.tsx
Normal file
58
apps/web/app/global-error.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { captureException } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for the web app. Next.js renders this (replacing
|
||||
* the root layout) when an error escapes everything below it — the full-page
|
||||
* white-screen case. React catches these before they reach window.onerror, so
|
||||
* posthog-js's automatic exception capture never sees them; we report them
|
||||
* explicitly here. Section-level failures are handled in place by
|
||||
* `@multica/ui` ErrorBoundary and don't reach this far.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
captureException(error, { source: "global-error", digest: error.digest });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body
|
||||
style={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</h1>
|
||||
<p style={{ marginTop: 8, color: "#666" }}>
|
||||
The page hit an unexpected error. Try reloading.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 16px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #ccc",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import { Inter, Geist_Mono, Source_Serif_4 } from "next/font/google";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
@@ -116,6 +117,24 @@ export default async function RootLayout({
|
||||
className={cn("antialiased font-sans h-full", inter.variable, geistMono.variable, sourceSerif.variable)}
|
||||
>
|
||||
<body className="h-full overflow-hidden">
|
||||
{/*
|
||||
react-grab: dev-only element inspector. Hold ⌘C (Mac) / Ctrl+C and click
|
||||
any element to copy its source path + line + component stack for pasting
|
||||
to an AI. Opt-in per developer: only loads when VITE_REACT_GRAB is set in
|
||||
a local, gitignored apps/web/.env.local — it never activates for anyone
|
||||
else. Both guards are read server-side, so the <Script> is omitted from
|
||||
the HTML entirely unless you opted in. The VITE_ prefix is shared with the
|
||||
desktop renderer (apps/desktop/src/renderer/src/main.tsx), where Vite only
|
||||
exposes VITE_-prefixed vars to client code, so one var name covers both
|
||||
apps. See https://www.react-grab.com/
|
||||
*/}
|
||||
{process.env.NODE_ENV === "development" && process.env.VITE_REACT_GRAB && (
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
crossOrigin="anonymous"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
)}
|
||||
<ThemeProvider>
|
||||
<WebProviders locale={locale} resources={resources}>
|
||||
{children}
|
||||
|
||||
@@ -5,7 +5,14 @@ import { MulticaIcon } from "@multica/ui/components/common/multica-icon";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { captureDownloadIntent } from "@multica/core/analytics";
|
||||
import { XMark, GitHubMark, githubUrl, twitterUrl } from "./shared";
|
||||
import {
|
||||
XMark,
|
||||
GitHubMark,
|
||||
DiscordMark,
|
||||
githubUrl,
|
||||
twitterUrl,
|
||||
discordUrl,
|
||||
} from "./shared";
|
||||
import { useLocale, locales, localeLabels } from "../i18n";
|
||||
|
||||
export function LandingFooter() {
|
||||
@@ -46,6 +53,15 @@ export function LandingFooter() {
|
||||
>
|
||||
<GitHubMark className="size-4" />
|
||||
</Link>
|
||||
<Link
|
||||
href={discordUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="Discord"
|
||||
className="text-white/40 transition-colors hover:text-white"
|
||||
>
|
||||
<DiscordMark className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export const githubUrl = "https://github.com/multica-ai/multica";
|
||||
export const twitterUrl = "https://x.com/MulticaAI";
|
||||
export const discordUrl = "https://discord.gg/W8gYBn226t";
|
||||
|
||||
export function GitHubMark({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -16,6 +17,19 @@ export function GitHubMark({ className }: { className?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function DiscordMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function XMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import { githubUrl, discordUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
@@ -244,6 +244,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
{ label: "Documentation", href: "/docs" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
{ label: "Discord", href: discordUrl },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
@@ -292,6 +293,190 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.27",
|
||||
date: "2026-06-19",
|
||||
title: "Smoother Desktop Sidebars and Cleaner Pins",
|
||||
changes: [],
|
||||
improvements: [
|
||||
"Desktop sidebars now open and close more smoothly while keeping navigation controls easy to reach",
|
||||
"Pinned Issues in the sidebar are easier to scan because labels focus on the Issue title",
|
||||
"Contributor guidance is shorter and clearer, making agent work easier to review before release",
|
||||
],
|
||||
fixes: [
|
||||
"Pinned Issues and projects now show a single active highlight instead of lighting up their parent navigation item too",
|
||||
"Issue and project detail sidebars no longer animate unexpectedly when entering a page or restoring a saved layout",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.25",
|
||||
date: "2026-06-18",
|
||||
title: "More Reliable Agent Work Across Skills, Autopilots, and Chat",
|
||||
changes: [],
|
||||
features: [
|
||||
"Local skill libraries on a developer machine can now be picked up automatically for agent runs",
|
||||
"Autopilots can include default subscribers so the right teammates are included when new Issues are created",
|
||||
"Chat attachments now stay tied to the current workspace and messages can continue sending without blocking the conversation",
|
||||
"Failed agent comments can be retried directly from the Issue timeline",
|
||||
],
|
||||
improvements: [
|
||||
"Usage reporting is more accurate when the same model name is available from different providers",
|
||||
"Older Codex usage records can be filled in for more complete usage history",
|
||||
"Runtime storage reporting is more complete across multiple workspace locations",
|
||||
"Background task guidance and release checks are stricter, helping catch risky changes earlier",
|
||||
],
|
||||
fixes: [
|
||||
"Issue mention chips in chat and comments now fit their container and no longer overlap nearby text",
|
||||
"Workspace links now use the correct deployment host more reliably",
|
||||
"Autopilot run folders are cleaned up after terminal runs finish",
|
||||
"Desktop builds now handle commit-based version names correctly",
|
||||
"Tencent CodeBuddy shows the correct provider logo",
|
||||
"Daemon claim responses are smaller and faster to transfer",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "Custom Runtimes",
|
||||
changes: [],
|
||||
features: [
|
||||
"Teams can create custom runtimes so agents use the right local tools and models",
|
||||
"CLI agent create and update now supports thinking level",
|
||||
],
|
||||
improvements: [
|
||||
"Runtime profiles sync faster and prefer the best match for the current environment",
|
||||
"Client error and freeze reports now group duplicates",
|
||||
"Issue trigger previews are easier to read",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 email delivery is more reliable",
|
||||
"GitHub installation context and pending CI display are more reliable",
|
||||
"Codex runs fail quickly when the app server exits",
|
||||
"Self-healing runtimes can be deleted again, and incompatible models are cleared on runtime switch",
|
||||
"Unknown Issue icons and plain filenames are handled safely",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue Date Filters and More Stable Agent Runs",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issues can now be filtered by created or updated date, with quick ranges and custom date selections",
|
||||
"Command line users can now delete runtimes with safer defaults and an explicit option for related data",
|
||||
"Lark connections can now use network proxies, helping teams in restricted network environments connect reliably",
|
||||
],
|
||||
improvements: [
|
||||
"Web and desktop failures are now easier to investigate with clearer reports for errors, freezes, and crashes",
|
||||
"Project rows, comment previews, and comment composers are more consistent and easier to use",
|
||||
],
|
||||
fixes: [
|
||||
"Reply and edit previews now show the right agents or squads before a comment is saved",
|
||||
"Plain Issue IDs in comments now stay as text unless they are intentionally linked",
|
||||
"Google sign-in from command line login now returns to the command line correctly after browser authentication",
|
||||
"Chat file uploads wait until an active agent is ready, avoiding failed uploads during loading",
|
||||
"Transcript actions remain visible on touch devices where hover is unavailable",
|
||||
"Agent instructions for posting comments now avoid shell formatting problems that could drop assignees, projects, or other fields",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "Faster Lists, Easier Runtime Setup, and Safer Issue Editing",
|
||||
changes: [],
|
||||
features: [
|
||||
"Agents, autopilots, projects, runtimes, skills, and squads now use a faster, more consistent list experience with clearer rows, filters, selections, and actions",
|
||||
"The command line can now manage workspace repositories, so local agents can pick up project repo context more easily",
|
||||
"Cursor and OpenClaw are easier to set up: Cursor connection settings can be managed for you, and OpenClaw can connect through an existing gateway",
|
||||
"When editing a comment, you can preview and control which agents or squads will run before saving",
|
||||
],
|
||||
improvements: [
|
||||
"Desktop recovery prompts now include more page context, making stuck-window reports easier to understand",
|
||||
"Long Issues and inbox views now keep their scroll position and comment anchors more reliably when you navigate away and return",
|
||||
"Cursor usage and billing details are clearer for Composer, cached inputs, and newer Cursor agent output",
|
||||
],
|
||||
fixes: [
|
||||
"Issue attachments, inline images, and file cards are more reliable across web, desktop, mobile, and shared token links",
|
||||
"The editor and read-only Issue content now handle dollar amounts and email links more predictably",
|
||||
"Desktop Cmd+W now closes the active tab first, then the window when no tab can be closed",
|
||||
"Self-hosted Docker Compose uploads and default settings fail less often, with missing values caught earlier",
|
||||
"Agent tasks now stop safely when their run credentials are invalid",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy can now run local Multica agents, with its available model and effort choices shown automatically",
|
||||
"Quick-created Issues now keep uploaded files attached from the first draft through the final Issue",
|
||||
],
|
||||
improvements: [
|
||||
"Skill import conflicts are clearer: locked skills show a person's name instead of an internal ID, and a single overwrite now completes in one click",
|
||||
"Desktop recovery prompts now explain what happened first and give clearer details to include when reporting a stuck window",
|
||||
"Views that sort or filter people by signup time can now load faster",
|
||||
],
|
||||
fixes: [
|
||||
"Chat now keeps messages and drafts in sync when sending, stopping, or recovering from a failed send",
|
||||
"Lark account binding now works reliably for users who are already signed in, and sign-in returns to the binding page",
|
||||
"Local agent runs no longer announce that work has started before the task folder is ready",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "Skill Imports, Cleaner Run History, and Resilient Agents",
|
||||
changes: [],
|
||||
features: [
|
||||
"Skill imports now let you choose what happens when a skill already exists: stop, replace it, save a renamed copy, or skip it",
|
||||
"Import results now clearly show which skills were added, updated, skipped, blocked by a conflict, or could not be imported",
|
||||
],
|
||||
improvements: [
|
||||
"Execution logs now show the newest past runs first on web and mobile, so recent progress is easier to scan",
|
||||
"Changelog content was cleaned up so the latest release notes stay grouped under the right release",
|
||||
],
|
||||
fixes: [
|
||||
"Issue thread replies now stay in the order they arrived, even when a slower agent reply lands later",
|
||||
"Agents can recover when a saved session disappears, starting fresh instead of failing again on every mention",
|
||||
"Reviving an Issue from a new workspace folder now starts a fresh session instead of retrying one that only existed in the old folder",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "Safer Comment Triggers, Reliable Agents, and Attachments",
|
||||
changes: [],
|
||||
features: [
|
||||
"Comment boxes now show which agents or squads will start work before you send, with controls to avoid accidental runs",
|
||||
"Run transcripts now include timestamps, making agent progress and handoffs easier to review",
|
||||
"Autopilot detail pages now show who created each autopilot",
|
||||
"Claude Fable 5 is now available in Multica's supported model and pricing list",
|
||||
"Issue conversations can now resolve a specific reply, making long threads easier to close while keeping the final answer visible",
|
||||
"Lark and Feishu conversations now show a typing reaction while Multica is preparing a reply, then clear it before the answer is sent",
|
||||
"Agent runs now know who started each task, making handoffs, audit trails, and privacy-aware behavior more accurate",
|
||||
"OpenClaw users can point Multica at a custom app location and data folder from their local configuration",
|
||||
],
|
||||
improvements: [
|
||||
"Comment trigger indicators are quieter, clearer, and less likely to crowd long agent names",
|
||||
"Desktop now disables daemon start and stop controls when the daemon is managed outside Multica, such as in WSL2",
|
||||
"The active agent indicator in an Issue header is easier to read, with motion only while work is running and clearer queued wording otherwise",
|
||||
"The CLI now gives clearer guidance around common errors, sign-in problems, and project setup values",
|
||||
],
|
||||
fixes: [
|
||||
"Inline images and files in Issue descriptions now stay visible across web and desktop after reloads",
|
||||
"Each Issue discussion thread now keeps only one resolved answer at a time, so replacing the conclusion is consistent for everyone",
|
||||
"Issue pages refresh their data after realtime reconnects, avoiding stale timelines after a connection drop",
|
||||
"Agent task initiator history now works more reliably for older task records",
|
||||
"Sticky Issue comments keep a cleaner visual edge while scrolling",
|
||||
"Newly posted attachments now use stable private download links, so images and files stay visible after temporary upload links expire",
|
||||
"Autopilot runs started from newly created Issues now fail cleanly when the assigned task cannot complete, instead of staying stuck",
|
||||
"Inbox deep links now scroll inside the Issue timeline without pushing the desktop window out of place",
|
||||
"Cursor and Codex sessions now end more cleanly after terminal results, preserving completion state and final telemetry",
|
||||
"Self-host setup now respects configured server URLs, and project creation returns clear validation errors instead of a generic failure",
|
||||
"A previous upload hardening change was rolled back after it conflicted with attachment behavior",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.18",
|
||||
date: "2026-06-08",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import { githubUrl, discordUrl } from "../components/shared";
|
||||
import { createEnDict } from "./en";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
@@ -244,6 +244,7 @@ export function createJaDict(allowSignup: boolean): LandingDict {
|
||||
{ label: "ドキュメント", href: "/docs/ja" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
{ label: "Discord", href: discordUrl },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
@@ -268,6 +269,190 @@ export function createJaDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "バグ修正",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.27",
|
||||
date: "2026-06-19",
|
||||
title: "デスクトップのサイドバーとピン表示を改善",
|
||||
changes: [],
|
||||
improvements: [
|
||||
"デスクトップのサイドバーがよりなめらかに開閉し、ナビゲーション操作もしやすくなりました。",
|
||||
"サイドバーに固定した Issue はタイトル中心の表示になり、見分けやすくなりました。",
|
||||
"コントリビューターとエージェント向けの作業ガイドが短く分かりやすくなり、リリース前の確認がしやすくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"固定した Issue やプロジェクトでは、親ナビゲーション項目まで同時に選択表示されなくなりました。",
|
||||
"Issue やプロジェクト詳細を開くとき、または保存済みレイアウトを復元するときに、詳細サイドバーが意図せず動かなくなりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.25",
|
||||
date: "2026-06-18",
|
||||
title: "スキル、オートパイロット、チャットでのエージェント作業をより信頼性高く",
|
||||
changes: [],
|
||||
features: [
|
||||
"開発者のマシンにあるローカルスキルライブラリを自動で見つけ、エージェント実行で使いやすくなりました。",
|
||||
"オートパイロットに既定の購読者を設定でき、新しい Issue の確認に必要なチームメイトを含めやすくなりました。",
|
||||
"チャット添付ファイルは現在のワークスペースに結び付き、送信中も会話を続けやすくなりました。",
|
||||
"失敗したエージェントコメントを Issue タイムラインから直接再試行できます。",
|
||||
],
|
||||
improvements: [
|
||||
"同じモデル名が複数のプロバイダーから提供される場合も、利用状況がより正確に集計されます。",
|
||||
"過去の Codex 利用状況を補完でき、利用履歴がより完全になります。",
|
||||
"複数のワークスペース場所にまたがるランタイムのストレージ使用量が分かりやすくなりました。",
|
||||
"バックグラウンドタスクの案内とリリース前チェックがより厳密になり、リスクのある変更を早く見つけやすくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"チャットとコメント内の Issue メンションチップが枠内に収まり、周辺テキストと重なりません。",
|
||||
"ワークスペースリンクが正しいデプロイ先ホストをより安定して使います。",
|
||||
"オートパイロット実行フォルダーは、ターミナル実行が終わると片付けられます。",
|
||||
"デスクトップビルドはコミット由来のバージョン名を正しく扱います。",
|
||||
"Tencent CodeBuddy に正しいプロバイダーロゴが表示されます。",
|
||||
"デーモンのタスク受け取り応答が小さくなり、転送が速くなりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "カスタムランタイム",
|
||||
changes: [],
|
||||
features: [
|
||||
"チームはカスタムランタイムで、エージェントに合うローカルツールとモデルを使えます。",
|
||||
"コマンドラインでエージェントを作成または更新するときに思考レベルを選べます。",
|
||||
],
|
||||
improvements: [
|
||||
"ランタイムプロファイルの同期が速くなり、現在の環境に合うものが優先されます。",
|
||||
"クライアントのエラーやフリーズ報告の重複が減りました。",
|
||||
"Issue コメントのトリガープレビューが読みやすくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 メールの代替送信がより安定しました。",
|
||||
"GitHub のインストール情報と CI 待機表示がより安定しました。",
|
||||
"Codex サービスが終了したときはすばやく失敗します。",
|
||||
"自己修復ランタイムを再び削除でき、合わないモデル選択は整理されます。",
|
||||
"不明な Issue アイコンと通常のファイル名リンクを安全に扱います。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue の日付フィルターとエージェント実行の安定性向上",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue を作成日または更新日で絞り込めるようになり、クイック期間やカスタム日付を選べます。",
|
||||
"コマンドラインからランタイムを削除できるようになり、既定の動作はより安全で、関連データの扱いも明示的に選べます。",
|
||||
"Lark 接続がネットワークプロキシを利用できるようになり、制限のあるネットワーク環境でも接続しやすくなりました。",
|
||||
],
|
||||
improvements: [
|
||||
"Web とデスクトップのエラー、フリーズ、クラッシュ報告が分かりやすくなり、原因調査がしやすくなりました。",
|
||||
"プロジェクト行、コメントのプレビュー、コメント作成まわりの操作がより一貫して使いやすくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"返信やコメント編集を保存する前に、どのエージェントやスクワッドが動き始めるかをより正確に確認できます。",
|
||||
"コメント内の通常の Issue 番号は、明示的にリンクしない限り通常のテキストのまま残ります。",
|
||||
"コマンドラインログインで Google ログインを使った場合も、ブラウザー認証後に正しくコマンドラインへ戻ります。",
|
||||
"チャットのファイルアップロードはアクティブなエージェントの準備ができてから有効になり、読み込み中の失敗を防ぎます。",
|
||||
"ホバーできないタッチ端末でも、実行履歴の操作ボタンが表示されます。",
|
||||
"エージェントがコメントを投稿するとき、担当者、プロジェクト、その他の項目がコマンド形式の問題で抜ける可能性が減りました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "より速いリスト体験、使いやすい実行設定、安全な Issue 編集",
|
||||
changes: [],
|
||||
features: [
|
||||
"エージェント、オートパイロット、プロジェクト、ランタイム、スキル、スクワッドのリストがより速く一貫した体験になり、行表示、絞り込み、選択、操作が分かりやすくなりました。",
|
||||
"コマンドラインからワークスペースのリポジトリを管理できるようになり、ローカルエージェントがプロジェクトのリポジトリ情報を受け取りやすくなりました。",
|
||||
"Cursor と OpenClaw の設定が簡単になりました。Cursor の接続設定は Multica に任せられ、OpenClaw は既存のゲートウェイにも接続できます。",
|
||||
"コメントを編集するとき、保存前にどのエージェントやスクワッドが動き始めるかをプレビューして制御できます。",
|
||||
],
|
||||
improvements: [
|
||||
"デスクトップの復旧案内にページの文脈が増え、固まったウィンドウを報告するときに状況を伝えやすくなりました。",
|
||||
"長い Issue と受信箱ビューでは、別の場所へ移動して戻ったときにスクロール位置やコメント位置がより安定して保たれます。",
|
||||
"Cursor の Composer、キャッシュ入力、新しい Cursor エージェント出力で、使用量と請求情報がより分かりやすく表示されます。",
|
||||
],
|
||||
fixes: [
|
||||
"Issue の添付ファイル、本文内画像、ファイルカードは、Web、デスクトップ、モバイル、トークン付き共有リンクでより安定して開けるようになりました。",
|
||||
"エディターと読み取り専用の Issue 内容で、ドル金額とメールリンクがより安定して扱われます。",
|
||||
"デスクトップの Cmd+W は、まず現在のタブを閉じ、閉じられるタブがない場合にウィンドウを閉じます。",
|
||||
"セルフホストの Docker Compose アップロードと既定設定は失敗しにくくなり、足りない設定値も早めに見つかります。",
|
||||
"実行に必要な認証情報が無効な場合、エージェントタスクは安全に停止するようになりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy でローカルの Multica エージェントを動かせるようになり、利用できるモデルと実行の強さが自動で表示されます。",
|
||||
"クイック作成した Issue では、下書きでアップロードしたファイルが最終的な Issue まで保持されます。",
|
||||
],
|
||||
improvements: [
|
||||
"スキル取り込みの競合が分かりやすくなり、ロックされたスキルには内部 ID ではなくメンバー名が表示され、単体の上書きも 1 クリックで完了します。",
|
||||
"デスクトップの復旧案内は、まず何が起きたかを説明し、固まったウィンドウを報告するときに含める情報も分かりやすくなりました。",
|
||||
"登録日時でメンバーを並べ替えたり絞り込んだりする画面が、より速く読み込まれるようになりました。",
|
||||
],
|
||||
fixes: [
|
||||
"チャットの送信、停止、送信失敗からの復旧時に、メッセージと下書きがより安定して同期されます。",
|
||||
"Lark アカウント連携は、すでにサインイン済みのユーザーでも安定して完了し、サインイン後も連携ページに戻ります。",
|
||||
"ローカルエージェントの実行は、タスクフォルダの準備が終わる前に開始済みとして表示されなくなりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "スキルのインポート、実行履歴、より安定したエージェント",
|
||||
changes: [],
|
||||
features: [
|
||||
"スキルのインポート時に同じスキルがすでにある場合、停止、置き換え、別名で保存、スキップを選べるようになりました。",
|
||||
"インポート結果では、追加、更新、スキップ、競合、失敗したスキルがわかりやすく表示されます。",
|
||||
],
|
||||
improvements: [
|
||||
"Web とモバイルの実行履歴は新しい過去実行を先に表示するため、最近の進捗を追いやすくなりました。",
|
||||
"変更履歴の内容を整理し、最新のリリースノートが正しいバージョンにまとまるようにしました。",
|
||||
],
|
||||
fixes: [
|
||||
"イシューの返信は到着した順番のまま表示され、遅れて届いたエージェント返信が途中に割り込まなくなりました。",
|
||||
"保存済みセッションが失われた場合でも、エージェントは新しく開始して復旧でき、以後のメンションで失敗し続けません。",
|
||||
"新しい作業フォルダーからイシューを再開すると、古いフォルダーにだけ存在したセッションではなく新しいセッションで始まります。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "より安全なコメントトリガー、安定したエージェントと添付ファイル",
|
||||
changes: [],
|
||||
features: [
|
||||
"コメント入力欄では、送信前にどのエージェントやスクワッドが動き始めるかを確認でき、誤って実行することを避けられます。",
|
||||
"実行記録に時刻が表示されるようになり、エージェントの進捗や引き継ぎを振り返りやすくなりました。",
|
||||
"オートパイロット詳細ページで、誰が作成したかを確認できるようになりました。",
|
||||
"Claude Fable 5 が Multica の対応モデルと料金一覧に加わりました。",
|
||||
"イシューの会話で特定の返信を解決として残せるようになり、長いスレッドを閉じても結論を確認しやすくなりました。",
|
||||
"Lark と Feishu の会話では、Multica が返信を準備している間に入力中のリアクションを表示し、返信前に自動で消します。",
|
||||
"エージェント実行は、誰がそのタスクを始めたかを把握できるようになり、引き継ぎ、監査、プライバシーに配慮した動作がより正確になります。",
|
||||
"OpenClaw ユーザーは、ローカル設定から独自のアプリ場所とデータフォルダーを指定できます。",
|
||||
],
|
||||
improvements: [
|
||||
"コメントトリガーの表示はより控えめで読みやすく、長いエージェント名でも混み合いにくくなりました。",
|
||||
"WSL2 など Multica の外でデーモンが管理されている場合、デスクトップは開始と停止の操作を無効にします。",
|
||||
"イシュー上部のアクティブなエージェント表示は、実行中だけ動き、待機中は待機状態を明確に示すため、読み取りやすくなりました。",
|
||||
"CLI は、よくあるエラー、サインインの問題、プロジェクト設定の値について、よりわかりやすく案内します。",
|
||||
],
|
||||
fixes: [
|
||||
"イシュー説明内の画像とファイルは、Web とデスクトップのどちらでも再読み込み後に表示され続けます。",
|
||||
"各イシュー会話スレッドは解決済みの回答を 1 つだけ保持するため、結論を置き換えたときの表示が全員でそろいます。",
|
||||
"リアルタイム接続が復帰したあと、イシュー画面はデータを更新し、古いタイムラインが残りにくくなりました。",
|
||||
"エージェントタスクの開始者履歴が、古いタスク記録でもより信頼できるようになりました。",
|
||||
"スクロール中の固定イシューコメントの境界がよりきれいに表示されます。",
|
||||
"新しく投稿された添付ファイルは安定した非公開ダウンロードリンクを使うため、一時的なアップロードリンクが期限切れになっても画像やファイルを表示できます。",
|
||||
"新規イシューから始まったオートパイロット実行は、割り当てられたタスクが完了できない場合に正しく失敗し、進行中のまま残りません。",
|
||||
"受信箱からコメントリンクを開いたとき、デスクトップ画面全体ではなくイシューのタイムラインだけがスクロールします。",
|
||||
"Cursor と Codex のセッションは最終結果後によりきれいに終了し、完了状態と最後のテレメトリーを保ちます。",
|
||||
"セルフホスト設定は指定済みのサーバー URL を尊重し、プロジェクト作成では汎用エラーではなく明確な検証エラーを返します。",
|
||||
"前回のアップロード強化は添付ファイル体験と衝突したためロールバックし、添付ファイルへのアクセスを安定させました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.18",
|
||||
date: "2026-06-08",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import { githubUrl, discordUrl } from "../components/shared";
|
||||
import { createEnDict } from "./en";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
@@ -243,6 +243,7 @@ export function createKoDict(allowSignup: boolean): LandingDict {
|
||||
{ label: "문서", href: "/docs/ko" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
{ label: "Discord", href: discordUrl },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
@@ -267,6 +268,190 @@ export function createKoDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "버그 수정",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.27",
|
||||
date: "2026-06-19",
|
||||
title: "더 부드러운 데스크톱 사이드바와 깔끔한 고정 항목",
|
||||
changes: [],
|
||||
improvements: [
|
||||
"데스크톱 사이드바가 더 부드럽게 열리고 닫히며, 자주 쓰는 탐색 컨트롤도 계속 쉽게 사용할 수 있습니다.",
|
||||
"사이드바에 고정한 Issue는 제목 중심으로 표시되어 더 빠르게 구분할 수 있습니다.",
|
||||
"기여자와 에이전트 작업 안내가 더 짧고 명확해져 릴리스 전 확인이 쉬워졌습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"고정한 Issue와 프로젝트에서 부모 탐색 항목까지 함께 선택 표시되는 문제가 사라졌습니다.",
|
||||
"Issue 또는 프로젝트 상세 화면에 들어가거나 저장된 레이아웃을 복원할 때 상세 사이드바가 의도치 않게 움직이지 않습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.25",
|
||||
date: "2026-06-18",
|
||||
title: "스킬, 오토파일럿, 채팅 전반의 에이전트 작업 안정성 강화",
|
||||
changes: [],
|
||||
features: [
|
||||
"개발자 기기의 로컬 스킬 라이브러리를 자동으로 찾아 에이전트 실행에서 더 쉽게 사용할 수 있습니다.",
|
||||
"오토파일럿에 기본 구독자를 설정할 수 있어 새 Issue를 만들 때 필요한 팀원을 함께 포함하기 쉽습니다.",
|
||||
"채팅 첨부 파일이 현재 워크스페이스에 연결되고, 메시지를 보내는 동안에도 대화를 계속하기 쉬워졌습니다.",
|
||||
"실패한 에이전트 댓글을 Issue 타임라인에서 바로 다시 시도할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"같은 모델 이름이 여러 제공자에서 제공될 때도 사용량 보고가 더 정확합니다.",
|
||||
"이전 Codex 사용 기록을 보완해 사용량 이력을 더 완전하게 만들 수 있습니다.",
|
||||
"여러 워크스페이스 위치에 걸친 런타임 저장 공간 사용량을 더 명확하게 보여 줍니다.",
|
||||
"백그라운드 작업 안내와 릴리스 전 검사가 더 엄격해져 위험한 변경을 더 일찍 찾을 수 있습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"채팅과 댓글의 Issue 멘션 칩이 영역 안에 맞게 표시되고 주변 텍스트와 겹치지 않습니다.",
|
||||
"워크스페이스 링크가 올바른 배포 호스트를 더 안정적으로 사용합니다.",
|
||||
"오토파일럿 실행 폴더는 터미널 실행이 끝난 뒤 정리됩니다.",
|
||||
"데스크톱 빌드는 커밋 기반 버전 이름을 올바르게 처리합니다.",
|
||||
"Tencent CodeBuddy에 올바른 제공자 로고가 표시됩니다.",
|
||||
"데몬의 작업 수신 응답이 더 작아져 전송이 빨라졌습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "사용자 지정 런타임",
|
||||
changes: [],
|
||||
features: [
|
||||
"팀은 사용자 지정 런타임으로 에이전트에 맞는 로컬 도구와 모델을 사용할 수 있습니다.",
|
||||
"명령줄에서 에이전트를 만들거나 업데이트할 때 사고 수준을 선택할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"런타임 프로필이 더 빠르게 동기화되고 현재 환경에 맞게 우선 적용됩니다.",
|
||||
"클라이언트 오류와 멈춤 보고의 중복이 줄었습니다.",
|
||||
"Issue 댓글 트리거 미리보기가 더 읽기 쉬워졌습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 메일의 대체 전송이 더 안정적입니다.",
|
||||
"GitHub 설치 맥락과 CI 대기 상태 표시가 더 안정적입니다.",
|
||||
"Codex 서비스가 종료되면 빠르게 실패합니다.",
|
||||
"셀프 힐링 런타임을 다시 삭제할 수 있고, 맞지 않는 모델 선택은 정리됩니다.",
|
||||
"알 수 없는 Issue 아이콘과 일반 파일 이름 링크 처리가 더 안전해졌습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue 날짜 필터와 더 안정적인 에이전트 실행",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue를 생성일 또는 수정일 기준으로 필터링할 수 있으며, 빠른 기간과 사용자 지정 날짜를 선택할 수 있습니다.",
|
||||
"명령줄에서 런타임을 삭제할 수 있고, 기본 동작은 더 안전하며 관련 데이터 처리도 명시적으로 선택할 수 있습니다.",
|
||||
"Lark 연결이 네트워크 프록시를 사용할 수 있어 제한된 네트워크 환경에서도 더 안정적으로 연결됩니다.",
|
||||
],
|
||||
improvements: [
|
||||
"웹과 데스크톱의 오류, 멈춤, 충돌 보고가 더 명확해져 문제를 조사하기 쉬워졌습니다.",
|
||||
"프로젝트 행, 댓글 미리보기, 댓글 작성기가 더 일관되고 사용하기 쉬워졌습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"답글과 댓글 편집을 저장하기 전에 어떤 에이전트나 스쿼드가 실행될지 더 정확하게 미리 보여줍니다.",
|
||||
"댓글의 일반 Issue 번호는 명시적으로 링크하지 않는 한 일반 텍스트로 유지됩니다.",
|
||||
"명령줄 로그인에서 Google 로그인을 사용해도 브라우저 인증 후 명령줄로 올바르게 돌아옵니다.",
|
||||
"채팅 파일 업로드는 활성 에이전트가 준비된 뒤에만 열려 로딩 중 실패를 줄입니다.",
|
||||
"호버가 없는 터치 기기에서도 실행 기록 작업 버튼이 계속 보입니다.",
|
||||
"에이전트가 댓글을 게시할 때 담당자, 프로젝트, 기타 필드가 명령 형식 문제로 누락될 가능성이 줄었습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "더 빠른 목록 경험, 쉬운 실행 설정, 안전한 Issue 편집",
|
||||
changes: [],
|
||||
features: [
|
||||
"에이전트, 오토파일럿, 프로젝트, 런타임, 스킬, 스쿼드의 목록이 더 빠르고 일관된 경험으로 바뀌어 행, 필터, 선택, 작업이 더 명확해졌습니다.",
|
||||
"명령줄에서 워크스페이스 저장소를 관리할 수 있어 로컬 에이전트가 프로젝트 저장소 정보를 더 쉽게 가져올 수 있습니다.",
|
||||
"Cursor와 OpenClaw 설정이 더 쉬워졌습니다. Cursor 연결 설정은 Multica가 관리할 수 있고, OpenClaw는 기존 게이트웨이에 연결할 수 있습니다.",
|
||||
"댓글을 편집할 때 저장하기 전에 어떤 에이전트나 스쿼드가 실행될지 미리 보고 제어할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"데스크톱 복구 안내에 페이지 맥락이 더 많이 포함되어 멈춘 창의 상황을 설명하기 쉬워졌습니다.",
|
||||
"긴 Issue와 받은함 보기에서 다른 곳으로 이동했다가 돌아와도 스크롤 위치와 댓글 위치가 더 안정적으로 유지됩니다.",
|
||||
"Cursor의 Composer, 캐시 입력, 새로운 Cursor 에이전트 출력에서 사용량과 청구 정보가 더 명확하게 표시됩니다.",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 첨부 파일, 본문 이미지, 파일 카드가 웹, 데스크톱, 모바일, 토큰 공유 링크에서 더 안정적으로 열립니다.",
|
||||
"편집기와 읽기 전용 Issue 내용에서 달러 금액과 이메일 링크가 더 안정적으로 처리됩니다.",
|
||||
"데스크톱 Cmd+W는 먼저 활성 탭을 닫고, 닫을 탭이 없을 때 창을 닫습니다.",
|
||||
"셀프 호스트 Docker Compose 업로드와 기본 설정이 덜 실패하고, 빠진 설정값을 더 일찍 확인합니다.",
|
||||
"실행에 필요한 인증 정보가 유효하지 않으면 에이전트 작업이 안전하게 중단됩니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy로 로컬 Multica 에이전트를 실행할 수 있으며, 사용할 수 있는 모델과 실행 강도 선택지가 자동으로 표시됩니다.",
|
||||
"빠르게 만든 Issue에서도 초안에서 올린 파일이 최종 Issue까지 함께 유지됩니다.",
|
||||
],
|
||||
improvements: [
|
||||
"스킬 가져오기 충돌이 더 이해하기 쉬워졌습니다. 잠긴 스킬은 내부 ID 대신 멤버 이름을 보여주고, 단일 덮어쓰기도 한 번의 클릭으로 끝납니다.",
|
||||
"데스크톱 복구 안내가 먼저 무슨 일이 있었는지 설명하고, 멈춘 창을 신고할 때 포함할 정보를 더 명확하게 보여줍니다.",
|
||||
"가입 시간으로 멤버를 정렬하거나 필터링하는 화면이 더 빠르게 로드될 수 있습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"채팅을 보내거나 중지하거나 전송 실패에서 복구할 때 메시지와 초안이 더 안정적으로 동기화됩니다.",
|
||||
"Lark 계정 연결은 이미 로그인한 사용자에게도 안정적으로 완료되며, 로그인 후에도 연결 페이지로 돌아옵니다.",
|
||||
"로컬 에이전트 실행은 작업 폴더가 준비되기 전에 시작된 것으로 표시되지 않습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "스킬 가져오기, 실행 기록, 더 안정적인 에이전트",
|
||||
changes: [],
|
||||
features: [
|
||||
"스킬을 가져올 때 같은 스킬이 이미 있으면 중단, 교체, 이름을 바꿔 저장, 건너뛰기 중에서 선택할 수 있습니다.",
|
||||
"가져오기 결과에서 추가, 업데이트, 건너뜀, 충돌, 실패한 스킬을 더 명확하게 확인할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"웹과 모바일 실행 기록은 최신 과거 실행을 먼저 보여 주어 최근 진행 상황을 더 쉽게 확인할 수 있습니다.",
|
||||
"변경 로그 콘텐츠를 정리해 최신 릴리스 노트가 올바른 버전에 묶이도록 했습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"이슈 스레드 답글은 도착한 순서대로 표시되어, 늦게 도착한 에이전트 답글이 중간에 끼어들지 않습니다.",
|
||||
"저장된 세션이 사라져도 에이전트가 새로 시작해 복구할 수 있어, 이후 멘션마다 계속 실패하지 않습니다.",
|
||||
"새 작업 폴더에서 이슈를 다시 시작할 때 이전 폴더에만 있던 세션을 재시도하지 않고 새 세션으로 시작합니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "더 안전한 댓글 트리거, 안정적인 에이전트와 첨부 파일",
|
||||
changes: [],
|
||||
features: [
|
||||
"댓글 입력창에서 보내기 전에 어떤 에이전트나 스쿼드가 작업을 시작할지 확인하고, 실수로 실행되는 일을 줄일 수 있습니다.",
|
||||
"실행 기록에 시간이 표시되어 에이전트 진행 상황과 인계를 더 쉽게 검토할 수 있습니다.",
|
||||
"오토파일럿 상세 페이지에서 누가 만들었는지 확인할 수 있습니다.",
|
||||
"Claude Fable 5가 Multica의 지원 모델과 가격 목록에 추가되었습니다.",
|
||||
"이슈 대화에서 특정 답글을 해결 답변으로 남길 수 있어, 긴 스레드를 접어도 결론을 더 쉽게 확인할 수 있습니다.",
|
||||
"Lark와 Feishu 대화는 Multica가 답변을 준비하는 동안 입력 중 반응을 표시하고, 답변을 보내기 전에 자동으로 지웁니다.",
|
||||
"에이전트 실행은 각 작업을 누가 시작했는지 알 수 있어 인계, 감사, 개인정보를 고려한 동작이 더 정확해집니다.",
|
||||
"OpenClaw 사용자는 로컬 설정에서 사용자 지정 앱 위치와 데이터 폴더를 지정할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"댓글 트리거 표시가 더 조용하고 명확해졌으며, 긴 에이전트 이름도 덜 비좁게 보입니다.",
|
||||
"WSL2처럼 Multica 밖에서 데몬을 관리하는 경우 데스크톱은 시작과 중지 조작을 비활성화합니다.",
|
||||
"이슈 헤더의 활성 에이전트 표시가 더 읽기 쉬워졌으며, 실제 실행 중일 때만 움직이고 대기 중일 때는 대기 상태를 명확히 보여 줍니다.",
|
||||
"CLI는 흔한 오류, 로그인 문제, 프로젝트 설정 값에 대해 더 명확하게 안내합니다.",
|
||||
],
|
||||
fixes: [
|
||||
"이슈 설명의 이미지와 파일은 웹과 데스크톱에서 다시 열어도 계속 표시됩니다.",
|
||||
"각 이슈 대화 스레드는 해결 답변을 하나만 유지해 결론을 바꿀 때 모두에게 일관되게 보입니다.",
|
||||
"실시간 연결이 복구된 뒤 이슈 화면이 데이터를 새로고침해 오래된 타임라인이 남지 않습니다.",
|
||||
"에이전트 작업을 시작한 사람의 기록이 오래된 작업에서도 더 안정적으로 유지됩니다.",
|
||||
"스크롤 중 고정된 이슈 댓글의 가장자리가 더 깔끔하게 보입니다.",
|
||||
"새로 올린 첨부 파일은 안정적인 비공개 다운로드 링크를 사용해 임시 업로드 링크가 만료된 뒤에도 이미지와 파일이 계속 표시됩니다.",
|
||||
"새 이슈에서 시작된 오토파일럿 실행은 배정된 작업이 완료되지 못하면 올바르게 실패 처리되어 진행 중에 멈춰 있지 않습니다.",
|
||||
"받은함에서 댓글 링크를 열 때 데스크톱 화면 전체가 밀리지 않고 이슈 타임라인 안에서만 스크롤됩니다.",
|
||||
"Cursor와 Codex 세션은 최종 결과 후 더 깔끔하게 종료되어 완료 상태와 마지막 텔레메트리를 보존합니다.",
|
||||
"셀프호스트 설정은 지정된 서버 URL을 따르며, 프로젝트 생성은 일반 실패 대신 명확한 검증 오류를 반환합니다.",
|
||||
"이전 업로드 강화 변경은 첨부 파일 동작과 충돌해 롤백했으며, 첨부 파일 접근을 안정적으로 유지했습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.18",
|
||||
date: "2026-06-08",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { githubUrl } from "../components/shared";
|
||||
import { githubUrl, discordUrl } from "../components/shared";
|
||||
import type { LandingDict } from "./types";
|
||||
|
||||
export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
@@ -244,6 +244,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
{ label: "\u6587\u6863", href: "/docs/zh" },
|
||||
{ label: "API", href: githubUrl },
|
||||
{ label: "X (Twitter)", href: "https://x.com/MulticaAI" },
|
||||
{ label: "Discord", href: discordUrl },
|
||||
],
|
||||
},
|
||||
company: {
|
||||
@@ -292,6 +293,190 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.27",
|
||||
date: "2026-06-19",
|
||||
title: "桌面侧边栏更顺滑,固定项更清爽",
|
||||
changes: [],
|
||||
improvements: [
|
||||
"桌面端侧边栏展开和收起更顺滑,同时保留常用导航控制,操作更连贯",
|
||||
"侧边栏固定的 Issue 现在突出显示 Issue 标题,更容易快速识别",
|
||||
"贡献者和智能体工作指引更简洁,发版前更容易审查和确认",
|
||||
],
|
||||
fixes: [
|
||||
"固定的 Issue 和项目现在只显示一个选中状态,不会同时点亮父级导航项",
|
||||
"进入 Issue 或项目详情页、恢复已保存布局时,详情侧边栏不会再意外播放动画",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.25",
|
||||
date: "2026-06-18",
|
||||
title: "让技能、自动任务和聊天中的智能体工作更可靠",
|
||||
changes: [],
|
||||
features: [
|
||||
"开发者机器上的本地技能库现在可以被自动识别,智能体运行时更容易复用团队能力",
|
||||
"自动任务可以配置默认订阅人,新建 Issue 时更容易把相关队友带入确认",
|
||||
"聊天附件会绑定到当前工作空间,发送消息时也不会阻塞后续对话",
|
||||
"智能体评论发送失败后,可以直接在 Issue 时间线里重试",
|
||||
],
|
||||
improvements: [
|
||||
"同名模型来自不同服务商时,使用量统计会更准确",
|
||||
"历史 Codex 使用量可以补齐,用量记录更完整",
|
||||
"运行时存储统计会覆盖更多工作目录,空间占用更清楚",
|
||||
"后台任务指引和发版检查更严格,可以更早发现高风险改动",
|
||||
],
|
||||
fixes: [
|
||||
"聊天和评论里的 Issue 提及标签会适配容器宽度,不再和周围文字重叠",
|
||||
"工作空间链接会更稳定地使用正确的部署域名",
|
||||
"自动任务运行结束后,会清理对应的运行目录",
|
||||
"桌面端可以正确处理基于提交版本的版本号",
|
||||
"Tencent CodeBuddy 会显示正确的服务商图标",
|
||||
"守护进程领取任务的响应更小,传输更快",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "自定义运行时",
|
||||
changes: [],
|
||||
features: [
|
||||
"团队可以创建自定义运行时,让智能体按环境使用合适的本地工具和模型",
|
||||
"命令行创建和更新智能体时可以选择思考强度",
|
||||
],
|
||||
improvements: [
|
||||
"运行时配置会更快同步到应用,并优先匹配当前环境",
|
||||
"客户端错误和卡顿反馈会合并重复信息",
|
||||
"Issue 评论触发预览文案更清楚",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 邮件的备用发送方式更稳定",
|
||||
"GitHub 安装上下文和 CI 等待状态显示更可靠",
|
||||
"Codex 服务退出时会快速失败",
|
||||
"自修复运行时可再次删除,切换运行时时会清理不兼容模型",
|
||||
"未知 Issue 图标和普通文件名链接识别更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue 日期筛选和提高智能体运行稳定性",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 现在可以按创建时间或更新时间筛选,支持快捷时间范围和自定义日期",
|
||||
"命令行现在可以删除运行环境,默认行为更安全,也可以明确选择是否连带处理相关数据",
|
||||
"Lark 连接现在可以使用网络代理,受限网络环境下的团队也能更稳定地连接",
|
||||
],
|
||||
improvements: [
|
||||
"网页端和桌面端的错误、卡顿和崩溃现在更容易定位,问题反馈会带上更清楚的信息",
|
||||
"项目列表行、评论预览和评论编辑器体验更一致,导航和附件操作更顺手",
|
||||
],
|
||||
fixes: [
|
||||
"回复和编辑评论前,现在会更准确地预览哪些智能体或小队会开始运行",
|
||||
"评论里的普通 Issue 编号会保持为普通文字,只有明确插入链接时才会变成链接",
|
||||
"通过命令行登录并选择 Google 登录时,浏览器认证完成后现在会正确回到命令行",
|
||||
"聊天上传文件会等到当前智能体准备好后再开放,避免加载过程中上传失败",
|
||||
"触屏设备上不需要悬停也能看到运行记录里的操作按钮",
|
||||
"智能体发布评论的指令更稳,不容易因为命令格式问题漏掉指派人、项目或其他字段",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "更快的列表体验、更顺手的运行配置和更安全的 Issue 编辑",
|
||||
changes: [],
|
||||
features: [
|
||||
"智能体、自动任务、项目、运行环境、技能和小队的列表体验更快也更一致,行内容、筛选、选择和操作都更清楚",
|
||||
"命令行现在可以管理工作区仓库,本地智能体更容易拿到项目仓库上下文",
|
||||
"Cursor 和 OpenClaw 更容易配置:Cursor 连接设置可以由 Multica 托管,OpenClaw 也可以连接已有网关",
|
||||
"编辑评论时,可以在保存前预览并控制哪些智能体或小队会开始运行",
|
||||
],
|
||||
improvements: [
|
||||
"桌面端恢复提示会带上更多页面上下文,反馈卡住窗口时更容易说清发生位置",
|
||||
"长 Issue 和收件箱视图在离开后返回时,会更稳定地保留滚动位置和评论锚点",
|
||||
"Cursor 的 Composer、缓存输入和新版 Cursor 智能体输出会展示更清楚的用量和计费信息",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 附件、正文图片和文件卡片在网页端、桌面端、移动端以及令牌分享链接里更稳定可用",
|
||||
"编辑器和只读 Issue 内容会更稳定地处理美元金额和邮箱链接",
|
||||
"桌面端 Cmd+W 现在会先关闭当前标签页,无法关闭标签页时再关闭窗口",
|
||||
"自托管 Docker Compose 上传和默认配置更少失败,缺失的配置值也会更早暴露",
|
||||
"智能体任务遇到无效运行凭证时,会安全停止而不是继续执行",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy 现在可以驱动本地 Multica 智能体,并会自动显示可用的模型和投入强度选项",
|
||||
"快速创建 Issue 时上传的文件现在会从草稿一直带到最终创建的 Issue 里",
|
||||
],
|
||||
improvements: [
|
||||
"技能导入冲突更容易理解:锁定的技能会显示成员名称,不再显示内部 ID;单个覆盖也可以一键完成",
|
||||
"桌面端恢复提示会先说明发生了什么,并给出更清楚的窗口卡住反馈信息",
|
||||
"按注册时间排序或筛选成员的页面现在加载更快",
|
||||
],
|
||||
fixes: [
|
||||
"聊天在发送、停止或发送失败恢复时,会更稳定地同步消息和草稿",
|
||||
"Lark 账号绑定现在对已登录用户也能稳定完成,登录后也会回到绑定页面",
|
||||
"本地智能体运行不会再在任务文件夹准备好之前就显示已经开始",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "技能导入、运行记录和更稳定的智能体",
|
||||
changes: [],
|
||||
features: [
|
||||
"导入技能时,如果同名技能已存在,现在可以选择停止、替换、另存为新名称或跳过",
|
||||
"导入结果会清楚显示哪些技能已新增、已更新、已跳过、发生冲突或导入失败",
|
||||
],
|
||||
improvements: [
|
||||
"网页端和移动端的执行记录现在会优先显示最新的历史运行,更容易看清最近进展",
|
||||
"更新日志内容已整理,最新发布内容会归在正确的版本下",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 讨论里的回复现在会按到达顺序显示,即使较慢的智能体回复稍后才出现,也不会插到前面",
|
||||
"当已保存的会话失效时,智能体可以自动重新开始,不会在后续每次提及时反复失败",
|
||||
"从新的工作目录重新唤起 Issue 时,现在会开始新会话,不会继续尝试只存在于旧目录里的会话",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "更安全的评论触发、更稳定的智能体和附件",
|
||||
changes: [],
|
||||
features: [
|
||||
"评论输入框现在会在发送前显示哪些智能体或小队会开始工作,也可以避免误触发运行",
|
||||
"智能体运行记录现在会显示时间点,回看进度和交接信息更清楚",
|
||||
"自动任务详情页现在会显示创建人",
|
||||
"Claude Fable 5 现在已加入 Multica 支持的模型和价格列表",
|
||||
"Issue 讨论可以把某一条回复设为解决结论,长讨论收起后也能直接看到最终答案",
|
||||
"在 Lark 和飞书里和 Multica 对话时,会显示等待中的输入状态,回复发出后自动清除",
|
||||
"每次智能体任务都会带上真实发起人信息,交接、审计和权限判断更准确",
|
||||
"OpenClaw 可以从本地配置中读取自定义程序位置和数据目录",
|
||||
],
|
||||
improvements: [
|
||||
"评论触发提示更安静、更清楚,遇到较长的智能体名称时也不容易拥挤",
|
||||
"桌面端在守护进程由 Multica 之外的环境管理时,会禁用启动和停止控制,例如 WSL2 场景",
|
||||
"Issue 顶部的智能体状态更容易区分:运行中才显示动效,等待中会明确显示排队状态",
|
||||
"命令行会直接说明常见错误、登录问题和项目配置问题的处理方式",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 描述里的图片和文件在网页端和桌面端重新打开后都会保持可见",
|
||||
"每个 Issue 讨论线程现在只会保留一个解决结论,替换结论时所有人看到的状态更一致",
|
||||
"实时连接断开并恢复后,Issue 页面会刷新数据,避免时间线停留在旧状态",
|
||||
"智能体任务的发起人历史在较早任务记录上也会更可靠",
|
||||
"滚动时置顶的 Issue 评论边缘显示更干净",
|
||||
"新上传的附件会使用稳定的私有下载链接,临时上传链接过期后图片和文件仍能正常显示",
|
||||
"自动任务通过新建 Issue 启动后,如果对应的智能体任务失败,会同步标记为失败,不会一直卡在进行中",
|
||||
"从收件箱打开评论链接时,只会滚动 Issue 时间线,不会把桌面窗口内容顶出可见区域",
|
||||
"Cursor 和 Codex 会话在收到最终结果后会正常收尾,并保留完成状态和最后的遥测信息",
|
||||
"自托管设置会遵循已配置的服务地址,创建项目时也会返回清楚的校验错误,而不是笼统失败",
|
||||
"上一轮上传加固改动因影响附件体验已回滚,附件访问保持稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.18",
|
||||
date: "2026-06-08",
|
||||
|
||||
@@ -11,6 +11,11 @@ if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
// jsdom doesn't implement elementFromPoint; input-otp uses it internally.
|
||||
if (typeof document.elementFromPoint !== "function") {
|
||||
document.elementFromPoint = () => null;
|
||||
}
|
||||
|
||||
// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
|
||||
// Create a proper localStorage mock if methods are missing.
|
||||
if (
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# docker compose -f docker-compose.selfhost.yml up -d
|
||||
#
|
||||
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
|
||||
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}
|
||||
# Backend: http://localhost:${BACKEND_PORT:-8080}
|
||||
|
||||
name: multica
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
|
||||
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
environment:
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
PORT: "8080"
|
||||
METRICS_ADDR: ${METRICS_ADDR:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-3000}}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
@@ -65,10 +65,12 @@ services:
|
||||
SMTP_EHLO_NAME: ${SMTP_EHLO_NAME:-}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:${FRONTEND_PORT:-3000}/auth/callback}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_REGION: ${S3_REGION:-us-west-2}
|
||||
AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
|
||||
ATTACHMENT_DOWNLOAD_MODE: ${ATTACHMENT_DOWNLOAD_MODE:-auto}
|
||||
ATTACHMENT_DOWNLOAD_URL_TTL: ${ATTACHMENT_DOWNLOAD_URL_TTL:-30m}
|
||||
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
|
||||
@@ -77,7 +79,7 @@ services:
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:${FRONTEND_PORT:-3000}}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
|
||||
91
docs/codex-usage-cache-backfill.md
Normal file
91
docs/codex-usage-cache-backfill.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Codex Usage Cache Backfill
|
||||
|
||||
This runbook describes the one-time hosted data repair for Codex usage rows
|
||||
created before cached input was normalized at ingestion time.
|
||||
|
||||
Do not run this as an automatic database migration. The write step needs an
|
||||
operator-selected cutoff, dry-run review, and an explicit execute command.
|
||||
|
||||
## When To Run
|
||||
|
||||
Run this only after the backend image containing `backfill_codex_usage_cache`
|
||||
has been deployed, and only for databases that need historical Codex usage
|
||||
correction.
|
||||
|
||||
Use the actual hosted deployment time of PR #4083 as `--cutoff`. Do not use the
|
||||
PR merge time unless it is also the real production cutover time.
|
||||
|
||||
## Execution Model
|
||||
|
||||
Run the command from the released backend image as a one-time operator job, such
|
||||
as a Kubernetes Job with the normal backend database secret and network access.
|
||||
Override the command to execute `./backfill_codex_usage_cache`.
|
||||
|
||||
The command defaults to dry-run. It mutates data only when `--execute` is passed.
|
||||
|
||||
## Dry Run
|
||||
|
||||
First run:
|
||||
|
||||
```bash
|
||||
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME>
|
||||
```
|
||||
|
||||
Optionally limit scope while validating:
|
||||
|
||||
```bash
|
||||
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --workspace-id <workspace-uuid>
|
||||
```
|
||||
|
||||
Review the per-workspace/date output:
|
||||
|
||||
- `rows`
|
||||
- `input_before`
|
||||
- `input_after`
|
||||
- `input_tokens_removed`
|
||||
- `clamped_rows`
|
||||
|
||||
Proceed only if the totals match the expected overcount shape.
|
||||
|
||||
## Execute
|
||||
|
||||
After dry-run review:
|
||||
|
||||
```bash
|
||||
./backfill_codex_usage_cache --cutoff <RFC3339_DEPLOY_TIME> --execute
|
||||
```
|
||||
|
||||
For large datasets, throttle writes:
|
||||
|
||||
```bash
|
||||
./backfill_codex_usage_cache \
|
||||
--cutoff <RFC3339_DEPLOY_TIME> \
|
||||
--execute \
|
||||
--batch-size 500 \
|
||||
--sleep-between-batches 1s
|
||||
```
|
||||
|
||||
By default, execution rebuilds affected hourly rollups by calling
|
||||
`rollup_task_usage_hourly_window(...)` for the database update window. Leave
|
||||
`--rebuild-rollup=true` unless an operator intentionally plans a separate rollup
|
||||
rebuild.
|
||||
|
||||
## Verification
|
||||
|
||||
After execution, run the dry-run command again with the same cutoff and scope.
|
||||
Eligible rows should be zero.
|
||||
|
||||
Then verify Usage / Runtime dashboard periods that were previously inflated.
|
||||
|
||||
## Safety Boundaries
|
||||
|
||||
The command updates only rows that match all of these conditions:
|
||||
|
||||
- `provider = 'codex'`
|
||||
- `cache_read_tokens > 0`
|
||||
- `input_tokens > 0`
|
||||
- `COALESCE(updated_at, created_at) < --cutoff`
|
||||
- optional `--workspace-id` match
|
||||
|
||||
Rows without persisted `cache_read_tokens` are intentionally ignored because the
|
||||
current database cannot accurately reconstruct cached input for them.
|
||||
@@ -534,10 +534,8 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
| Provider | 厂商 | Session Resume | MCP | Skill 注入路径 | custom_args | 备注 |
|
||||
- 每个 provider 一小段(80-150 字):核心定位 + 用户画像 + 官网链接 + Multica 兼容性
|
||||
- **Session resume 精确现状**:
|
||||
- ✅ 真用:Claude / Hermes / Kimi / OpenCode / Copilot
|
||||
- ⚠️ Codex:代码有 thread/resume 但 unreachable(future feature)
|
||||
- ❌ 不支持:Pi / Gemini / OpenClaw
|
||||
- ❓ 未审:Cursor
|
||||
- ✅ 真用:Antigravity / Claude / Codex / Copilot / Cursor / Hermes / Kimi / Kiro CLI / OpenCode / OpenClaw / Pi
|
||||
- ❌ 不支持:Gemini
|
||||
- **不写**: provider 官方使用文档(外链)、MCP 协议本身
|
||||
- **写前要验证**:
|
||||
- 认领者**必须逐个打开 `server/pkg/agent/*.go`** 确认
|
||||
@@ -546,7 +544,7 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
- **⚠️ 动笔前必读**:
|
||||
- ⚠️ 这是最容易过时的一页,provider 代码频繁变动
|
||||
- 精确到 "代码里这个 flag 传给这个 CLI" 级别,不模糊说"支持"
|
||||
- Codex "unreachable" 状态必须明确(不是承诺)
|
||||
- Codex fallback 语义必须明确:`thread/resume` 可用,但 stale / missing thread 会回退到 fresh thread
|
||||
- **Owner**: –
|
||||
|
||||
---
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
import { createTestApi, loginAsDefault, openWorkspaceMenu, waitForPageText } from "./helpers";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("login page renders correctly", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Sign in to Multica");
|
||||
|
||||
await expect(page.locator("h1")).toContainText("Multica");
|
||||
await expect(page.locator('input[placeholder="Email"]')).toBeVisible();
|
||||
await expect(page.locator('input[placeholder="Name"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toContainText(
|
||||
"Sign in",
|
||||
);
|
||||
await expect(page.getByText("Sign in to Multica")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("you@example.com")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("login and redirect to /issues", async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
const workspaceSlug = await loginAsDefault(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/issues/);
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/issues$`));
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated user is redirected to /login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("multica_token");
|
||||
});
|
||||
const api = await createTestApi();
|
||||
const [workspace] = await api.getWorkspaces();
|
||||
if (!workspace) {
|
||||
throw new Error("E2E workspace was not created");
|
||||
}
|
||||
|
||||
// Visit a workspace-scoped route; DashboardGuard should redirect to /login.
|
||||
// The slug here need not exist — the guard runs before workspace resolution
|
||||
// for unauthenticated users.
|
||||
await page.goto("/e2e-workspace/issues");
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
await page.goto(`/${workspace.slug}/issues`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForURL("**/login", { timeout: 10000, waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Sign in to Multica");
|
||||
});
|
||||
|
||||
test("logout redirects to /login", async ({ page }) => {
|
||||
@@ -39,10 +37,10 @@ test.describe("Authentication", () => {
|
||||
// Open the workspace dropdown menu
|
||||
await openWorkspaceMenu(page);
|
||||
|
||||
// Click Sign out
|
||||
await page.locator("text=Sign out").click();
|
||||
await page.getByRole("menuitem", { name: "Log out" }).click();
|
||||
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
await page.waitForURL("**/login", { timeout: 10000, waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Sign in to Multica");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe("Chat attachments", () => {
|
||||
|
||||
const userRow = await pgc.query(
|
||||
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
|
||||
["e2e@multica.ai"],
|
||||
[api.getEmail()],
|
||||
);
|
||||
if (userRow.rows.length === 0) throw new Error("e2e user missing");
|
||||
const userId = userRow.rows[0].id as string;
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createTestApi, loginAsDefault } from "./helpers";
|
||||
import { createTestApi, loginAsDefault, waitForPageText } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
test.describe("Comments", () => {
|
||||
let api: TestApiClient;
|
||||
let issueId: string;
|
||||
let issueTitle: string;
|
||||
let workspaceSlug: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi();
|
||||
await api.createIssue("E2E Comment Test " + Date.now());
|
||||
await loginAsDefault(page);
|
||||
issueTitle = "E2E Comment Test " + Date.now();
|
||||
const issue = await api.createIssue(issueTitle);
|
||||
issueId = issue.id;
|
||||
workspaceSlug = await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
if (api) {
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("can add a comment on an issue", async ({ page }) => {
|
||||
// Wait for issues to load and click first one. `*=` matches both legacy
|
||||
// `/issues/{id}` and URL-refactored `/{slug}/issues/{id}` hrefs.
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await page.goto(`/${workspaceSlug}/issues/${issueId}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, issueTitle);
|
||||
|
||||
// Wait for issue detail to load
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
|
||||
// Type a comment
|
||||
const commentText = "E2E comment " + Date.now();
|
||||
const commentInput = page.locator(
|
||||
'input[placeholder="Leave a comment..."]',
|
||||
);
|
||||
await commentInput.fill(commentText);
|
||||
const editor = page
|
||||
.locator('.ProseMirror[data-placeholder="Leave a comment..."], .ProseMirror:has([data-placeholder="Leave a comment..."])')
|
||||
.first();
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.click({ force: true });
|
||||
await editor.fill(commentText);
|
||||
|
||||
// Submit the comment
|
||||
await page.locator('form button[type="submit"]').last().click();
|
||||
await page.keyboard.press("ControlOrMeta+Enter");
|
||||
|
||||
// Comment should appear in the activity section
|
||||
await expect(page.locator(`text=${commentText}`)).toBeVisible({
|
||||
@@ -43,15 +47,18 @@ test.describe("Comments", () => {
|
||||
});
|
||||
|
||||
test("comment submit button is disabled when empty", async ({ page }) => {
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await page.goto(`/${workspaceSlug}/issues/${issueId}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, issueTitle);
|
||||
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
|
||||
// Submit button should be disabled when input is empty
|
||||
const submitBtn = page.locator('form button[type="submit"]').last();
|
||||
const editor = page
|
||||
.locator('.ProseMirror[data-placeholder="Leave a comment..."], .ProseMirror:has([data-placeholder="Leave a comment..."])')
|
||||
.first();
|
||||
await expect(editor).toBeVisible();
|
||||
const composer = editor.locator("xpath=ancestor::div[contains(@class, 'rounded-lg')][1]");
|
||||
const submitBtn = composer.locator("button:has(svg.lucide-arrow-up)").last();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export class TestApiClient {
|
||||
private token: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private email: string | null = null;
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
async login(email: string, name: string) {
|
||||
@@ -52,11 +53,14 @@ export class TestApiClient {
|
||||
throw new Error(`No verification code found for ${email}`);
|
||||
}
|
||||
|
||||
const configuredDevCode = process.env.MULTICA_DEV_VERIFICATION_CODE?.trim();
|
||||
const code = configuredDevCode || result.rows[0].code;
|
||||
|
||||
// Step 3: Verify code to get JWT
|
||||
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code: result.rows[0].code }),
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
throw new Error(`verify-code failed: ${verifyRes.status}`);
|
||||
@@ -64,6 +68,7 @@ export class TestApiClient {
|
||||
const data = await verifyRes.json();
|
||||
|
||||
this.token = data.token;
|
||||
this.email = email;
|
||||
|
||||
// Update user name if needed
|
||||
if (name && data.user?.name !== name) {
|
||||
@@ -110,6 +115,7 @@ export class TestApiClient {
|
||||
if (res.ok) {
|
||||
const created = (await res.json()) as TestWorkspace;
|
||||
this.workspaceId = created.id;
|
||||
this.workspaceSlug = created.slug;
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -117,12 +123,40 @@ export class TestApiClient {
|
||||
const created = refreshed.find((item) => item.slug === slug) ?? refreshed[0];
|
||||
if (created) {
|
||||
this.workspaceId = created.id;
|
||||
this.workspaceSlug = created.slug;
|
||||
return created;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to ensure workspace ${slug}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
async markUserOnboarded() {
|
||||
if (!this.email) {
|
||||
throw new Error("Cannot mark E2E user onboarded before login");
|
||||
}
|
||||
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`
|
||||
UPDATE "user"
|
||||
SET
|
||||
onboarded_at = COALESCE(onboarded_at, now()),
|
||||
onboarding_questionnaire = COALESCE(onboarding_questionnaire, '{}'::jsonb)
|
||||
|| '{"source":["friends_colleagues"],"source_other":null,"source_skipped":false}'::jsonb
|
||||
WHERE email = $1
|
||||
`,
|
||||
[this.email],
|
||||
);
|
||||
if (result.rowCount !== 1) {
|
||||
throw new Error(`Failed to mark E2E user onboarded: ${this.email}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async createIssue(title: string, opts?: Record<string, unknown>) {
|
||||
const res = await this.authedFetch("/api/issues", {
|
||||
method: "POST",
|
||||
@@ -153,6 +187,13 @@ export class TestApiClient {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
getEmail() {
|
||||
if (!this.email) {
|
||||
throw new Error("Test API client is not logged in");
|
||||
}
|
||||
return this.email;
|
||||
}
|
||||
|
||||
private async authedFetch(path: string, init?: RequestInit) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
|
||||
const DEFAULT_E2E_NAME = "E2E User";
|
||||
const DEFAULT_E2E_EMAIL = "e2e@multica.ai";
|
||||
const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
|
||||
const E2E_WORKER = process.env.TEST_PARALLEL_INDEX ?? process.env.TEST_WORKER_INDEX ?? "0";
|
||||
const E2E_RUN_ID = process.env.E2E_RUN_ID ?? `${Date.now().toString(36)}-${process.pid.toString(36)}`;
|
||||
const DEFAULT_E2E_EMAIL = `e2e-${E2E_WORKER}-${E2E_RUN_ID}@multica.ai`;
|
||||
const DEFAULT_E2E_WORKSPACE = `e2e-workspace-${E2E_WORKER}-${E2E_RUN_ID}`;
|
||||
|
||||
async function waitForIssuesPage(page: Page) {
|
||||
await waitForPageText(page, "New Issue");
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPageText(page: Page, text: string, timeout = 30000) {
|
||||
await page.waitForFunction(
|
||||
(expected) => document.body?.innerText.includes(expected),
|
||||
text,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadAppPage(page: Page) {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Issues");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the default E2E user and ensure the workspace exists first.
|
||||
@@ -16,17 +38,22 @@ export async function loginAsDefault(page: Page): Promise<string> {
|
||||
const api = new TestApiClient();
|
||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||
const workspace = await api.ensureWorkspace(
|
||||
"E2E Workspace",
|
||||
`E2E Workspace ${E2E_WORKER}`,
|
||||
DEFAULT_E2E_WORKSPACE,
|
||||
);
|
||||
await api.markUserOnboarded();
|
||||
|
||||
const token = api.getToken();
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
if (!token) {
|
||||
throw new Error("E2E login did not return an auth token");
|
||||
}
|
||||
|
||||
await page.addInitScript((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
localStorage.setItem("multica:chat:isOpen", "false");
|
||||
}, token);
|
||||
await page.goto(`/${workspace.slug}/issues`);
|
||||
await page.waitForURL("**/issues", { timeout: 10000 });
|
||||
await page.goto(`/${workspace.slug}/issues`, { waitUntil: "domcontentloaded" });
|
||||
await waitForIssuesPage(page);
|
||||
return workspace.slug;
|
||||
}
|
||||
|
||||
@@ -37,13 +64,27 @@ export async function loginAsDefault(page: Page): Promise<string> {
|
||||
export async function createTestApi(): Promise<TestApiClient> {
|
||||
const api = new TestApiClient();
|
||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
|
||||
await api.ensureWorkspace(`E2E Workspace ${E2E_WORKER}`, DEFAULT_E2E_WORKSPACE);
|
||||
await api.markUserOnboarded();
|
||||
return api;
|
||||
}
|
||||
|
||||
export async function preferManualCreateMode(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
"multica_create_mode",
|
||||
JSON.stringify({ state: { lastMode: "manual" }, version: 0 }),
|
||||
);
|
||||
});
|
||||
await reloadAppPage(page);
|
||||
await waitForIssuesPage(page);
|
||||
}
|
||||
|
||||
export async function openWorkspaceMenu(page: Page) {
|
||||
// Click the workspace switcher button (has ChevronDown icon)
|
||||
await page.locator("aside button").first().click();
|
||||
const workspaceButton = page.getByRole("button", { name: /E2E Workspace/ }).first();
|
||||
await expect(workspaceButton).toBeVisible({ timeout: 15000 });
|
||||
await workspaceButton.click();
|
||||
// Wait for dropdown to appear
|
||||
await page.locator('[class*="popover"]').waitFor({ state: "visible" });
|
||||
await expect(page.locator('[class*="popover"]')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import pg from "pg";
|
||||
import { loginAsDefault, createTestApi, preferManualCreateMode, reloadAppPage } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||
|
||||
async function setIssueTimestamps(
|
||||
issueId: string,
|
||||
timestamps: { createdAt: Date; updatedAt?: Date },
|
||||
) {
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE issue
|
||||
SET created_at = $2, updated_at = $3
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
issueId,
|
||||
timestamps.createdAt.toISOString(),
|
||||
(timestamps.updatedAt ?? timestamps.createdAt).toISOString(),
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Issues", () => {
|
||||
let api: TestApiClient;
|
||||
|
||||
@@ -18,7 +46,7 @@ test.describe("Issues", () => {
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await api.createIssue("E2E Board View " + Date.now());
|
||||
await page.reload();
|
||||
await reloadAppPage(page);
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
@@ -29,7 +57,7 @@ test.describe("Issues", () => {
|
||||
test("can switch from board to list view", async ({ page }) => {
|
||||
const title = "E2E List Switch " + Date.now();
|
||||
await api.createIssue(title);
|
||||
await page.reload();
|
||||
await reloadAppPage(page);
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
|
||||
// Switch to list view
|
||||
@@ -37,7 +65,77 @@ test.describe("Issues", () => {
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can filter issues by created and updated dates", async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const todayTitle = `E2E Date Today ${suffix}`;
|
||||
const oldTitle = `E2E Date Old ${suffix}`;
|
||||
const updatedTodayTitle = `E2E Date Updated Today ${suffix}`;
|
||||
await api.createIssue(todayTitle);
|
||||
const oldIssue = await api.createIssue(oldTitle);
|
||||
const updatedTodayIssue = await api.createIssue(updatedTodayTitle);
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 8);
|
||||
await setIssueTimestamps(oldIssue.id, { createdAt: oldDate });
|
||||
await setIssueTimestamps(updatedTodayIssue.id, {
|
||||
createdAt: oldDate,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await reloadAppPage(page);
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeVisible();
|
||||
await expect(page.getByText(updatedTodayTitle)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /filter/i }).click();
|
||||
await page.getByRole("menuitem", { name: /^Date\b/ }).hover();
|
||||
await page.getByRole("menuitem", { name: "Today" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /1 filter/i })).toBeVisible();
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
|
||||
await expect(page.getByText(updatedTodayTitle)).toBeHidden({ timeout: 10000 });
|
||||
|
||||
await page.getByRole("button", { name: /1 filter/i }).click();
|
||||
const dateFilterItem = page.getByRole("menuitem", { name: /^Date\b/ });
|
||||
await dateFilterItem.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const updatedDateField = page.getByRole("menuitemradio", { name: "Updated" });
|
||||
await expect(updatedDateField).toBeVisible();
|
||||
await updatedDateField.press("Enter");
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(updatedTodayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("can filter issues by custom created date", async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const todayTitle = `E2E Date Custom Today ${suffix}`;
|
||||
const oldTitle = `E2E Date Custom Old ${suffix}`;
|
||||
await api.createIssue(todayTitle);
|
||||
const oldIssue = await api.createIssue(oldTitle);
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 8);
|
||||
await setIssueTimestamps(oldIssue.id, { createdAt: oldDate });
|
||||
|
||||
await reloadAppPage(page);
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /filter/i }).click();
|
||||
await page.getByRole("menuitem", { name: /^Date\b/ }).hover();
|
||||
const customDateButton = page.getByRole("button", { name: "Custom date or range" });
|
||||
await expect(customDateButton).toBeVisible();
|
||||
await customDateButton.click();
|
||||
const todayDataDay = await page.evaluate(() => new Date().toLocaleDateString());
|
||||
await page.locator(`[data-day="${todayDataDay}"]`).click();
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await preferManualCreateMode(page);
|
||||
|
||||
const newIssueButton = page.getByRole("button", { name: "New Issue" });
|
||||
await expect(newIssueButton).toBeVisible();
|
||||
await newIssueButton.click();
|
||||
@@ -63,7 +161,7 @@ test.describe("Issues", () => {
|
||||
const issue = await api.createIssue("E2E Detail Test " + Date.now());
|
||||
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
await reloadAppPage(page);
|
||||
|
||||
// Navigate to the issue detail. Use a suffix match so the selector works
|
||||
// whether the href is legacy `/issues/{id}` or URL-refactored
|
||||
@@ -83,6 +181,8 @@ test.describe("Issues", () => {
|
||||
});
|
||||
|
||||
test("can dismiss issue creation", async ({ page }) => {
|
||||
await preferManualCreateMode(page);
|
||||
|
||||
await page.getByRole("button", { name: "New Issue" }).click();
|
||||
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
import { loginAsDefault, waitForPageText } from "./helpers";
|
||||
|
||||
const ROUTE_CHANGE_TIMEOUT = 30000;
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("sidebar navigation works", async ({ page }) => {
|
||||
// Click Inbox
|
||||
await page.locator("nav a", { hasText: "Inbox" }).click();
|
||||
await page.waitForURL("**/inbox");
|
||||
await expect(page).toHaveURL(/\/inbox/);
|
||||
await page.getByRole("link", { name: "Inbox" }).click();
|
||||
await expect(page).toHaveURL(/\/inbox/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Inbox");
|
||||
|
||||
// Click Agents
|
||||
await page.locator("nav a", { hasText: "Agents" }).click();
|
||||
await page.waitForURL("**/agents");
|
||||
await expect(page).toHaveURL(/\/agents/);
|
||||
await page.getByRole("link", { name: "Agents" }).click();
|
||||
await expect(page).toHaveURL(/\/agents/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Agents");
|
||||
|
||||
// Click Issues
|
||||
await page.locator("nav a", { hasText: "Issues" }).click();
|
||||
await page.waitForURL("**/issues");
|
||||
await expect(page).toHaveURL(/\/issues/);
|
||||
await page.getByRole("link", { name: "Issues", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/issues/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Issues");
|
||||
});
|
||||
|
||||
test("settings page loads via workspace menu", async ({ page }) => {
|
||||
// Settings is inside the workspace dropdown menu
|
||||
await openWorkspaceMenu(page);
|
||||
await page.locator("text=Settings").click();
|
||||
await page.waitForURL("**/settings");
|
||||
test("settings page loads via sidebar", async ({ page }) => {
|
||||
await page.getByRole("link", { name: "Settings", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Settings");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Workspace" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Members" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Members" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("agents page shows agent list", async ({ page }) => {
|
||||
await page.locator("nav a", { hasText: "Agents" }).click();
|
||||
await page.waitForURL("**/agents");
|
||||
await page.getByRole("link", { name: "Agents" }).click();
|
||||
await expect(page).toHaveURL(/\/agents/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Agents");
|
||||
|
||||
// Should show "Agents" heading
|
||||
await expect(page.locator("text=Agents").first()).toBeVisible();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
import { waitForPageText } from "./helpers";
|
||||
|
||||
// Smoke test for Onboarding V2: verifies the new per-question flow
|
||||
// renders and captures screenshots for review. Uses a unique email
|
||||
@@ -16,12 +17,11 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
await api.login(EMAIL, "OBv2 Tester");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
await page.addInitScript((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Continue on web");
|
||||
|
||||
// 1. Welcome screen
|
||||
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
|
||||
@@ -32,7 +32,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 2. Source step
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
|
||||
await expect(page.getByText(/Step 1 of \d+/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
@@ -42,7 +42,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await expect(page.getByText(/Step 2 of \d+/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
@@ -51,12 +51,12 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 4. Use case step
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
|
||||
await expect(page.getByText(/Step 3 of \d+/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
|
||||
|
||||
// Pick ship_code then Continue → workspace step.
|
||||
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
|
||||
await page.getByRole("checkbox", { name: /Ship code with AI agents/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 5. Workspace step (legacy)
|
||||
@@ -69,10 +69,9 @@ test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
|
||||
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.addInitScript((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Continue on web");
|
||||
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
@@ -97,10 +96,9 @@ test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context
|
||||
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.addInitScript((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "在 web 端继续");
|
||||
|
||||
await page.getByRole("button").first().click().catch(() => {});
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
import { loginAsDefault, waitForPageText } from "./helpers";
|
||||
|
||||
test.describe("Settings", () => {
|
||||
test("updating workspace name reflects in sidebar immediately", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAsDefault(page);
|
||||
const workspaceSlug = await loginAsDefault(page);
|
||||
|
||||
// Read the current workspace name from the sidebar
|
||||
const sidebarName = page.locator("aside button").first();
|
||||
const originalName = await sidebarName.innerText();
|
||||
const sidebarName = page.getByRole("button", { name: /E2E Workspace/ }).first();
|
||||
const originalName = (await sidebarName.innerText()).split("\n").pop()?.trim() ?? "E2E Workspace";
|
||||
|
||||
// Navigate to settings
|
||||
await openWorkspaceMenu(page);
|
||||
await page.locator("text=Settings").click();
|
||||
await page.waitForURL("**/settings");
|
||||
await page.goto(`/${workspaceSlug}/settings?tab=workspace`, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "General");
|
||||
|
||||
// Change workspace name
|
||||
const nameInput = page
|
||||
@@ -27,16 +25,16 @@ test.describe("Settings", () => {
|
||||
// Save
|
||||
await page.locator("button", { hasText: "Save" }).click();
|
||||
|
||||
// Wait for "Saved!" confirmation
|
||||
await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Sidebar should reflect the new name WITHOUT page refresh
|
||||
await expect(sidebarName).toContainText(newName);
|
||||
await expect(page.getByRole("button", { name: new RegExp(newName) }).first()).toBeVisible();
|
||||
|
||||
// Restore original name so other tests aren't affected
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(originalName.trim());
|
||||
await page.locator("button", { hasText: "Save" }).click();
|
||||
await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("button", { name: new RegExp(originalName) }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./constants";
|
||||
export * from "./visibility-label";
|
||||
export * from "./use-workspace-agent-availability";
|
||||
export * from "./mcp-support";
|
||||
export * from "./openclaw-runtime-config";
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
// forwards MCP servers to the underlying CLI. The MCP config tab is hidden
|
||||
// for every other provider so a user can't save a value the runtime will
|
||||
// silently ignore. Keep this list in sync with the backends in
|
||||
// `server/pkg/agent/` that read `ExecOptions.McpConfig`, plus the OpenClaw
|
||||
// per-task wrapper preparer in `server/internal/daemon/execenv/` which
|
||||
// materialises `mcp.servers` into the synthesised config rather than going
|
||||
// through ExecOptions.
|
||||
// `server/pkg/agent/` that read `ExecOptions.McpConfig`, plus providers whose
|
||||
// per-task preparers in `server/internal/daemon/execenv/` materialise MCP
|
||||
// config for CLIs that do not receive it through ExecOptions.
|
||||
const MCP_SUPPORTED_PROVIDERS = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"hermes",
|
||||
"kimi",
|
||||
"kiro",
|
||||
|
||||
63
packages/core/agents/openclaw-runtime-config.test.ts
Normal file
63
packages/core/agents/openclaw-runtime-config.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
OPENCLAW_GATEWAY_TOKEN_MASK,
|
||||
serializeOpenclawRuntimeConfig,
|
||||
} from "./openclaw-runtime-config";
|
||||
|
||||
describe("serializeOpenclawRuntimeConfig", () => {
|
||||
it("keeps the masked gateway token sentinel so the API can preserve the persisted token", () => {
|
||||
expect(
|
||||
serializeOpenclawRuntimeConfig({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
token: OPENCLAW_GATEWAY_TOKEN_MASK,
|
||||
tls: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
token: OPENCLAW_GATEWAY_TOKEN_MASK,
|
||||
tls: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an empty gateway token so users can clear a persisted token", () => {
|
||||
expect(
|
||||
serializeOpenclawRuntimeConfig({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through a real gateway token value", () => {
|
||||
expect(
|
||||
serializeOpenclawRuntimeConfig({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
token: "rotated-secret",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
token: "rotated-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
94
packages/core/agents/openclaw-runtime-config.ts
Normal file
94
packages/core/agents/openclaw-runtime-config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// OpenClaw-specific `runtime_config` schema (issue #3260).
|
||||
//
|
||||
// Stored under `agent.runtime_config` as freeform JSONB; only meaningful for
|
||||
// agents whose runtime provider is openclaw. The daemon decodes the same
|
||||
// schema in `server/internal/daemon/openclaw_runtime_config.go` — keep both
|
||||
// sides in lockstep when changing field names.
|
||||
|
||||
export type OpenclawRoutingMode = "local" | "gateway";
|
||||
|
||||
export interface OpenclawGatewayPin {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
tls?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenclawRuntimeConfig {
|
||||
mode?: OpenclawRoutingMode;
|
||||
gateway?: OpenclawGatewayPin;
|
||||
}
|
||||
|
||||
// Sentinel the API substitutes for a non-empty `gateway.token` on every read.
|
||||
// When the form re-submits the same sentinel, the backend's matching
|
||||
// preserve hook restores the persisted token instead of overwriting it.
|
||||
// Mirrors `runtimeConfigGatewayTokenMask` in server/internal/handler/agent.go.
|
||||
export const OPENCLAW_GATEWAY_TOKEN_MASK = "***";
|
||||
|
||||
// Parse an arbitrary runtime_config payload into the typed schema. Unknown
|
||||
// keys are dropped, malformed payloads collapse to an empty object. The form
|
||||
// never throws on bad input — invalid configs simply render as defaults so
|
||||
// the user can correct them without a JSON parse error blocking the UI.
|
||||
export function parseOpenclawRuntimeConfig(
|
||||
raw: unknown,
|
||||
): OpenclawRuntimeConfig {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const root = raw as Record<string, unknown>;
|
||||
const out: OpenclawRuntimeConfig = {};
|
||||
if (root.mode === "local" || root.mode === "gateway") {
|
||||
out.mode = root.mode;
|
||||
}
|
||||
if (root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway)) {
|
||||
const gw = root.gateway as Record<string, unknown>;
|
||||
const pin: OpenclawGatewayPin = {};
|
||||
if (typeof gw.host === "string" && gw.host !== "") pin.host = gw.host;
|
||||
if (typeof gw.port === "number" && Number.isFinite(gw.port) && gw.port > 0) pin.port = gw.port;
|
||||
if (typeof gw.token === "string" && gw.token !== "") pin.token = gw.token;
|
||||
if (typeof gw.tls === "boolean") pin.tls = gw.tls;
|
||||
if (Object.keys(pin).length > 0) out.gateway = pin;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Render the typed form state back into the wire shape the API accepts.
|
||||
// Empty gateway sub-objects collapse to `undefined` so the wire payload
|
||||
// only carries fields the user actually populated — partial pins (host+port
|
||||
// only, etc.) work as documented.
|
||||
export function serializeOpenclawRuntimeConfig(
|
||||
cfg: OpenclawRuntimeConfig,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (cfg.mode) out.mode = cfg.mode;
|
||||
if (cfg.gateway) {
|
||||
const gw: Record<string, unknown> = {};
|
||||
if (cfg.gateway.host) gw.host = cfg.gateway.host;
|
||||
if (cfg.gateway.port) gw.port = cfg.gateway.port;
|
||||
if (cfg.gateway.tls) gw.tls = true;
|
||||
// The mask sentinel is the explicit "keep persisted token" signal for
|
||||
// the API. Omitting the field means "clear/no token" for partial
|
||||
// gateway pins, so the sentinel must survive serialization.
|
||||
if (cfg.gateway.token) {
|
||||
gw.token = cfg.gateway.token;
|
||||
}
|
||||
if (Object.keys(gw).length > 0) out.gateway = gw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Stable shallow equality across two parsed configs, used by the form's
|
||||
// dirty detector. Treats absent gateway block and an empty gateway block as
|
||||
// identical so toggling between local/gateway without filling endpoint
|
||||
// fields doesn't surface a spurious "unsaved changes" notice.
|
||||
export function openclawRuntimeConfigEquals(
|
||||
a: OpenclawRuntimeConfig,
|
||||
b: OpenclawRuntimeConfig,
|
||||
): boolean {
|
||||
if ((a.mode ?? "local") !== (b.mode ?? "local")) return false;
|
||||
const aGw = a.gateway ?? {};
|
||||
const bGw = b.gateway ?? {};
|
||||
if ((aGw.host ?? "") !== (bGw.host ?? "")) return false;
|
||||
if ((aGw.port ?? 0) !== (bGw.port ?? 0)) return false;
|
||||
if ((aGw.token ?? "") !== (bGw.token ?? "")) return false;
|
||||
if (Boolean(aGw.tls) !== Boolean(bGw.tls)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
export {
|
||||
useAgentsViewStore,
|
||||
AGENT_SCOPES,
|
||||
AGENT_SORT_DEFAULT_DIRECTION,
|
||||
AGENT_DEFAULT_HIDDEN_COLUMNS,
|
||||
EMPTY_AGENT_FILTERS,
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
type AgentSortField,
|
||||
type AgentSortDirection,
|
||||
type AgentColumnKey,
|
||||
type AgentListFilters,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("useAgentsViewStore", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
it("partialize persists only view prefs (no actions) under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
@@ -52,7 +52,14 @@ describe("useAgentsViewStore", () => {
|
||||
const raw = localStorage.getItem("multica_agents_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ scope: "all" });
|
||||
expect(Object.keys(parsed.state).sort()).toEqual([
|
||||
"filters",
|
||||
"hiddenColumns",
|
||||
"scope",
|
||||
"sortDirection",
|
||||
"sortField",
|
||||
]);
|
||||
expect(parsed.state.scope).toBe("all");
|
||||
});
|
||||
|
||||
it("rehydrates a different saved scope on workspace switch", async () => {
|
||||
@@ -93,4 +100,25 @@ describe("useAgentsViewStore", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("backfills new filter dimensions when rehydrating a pre-owners payload", async () => {
|
||||
// A payload persisted before the `owners` filter existed must not drop
|
||||
// the key to undefined (the agents list filter predicate reads
|
||||
// `filters.owners.length` and would crash).
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({
|
||||
state: { filters: { availability: ["online"], runtimes: [] } },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const filters = useAgentsViewStore.getState().filters;
|
||||
expect(filters.owners).toEqual([]);
|
||||
expect(filters.availability).toEqual(["online"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,30 +8,181 @@ import {
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type AgentsScope = "mine" | "all";
|
||||
// View preferences for the agents list page: scope, sort, column visibility,
|
||||
// and filters. Persisted per workspace, per user/device. Row selection is
|
||||
// session-scoped on purpose (same rationale as the skills/autopilots view
|
||||
// stores).
|
||||
|
||||
// Scope mixes the ownership lens (mine/all) with the archived lifecycle
|
||||
// stage. Impure on paper, but the three are mutually exclusive in practice
|
||||
// and "mine" is the historical product default; the archived view ignores
|
||||
// the ownership lens entirely (showing only *my* archived agents would hide
|
||||
// other people's archived agents with no UI to explain why).
|
||||
export type AgentsScope = "mine" | "all" | "archived";
|
||||
|
||||
export const AGENT_SCOPES: AgentsScope[] = ["mine", "all", "archived"];
|
||||
|
||||
export type AgentSortField = "lastActive" | "name" | "runs" | "created";
|
||||
|
||||
export type AgentSortDirection = "asc" | "desc";
|
||||
|
||||
/** Per-field direction applied when the user switches TO that field. */
|
||||
export const AGENT_SORT_DEFAULT_DIRECTION: Record<
|
||||
AgentSortField,
|
||||
AgentSortDirection
|
||||
> = {
|
||||
lastActive: "desc",
|
||||
name: "asc",
|
||||
runs: "desc",
|
||||
created: "desc",
|
||||
};
|
||||
|
||||
/** Multi-select filter state. Empty array per dimension = inactive. */
|
||||
export interface AgentListFilters {
|
||||
/** AgentAvailability values (online / unstable / offline). */
|
||||
availability: string[];
|
||||
/** Runtime ids. */
|
||||
runtimes: string[];
|
||||
/** Owner user ids. Owner is the same person-axis as the Mine scope: the
|
||||
* "mine" scope is the clean no-filter personal view, and applying any
|
||||
* filter (owner or otherwise) leaves it for "all" — see setScope /
|
||||
* toggleFilter. So owner-as-filter and Mine never coexist, which keeps
|
||||
* the axis orthogonal (no "mine + owner=someone-else = empty" state). */
|
||||
owners: string[];
|
||||
/** Runtime-native model identifiers (e.g. claude / codex / gpt-…). */
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_AGENT_FILTERS: AgentListFilters = {
|
||||
availability: [],
|
||||
runtimes: [],
|
||||
owners: [],
|
||||
models: [],
|
||||
};
|
||||
|
||||
// User-hideable columns. Name and the structural columns (checkbox, kebab)
|
||||
// are always visible.
|
||||
export type AgentColumnKey =
|
||||
| "status"
|
||||
| "owner"
|
||||
| "runtime"
|
||||
| "lastActive"
|
||||
| "runs"
|
||||
| "model"
|
||||
| "created";
|
||||
|
||||
/** Model and created are opt-in: hidden until the user enables them. Owner
|
||||
* is shown by default (the user wants to see who owns each agent). */
|
||||
export const AGENT_DEFAULT_HIDDEN_COLUMNS: AgentColumnKey[] = [
|
||||
"model",
|
||||
"created",
|
||||
];
|
||||
|
||||
export interface AgentsViewState {
|
||||
scope: AgentsScope;
|
||||
sortField: AgentSortField;
|
||||
sortDirection: AgentSortDirection;
|
||||
hiddenColumns: AgentColumnKey[];
|
||||
filters: AgentListFilters;
|
||||
setScope: (scope: AgentsScope) => void;
|
||||
/** Header click: toggles direction on the active field, otherwise switches
|
||||
* to the field with its default direction. */
|
||||
toggleSort: (field: AgentSortField) => void;
|
||||
/** Display panel select: switches field (default direction), no toggle. */
|
||||
setSortField: (field: AgentSortField) => void;
|
||||
setSortDirection: (direction: AgentSortDirection) => void;
|
||||
toggleColumn: (key: AgentColumnKey) => void;
|
||||
toggleFilter: (key: keyof AgentListFilters, value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
// "mine" is the historical default — most members care about their own
|
||||
// agents first; admins flip to "all".
|
||||
scope: "mine" as AgentsScope,
|
||||
sortField: "lastActive" as AgentSortField,
|
||||
sortDirection: AGENT_SORT_DEFAULT_DIRECTION.lastActive,
|
||||
hiddenColumns: AGENT_DEFAULT_HIDDEN_COLUMNS,
|
||||
filters: EMPTY_AGENT_FILTERS,
|
||||
};
|
||||
|
||||
export const useAgentsViewStore = create<AgentsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
...DEFAULTS,
|
||||
// "Mine" is the clean personal view: entering it clears all filters,
|
||||
// so Mine never carries filters. Switching to all/archived leaves
|
||||
// filters intact (you can carry "owner = Bob" between them).
|
||||
setScope: (scope) =>
|
||||
set(scope === "mine" ? { scope, filters: EMPTY_AGENT_FILTERS } : { scope }),
|
||||
toggleSort: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {
|
||||
sortDirection: state.sortDirection === "asc" ? "desc" : "asc",
|
||||
}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: AGENT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortField: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: AGENT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortDirection: (direction) => set({ sortDirection: direction }),
|
||||
toggleColumn: (key) =>
|
||||
set((state) => ({
|
||||
hiddenColumns: state.hiddenColumns.includes(key)
|
||||
? state.hiddenColumns.filter((k) => k !== key)
|
||||
: [...state.hiddenColumns, key],
|
||||
})),
|
||||
toggleFilter: (key, value) =>
|
||||
set((state) => {
|
||||
const list = state.filters[key] as string[];
|
||||
const next = list.includes(value)
|
||||
? list.filter((v) => v !== value)
|
||||
: [...list, value];
|
||||
// Applying any filter leaves the clean "mine" view for "all" —
|
||||
// Mine is the no-filter mode (see setScope). Archived keeps its
|
||||
// own scope (it can carry filters).
|
||||
const scope = state.scope === "mine" ? "all" : state.scope;
|
||||
return { scope, filters: { ...state.filters, [key]: next } };
|
||||
}),
|
||||
clearFilters: () => set({ filters: EMPTY_AGENT_FILTERS }),
|
||||
}),
|
||||
{
|
||||
name: "multica_agents_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({
|
||||
scope: state.scope,
|
||||
sortField: state.sortField,
|
||||
sortDirection: state.sortDirection,
|
||||
hiddenColumns: state.hiddenColumns,
|
||||
filters: state.filters,
|
||||
}),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the default "mine" instead of leaving the previous workspace's in-
|
||||
// memory scope in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak "all" across workspaces.
|
||||
// the defaults instead of leaving the previous workspace's in-memory
|
||||
// view state in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak state across workspaces.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, scope: "mine" };
|
||||
return { ...current, ...(persisted as Partial<AgentsViewState>) };
|
||||
if (!persisted) return { ...current, ...DEFAULTS };
|
||||
const p = persisted as Partial<AgentsViewState>;
|
||||
// Deep-merge filters so a payload persisted before a new filter
|
||||
// dimension existed (e.g. `owners`) still gets that key's default
|
||||
// instead of dropping it to `undefined` and crashing `.length`.
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
filters: { ...EMPTY_AGENT_FILTERS, ...(p.filters ?? {}) },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
72
packages/core/analytics/benign-exceptions.test.ts
Normal file
72
packages/core/analytics/benign-exceptions.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isBenignException } from "./benign-exceptions";
|
||||
|
||||
describe("isBenignException", () => {
|
||||
it("drops ResizeObserver loop errors via $exception_list value", () => {
|
||||
expect(
|
||||
isBenignException({
|
||||
$exception_list: [
|
||||
{
|
||||
type: "Error",
|
||||
value: "ResizeObserver loop completed with undelivered notifications.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops the older 'loop limit exceeded' phrasing", () => {
|
||||
expect(
|
||||
isBenignException({
|
||||
$exception_list: [
|
||||
{ type: "Error", value: "ResizeObserver loop limit exceeded" },
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("drops when the signal is on the top-level $exception_message", () => {
|
||||
expect(
|
||||
isBenignException({
|
||||
$exception_message: "ResizeObserver loop limit exceeded",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches case-insensitively", () => {
|
||||
expect(
|
||||
isBenignException({ $exception_message: "resizeobserver LOOP limit exceeded" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps real errors", () => {
|
||||
expect(
|
||||
isBenignException({
|
||||
$exception_list: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "Cannot read properties of undefined (reading 'split')",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not match an unrelated mention of ResizeObserver", () => {
|
||||
// Only the benign "loop" phrasing is silenced; a genuine bug in
|
||||
// ResizeObserver usage must still be reported.
|
||||
expect(
|
||||
isBenignException({
|
||||
$exception_message: "ResizeObserver is not defined",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open on missing or malformed properties", () => {
|
||||
expect(isBenignException(undefined)).toBe(false);
|
||||
expect(isBenignException({})).toBe(false);
|
||||
expect(isBenignException({ $exception_list: "not-an-array" })).toBe(false);
|
||||
expect(isBenignException({ $exception_list: [null, 42, {}] })).toBe(false);
|
||||
expect(isBenignException({ $exception_message: 123 })).toBe(false);
|
||||
});
|
||||
});
|
||||
52
packages/core/analytics/benign-exceptions.ts
Normal file
52
packages/core/analytics/benign-exceptions.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// Known-benign browser exceptions that are pure noise in `$exception`
|
||||
// telemetry. These are dropped ENTIRELY in `before_send` (not merely deduped by
|
||||
// exception-dedupe.ts) — they carry no actionable signal, the browser
|
||||
// self-recovers, and at scale they dominate the error stream, drowning real
|
||||
// failures and burning the billed event budget.
|
||||
//
|
||||
// ResizeObserver "loop ..." errors are the canonical case: the spec fires them
|
||||
// when observation callbacks don't settle within a single animation frame. The
|
||||
// browser resumes delivery on the next frame, so nothing actually breaks. Every
|
||||
// app that uses ResizeObserver (directly or via a UI library) emits them. The
|
||||
// CSSWG explicitly considers them benign — see w3c/csswg-drafts#5023. Across
|
||||
// Chrome versions the message is either "ResizeObserver loop limit exceeded"
|
||||
// (older) or "ResizeObserver loop completed with undelivered notifications"
|
||||
// (newer); both contain "ResizeObserver loop".
|
||||
//
|
||||
// The bar for adding a pattern here is high: it must be a benign,
|
||||
// self-recovering error with no actionable signal. A real bug must never be
|
||||
// silenced — when unsure, leave it to the dedupe fuse, which only caps repeats.
|
||||
|
||||
const BENIGN_MESSAGE_PATTERNS: RegExp[] = [/ResizeObserver loop/i];
|
||||
|
||||
/**
|
||||
* Whether this `$exception` event is known-benign browser noise that should be
|
||||
* dropped entirely. Reads the message from the (pre-redaction) event
|
||||
* properties — the matched messages carry no PII, so reading them raw is safe,
|
||||
* and matching before redaction avoids any chance of a scrub mangling the
|
||||
* signal. Never throws: any unexpected shape returns `false` (keep the event),
|
||||
* the fail-open direction `before_send` requires.
|
||||
*/
|
||||
export function isBenignException(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!properties || typeof properties !== "object") return false;
|
||||
|
||||
const messages: unknown[] = [properties.$exception_message];
|
||||
const list = properties.$exception_list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === "object" && "value" in entry) {
|
||||
messages.push((entry as { value: unknown }).value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message !== "string") continue;
|
||||
for (const pattern of BENIGN_MESSAGE_PATTERNS) {
|
||||
if (pattern.test(message)) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
234
packages/core/analytics/exception-dedupe.test.ts
Normal file
234
packages/core/analytics/exception-dedupe.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { shouldDropException } from "./exception-dedupe";
|
||||
|
||||
const STORAGE_KEY = "mc_exc_fp";
|
||||
|
||||
// In-memory sessionStorage stand-in. Optional flags let a test force getItem /
|
||||
// setItem to throw (quota, disabled storage) so we can assert the fail-open
|
||||
// direction.
|
||||
function makeStorage(opts: { throwOnGet?: boolean; throwOnSet?: boolean } = {}) {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
data,
|
||||
getItem(k: string): string | null {
|
||||
if (opts.throwOnGet) throw new Error("getItem blocked");
|
||||
return data.has(k) ? data.get(k)! : null;
|
||||
},
|
||||
setItem(k: string, v: string): void {
|
||||
if (opts.throwOnSet) throw new Error("quota exceeded");
|
||||
data.set(k, v);
|
||||
},
|
||||
removeItem(k: string): void {
|
||||
data.delete(k);
|
||||
},
|
||||
clear(): void {
|
||||
data.clear();
|
||||
},
|
||||
key(i: number): string | null {
|
||||
return Array.from(data.keys())[i] ?? null;
|
||||
},
|
||||
get length(): number {
|
||||
return data.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build a redacted-shape `$exception` properties object. By the time dedupe
|
||||
// runs, redactExceptionProperties has already scrubbed value/message.
|
||||
function exc(o: {
|
||||
type?: string;
|
||||
value?: string;
|
||||
frames?: Array<Record<string, unknown>> | null;
|
||||
} = {}): Record<string, unknown> {
|
||||
const entry: Record<string, unknown> = {
|
||||
type: o.type ?? "TypeError",
|
||||
value: o.value ?? "boom",
|
||||
};
|
||||
if (o.frames !== null) {
|
||||
entry.stacktrace = {
|
||||
type: "raw",
|
||||
frames: o.frames ?? [
|
||||
{ filename: "app.tsx", function: "render", lineno: 10, colno: 5 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { $exception_list: [entry] };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldDropException — per-fingerprint limit", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage());
|
||||
});
|
||||
|
||||
it("keeps the first 3 of a fingerprint and drops from the 4th", () => {
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(true);
|
||||
expect(shouldDropException(exc())).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different fingerprints independently — one does not drop the other", () => {
|
||||
// Exhaust fingerprint A.
|
||||
const a = () => exc({ type: "TypeError", value: "a" });
|
||||
const b = () => exc({ type: "RangeError", value: "b" });
|
||||
shouldDropException(a());
|
||||
shouldDropException(a());
|
||||
shouldDropException(a());
|
||||
expect(shouldDropException(a())).toBe(true); // A fused
|
||||
// B is untouched.
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(true);
|
||||
});
|
||||
|
||||
it("discriminates on colno (minified bundles collapse statements onto one line)", () => {
|
||||
const at = (colno: number) =>
|
||||
exc({ frames: [{ filename: "b.js", function: "x", lineno: 1, colno }] });
|
||||
// Same file/line/function, different column → distinct fingerprints, so
|
||||
// each keeps its own first-3 budget.
|
||||
shouldDropException(at(10));
|
||||
shouldDropException(at(10));
|
||||
shouldDropException(at(10));
|
||||
expect(shouldDropException(at(10))).toBe(true);
|
||||
expect(shouldDropException(at(20))).toBe(false);
|
||||
});
|
||||
|
||||
it("stores only a hash + counter — no raw value reaches storage", () => {
|
||||
const storage = makeStorage();
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
shouldDropException(exc({ value: "secret-marker-12345" }));
|
||||
const blob = storage.data.get(STORAGE_KEY) ?? "";
|
||||
expect(blob).not.toContain("secret-marker-12345");
|
||||
expect(blob).not.toContain("app.tsx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — degraded frames", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage());
|
||||
});
|
||||
|
||||
it("tolerates missing lineno/colno/function and still dedupes", () => {
|
||||
const partial = () => exc({ frames: [{ filename: "only-file.js" }] });
|
||||
expect(() => shouldDropException(partial())).not.toThrow();
|
||||
shouldDropException(partial());
|
||||
shouldDropException(partial());
|
||||
expect(shouldDropException(partial())).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates no stacktrace at all (fingerprints on type + value)", () => {
|
||||
const noframes = () => exc({ frames: null });
|
||||
shouldDropException(noframes());
|
||||
shouldDropException(noframes());
|
||||
shouldDropException(noframes());
|
||||
expect(shouldDropException(noframes())).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps events with no usable signal (empty type/value/frames)", () => {
|
||||
const empty = { $exception_list: [{ type: "", value: "" }] };
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false); // never fused — no fingerprint
|
||||
});
|
||||
|
||||
it("is safe on undefined / malformed properties", () => {
|
||||
expect(shouldDropException(undefined)).toBe(false);
|
||||
expect(
|
||||
shouldDropException({ $exception_list: "nope" as unknown as [] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — storage fail-open", () => {
|
||||
it("fails open when sessionStorage is undefined (SSR)", () => {
|
||||
vi.stubGlobal("sessionStorage", undefined);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open when accessing sessionStorage throws (sandboxed iframe)", () => {
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("blocked by sandbox");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(() => shouldDropException(exc())).not.toThrow();
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
} finally {
|
||||
// Remove the throwing getter so it doesn't leak into other tests.
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails open when getItem throws", () => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage({ throwOnGet: true }));
|
||||
expect(() => shouldDropException(exc())).not.toThrow();
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open on a corrupted JSON blob and re-seeds clean state", () => {
|
||||
const storage = makeStorage();
|
||||
storage.data.set(STORAGE_KEY, "{not valid json");
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
// Blob is now valid JSON again with this fingerprint counted once.
|
||||
const reseeded = JSON.parse(storage.data.get(STORAGE_KEY)!);
|
||||
expect(typeof reseeded).toBe("object");
|
||||
expect(Object.values(reseeded)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("setItem failure under-counts (fewer drops), never over-drops", () => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage({ throwOnSet: true }));
|
||||
// Persisting the increment always fails, so the counter never advances and
|
||||
// no event is ever dropped — the required "less drop" direction.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — distinct-fingerprint cap", () => {
|
||||
it("keeps (does not track) a new fingerprint once the cap is reached", () => {
|
||||
const storage = makeStorage();
|
||||
// Seed 50 distinct fingerprints already at count 1.
|
||||
const seed: Record<string, number> = {};
|
||||
for (let i = 0; i < 50; i++) seed[`seed-${i}`] = 1;
|
||||
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
// The 51st, brand-new fingerprint is kept and NOT added to the blob.
|
||||
expect(shouldDropException(exc({ value: "fingerprint-51" }))).toBe(false);
|
||||
const after = JSON.parse(storage.data.get(STORAGE_KEY)!);
|
||||
expect(Object.keys(after)).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("still fuses a fingerprint that is already tracked at the cap", () => {
|
||||
const storage = makeStorage();
|
||||
const seed: Record<string, number> = {};
|
||||
for (let i = 0; i < 49; i++) seed[`seed-${i}`] = 1;
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
// Track a real one to reach 50 distinct, exhausting its budget.
|
||||
const target = () => exc({ value: "tracked-at-cap" });
|
||||
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
shouldDropException(target()); // 50th distinct, count 1
|
||||
shouldDropException(target()); // 2
|
||||
shouldDropException(target()); // 3
|
||||
expect(shouldDropException(target())).toBe(true); // fused despite cap
|
||||
});
|
||||
});
|
||||
193
packages/core/analytics/exception-dedupe.ts
Normal file
193
packages/core/analytics/exception-dedupe.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Session-scoped dedupe / throttle for `$exception` events.
|
||||
//
|
||||
// Runs in posthog-js `before_send` AFTER `redactExceptionProperties`, so the
|
||||
// fingerprint is built purely from already-redacted fields — no raw message,
|
||||
// value, or PII is ever written to storage (only a hash + a small counter).
|
||||
//
|
||||
// The fuse: keep the first EXCEPTION_SAMPLE_LIMIT of each (tab-session,
|
||||
// fingerprint) pair and drop the rest. One runaway error — a render loop, a
|
||||
// polling fetch that keeps throwing — otherwise emits 100+ identical
|
||||
// `$exception` events per session (MUL-3331 / MUL-3330). Different fingerprints
|
||||
// never affect each other.
|
||||
//
|
||||
// Safety invariant (load-bearing): `before_send` must never throw — a throw
|
||||
// there breaks ALL event delivery — and every storage failure must fail OPEN.
|
||||
// When in doubt we KEEP the event: emitting a duplicate is cheap, silently
|
||||
// dropping a real first-occurrence error is not. setItem failures therefore
|
||||
// only ever under-count (fewer drops), never over-drop.
|
||||
//
|
||||
// Scope is the browser tab session (`sessionStorage`): cleared when the tab
|
||||
// closes, isolated per tab. This is intentionally NOT the posthog 30-min
|
||||
// session — see the dedupe discussion on MUL-3331.
|
||||
|
||||
const STORAGE_KEY = "mc_exc_fp";
|
||||
// Keep the first N of each fingerprint per session, drop from N+1.
|
||||
const EXCEPTION_SAMPLE_LIMIT = 3;
|
||||
// Cap distinct fingerprints tracked per session so a session that throws many
|
||||
// *different* errors can't grow the blob without bound. Past the cap, new
|
||||
// fingerprints are not tracked and fail open (kept).
|
||||
const MAX_FINGERPRINTS = 50;
|
||||
|
||||
type FingerprintCounts = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Decide whether this already-redacted `$exception` event should be dropped as
|
||||
* a session-level duplicate. Returns `true` to drop, `false` to keep.
|
||||
*
|
||||
* Never throws. Any missing fingerprint signal, unavailable/corrupt storage, or
|
||||
* unexpected error results in `false` (keep) — the fail-open direction.
|
||||
*/
|
||||
export function shouldDropException(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
const fingerprint = buildFingerprint(properties);
|
||||
// Nothing stable to dedupe on → keep.
|
||||
if (fingerprint === null) return false;
|
||||
|
||||
const storage = getSessionStorage();
|
||||
if (!storage) return false;
|
||||
|
||||
// The entire read-decide-write sequence is guarded: a throw anywhere (parse,
|
||||
// getItem, property access) degrades to keep.
|
||||
try {
|
||||
const counts = readCounts(storage);
|
||||
const current = typeof counts[fingerprint] === "number" ? counts[fingerprint] : 0;
|
||||
|
||||
// Already at the limit for this fingerprint → fuse blows, drop.
|
||||
if (current >= EXCEPTION_SAMPLE_LIMIT) return true;
|
||||
|
||||
// A brand-new fingerprint once the cap is reached: don't track it (would
|
||||
// grow the blob), and keep the event.
|
||||
if (current === 0 && Object.keys(counts).length >= MAX_FINGERPRINTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
counts[fingerprint] = current + 1;
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(counts));
|
||||
} catch {
|
||||
// Persisting the increment failed (quota / disabled). We still keep this
|
||||
// event (return false below). The unpersisted increment only means the
|
||||
// next identical error is also kept — under-counting toward the limit,
|
||||
// i.e. fewer drops, never more. This is the required failure direction.
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the counts blob. A corrupt or unexpected payload is
|
||||
* treated as empty (fail open — this event is kept and re-seeds the blob). */
|
||||
function readCounts(storage: Storage): FingerprintCounts {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as FingerprintCounts;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON blob → start fresh.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable fingerprint from the redacted exception properties. Uses the
|
||||
* exception type, the redacted message/value, and a single deterministic stack
|
||||
* frame. Returns `null` when there's nothing stable to key on (keep the event).
|
||||
*
|
||||
* Every frame field (`function` / `lineno` / `colno`) is treated as optional
|
||||
* and degrades to empty — minified or partial stacks must not throw or collapse
|
||||
* every error into one bucket via an undefined access.
|
||||
*/
|
||||
function buildFingerprint(properties: Record<string, unknown> | undefined): string | null {
|
||||
if (!properties || typeof properties !== "object") return null;
|
||||
|
||||
const list = properties.$exception_list;
|
||||
const entry =
|
||||
Array.isArray(list) && list.length > 0 && list[0] && typeof list[0] === "object"
|
||||
? (list[0] as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const type = readString(entry?.type) ?? readString(properties.$exception_type) ?? "";
|
||||
const value =
|
||||
readString(entry?.value) ?? readString(properties.$exception_message) ?? "";
|
||||
const frame = topFrame(entry);
|
||||
|
||||
// No signal at all → don't dedupe.
|
||||
if (type === "" && value === "" && !frame) return null;
|
||||
|
||||
const parts = [type, value];
|
||||
if (frame) {
|
||||
// colno is kept (load-bearing): minified bundles collapse many statements
|
||||
// onto one line, so line alone under-discriminates distinct errors.
|
||||
parts.push(frame.filename, frame.fn, frame.lineno, frame.colno);
|
||||
}
|
||||
return hash(parts.join(""));
|
||||
}
|
||||
|
||||
interface TopFrame {
|
||||
filename: string;
|
||||
fn: string;
|
||||
lineno: string;
|
||||
colno: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single deterministic stack frame for fingerprinting. We always take
|
||||
* the LAST frame in the array — a fixed end, with NO engine/order detection.
|
||||
* The same error within a session yields the same frames array and therefore
|
||||
* the same chosen frame, which is all the fingerprint needs; we don't care
|
||||
* which end is semantically "topmost". Missing pieces degrade to "".
|
||||
*/
|
||||
function topFrame(entry: Record<string, unknown> | undefined): TopFrame | null {
|
||||
if (!entry) return null;
|
||||
const stacktrace = entry.stacktrace;
|
||||
const frames =
|
||||
stacktrace && typeof stacktrace === "object"
|
||||
? (stacktrace as Record<string, unknown>).frames
|
||||
: undefined;
|
||||
if (!Array.isArray(frames) || frames.length === 0) return null;
|
||||
|
||||
const f = frames[frames.length - 1];
|
||||
if (!f || typeof f !== "object") return null;
|
||||
const frame = f as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
filename: readString(frame.filename) ?? "",
|
||||
fn: readString(frame.function) ?? "",
|
||||
lineno: readNumberAsString(frame.lineno) ?? "",
|
||||
colno: readNumberAsString(frame.colno) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function readNumberAsString(v: unknown): string | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? String(v) : undefined;
|
||||
}
|
||||
|
||||
/** djb2 — a tiny stable string hash. Only used to bound the storage-key length;
|
||||
* collision risk across a single tab session's exceptions is negligible. */
|
||||
function hash(input: string): string {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h = ((h << 5) + h) ^ input.charCodeAt(i);
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/** Resolve `sessionStorage`, returning `null` if it is absent (SSR) or throws
|
||||
* on access (sandboxed iframe, storage disabled). */
|
||||
function getSessionStorage(): Storage | null {
|
||||
try {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
return sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ vi.mock("posthog-js", () => {
|
||||
reset: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
};
|
||||
return { default: mock };
|
||||
});
|
||||
@@ -22,10 +23,12 @@ async function loadModule() {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
captureException: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
posthog.init.mockClear();
|
||||
posthog.register.mockClear();
|
||||
posthog.reset.mockClear();
|
||||
posthog.captureException.mockClear();
|
||||
return { analytics, posthog };
|
||||
}
|
||||
|
||||
@@ -183,3 +186,105 @@ describe("capturePageview", () => {
|
||||
expect(capture).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureException", () => {
|
||||
it("buffers a pre-init exception and flushes it on init", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
const err = new Error("boom");
|
||||
|
||||
// Before init: buffered, nothing sent yet.
|
||||
analytics.captureException(err, { source: "global-error" });
|
||||
expect(posthog.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// Init flushes the buffer in order.
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
err,
|
||||
expect.objectContaining({ source: "global-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends immediately once initialized", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
posthog.captureException.mockClear();
|
||||
|
||||
const err = new Error("later");
|
||||
analytics.captureException(err);
|
||||
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_send $exception pipeline", () => {
|
||||
// before_send is registered inside posthog.init's config; pull it back out of
|
||||
// the mock and drive it directly. Dedupe needs a working sessionStorage.
|
||||
function makeMemoryStorage() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void data.set(k, v),
|
||||
removeItem: (k: string) => void data.delete(k),
|
||||
clear: () => data.clear(),
|
||||
key: (i: number) => Array.from(data.keys())[i] ?? null,
|
||||
get length() {
|
||||
return data.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BeforeSend = (
|
||||
e: { event: string; properties: Record<string, unknown> } | null,
|
||||
) => unknown;
|
||||
|
||||
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
|
||||
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
|
||||
return config.before_send;
|
||||
}
|
||||
|
||||
function excEvent() {
|
||||
return {
|
||||
event: "$exception",
|
||||
properties: {
|
||||
$exception_list: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "Bad email bob@corp.com",
|
||||
stacktrace: {
|
||||
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeMemoryStorage());
|
||||
});
|
||||
|
||||
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const beforeSend = getBeforeSend(posthog);
|
||||
|
||||
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
|
||||
// Redaction still runs before the fuse.
|
||||
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
|
||||
|
||||
expect(beforeSend(excEvent())).not.toBeNull();
|
||||
expect(beforeSend(excEvent())).not.toBeNull();
|
||||
// 4th identical exception is dropped.
|
||||
expect(beforeSend(excEvent())).toBeNull();
|
||||
});
|
||||
|
||||
it("passes non-$exception events through untouched", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const beforeSend = getBeforeSend(posthog);
|
||||
|
||||
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
|
||||
expect(beforeSend(evt)).toBe(evt);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user