Compare commits
8 Commits
revert/exe
...
quick-crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e96909ea8 | ||
|
|
126df05671 | ||
|
|
e68976ce18 | ||
|
|
5f2222b83d | ||
|
|
68edf57f64 | ||
|
|
31ec36146f | ||
|
|
6fafd86ecc | ||
|
|
f435dd2cd4 |
63
.env.example
@@ -29,22 +29,6 @@ PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers. Leave unset behind a same-origin reverse proxy or for plain
|
||||
# localhost dev — the frontend will compose the URL from
|
||||
# window.origin + webhook_path in that case. Headers are intentionally
|
||||
# not used to derive this value, to avoid Host / X-Forwarded-Host
|
||||
# spoofing when a self-hosted reverse proxy is not hardened.
|
||||
MULTICA_PUBLIC_URL=
|
||||
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
|
||||
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
|
||||
# Empty (the default) means "trust no headers" — the limiter uses
|
||||
# r.RemoteAddr only, which is the safe shape when the backend is
|
||||
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
|
||||
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
|
||||
# announced ranges for cloud deployments.
|
||||
MULTICA_TRUSTED_PROXIES=
|
||||
MULTICA_DAEMON_CONFIG=
|
||||
MULTICA_WORKSPACE_ID=
|
||||
MULTICA_DAEMON_ID=
|
||||
@@ -64,26 +48,11 @@ MULTICA_IMAGE_TAG=latest
|
||||
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# Email
|
||||
# Two delivery options - only one needs to be configured:
|
||||
#
|
||||
# Option A: Resend (SaaS, recommended for cloud deployments)
|
||||
# Set RESEND_API_KEY to a key from resend.com and verify your sending domain there.
|
||||
# For local/dev use, leave RESEND_API_KEY empty - codes print to stdout. To
|
||||
# accept a fixed local code, also set MULTICA_DEV_VERIFICATION_CODE above
|
||||
# (ignored when APP_ENV=production).
|
||||
# Email (Resend)
|
||||
# For local/dev use, leave RESEND_API_KEY empty — generated codes print to stdout.
|
||||
# For production, set your Resend API key and change RESEND_FROM_EMAIL to a domain verified in your Resend account.
|
||||
RESEND_API_KEY=
|
||||
RESEND_FROM_EMAIL=noreply@multica.ai
|
||||
#
|
||||
# Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
# Takes priority over Resend when SMTP_HOST is set.
|
||||
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
|
||||
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=25
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_TLS_INSECURE=false
|
||||
|
||||
# Google OAuth
|
||||
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
|
||||
@@ -119,30 +88,8 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Security
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket connections.
|
||||
# Defaults to localhost dev origins when unset.
|
||||
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# ==================== Rate limiting (optional Redis) ====================
|
||||
# Per-IP fixed-window rate limiter on the public auth endpoints
|
||||
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
|
||||
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
|
||||
# backend logs "rate limiting disabled: REDIS_URL not configured" at
|
||||
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
|
||||
# the PAT cache, and the daemon-token cache.
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
# Max requests per IP per minute. Defaults are 5 for send-code/google
|
||||
# and 20 for verify-code.
|
||||
# RATE_LIMIT_AUTH=5
|
||||
# RATE_LIMIT_AUTH_VERIFY=20
|
||||
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
|
||||
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
|
||||
# REQUIRED behind a reverse proxy — otherwise every real user shares
|
||||
# the proxy IP and the whole deployment lands in one bucket, turning
|
||||
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
|
||||
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
|
||||
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
|
||||
# governs the autopilot webhook limiter).
|
||||
# RATE_LIMIT_TRUSTED_PROXIES=
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -58,3 +58,6 @@ server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
18
README.md
@@ -32,8 +32,6 @@ Multica turns coding agents into real teammates. Assign issues to an agent like
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
@@ -55,7 +53,6 @@ Like Multics before it, the bet is on multiplexing: a small team shouldn't feel
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
@@ -131,6 +128,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
@@ -32,8 +32,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
|
||||
面向更大的团队,Squads(小队)提供稳定的路由层:把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
@@ -55,7 +53,6 @@ Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **Squads(小队)** — 把多个 Agent(以及人类成员)组合成由 leader agent 带队的小队,直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
@@ -134,6 +131,19 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
|
||||
@@ -25,30 +25,14 @@ These have sensible defaults and only need to be set when tuning a large or cons
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica supports two email backends. `SMTP_HOST` takes priority when set; otherwise `RESEND_API_KEY` is used. With neither configured, verification codes are printed to the server log — copy them from there to log in.
|
||||
|
||||
#### Option A: Resend (recommended for cloud deployments)
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
#### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this option when your deployment cannot reach the public internet or you already have an internal mail relay (e.g. Exchange, Postfix, SendGrid on-prem).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `SMTP_HOST` | SMTP relay hostname (setting this activates SMTP mode) | - |
|
||||
| `SMTP_PORT` | SMTP port | `25` |
|
||||
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
|
||||
| `SMTP_PASSWORD` | SMTP password | - |
|
||||
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
|
||||
|
||||
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
|
||||
|
||||
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
|
||||
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 782 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -46,31 +46,20 @@ linux:
|
||||
# Yaru). Forcing `multica` makes every Linux identity slot agree and
|
||||
# matches `StartupWMClass=Multica` (productName-derived).
|
||||
executableName: multica
|
||||
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
|
||||
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
|
||||
# ASAR's `package.json` — `productName` if present, otherwise `name`.
|
||||
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
|
||||
# directly; it does not. With our source package.json carrying only
|
||||
# `name: "@multica/desktop"`, packaged Electron emitted
|
||||
# `WM_CLASS=@multica/desktop`, which broke association with this entry
|
||||
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
|
||||
# outside this file — `productName: "Multica"` on the source
|
||||
# package.json (so the ASAR carries it) and `app.setName("Multica")`
|
||||
# in the production branch of `src/main/index.ts` (belt-and-braces).
|
||||
# Keep `StartupWMClass: Multica` pinned here so any future drift in
|
||||
# those two anchors shows up as a diff against this declaration.
|
||||
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
|
||||
# window prints `Multica` for both fields.
|
||||
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
|
||||
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
|
||||
# builds resolves to `productName` (`Multica`). Without an explicit
|
||||
# `StartupWMClass`, electron-builder writes `productName` as the default
|
||||
# — making this declaration redundant with current settings — but
|
||||
# pinning the value here turns a silent future drift (e.g. if anyone
|
||||
# renames productName or sets app.setName at boot) into a visible diff
|
||||
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
|
||||
# GNOME associate the running window with the `.desktop` entry and
|
||||
# therefore render the right icon. The post-build verification step in
|
||||
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
|
||||
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
|
||||
# auto-generation silently shipped only the 1024×1024 source in our
|
||||
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
|
||||
# with no usable size and falling back to the theme default. Shipping
|
||||
# the sizes from source removes the toolchain dependency entirely.
|
||||
icon: build/icons
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"productName": "Multica",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
|
||||
@@ -7,7 +7,6 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
@@ -134,27 +133,6 @@ function createWindow(): void {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
// Required for the Chromium PDF viewer (PDFium) to activate inside
|
||||
// iframes — used by the attachment preview modal for application/pdf
|
||||
// files. Default is false in Electron; without it <iframe src=*.pdf>
|
||||
// renders blank.
|
||||
//
|
||||
// Security trade-off, accepted intentionally:
|
||||
// 1. This window already runs with `webSecurity: false` + `sandbox: false`,
|
||||
// so `plugins: true` does NOT meaningfully widen the renderer's
|
||||
// attack surface beyond what is already accepted.
|
||||
// 2. The only PDFs that reach an iframe here are signed CloudFront URLs
|
||||
// we ourselves issued (see useDownloadAttachment); user-supplied URLs
|
||||
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
|
||||
// cannot land in this renderer.
|
||||
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
|
||||
// and only handles the `application/pdf` MIME — it does not expose
|
||||
// Flash, Java, or other historical plugin surfaces.
|
||||
//
|
||||
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
|
||||
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
|
||||
// to that view, keeping the main renderer plugin-free.
|
||||
plugins: true,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
@@ -190,13 +168,19 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, mainWindow!.webContents)) {
|
||||
event.preventDefault();
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,14 +212,6 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
if (is.dev) {
|
||||
app.setName(DEV_APP_NAME);
|
||||
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
|
||||
} else {
|
||||
// Pin the production app name in code. Electron's Linux WM_CLASS is set
|
||||
// from app.getName() when the first BrowserWindow is realized; the
|
||||
// packaged ASAR's package.json `productName` already steers app.getName()
|
||||
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
|
||||
// (declared in electron-builder.yml) survive a regression in
|
||||
// productName / the build pipeline. Must run before requestSingleInstanceLock().
|
||||
app.setName("Multica");
|
||||
}
|
||||
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
|
||||
|
||||
function makeWc(initialLevel = 0) {
|
||||
let level = initialLevel;
|
||||
return {
|
||||
getZoomLevel: vi.fn(() => level),
|
||||
setZoomLevel: vi.fn((next: number) => {
|
||||
level = next;
|
||||
}),
|
||||
currentLevel: () => level,
|
||||
};
|
||||
}
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleAppShortcut — reload blocking", () => {
|
||||
it("swallows Cmd+R on macOS", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows Ctrl+R on Linux/Windows", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows F5 regardless of modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-keyDown events", () => {
|
||||
const wc = makeWc();
|
||||
expect(
|
||||
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom in", () => {
|
||||
it("zooms in on Cmd+= (unshifted)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Cmd++ (Shift+=)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Ctrl+= on non-mac", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps zoom-in at the upper bound", () => {
|
||||
const wc = makeWc(4.5);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
|
||||
it("zooms out on Cmd+- (unshifted)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Cmd+_ (Shift+-)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Ctrl+- on non-mac", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("undoes a prior Cmd+= so the user can return to 100%", () => {
|
||||
const wc = makeWc(0);
|
||||
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps zoom-out at the lower bound", () => {
|
||||
const wc = makeWc(-3);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(-3);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — reset zoom", () => {
|
||||
it("resets to 0 on Cmd+0", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("resets to 0 on Ctrl+0", () => {
|
||||
const wc = makeWc(-1.5);
|
||||
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores plain 0 without modifier", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
it("does not capture plain letters", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
// Shape of the input subset we read from Electron's `before-input-event`.
|
||||
// Modeled as a structural type so the handler is unit-testable without a
|
||||
// real Electron Input instance.
|
||||
export type ShortcutInput = {
|
||||
type: string;
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
|
||||
|
||||
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
|
||||
// per step). Clamp to a range that keeps the UI legible — values outside
|
||||
// this band turn the workspace into either confetti or a microfiche.
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 4.5;
|
||||
|
||||
/**
|
||||
* Inspect a `before-input-event` key and apply (or block) the matching
|
||||
* window-level shortcut. Returns `true` when the caller should call
|
||||
* `event.preventDefault()` — that both swallows the renderer keydown and
|
||||
* prevents the application menu accelerator from firing, so we don't
|
||||
* double-trigger zoom on macOS where the default menu also binds these
|
||||
* keys.
|
||||
*
|
||||
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
|
||||
* default `Cmd+-` accelerator does not fire reliably across keyboard
|
||||
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
|
||||
// (tabs, drafts, WS connections) with no URL bar to recover from.
|
||||
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!cmdOrCtrl) return false;
|
||||
|
||||
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
|
||||
if (input.key === "=" || input.key === "+") {
|
||||
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
|
||||
if (input.key === "-" || input.key === "_") {
|
||||
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + 0 → reset zoom to 100%.
|
||||
if (input.key === "0") {
|
||||
webContents.setZoomLevel(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
// downloaded and ready to install on next quit.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
@@ -29,39 +26,8 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Single-flight guard around checkForUpdates(). With autoDownload=true the
|
||||
// startup, periodic, and manual triggers can all kick off downloads, and
|
||||
// overlapping calls have caused duplicate download warnings in the past
|
||||
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
|
||||
// callers onto the same in-flight promise.
|
||||
let inFlightCheck: Promise<unknown> | null = null;
|
||||
function checkForUpdatesOnce(): Promise<unknown> {
|
||||
if (inFlightCheck) return inFlightCheck;
|
||||
const p = autoUpdater
|
||||
.checkForUpdates()
|
||||
.then((result) => {
|
||||
// checkForUpdates resolves as soon as metadata is fetched; the actual
|
||||
// download (when autoDownload=true) is exposed on result.downloadPromise.
|
||||
// Without a handler a download failure becomes an unhandled rejection
|
||||
// in the main process — Node may terminate it on future versions.
|
||||
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
|
||||
(err) => {
|
||||
console.error("Failed to download update:", err);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightCheck === p) inFlightCheck = null;
|
||||
});
|
||||
inFlightCheck = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
@@ -76,20 +42,15 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
// Retained for IPC back-compat with older renderer bundles. With
|
||||
// autoDownload=true the renderer no longer triggers this path.
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
@@ -100,9 +61,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = (await checkForUpdatesOnce()) as
|
||||
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
|
||||
| null;
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
@@ -126,7 +85,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -134,7 +93,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
|
||||
4
apps/desktop/src/preload/index.d.ts
vendored
@@ -84,9 +84,7 @@ interface DaemonAPI {
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
|
||||
@@ -207,11 +207,8 @@ const updaterAPI = {
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
|
||||
callback(info);
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
@@ -11,7 +12,15 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -23,13 +32,24 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Desktop-only controls for the daemon embedded in this Electron app. The
|
||||
* shared runtimes page renders this inside the selected local machine header.
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
*/
|
||||
export function DaemonRuntimeActions() {
|
||||
export function DaemonRuntimeCard() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -37,8 +57,14 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
@@ -50,6 +76,10 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
@@ -78,6 +108,9 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
@@ -86,6 +119,8 @@ export function DaemonRuntimeActions() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
@@ -101,6 +136,9 @@ export function DaemonRuntimeActions() {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
@@ -124,64 +162,106 @@ export function DaemonRuntimeActions() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
@@ -32,9 +32,7 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
// Downloads run silently in the background (main process has
|
||||
// autoDownload=true). The renderer only renders UI once the package is fully
|
||||
// downloaded and waiting for a restart.
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "ready"; version: string };
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.updater.onUpdateDownloaded((info) => {
|
||||
setState({ status: "ready", version: info.version });
|
||||
setDismissed(false);
|
||||
});
|
||||
return cleanup;
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed) return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
@@ -32,31 +60,78 @@ export function UpdateNotification() {
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} will be applied on next launch.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.updater.installUpdate()}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ export function UpdatesSettingsTab() {
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch, downloading them in the background. You'll
|
||||
be prompted to restart once an update is ready.
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
@@ -51,8 +50,7 @@ export function UpdatesSettingsTab() {
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates download in the background and show a
|
||||
restart prompt when ready.
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
@@ -63,8 +61,8 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is downloading in the background —
|
||||
you'll be notified when it's ready to install.
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemberDetailPage as SharedMemberDetailPage } from "@multica/views/members";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function MemberDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const member = members.find((m) => m.user_id === id) ?? null;
|
||||
|
||||
useDocumentTitle(member?.name ?? "Member");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedMemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { MemberDetailPage } from "./pages/member-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
@@ -21,7 +20,6 @@ import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
@@ -148,17 +146,6 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{
|
||||
path: "members/:id",
|
||||
element: <MemberDetailPage />,
|
||||
handle: { title: "Member" },
|
||||
},
|
||||
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
|
||||
{
|
||||
path: "squads/:id",
|
||||
element: <SquadDetailPageView />,
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "usage",
|
||||
|
||||
@@ -45,5 +45,4 @@ New agents default to **private**. To make one available to the whole workspace,
|
||||
|
||||
- [Create and configure an agent](/agents-create) — how to build one
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [Squads](/squads) — group agents under a leader so the right one picks up the right issue
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run
|
||||
|
||||
@@ -45,5 +45,4 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
|
||||
- [Skills](/skills) —— 给智能体挂上专业知识包
|
||||
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Hand an issue to an agent and it takes over as the official assigne
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths. The same flow also accepts a [squad](/squads) as the assignee — Multica then triggers the squad's **leader agent** instead.
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
|
||||
|
||||
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -18,7 +18,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
|
||||
|
||||
## Assign from the UI
|
||||
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace, all non-archived agents, and every non-archived [squad](/squads). Pick an agent (or squad) and the issue is assigned right away.
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
|
||||
|
||||
A few rules:
|
||||
|
||||
@@ -78,6 +78,5 @@ But **different agents can work on the same issue in parallel** — for example,
|
||||
## Next
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Squads**](/squads) — assign to a group of agents and let the leader decide who picks it up
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 把 issue 交给智能体,它作为正式负责人一直工作到
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。同样的流程也接受 [小队(squad)](/squads) 作为 assignee——这种情况下 Multica 会触发小队的**队长智能体**。
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
|
||||
|
||||
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -18,7 +18,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 在界面里分配
|
||||
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员、未归档的智能体、以及未归档的 [小队](/squads)。选一个智能体(或小队),issue 立刻分配。
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员和未归档的智能体。选一个智能体,issue 立刻分给它。
|
||||
|
||||
几条规则:
|
||||
|
||||
@@ -78,6 +78,5 @@ multica issue assign MUL-42 --unassign
|
||||
## 下一步
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**小队**](/squads) —— 把 issue 分给一组智能体,由队长决定谁接手
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
|
||||
@@ -12,11 +12,9 @@ For the list of environment variables referenced below, see [Environment variabl
|
||||
|
||||
## How email + verification code sign-in works
|
||||
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. Two delivery backends are supported — pick whichever fits your deployment:
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
|
||||
|
||||
### Option A: Resend (recommended for cloud / public-internet deployments)
|
||||
|
||||
1. Create a [Resend](https://resend.com/) account and verify your domain
|
||||
1. Create a Resend account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
@@ -27,22 +25,7 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
4. Restart the server
|
||||
|
||||
### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
|
||||
|
||||
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## Fixed local testing codes
|
||||
|
||||
@@ -51,7 +34,7 @@ STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTP
|
||||
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
Local development without any email backend configured (no Resend, no SMTP) should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -12,11 +12,9 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一:
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
|
||||
### Option A:Resend(公网/云端部署推荐)
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
@@ -27,22 +25,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
4. 重启 server
|
||||
|
||||
### Option B:SMTP relay(内网/自部署)
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。
|
||||
|
||||
**两种都不配**:server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
@@ -51,7 +34,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
没配任何邮件后端(Resend 和 SMTP 都没设)的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,7 +16,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
@@ -50,109 +50,15 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## Trigger from a webhook
|
||||
|
||||
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
|
||||
on the autopilot detail page; Multica generates a unique URL of the shape:
|
||||
|
||||
```
|
||||
https://<your-multica-host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
POST any JSON to that URL — Multica records a run with `source = webhook`,
|
||||
stores the body as the run's `trigger_payload`, and dispatches the agent
|
||||
exactly the way a schedule trigger would.
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
In **create issue mode**, the inbound payload is appended to the new issue's
|
||||
description so the agent can read it inline. In **run-only mode**, the
|
||||
payload is part of the run context the daemon hands the agent.
|
||||
|
||||
### Payload shape
|
||||
|
||||
You can send your own envelope:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
…or any JSON object/array. Multica normalizes it into an internal envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<inferred>",
|
||||
"eventPayload": <your body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
When you don't provide an `event` field, Multica infers it from common
|
||||
headers and body fields (`X-GitHub-Event` + body `action`,
|
||||
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
|
||||
nothing matches, the event is `webhook.received`.
|
||||
|
||||
When configuring GitHub or similar sources, set the content type to
|
||||
`application/json` — form-encoded webhook payloads are not accepted.
|
||||
|
||||
### URL is a bearer secret
|
||||
|
||||
The generated URL **is** the credential. Anyone with it can fire the
|
||||
autopilot. Treat it like a token:
|
||||
|
||||
- **Don't paste it into public issue threads, screenshots, or chat history.**
|
||||
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
|
||||
old URL stops working immediately.
|
||||
- For sources that require strong source authentication, wait for
|
||||
per-trigger HMAC signature verification; this v1 URL is bearer-only.
|
||||
- Workspace members who can view the autopilot can read its webhook URLs
|
||||
for now — tighter per-role secret visibility is a follow-up.
|
||||
|
||||
### Status-code semantics
|
||||
|
||||
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
|
||||
your provider's webhook-retry machinery doesn't keep hammering the URL:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
— a run was dispatched.
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
— the assignee's runtime is offline; recorded as a `skipped` run.
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
|
||||
|
||||
Non-2xx responses cover real failures:
|
||||
|
||||
- `400` — invalid JSON, scalar body, or empty body.
|
||||
- `404` — unknown token (`{"error":"webhook not found"}`).
|
||||
- `413` — payload exceeded 256 KiB.
|
||||
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
|
||||
|
||||
### Self-hosted: configure your public URL
|
||||
|
||||
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
|
||||
the trigger response includes an absolute `webhook_url` and the UI shows a
|
||||
ready-to-copy URL. Without it, the UI composes the URL from the client's
|
||||
API origin — which is fine for desktop and same-origin web, but not for
|
||||
custom self-hosted reverse proxies. Multica deliberately does not derive
|
||||
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
|
||||
reverse proxy cannot trick the server into minting webhook URLs pointing at
|
||||
an attacker-controlled host.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual` / `webhook`)
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed or skipped)
|
||||
- Failure reason (if failed)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
@@ -166,11 +72,7 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
|
||||
kind, but no ingress route fires it; the UI shows a Deprecated badge for
|
||||
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
|
||||
signature verification, IP allowlists, and provider-specific event presets
|
||||
are tracked as follow-ups; v1 URLs are bearer-only.
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,7 +16,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)或 `webhook`
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
@@ -50,105 +50,15 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 通过 Webhook 触发
|
||||
|
||||
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
|
||||
触发器,Multica 会生成一个唯一的 URL:
|
||||
|
||||
```
|
||||
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
|
||||
run,把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
|
||||
完全一致的方式派发给智能体。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
|
||||
直接读到;**直跑模式**下,payload 也会随 run 一并交给 daemon。
|
||||
|
||||
### Payload 形态
|
||||
|
||||
可以发自己的封装:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<推断>",
|
||||
"eventPayload": <你的 body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
不带 `event` 字段时,Multica 会按以下顺序从常见 header 和 body 字段
|
||||
推断:`X-GitHub-Event` + body `action`,`X-Gitlab-Event`、
|
||||
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
|
||||
名退化为 `webhook.received`。
|
||||
|
||||
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
|
||||
表单编码的 webhook payload 在 v1 里不接受。
|
||||
|
||||
### URL 即 bearer secret
|
||||
|
||||
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
|
||||
|
||||
- **不要贴到公开 issue 评论、截图、聊天记录里。**
|
||||
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
|
||||
旧 URL 立即失效。
|
||||
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线;v1 URL 仅
|
||||
bearer。
|
||||
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
|
||||
权限可见性是后续工作。
|
||||
|
||||
### 状态码语义
|
||||
|
||||
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
|
||||
机制反复打:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
—— 已派发一次 run。
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
—— 受派智能体的 runtime 离线,记为 `skipped` run。
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
|
||||
|
||||
非 2xx 是真正的失败:
|
||||
|
||||
- `400` —— 无效 JSON、scalar body、空 body。
|
||||
- `404` —— 未知 token(`{"error":"webhook not found"}`)。
|
||||
- `413` —— 请求体超过 256 KiB。
|
||||
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
|
||||
|
||||
### 自托管:配置公开 URL
|
||||
|
||||
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
|
||||
触发器响应里会带绝对的 `webhook_url`,UI 直接显示可复制的 URL。没设
|
||||
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
|
||||
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
|
||||
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
|
||||
指向攻击者域名的 webhook URL。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual` / `webhook`)
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 开始时间、完成时间
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed`)
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(失败或跳过时)
|
||||
- 失败原因(如果失败)
|
||||
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
@@ -162,10 +72,7 @@ curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
|
||||
## 暂不可用的能力
|
||||
|
||||
**API 类型触发器尚未接入。** 触发器 schema 里保留了 `api` 类型但没有
|
||||
入站路由会触发它;UI 会给已有的此类记录打 Deprecated 标签,也不显示
|
||||
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
|
||||
的事件预设是后续工作;v1 URL 仅 bearer。
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -79,20 +79,6 @@ 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 |
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica squad list` | List squads in the workspace |
|
||||
| `multica squad get <id>` | Show a single squad |
|
||||
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
|
||||
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
|
||||
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
|
||||
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
|
||||
|
||||
See [Squads](/squads) for the full model.
|
||||
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
@@ -79,20 +79,6 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | 列出工作区里的小队 |
|
||||
| `multica squad get <id>` | 查看一个小队 |
|
||||
| `multica squad create --name "..." --leader <agent>` | 创建小队(owner / admin)|
|
||||
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
|
||||
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
|
||||
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
|
||||
|
||||
完整模型见 [小队](/squads)。
|
||||
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
|
||||
@@ -160,7 +160,6 @@ Chinese term reference:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -160,7 +160,6 @@ Multica 的产品名词分两类:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -35,28 +35,14 @@ These are the core variables you must think about before deploying — some have
|
||||
|
||||
## Email configuration
|
||||
|
||||
Multica supports two delivery backends — [Resend](https://resend.com/) for cloud deployments, or an SMTP relay for internal / on-premise networks. `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
### Resend
|
||||
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | empty | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account; also reused as the `From:` header when SMTP is in use) |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission; **port 465 (SMTPS / implicit TLS) is not supported** |
|
||||
| `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay |
|
||||
| `SMTP_PASSWORD` | empty | SMTP password |
|
||||
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
|
||||
|
||||
**Behavior when neither is set**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
@@ -128,25 +114,6 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
|
||||
|
||||
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
|
||||
|
||||
## Rate limiting (optional Redis)
|
||||
|
||||
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
|
||||
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
|
||||
|
||||
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
|
||||
|
||||
<Callout type="warning">
|
||||
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
|
||||
</Callout>
|
||||
|
||||
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
|
||||
|
||||
## Daemon tuning parameters
|
||||
|
||||
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
|
||||
|
||||
@@ -35,28 +35,14 @@ Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量
|
||||
|
||||
## 怎么配邮件
|
||||
|
||||
Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合公网部署,SMTP relay 适合内网/自部署。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
### Resend
|
||||
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头)|
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | 空 | SMTP relay 主机名。设置后即启用 SMTP 模式并覆盖 Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`;**暂不支持 465(SMTPS / 隐式 TLS)** |
|
||||
| `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay |
|
||||
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
|
||||
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s,整个 SMTP 会话有 30s deadline,避免 relay 黑洞把 auth handler 挂死。
|
||||
|
||||
**两种都不设的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发方便(你从 server 日志里抄验证码);**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
**不设 `RESEND_API_KEY` 时的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
@@ -128,25 +114,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
|
||||
|
||||
## 速率限制(可选 Redis)
|
||||
|
||||
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**(fail-open),后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | 空 | Redis 连接 URL(例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
|
||||
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
|
||||
|
||||
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
|
||||
|
||||
<Callout type="warning">
|
||||
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP,整个部署落到同一个桶里,`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`;Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
|
||||
</Callout>
|
||||
|
||||
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
|
||||
|
||||
## 守护进程的调节参数
|
||||
|
||||
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
|
||||
|
||||
@@ -16,10 +16,6 @@ Same as mentioning a member — type `@` to open the picker and select an agent.
|
||||
|
||||
The `@mention` Markdown syntax, the picker, and `@all` semantics are covered in [**Comments**](/comments).
|
||||
|
||||
<Callout type="info">
|
||||
**You can also `@`-mention a [squad](/squads) in a comment.** The same picker surfaces squads alongside members and agents; selecting one inserts `[@SquadName](mention://squad/<uuid>)` and triggers the squad's **leader agent** to coordinate a response — assignee and status stay untouched.
|
||||
</Callout>
|
||||
|
||||
## How it differs from assignment
|
||||
|
||||
Both put the agent to work, but the mechanics are entirely different:
|
||||
@@ -57,7 +53,6 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
|
||||
|
||||
## Next
|
||||
|
||||
- [**Squads**](/squads) — `@`-mention a squad to have the leader route the question to the right member
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics
|
||||
|
||||
@@ -16,10 +16,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
`@mention` 的 Markdown 语法、picker 的用法、`@all` 的语义见 [**评论**](/comments)。
|
||||
|
||||
<Callout type="info">
|
||||
**`@` 也可以指向 [小队(squad)](/squads)。** picker 里小队和成员、智能体并列;选中后会插入 `[@SquadName](mention://squad/<uuid>)`,触发小队的**队长智能体**来协调响应——assignee 和 status 都不会变。
|
||||
</Callout>
|
||||
|
||||
## 和分配的差别
|
||||
|
||||
同样是让智能体工作,但机制完全不同:
|
||||
@@ -57,7 +53,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**小队**](/squads) —— `@` 一个小队,由队长把问题派给合适的成员
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---智能体怎么运行---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
|
||||
@@ -45,10 +45,6 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
</Callout>
|
||||
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -63,9 +59,7 @@ Before any public deployment, make sure `.env` has `APP_ENV=production` and `MUL
|
||||
|
||||
Without email configured, your users can't receive verification codes by email; the server prints generated codes to stdout instead.
|
||||
|
||||
Two delivery backends are supported — pick whichever fits your network:
|
||||
|
||||
**Option A — Resend (cloud / public-internet deployments):**
|
||||
To actually send verification emails:
|
||||
|
||||
1. Sign up at [Resend](https://resend.com/) and get an API key
|
||||
2. Verify a sending domain you control
|
||||
@@ -76,80 +70,36 @@ Two delivery backends are supported — pick whichever fits your network:
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Option B — SMTP relay (internal networks / on-premise):**
|
||||
4. Restart: `docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com`, or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over Resend when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
Then restart: `docker compose -f docker-compose.selfhost.yml restart backend`.
|
||||
|
||||
For more auth configuration (OAuth, signup allowlist) and the full SMTP variable reference, see [Auth setup](/auth-setup) and [Environment variables → Email](/environment-variables#email-configuration).
|
||||
For more auth configuration (OAuth, signup allowlist), see [Auth setup](/auth-setup).
|
||||
|
||||
## 4. First login + create a workspace
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- Enter your email
|
||||
- Grab the verification code from your configured email backend (Resend or SMTP relay); if neither is configured, copy it from the server container stdout — look for the `[DEV] Verification code` line
|
||||
- Grab the verification code from the Resend email (or, if you haven't configured Resend, from the server container stdout — look for the `[DEV] Verification code` line)
|
||||
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
|
||||
- Log in and create your first workspace
|
||||
|
||||
## 5. Point the CLI at your own server
|
||||
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
|
||||
|
||||
### 5a. Same machine
|
||||
```bash
|
||||
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
|
||||
```
|
||||
|
||||
If the CLI and the server run on the same host, the defaults already work:
|
||||
If you're running everything on one local machine:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
|
||||
|
||||
### 5b. Cross-machine: front with a reverse proxy
|
||||
|
||||
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<your-domain> \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
|
||||
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
|
||||
## 6. Create an agent + assign your first task
|
||||
|
||||
@@ -158,7 +108,7 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
## Common issues
|
||||
|
||||
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
|
||||
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **Verification code not received**: Resend isn't configured → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -44,10 +44,6 @@ make selfhost
|
||||
- **前端**:[http://localhost:3000](http://localhost:3000)
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
|
||||
</Callout>
|
||||
|
||||
## 2. 重要:保持生产安全配置
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -62,9 +58,7 @@ make selfhost
|
||||
|
||||
如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
|
||||
|
||||
支持两种发送通道,按部署环境二选一:
|
||||
|
||||
**Option A — Resend(公网/云端部署):**
|
||||
要真的发验证码邮件:
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 注册并拿一个 API key
|
||||
2. 验证一个你控制的发件域名
|
||||
@@ -75,80 +69,36 @@ make selfhost
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
**Option B — SMTP relay(内网/自部署):**
|
||||
4. 重启:`docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 Resend。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
|
||||
|
||||
更多 auth 配置(OAuth、注册白名单)以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
|
||||
更多 auth 配置(OAuth、注册白名单)见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 4. 首次登录 + 创建工作区
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
## 5. 连接命令行工具到你自己的 server
|
||||
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**:
|
||||
|
||||
### 5a. 同一台机器
|
||||
```bash
|
||||
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
|
||||
```
|
||||
|
||||
CLI 和 server 在同一台机器上时,默认参数就够用:
|
||||
本地就是一台电脑跑整套的话:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
会自动连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend),引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**。
|
||||
默认连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend)。
|
||||
|
||||
### 5b. 跨机访问:用反向代理把服务挡在前面
|
||||
|
||||
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<你的域名> \
|
||||
--app-url https://<你的域名>
|
||||
```
|
||||
|
||||
最小可用的 Caddyfile,单域名同时挂前后端(带 WebSocket 转发,daemon 和网页端都依赖):
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket 路由——必须在 catch-all 之前
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# 其它请求 → 前端
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket),关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
|
||||
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
|
||||
|
||||
## 6. 创建智能体 + 分配第一个任务
|
||||
|
||||
@@ -157,7 +107,7 @@ multica.example.com {
|
||||
## 常见问题
|
||||
|
||||
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
|
||||
- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **验证码收不到**:没配 Resend → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: Squads
|
||||
description: "A squad is a group of agents (and optionally human members) led by one designated leader agent. Assign an issue to a squad and the leader decides who picks it up."
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A squad is a **named group of [agents](/agents) and human [members](/members-roles)**, with one designated **leader agent**. The squad is itself a first-class assignee: pick it from any **Assignee** picker and the leader takes the trigger, reads the issue, then `@`-mentions the squad member best suited to do the work. Squads let you assemble specialists once and dispatch them **by topic instead of by name** — the team grows, the routing stays the same.
|
||||
|
||||
## What a squad is, in mechanics
|
||||
|
||||
- **One leader, many members.** The leader must be an agent; members can be agents or human members. A squad with only the leader is allowed (the leader briefing notes "no other members"), and the same agent can sit in multiple squads.
|
||||
- **Assignable everywhere a person is.** Squads appear in the Assignee picker, the @-mention picker, and the quick-create modal — anywhere you'd pick an agent or member, you can pick a squad.
|
||||
- **Soft-deleted via archive.** Archive a squad and it disappears from pickers and lists; any issue currently assigned to it is **transferred to the leader agent** so the work doesn't go silent. Archived squads can't be assigned to new issues.
|
||||
|
||||
## When to use a squad versus a single agent
|
||||
|
||||
| Pick a squad when… | Pick a single agent when… |
|
||||
|---|---|
|
||||
| You have several specialists and don't know which one fits this issue in advance | The work is well-scoped to one specialty and you know who should do it |
|
||||
| You want one stable assignee (the squad) while the actual responder changes per issue | You want the agent's name on the issue and clear individual accountability |
|
||||
| You want a `@FrontendTeam` style routing target in comments | One-on-one `@agent-name` is enough |
|
||||
|
||||
The squad doesn't add capability — it adds **routing**. The members are still ordinary agents; the leader's only job is to pick the right one.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Who can do it |
|
||||
|---|---|
|
||||
| Create / update / archive a squad | Workspace **owner** or **admin** |
|
||||
| Add or remove members, change roles | Workspace **owner** or **admin** |
|
||||
| Assign an issue to a squad | Any workspace member (same as assigning to an agent) |
|
||||
| `@`-mention a squad in a comment | Any workspace member |
|
||||
| Record a squad-leader evaluation | The squad leader agent only (via CLI) |
|
||||
|
||||
The full role matrix lives in [Members and roles](/members-roles).
|
||||
|
||||
## Create a squad
|
||||
|
||||
In the sidebar, open **Squads → New squad** and fill in:
|
||||
|
||||
- **Name** — e.g. `Frontend Team`, `Bug Triage`. Doesn't need to be unique within the workspace.
|
||||
- **Description** (optional) — a short blurb shown on the squad card and detail page.
|
||||
- **Leader** — pick an existing agent. The leader is added to the squad automatically with role `leader`.
|
||||
|
||||
After creation, open the squad's detail page to:
|
||||
|
||||
- **Add members** — pick agents or human members, optionally give each a short role description (e.g. "owns the migrations", "reviewer of last resort"). The leader uses these roles when deciding who to delegate to.
|
||||
- **Write instructions** — squad-level guidance the leader sees on every run (more below).
|
||||
- **Set an avatar** — picked from the same picker used for agents.
|
||||
|
||||
CLI equivalent:
|
||||
|
||||
```bash
|
||||
multica squad create --name "Frontend Team" --leader frontend-lead-agent
|
||||
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
|
||||
```
|
||||
|
||||
## How a squad-assigned issue runs
|
||||
|
||||
When a non-Backlog issue is assigned to a squad, Multica immediately enqueues a `task` for the **leader agent** (not for every member). The flow then looks like this:
|
||||
|
||||
1. **Leader claims the task.** The agent runtime picks up the task on its next poll, same as any other agent assignment.
|
||||
2. **Leader is briefed.** On claim, Multica appends three sections to the leader's system prompt — see [What the leader sees on every turn](#what-the-leader-sees-on-every-turn) below.
|
||||
3. **Leader posts one delegation comment.** The comment `@`-mentions the chosen member(s) using the exact mention markdown from the roster — that mention triggers a new `task` for each mentioned agent.
|
||||
4. **Leader records its evaluation** via `multica squad activity <issue-id> action --reason "..."`. This writes an entry to the issue's activity timeline so humans can see the leader actually evaluated the trigger.
|
||||
5. **Leader stops.** The leader does not do the implementation itself. When the delegated member posts back, the leader is re-triggered to read the update and either delegate the next step, escalate, or stay silent.
|
||||
|
||||
If the issue is in **Backlog**, the leader is not triggered — Backlog is a parking lot, same rule as for direct agent assignment.
|
||||
|
||||
### What the leader sees on every turn
|
||||
|
||||
On each squad-leader run, three blocks are appended to the leader's instructions:
|
||||
|
||||
- **Squad Operating Protocol** — a hard-coded rule set: read the issue, delegate by `@`-mention, be terse (don't restate the issue body — the assignee can read it), record an evaluation every turn, and **stop after dispatching**. This protocol is system-managed and not editable.
|
||||
- **Squad Roster** — the leader's self-row plus one row per non-archived member. Each row carries the exact mention markdown (`[@Name](mention://agent/<uuid>)` or `[@Name](mention://member/<uuid>)`) the leader should paste — typing a plain `@name` won't trigger anyone.
|
||||
- **Squad Instructions** — your custom guidance for this squad (set on the squad detail page or via `multica squad update --instructions`). Use this for routing rules ("send DB work to Alice, frontend to Bob"), escalation policies, or anything else the leader needs to know that isn't already in the issue.
|
||||
|
||||
## When the leader is re-triggered
|
||||
|
||||
After the first dispatch, the leader is woken up automatically by **most subsequent comments** on the issue. The exact rules:
|
||||
|
||||
| Event | Leader triggered? |
|
||||
|---|---|
|
||||
| A non-member (human reporter, external agent) posts a comment | **Yes** |
|
||||
| A squad member posts a progress update with no `@mention` | **Yes** — the leader re-evaluates whether the next step is needed |
|
||||
| Anyone posts a comment that explicitly `@`-mentions another agent / member / squad / `@all` | **No** — the explicit `@` is the routing signal; the leader gets out of the way |
|
||||
| The leader's own comment (self-trigger) | **No** — guarded to prevent a loop |
|
||||
| A comment containing only an issue cross-reference (`[MUL-123](mention://issue/...)`) | **Yes** — issue references aren't routing |
|
||||
|
||||
Dedup applies on top of these rules: if the leader already has a `queued` or `dispatched` task on this issue, a new trigger won't enqueue a duplicate.
|
||||
|
||||
<Callout type="info">
|
||||
**Why the leader doesn't trigger when a member posts an `@`-mention.** Once a squad member directly `@`s someone, that comment is a deliberate hand-off — having the leader wake up to "observe" the routing would just produce a no-op turn and clutter the timeline. Agent-authored comments are the exception: when an agent posts a result that `@`s another agent, the leader still wakes up so it can coordinate the thread.
|
||||
</Callout>
|
||||
|
||||
## `@`-mention a squad in a comment
|
||||
|
||||
Squads appear in the `@` picker alongside members and agents. Mentioning a squad inserts `[@SquadName](mention://squad/<uuid>)` and triggers the **squad leader** as if you had assigned the issue to the squad — without changing the assignee or the status. Use this when you want the squad to pick someone for a question or sub-task while keeping the current owner.
|
||||
|
||||
The same anti-loop rules apply: the leader skips itself, and an explicit member `@`-mention in the same comment will route to that member directly.
|
||||
|
||||
## Reassign or archive a squad
|
||||
|
||||
**Reassigning an issue away from a squad** behaves like any other assignee change: all of the issue's active tasks (including the leader's) are cancelled, and the new assignee — agent, member, or another squad — is enqueued. There is no separate "remove squad without changing assignee" action; pick a different assignee.
|
||||
|
||||
**Archiving a squad** (`multica squad delete <id>`, or the Archive button on the detail page):
|
||||
|
||||
1. **Transfers issues currently assigned to the squad to the leader agent**, so the work continues against a concrete agent instead of going silent.
|
||||
2. Marks the squad with `archived_at` / `archived_by` — the row is preserved so historical activity entries still resolve, but the squad disappears from lists, pickers, and the @-mention dropdown.
|
||||
3. **Rejects future assignments** to this squad with `cannot assign to an archived squad`.
|
||||
|
||||
There is currently no unarchive command; create a new squad if you need the routing back.
|
||||
|
||||
## Squad operations from the CLI
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica squad list` | List squads in the workspace |
|
||||
| `multica squad get <id>` | Show one squad's name, leader, description, instructions |
|
||||
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
|
||||
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | Update one or more fields |
|
||||
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
|
||||
| `multica squad member list <id>` | List a squad's members |
|
||||
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | Add a member (owner / admin) |
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
|
||||
|
||||
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
|
||||
|
||||
## Next
|
||||
|
||||
- [Assign issues to agents](/assigning-issues) — same flow, applies to squad assignees too
|
||||
- [`@`-mention agents in comments](/mentioning-agents) — the `@` picker also surfaces squads
|
||||
- [Agents](/agents) — what an agent is, the building block of every squad
|
||||
- [Members and roles](/members-roles) — the full owner / admin / member permission matrix
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: 小队
|
||||
description: 小队(squad)是一组智能体(可选附带成员),由一名指定的"队长"智能体(leader)领导。把 issue 分配给小队,队长来决定谁接手。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
小队(squad)是一组 [智能体](/agents) 和 [人类成员](/members-roles) 的**命名集合**,其中有一名指定的**队长(leader),必须是智能体**。小队本身是一等可分配对象——在任意 **Assignee** 选择器里直接挑它,触发会落到队长身上:队长读 issue、判断谁最合适,然后用 `@` 提及把活派给那个成员。小队让你把一组专家**一次性编好队**,之后**按主题派活,而不是按名字派活**——队伍扩展,路由不变。
|
||||
|
||||
## 小队的运转机制
|
||||
|
||||
- **一个队长,多名成员。** 队长必须是智能体;成员可以是智能体或人类成员。只有队长一个人的小队也是允许的(队长 briefing 会注明"没有其他成员"),同一个智能体也能加入多个小队。
|
||||
- **任何能选人的地方都能选小队。** Assignee picker、@ 提及 picker、快速创建 modal——只要能选智能体或成员的位置,小队都会出现。
|
||||
- **删除走"归档"软删除。** 归档一个小队后,它会从 picker 和列表里消失;当前分配给它的 issue 会被**自动转给队长智能体**,让工作不至于卡住。归档的小队不能再被分配新 issue。
|
||||
|
||||
## 什么时候用小队,什么时候用单个智能体
|
||||
|
||||
| 用小队的场景 | 用单个智能体的场景 |
|
||||
|---|---|
|
||||
| 有几个专家,但事先不知道这条 issue 该归谁 | 工作范围很明确,明确知道该谁干 |
|
||||
| 想让 assignee(小队)稳定,实际响应人按 issue 变 | 希望 issue 上挂的是这个智能体的名字,责任清晰 |
|
||||
| 想要一个 `@FrontendTeam` 那样的路由目标 | 一对一 `@agent-name` 就够用 |
|
||||
|
||||
小队不增加能力——它增加**路由**。成员还是那些智能体,队长唯一的工作是**挑对人**。
|
||||
|
||||
## 权限
|
||||
|
||||
| 操作 | 谁能做 |
|
||||
|---|---|
|
||||
| 创建 / 更新 / 归档小队 | 工作区 **owner** 或 **admin** |
|
||||
| 增删成员、改成员角色 | 工作区 **owner** 或 **admin** |
|
||||
| 把 issue 分配给小队 | 任何工作区成员(和分配给智能体一样)|
|
||||
| 在评论里 `@` 小队 | 任何工作区成员 |
|
||||
| 记录小队队长的 evaluation | 只有队长智能体本人(通过 CLI)|
|
||||
|
||||
完整角色权限对照见 [成员与权限](/members-roles)。
|
||||
|
||||
## 创建小队
|
||||
|
||||
在侧边栏打开 **Squads → New squad**,填几个字段:
|
||||
|
||||
- **名字(Name)** —— 例如 `Frontend Team`、`Bug Triage`。在工作区里**不要求唯一**。
|
||||
- **描述(Description,可选)** —— 一句话简介,展示在小队卡片和详情页上。
|
||||
- **队长(Leader)** —— 选一个已有的智能体。创建后队长会自动以 `leader` 角色加入小队。
|
||||
|
||||
创建完打开小队详情页可以:
|
||||
|
||||
- **加成员** —— 选智能体或人类成员;可以给每个成员加一句"角色描述"(例如 "owns the migrations"、"reviewer of last resort")。队长派活时会参考这些角色。
|
||||
- **写 instructions** —— 小队级别的指令,队长每次执行都能看到(见下文)。
|
||||
- **设头像** —— 用和智能体一样的头像选择器。
|
||||
|
||||
CLI 等价命令:
|
||||
|
||||
```bash
|
||||
multica squad create --name "Frontend Team" --leader frontend-lead-agent
|
||||
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
|
||||
```
|
||||
|
||||
## 分配给小队的 issue 是怎么跑的
|
||||
|
||||
非 Backlog 状态的 issue 一旦分配给小队,Multica 会立刻给**队长智能体**入队一个 `task`(不是给每个成员都入一个)。整个流程是这样的:
|
||||
|
||||
1. **队长领走 task。** 队长所在的 daemon 在下次轮询时把 task 领走,和普通智能体的分配流程一样。
|
||||
2. **队长拿到 briefing。** 领走的瞬间,Multica 会在队长的系统提示后面追加三段内容——详见下文 [队长每次执行看到的内容](#队长每次执行看到的内容)。
|
||||
3. **队长发一条"派活"评论。** 评论里用 roster 里给好的 mention markdown `@` 选中的成员——这个 `@` 会触发被派的成员入队新 `task`。
|
||||
4. **队长记录 evaluation:** `multica squad activity <issue-id> action --reason "..."`。这一行会写进 issue 的 activity 时间线,方便人类回溯队长确实评估过这一次触发。
|
||||
5. **队长停下。** 派完活,队长**不动手干活**。当被派的成员有回复时,队长会被自动唤醒,决定下一步:继续派活、上抛给人类、还是保持沉默。
|
||||
|
||||
如果 issue 是 **Backlog** 状态,队长不会被触发——Backlog 是停泊场,规则和直接分配给智能体一样。
|
||||
|
||||
### 队长每次执行看到的内容
|
||||
|
||||
每次队长被触发,三段内容会被附加到它的 instructions 上:
|
||||
|
||||
- **Squad Operating Protocol(小队工作规范)** —— 一段硬编码的规则集:读 issue → 用 `@` 派活 → 简洁(**不要**复述 issue 内容,被派的成员自己能读)→ 每次都记 evaluation → **派完就停**。这段是系统管理的,不可编辑。
|
||||
- **Squad Roster(小队花名册)** —— 队长自己一行 + 每个未归档成员一行。每一行带上**确切可用**的 mention markdown(`[@Name](mention://agent/<uuid>)` 或 `[@Name](mention://member/<uuid>)`)让队长直接复制——纯文本 `@name` 是**不会**触发任何人的。
|
||||
- **Squad Instructions(小队自定义指令)** —— 你为这个小队写的私货(在详情页里编辑,或用 `multica squad update --instructions`)。用来写路由规则("DB 相关派给 Alice,前端派给 Bob")、上报策略,或者任何 issue 本身不会有的背景。
|
||||
|
||||
## 队长什么时候会被再次触发
|
||||
|
||||
第一次派活完之后,**大多数后续评论**都会自动唤醒队长。具体规则:
|
||||
|
||||
| 事件 | 触发队长?|
|
||||
|---|---|
|
||||
| 非小队成员(人类 reporter、外部智能体)发评论 | **会** |
|
||||
| 小队成员发"进展更新",**不带任何** `@mention` | **会**——队长重新评估是否需要下一步 |
|
||||
| 任何人发的评论里**显式 `@`** 智能体 / 成员 / 小队 / `@all` | **不会**——显式 `@` 就是路由信号,队长让位 |
|
||||
| 队长自己发的评论 | **不会**——硬编码防自触发 |
|
||||
| 评论里只有 issue 互链 `[MUL-123](mention://issue/...)` | **会**——issue 引用不算路由 |
|
||||
|
||||
以上规则之上还有去重:如果队长在这个 issue 上已经有 `queued` 或 `dispatched` 的 task,新一次触发不会重复入队。
|
||||
|
||||
<Callout type="info">
|
||||
**为什么成员发的 `@` 评论不会唤醒队长。** 小队成员一旦直接 `@` 谁,那条评论就是**有意识的交接**——再让队长唤醒一次"观察"路由,只会产出一次空回合、把时间线搞乱。智能体作者的评论是个例外:当某个智能体发出一条结果还顺手 `@` 了另一个智能体时,队长仍然会被唤醒,以便协调整条线程。
|
||||
</Callout>
|
||||
|
||||
## 在评论里 `@` 一个小队
|
||||
|
||||
小队会出现在 `@` picker 里,和成员、智能体并列。点选小队会插入 `[@SquadName](mention://squad/<uuid>)`,效果等同于把这个 issue 分配给小队触发的**队长**——但**不改 assignee、不改 status**。适合"我想让小队挑个人回答一下/做一小步,但 issue 还归原来的人"这种场景。
|
||||
|
||||
防循环规则同样适用:队长跳过自己;同一条评论里如果还显式 `@` 了某个成员,路由会直接落到那个成员。
|
||||
|
||||
## 重新分配或归档一个小队
|
||||
|
||||
**把分配人从小队改成别的**,行为和换 assignee 完全一致:当前 issue 上所有活跃 task(包括队长的)会被取消,新的 assignee(智能体、成员、或另一个小队)被入队。没有"不改 assignee 只移除小队"的单独操作;要换就选新的 assignee。
|
||||
|
||||
**归档小队**(`multica squad delete <id>`,或详情页的 Archive 按钮):
|
||||
|
||||
1. **当前分配给这个小队的 issue 会被自动转给队长智能体**,让工作落到一个具体智能体上,避免无人接手。
|
||||
2. 在 squad 表上写入 `archived_at` / `archived_by`——记录被保留下来,历史的 activity 还能解析;但从列表、picker、`@` 下拉里它都消失。
|
||||
3. **拒绝后续分配**——`cannot assign to an archived squad`。
|
||||
|
||||
目前没有"反归档"命令;要恢复路由,重新建一个小队即可。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | 列出工作区里的小队 |
|
||||
| `multica squad get <id>` | 查看小队的名字、队长、描述、instructions |
|
||||
| `multica squad create --name "..." --leader <agent>` | 创建小队(owner / admin)|
|
||||
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | 修改一个或多个字段 |
|
||||
| `multica squad delete <id>` | 归档(软删除)——同时把当前分配给小队的 issue 转给队长 |
|
||||
| `multica squad member list <id>` | 列出小队成员 |
|
||||
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 加成员(owner / admin)|
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
|
||||
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [分配 issue 给智能体](/assigning-issues) —— 流程相同,对小队 assignee 也适用
|
||||
- [在评论里 `@` 智能体](/mentioning-agents) —— `@` picker 同样能选到小队
|
||||
- [智能体](/agents) —— 小队的"零件"
|
||||
- [成员与权限](/members-roles) —— owner / admin / member 的完整权限对照
|
||||
@@ -18,7 +18,7 @@
|
||||
"fumadocs-ui": "^15.5.2",
|
||||
"lucide-react": "catalog:",
|
||||
"mermaid": "^11.14.0",
|
||||
"next": "^15.5.16",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
|
||||
// Rendered by Next.js as the Suspense fallback during route transitions
|
||||
// inside the (dashboard) segment. Scoped to this segment only — auth /
|
||||
// landing keep their own full-screen fallbacks.
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex h-svh w-full flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<Skeleton className="h-5 w-5 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 p-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { MemberDetailPage } from "@multica/views/members";
|
||||
|
||||
export default function MemberDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <MemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { SquadDetailPage as default } from "@multica/views/squads";
|
||||
@@ -1 +0,0 @@
|
||||
export { SquadsPage as default } from "@multica/views/squads";
|
||||
@@ -284,108 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title:
|
||||
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
|
||||
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
|
||||
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
|
||||
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
|
||||
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"Agent runs recover more reliably from stuck commands, idle sessions, and long-running work",
|
||||
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
|
||||
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
|
||||
"Search results are ranked more usefully and include better snippets",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
|
||||
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
|
||||
"Squad pages scroll correctly and show which members are already working",
|
||||
"Desktop zoom shortcuts work again across the common keyboard combinations",
|
||||
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "Faster Navigation, Background Updates & More Reliable Squads",
|
||||
changes: [],
|
||||
features: [
|
||||
"Member and agent detail pages now show related tasks so teams can review who is working on what",
|
||||
"The desktop app downloads updates in the background so a new version is ready when you are",
|
||||
"Self-hosted deployments can send email through SMTP as an alternative to Resend",
|
||||
"Create Squad has a clearer setup flow with member selection that works better for team coordination",
|
||||
],
|
||||
improvements: [
|
||||
"Page transitions are faster, with issue pages prepared ahead of time and smoother loading states",
|
||||
"Long issue activity blocks collapse so comments and conclusions are easier to scan",
|
||||
"Agents and Squads remember the Mine/All view when you return to the list",
|
||||
"Repository setup accepts more SSH URL formats across settings, projects, and quick create",
|
||||
"Squad handoffs are more dependable when agents have multiple roles or delegate to a specific member",
|
||||
],
|
||||
fixes: [
|
||||
"Self-hosted local file cards render and preview correctly",
|
||||
"Agent-run tasks are more dependable when local tools or skills need to be found automatically",
|
||||
"Claude usage totals match more of the model names reported by connected tools",
|
||||
"After switching workspaces, live updates come from the correct workspace and show the right source",
|
||||
"Chat session menus and runtime names hold their shape in narrower spaces",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
title: "Squads & Attachment Previews",
|
||||
changes: [],
|
||||
features: [
|
||||
"Squads let teams assign work to a group, with a leader agent coordinating the next step",
|
||||
"Attachments can be previewed in place for PDFs, audio, video, markdown, code, logs, and plain text",
|
||||
"Chinese names can be found by pinyin across mentions, assignees, subscribers, agents, projects, and squads",
|
||||
],
|
||||
improvements: [
|
||||
"Squad pages now include member management, faster agent creation from a squad, clearer row actions, and a wider detail layout",
|
||||
"Quick-create and picker flows are easier to search and now include squad-aware routing",
|
||||
"Usage charts can switch between cost and token views, with the same timezone controls used by runtimes",
|
||||
"Workspace operators get command-line controls for managing squads and stopping a runaway issue run",
|
||||
"Shared interface labels are translated more consistently in English and Chinese",
|
||||
],
|
||||
fixes: [
|
||||
"Squad leaders stay quiet when a human already routed the conversation to someone specific",
|
||||
"Mentioning a squad now wakes the right leader while preserving private-agent access rules",
|
||||
"Issue lists stay fresher after deletes and follow-up comments no longer trigger stale Done replies",
|
||||
"Attachment previews keep working for files added while writing or editing issues and comments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.32",
|
||||
date: "2026-05-13",
|
||||
title: "Usage Insights, Chat Renaming & Smoother Desktop Flows",
|
||||
changes: [],
|
||||
features: [
|
||||
"Usage now shows workspace and project token activity, runtime trends, and per-agent rankings in one place",
|
||||
"Chat sessions can be renamed directly from the chat header",
|
||||
"Feedback reports can include screenshots or files so teams have the context they need",
|
||||
],
|
||||
improvements: [
|
||||
"The Usage page has clearer naming and a more dynamic agent leaderboard",
|
||||
"New chats and completed chat responses update more smoothly with fewer loading flashes",
|
||||
"Self-hosted GitHub setup is easier to configure and the setup docs point to the right cloud URL",
|
||||
"User-installed Codex skills are available automatically when new tasks run",
|
||||
],
|
||||
fixes: [
|
||||
"Empty successful agent responses are marked completed instead of blocked",
|
||||
"Pasted mentions in instruction editors keep their mention links",
|
||||
"Desktop attachment downloads use the native Linux flow and tab closing no longer loops",
|
||||
"Gemini and Windows runtime startup checks are more reliable in unattended runs",
|
||||
"Long GitHub repository lists stay usable when adding project resources",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
|
||||
@@ -284,107 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
|
||||
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
|
||||
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
|
||||
"Skills 支持从本地 runtime 批量复制到 workspace,团队初始化更快",
|
||||
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"Agent 运行在遇到卡住的命令、空闲会话和长时间任务时更容易恢复",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
|
||||
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
|
||||
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
|
||||
"桌面端缩放快捷键在常见组合下恢复正常",
|
||||
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "更快的导航、后台更新与更可靠的小队协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"成员和 agent 详情页现在可以看到关联任务,方便回看每个人和每个 agent 正在推进的工作",
|
||||
"桌面端会在后台提前下载新版本,等你准备好时再安装更新",
|
||||
"自托管部署可以使用 SMTP 发送邮件,不再只依赖 Resend",
|
||||
"创建 Squad 的流程更清晰,成员选择和初始设置更适合团队协作",
|
||||
],
|
||||
improvements: [
|
||||
"页面切换更快,Issue 页面会提前准备内容,并在加载时展示更自然的过渡状态",
|
||||
"Issue 时间线会把较长的活动记录收起,重点评论和结论更容易扫读",
|
||||
"Agents 和 Squads 页会记住你上次选择的 Mine/All 视图,返回列表时不再重置",
|
||||
"仓库设置、项目资源和快速创建流程更好地支持 SSH 形式的仓库地址",
|
||||
"小队分工更稳定,leader 能正确接续双角色 agent 的回复,也会更明确地把任务交给指定成员",
|
||||
],
|
||||
fixes: [
|
||||
"自托管本地文件卡片可以正常展示和预览",
|
||||
"Agent 在自动寻找本地工具、加载技能以及无人值守运行时更可靠",
|
||||
"Claude 用量统计能识别更多接入工具上报的模型名称",
|
||||
"切换 workspace 后,实时更新会来自正确的 workspace,消息来源也更准确",
|
||||
"聊天会话下拉菜单和 runtime 名称展示在窄空间里更稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
title: "Squads 与附件预览",
|
||||
changes: [],
|
||||
features: [
|
||||
"Squads 支持把任务交给一个小组,由 leader agent 负责协调下一步",
|
||||
"附件可以直接预览,支持 PDF、音频、视频、Markdown、代码、日志和纯文本",
|
||||
"中文姓名支持用拼音搜索,适用于 mention、负责人、订阅人、agents、projects 和 squads",
|
||||
],
|
||||
improvements: [
|
||||
"Squad 页面补齐成员管理、从 squad 内快速创建 agent、清晰的成员操作按钮,以及更宽的详情布局",
|
||||
"快速创建和各类选择器更容易搜索,并能识别 squad 相关的指派和提及",
|
||||
"Usage 图表可以在费用和 token 视图之间切换,并复用 runtime 的时区控制",
|
||||
"工作区管理员可以通过命令行管理 squads,并在必要时停止失控的 issue 执行",
|
||||
"共享界面文案的中英文翻译更完整",
|
||||
],
|
||||
fixes: [
|
||||
"当成员已经明确把讨论指向某个人或小组时,Squad leader 不再重复发言",
|
||||
"提及 squad 时会正确唤起对应 leader,同时保留私有 agent 的访问限制",
|
||||
"删除 Issue 后列表刷新更准确,后续评论也不再触发过期的 Done 回复",
|
||||
"在撰写或编辑 issue 和评论时新增的附件,也可以稳定使用预览",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.32",
|
||||
date: "2026-05-13",
|
||||
title: "用量洞察、聊天重命名与桌面体验优化",
|
||||
changes: [],
|
||||
features: [
|
||||
"Usage 页面集中展示 workspace 和 project 的 token 使用、runtime 趋势和 agent 排名",
|
||||
"聊天会话可以直接在聊天页顶部重命名",
|
||||
"反馈时可以附带截图或文件,方便团队快速理解问题",
|
||||
],
|
||||
improvements: [
|
||||
"Dashboard 更名为 Usage,并加入更清晰的 agent 排行展示",
|
||||
"新聊天和消息完成状态切换更顺,不再频繁闪加载状态",
|
||||
"自托管 GitHub 配置更完整,文档里的云端链接也已修正",
|
||||
"用户安装的 Codex Skills 会自动带入新的 agent 任务",
|
||||
],
|
||||
fixes: [
|
||||
"没有输出内容但成功完成的 agent 任务会显示为 completed,不再误判为 blocked",
|
||||
"在指令编辑器中粘贴的 mention 会保留可点击链接",
|
||||
"Linux 桌面端下载附件时走系统原生流程,关闭标签页也不再触发循环跳转",
|
||||
"Gemini 和 Windows runtime 的启动检查更稳定,适合无人值守执行",
|
||||
"添加项目资源时,较长的 GitHub 仓库列表可以正常滚动",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"linkify-it": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "^16.2.5",
|
||||
"next": "^16.2.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
|
||||
@@ -24,12 +24,6 @@ function NavigationProviderInner({
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
getShareableUrl: (path: string) =>
|
||||
typeof window === "undefined" ? path : window.location.origin + path,
|
||||
// router.prefetch is a no-op in dev mode by Next.js design; in production
|
||||
// it warms the RSC payload + route chunk so the next push() commits with
|
||||
// no network round-trip. Safe to call repeatedly — Next dedupes internally.
|
||||
prefetch: (path: string) => {
|
||||
router.prefetch(path);
|
||||
},
|
||||
};
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
|
||||
#
|
||||
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
|
||||
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
|
||||
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
|
||||
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
|
||||
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
|
||||
# with the default JWT_SECRET and Postgres credentials. See:
|
||||
# apps/docs/content/docs/self-host-quickstart.mdx
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# # Edit .env — change JWT_SECRET at minimum
|
||||
@@ -26,7 +18,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
@@ -42,7 +34,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:${PORT:-8080}:8080"
|
||||
- "${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
environment:
|
||||
@@ -54,11 +46,6 @@ services:
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||
@@ -76,19 +63,6 @@ services:
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
||||
# Public URL the API is reachable at from the open internet, no
|
||||
# trailing slash. Used to mint absolute webhook URLs for autopilot
|
||||
# webhook triggers. Leave unset behind a same-origin reverse proxy
|
||||
# (e.g. plain localhost dev); the frontend will compose the URL
|
||||
# from window.origin + webhook_path in that case. Headers are
|
||||
# intentionally NOT used to derive this value, to avoid Host /
|
||||
# X-Forwarded-Host spoofing on misconfigured proxies.
|
||||
MULTICA_PUBLIC_URL: ${MULTICA_PUBLIC_URL:-}
|
||||
# Comma-separated CIDRs whose source IP is allowed to set
|
||||
# X-Forwarded-For / X-Real-IP for the webhook per-IP rate limiter.
|
||||
# Empty default = headers ignored, RemoteAddr used. Set e.g.
|
||||
# "127.0.0.1/32" when running behind a same-host reverse proxy.
|
||||
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
@@ -96,7 +70,7 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "127.0.0.1:5432:5432"
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
useAgentsViewStore,
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
@@ -1,96 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useAgentsViewStore } from "./view-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAgentsViewStore.setState({ scope: "mine" });
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("useAgentsViewStore", () => {
|
||||
it("defaults to 'mine'", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("setScope mutates the store", () => {
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
it("rehydrates a different saved scope on workspace switch", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:beta",
|
||||
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type AgentsScope = "mine" | "all";
|
||||
|
||||
export interface AgentsViewState {
|
||||
scope: AgentsScope;
|
||||
setScope: (scope: AgentsScope) => void;
|
||||
}
|
||||
|
||||
export const useAgentsViewStore = create<AgentsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: "multica_agents_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
// 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.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, scope: "mine" };
|
||||
return { ...current, ...(persisted as Partial<AgentsViewState>) };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useAgentsViewStore.persist.rehydrate());
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions, squadListOptions } from "../workspace/queries";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
import { agentTaskSnapshotOptions } from "./queries";
|
||||
|
||||
// Subscribe to the queries that power agent presence and the @mention
|
||||
// suggestion list so they're warm by the time any hover card / inline
|
||||
// indicator / mention popup first renders. Without this warm-up, surfaces
|
||||
// that don't otherwise touch the snapshot (inbox, issues, chat) flash a
|
||||
// skeleton on first hover while the fetch is in flight, and the @mention
|
||||
// list may show incomplete results (e.g. missing squads).
|
||||
// Subscribe to the three queries that power agent presence so they're warm
|
||||
// by the time any hover card / inline indicator first renders. Without this
|
||||
// warm-up, surfaces that don't otherwise touch the snapshot (inbox, issues,
|
||||
// chat) flash a skeleton on first hover while the fetch is in flight.
|
||||
//
|
||||
// useRealtimeSync (WS task / agent / daemon / squad invalidations) and the
|
||||
// 30s presence tick keep these caches fresh after the initial fetch — this
|
||||
// hook only collapses the cold-start window.
|
||||
// useRealtimeSync (WS task / agent / daemon invalidations) and the 30s
|
||||
// presence tick keep these caches fresh after the initial fetch — this hook
|
||||
// only collapses the cold-start window.
|
||||
//
|
||||
// All queries are workspace-scoped; the queryKeys include wsId so workspace
|
||||
// All three are workspace-scoped; the queryKeys include wsId so workspace
|
||||
// switch automatically refetches the new workspace's data with no extra
|
||||
// wiring here. The workspace-scoped layouts on both apps gate rendering on
|
||||
// "workspace resolved", so callers can safely pass useWorkspaceId() — by the
|
||||
@@ -25,5 +23,4 @@ export function useWorkspacePresencePrefetch(wsId: string | undefined): void {
|
||||
useQuery({ ...agentListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...runtimeListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...agentTaskSnapshotOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...squadListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ describe("ApiClient", () => {
|
||||
});
|
||||
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
||||
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
||||
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
|
||||
|
||||
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
||||
url,
|
||||
@@ -105,10 +104,6 @@ describe("ApiClient", () => {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
|
||||
method: "POST",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -205,60 +200,6 @@ describe("ApiClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentTextContent", () => {
|
||||
it("returns body text and the original content type from the X-* header", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("# heading\n\nbody\n", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Original-Content-Type": "text/markdown",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const { text, originalContentType } =
|
||||
await client.getAttachmentTextContent("att-1");
|
||||
|
||||
expect(text).toBe("# heading\n\nbody\n");
|
||||
expect(originalContentType).toBe("text/markdown");
|
||||
});
|
||||
|
||||
it("throws PreviewTooLargeError on 413", async () => {
|
||||
const { PreviewTooLargeError } = await import("./client");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("", { status: 413, statusText: "Payload Too Large" }),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
||||
PreviewTooLargeError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws PreviewUnsupportedError on 415", async () => {
|
||||
const { PreviewUnsupportedError } = await import("./client");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
||||
PreviewUnsupportedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat attachment wiring", () => {
|
||||
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -2,7 +2,6 @@ import type {
|
||||
Issue,
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
@@ -10,7 +9,6 @@ import type {
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
ListIssuesParams,
|
||||
ListGroupedIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
@@ -47,7 +45,6 @@ import type {
|
||||
DashboardUsageDaily,
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
DashboardRunTimeDaily,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
@@ -89,16 +86,11 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -113,26 +105,17 @@ import {
|
||||
CommentsListSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
EMPTY_WEBHOOK_DELIVERY,
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -223,27 +206,6 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown by getAttachmentTextContent when the server refuses to inline a
|
||||
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
|
||||
// download" affordance with the Download CTA still available.
|
||||
export class PreviewTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("attachment too large for inline preview");
|
||||
this.name = "PreviewTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown by getAttachmentTextContent when the server's text whitelist
|
||||
// rejects the content type. Normally the client's isPreviewable() guard
|
||||
// catches this earlier, but the two whitelists can drift — surfacing the
|
||||
// 415 as a typed error makes the drift visible.
|
||||
export class PreviewUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super("attachment type not supported for inline preview");
|
||||
this.name = "PreviewUnsupportedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
@@ -318,23 +280,15 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends the request with the standard headers (auth, CSRF, request id,
|
||||
// client identity) and runs the shared error path (401 → handleUnauthorized,
|
||||
// structured ApiError, status-aware log level). Returns the raw Response so
|
||||
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
|
||||
// path, plain text for the attachment-preview proxy, etc.
|
||||
private async fetchRaw(
|
||||
path: string,
|
||||
init?: RequestInit & { extraHeaders?: Record<string, string> },
|
||||
): Promise<Response> {
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": rid,
|
||||
...this.authHeaders(),
|
||||
...(init?.extraHeaders ?? {}),
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
|
||||
@@ -355,18 +309,12 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
return res;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await this.fetchRaw(path, {
|
||||
...init,
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -488,36 +436,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
|
||||
const search = new URLSearchParams({ group_by: params.group_by });
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.offset) search.set("offset", String(params.offset));
|
||||
if (params.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
if (params.statuses?.length) search.set("statuses", params.statuses.join(","));
|
||||
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
|
||||
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
|
||||
if (params.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.assignee_filters?.length) {
|
||||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
if (params.include_no_assignee) search.set("include_no_assignee", "true");
|
||||
if (params.creator_filters?.length) {
|
||||
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
|
||||
if (params.include_no_project) search.set("include_no_project", "true");
|
||||
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
|
||||
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
|
||||
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
|
||||
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
|
||||
endpoint: "GET /api/issues/grouped",
|
||||
});
|
||||
}
|
||||
|
||||
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
@@ -545,12 +463,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: {
|
||||
agent_id?: string;
|
||||
squad_id?: string;
|
||||
prompt: string;
|
||||
project_id?: string | null;
|
||||
}): Promise<{ task_id: string }> {
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -637,10 +550,10 @@ export class ApiClient {
|
||||
return this.fetch("/api/assignee-frequency");
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -900,21 +813,6 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardRunTimeDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||||
raw,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/dashboard/runtime/daily" },
|
||||
);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
@@ -1295,13 +1193,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||||
}
|
||||
@@ -1356,38 +1247,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Fetches the raw bytes of a text-previewable attachment.
|
||||
//
|
||||
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
||||
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
||||
// of which would otherwise prevent the renderer from getting the body.
|
||||
// The server always replies with `text/plain; charset=utf-8` for safety;
|
||||
// the original MIME ships back in the `X-Original-Content-Type` header so
|
||||
// the preview dispatcher can choose between markdown / html / plain code.
|
||||
//
|
||||
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
||||
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
||||
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
||||
// the modal can render specific fallbacks instead of generic failure.
|
||||
async getAttachmentTextContent(
|
||||
id: string,
|
||||
): Promise<{ text: string; originalContentType: string }> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 413) throw new PreviewTooLargeError();
|
||||
if (err.status === 415) throw new PreviewUnsupportedError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
text: await res.text(),
|
||||
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// Projects
|
||||
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
@@ -1510,54 +1369,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Squads
|
||||
async listSquads(): Promise<Squad[]> {
|
||||
return this.fetch(`/api/squads`);
|
||||
}
|
||||
|
||||
async getSquad(id: string): Promise<Squad> {
|
||||
return this.fetch(`/api/squads/${id}`);
|
||||
}
|
||||
|
||||
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
|
||||
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
||||
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async deleteSquad(id: string): Promise<void> {
|
||||
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
|
||||
return this.fetch(`/api/squads/${squadId}/members`);
|
||||
}
|
||||
|
||||
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
|
||||
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
|
||||
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
|
||||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// Per-squad members status snapshot: one row per member with derived
|
||||
// working/idle/offline/unstable plus the issues each agent is currently
|
||||
// running. Parsed with a lenient schema so a new server-side status
|
||||
// value or extra field can't white-screen the Squad page (#2143).
|
||||
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
|
||||
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
|
||||
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
|
||||
endpoint: "GET /api/squads/:id/members/status",
|
||||
}) as SquadMemberStatusListResponse;
|
||||
}
|
||||
|
||||
// Autopilots
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
@@ -1598,13 +1409,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
||||
}
|
||||
|
||||
// Returns a single run including its full trigger_payload. List responses
|
||||
// omit trigger_payload to keep them small (a webhook envelope can be
|
||||
// up to 256 KiB × limit rows), so the detail view fetches via this route.
|
||||
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
|
||||
}
|
||||
|
||||
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
|
||||
method: "POST",
|
||||
@@ -1623,74 +1427,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async rotateAutopilotTriggerWebhookToken(
|
||||
autopilotId: string,
|
||||
triggerId: string,
|
||||
): Promise<AutopilotTrigger> {
|
||||
return this.fetch(
|
||||
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook deliveries — list is slim (no raw_body / selected_headers /
|
||||
// response_body); detail returns the full row. Both responses are parsed
|
||||
// through a lenient schema so an unknown server-side `status` /
|
||||
// `signature_status` value degrades to a generic row instead of dropping
|
||||
// the whole list.
|
||||
async listAutopilotDeliveries(
|
||||
autopilotId: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
): Promise<ListWebhookDeliveriesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", params.limit.toString());
|
||||
if (params?.offset) search.set("offset", params.offset.toString());
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries?${search}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
{ endpoint: "GET /api/autopilots/:id/deliveries" },
|
||||
);
|
||||
}
|
||||
|
||||
async getAutopilotDelivery(
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
): Promise<WebhookDelivery> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
WebhookDeliveryResponseSchema,
|
||||
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
|
||||
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
|
||||
);
|
||||
}
|
||||
|
||||
// Replay creates a NEW delivery row referencing the original via
|
||||
// `replayed_from_delivery_id`. Server rejects replays of
|
||||
// signature-invalid / rejected deliveries with 400 — the UI keeps the
|
||||
// button disabled for those rows, but the server is the source of truth.
|
||||
async replayAutopilotDelivery(
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
): Promise<WebhookDelivery> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
WebhookDeliveryResponseSchema,
|
||||
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
|
||||
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub integration
|
||||
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
@@ -13,8 +8,6 @@ export type {
|
||||
} from "./client";
|
||||
export { parseWithFallback, setSchemaLogger } from "./schema";
|
||||
export type { ParseOptions } from "./schema";
|
||||
export { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
export type { DuplicateIssueErrorBody } from "./schemas";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
@@ -91,15 +91,6 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGroupedIssues", () => {
|
||||
it("falls back to empty groups when the response is malformed", async () => {
|
||||
stubFetchJson({ groups: "not-an-array" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listGroupedIssues({ group_by: "assignee" });
|
||||
expect(res).toEqual({ groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listComments", () => {
|
||||
it("returns [] when the response is not an array", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
@@ -198,68 +189,6 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutopilotDeliveries", () => {
|
||||
it("falls back to an empty list when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res).toEqual({ deliveries: [], total: 0 });
|
||||
});
|
||||
|
||||
it("falls back to an empty list when `deliveries` is not an array", async () => {
|
||||
stubFetchJson({ deliveries: "not-an-array", total: 0 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res).toEqual({ deliveries: [], total: 0 });
|
||||
});
|
||||
|
||||
it("accepts an unknown future status value rather than dropping the row", async () => {
|
||||
// Server-side enum drift (e.g. new `quarantined` state). The list
|
||||
// must still surface the row; downstream UI code's `default` arm
|
||||
// handles unknown values with a generic visual.
|
||||
stubFetchJson({
|
||||
deliveries: [
|
||||
{
|
||||
id: "d-1",
|
||||
workspace_id: "ws-1",
|
||||
autopilot_id: "ap-1",
|
||||
trigger_id: "t-1",
|
||||
provider: "github",
|
||||
event: "pull_request.opened",
|
||||
dedupe_key: "abc",
|
||||
dedupe_source: "x-github-delivery",
|
||||
signature_status: "valid",
|
||||
status: "quarantined",
|
||||
attempt_count: 1,
|
||||
content_type: "application/json",
|
||||
response_status: 200,
|
||||
autopilot_run_id: null,
|
||||
replayed_from_delivery_id: null,
|
||||
error: null,
|
||||
received_at: "2026-01-01T00:00:00Z",
|
||||
last_attempt_at: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res.deliveries).toHaveLength(1);
|
||||
expect(res.deliveries[0]?.status).toBe("quarantined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutopilotDelivery", () => {
|
||||
it("falls back to a placeholder carrying the requested id", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
|
||||
expect(detail.id).toBe("d-1");
|
||||
expect(detail.autopilot_id).toBe("ap-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentFromTemplate", () => {
|
||||
it("falls back to an empty agent when the response is malformed", async () => {
|
||||
// The agent was created server-side even though the client can't
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
|
||||
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
|
||||
// (typed as `unknown`) through this schema. Any future server drift that
|
||||
// loses the contract MUST fail the parse so the UI falls back to a normal
|
||||
// error toast instead of rendering an empty / partial duplicate card.
|
||||
describe("DuplicateIssueErrorBodySchema", () => {
|
||||
const valid = {
|
||||
code: "active_duplicate_issue",
|
||||
error: "An active issue with this title already exists: MUL-12 – Login bug",
|
||||
issue: {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
identifier: "MUL-12",
|
||||
title: "Login bug",
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts a well-formed body", () => {
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts unknown extra fields via .loose()", () => {
|
||||
const forwardCompat = {
|
||||
...valid,
|
||||
hint: "Try a different title",
|
||||
issue: { ...valid.issue, workspace_id: "ws-1", status: "todo" },
|
||||
};
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(forwardCompat).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a renamed code (so renames degrade to the generic toast)", () => {
|
||||
const renamed = { ...valid, code: "duplicate_issue" };
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(renamed).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing issue object", () => {
|
||||
const { issue: _omit, ...without } = valid;
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-string issue.id", () => {
|
||||
const broken = { ...valid, issue: { ...valid.issue, id: 42 } };
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(broken).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a missing error field (it is optional)", () => {
|
||||
const { error: _omit, ...without } = valid;
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,8 @@ import type {
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
CreateAgentFromTemplateResponse,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
TimelineEntry,
|
||||
WebhookDelivery,
|
||||
} from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -150,7 +147,6 @@ const IssueSchema = z.object({
|
||||
parent_issue_id: z.string().nullable(),
|
||||
project_id: z.string().nullable(),
|
||||
position: z.number(),
|
||||
start_date: z.string().nullable(),
|
||||
due_date: z.string().nullable(),
|
||||
reactions: z.array(z.unknown()).optional(),
|
||||
labels: z.array(z.unknown()).optional(),
|
||||
@@ -168,22 +164,6 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const IssueAssigneeGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
assignee_type: z.string().nullable(),
|
||||
assignee_id: z.string().nullable(),
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const GroupedIssuesResponseSchema = z.object({
|
||||
groups: z.array(IssueAssigneeGroupSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_GROUPED_ISSUES_RESPONSE: GroupedIssuesResponse = {
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const SubscriberSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
user_type: z.string(),
|
||||
@@ -241,15 +221,6 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
const DashboardRunTimeDailySchema = z.object({
|
||||
date: z.string(),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
@@ -335,140 +306,3 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
|
||||
imported_skill_ids: [],
|
||||
reused_skill_ids: [],
|
||||
};
|
||||
|
||||
// Squad member status — backs the Squad detail page's Members tab. status
|
||||
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
|
||||
// new server-side status doesn't fail the parse; the UI defaults to a
|
||||
// neutral pill for unknown values.
|
||||
const SquadActiveIssueBriefSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
issue_status: z.string(),
|
||||
}).loose();
|
||||
|
||||
const SquadMemberStatusSchema = z.object({
|
||||
member_type: z.string(),
|
||||
member_id: z.string(),
|
||||
status: z.string().nullable().optional().transform((v) => v ?? null),
|
||||
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
|
||||
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
|
||||
}).loose();
|
||||
|
||||
export const SquadMemberStatusListResponseSchema = z.object({
|
||||
members: z.array(SquadMemberStatusSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
|
||||
//
|
||||
// When the server detects an active issue with the same title in the same
|
||||
// workspace, it returns `{ code: "active_duplicate_issue", error, issue }`
|
||||
// instead of letting the create through. The UI uses the embedded issue ref
|
||||
// to offer "view existing" rather than dropping the user into a generic
|
||||
// "create failed" toast.
|
||||
//
|
||||
// Strict guarantees:
|
||||
// - `code` is a literal so a future server rename (e.g. `duplicate_issue`)
|
||||
// fails the parse and falls back to a normal error toast — drift never
|
||||
// ships as a broken duplicate UI.
|
||||
// - `issue` is required; without an id/identifier/title the "view existing"
|
||||
// button has nothing to point at, so we'd rather fall back than guess.
|
||||
// - `issue.status` is intentionally OMITTED: the duplicate toast doesn't
|
||||
// render a StatusIcon (which has no fallback for unknown enum values),
|
||||
// so a future server-side rename of `status` must not knock this branch
|
||||
// out. `.loose()` lets the field pass through unchanged for any other
|
||||
// consumer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DuplicateIssueErrorBodySchema = z.object({
|
||||
code: z.literal("active_duplicate_issue"),
|
||||
error: z.string().optional(),
|
||||
issue: z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
}).loose(),
|
||||
}).loose();
|
||||
|
||||
export interface DuplicateIssueErrorBody {
|
||||
code: "active_duplicate_issue";
|
||||
error?: string;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
|
||||
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
|
||||
// future server-side value (e.g. a Stripe provider, a new dedupe state)
|
||||
// degrades to a generic UI fallback rather than collapsing the list into
|
||||
// the empty array. `.loose()` lets unknown fields pass through, matching
|
||||
// the rule used by every other endpoint here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WebhookDeliverySchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
autopilot_id: z.string(),
|
||||
trigger_id: z.string(),
|
||||
provider: z.string(),
|
||||
event: z.string(),
|
||||
dedupe_key: z.string().nullable(),
|
||||
dedupe_source: z.string().nullable(),
|
||||
signature_status: z.string(),
|
||||
status: z.string(),
|
||||
attempt_count: z.number().default(0),
|
||||
content_type: z.string().nullable(),
|
||||
response_status: z.number().nullable(),
|
||||
autopilot_run_id: z.string().nullable(),
|
||||
replayed_from_delivery_id: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
received_at: z.string(),
|
||||
last_attempt_at: z.string(),
|
||||
created_at: z.string(),
|
||||
// Detail-only fields. The list endpoint omits them; the detail endpoint
|
||||
// populates raw_body / selected_headers / response_body.
|
||||
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
raw_body: z.string().nullable().optional(),
|
||||
response_body: z.string().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const ListWebhookDeliveriesResponseSchema = z.object({
|
||||
deliveries: z.array(WebhookDeliverySchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
|
||||
|
||||
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
|
||||
deliveries: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
autopilot_id: "",
|
||||
trigger_id: "",
|
||||
provider: "",
|
||||
event: "",
|
||||
dedupe_key: null,
|
||||
dedupe_source: null,
|
||||
signature_status: "not_required",
|
||||
status: "queued",
|
||||
attempt_count: 0,
|
||||
content_type: null,
|
||||
response_status: null,
|
||||
autopilot_run_id: null,
|
||||
replayed_from_delivery_id: null,
|
||||
error: null,
|
||||
received_at: "",
|
||||
last_attempt_at: "",
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WSMessage, WSEventType } from "../types/events";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
|
||||
/** Identifies the WS client to the server. Sent as `client_platform`,
|
||||
* `client_version`, and `client_os` query parameters on the upgrade URL —
|
||||
@@ -84,7 +84,7 @@ export class WSClient {
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload, msg.actor_id, msg.actor_type);
|
||||
handler(msg.payload, msg.actor_id);
|
||||
}
|
||||
}
|
||||
for (const handler of this.anyHandlers) {
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
export {
|
||||
autopilotKeys,
|
||||
autopilotListOptions,
|
||||
autopilotDetailOptions,
|
||||
autopilotRunsOptions,
|
||||
autopilotDeliveriesOptions,
|
||||
autopilotDeliveryOptions,
|
||||
} from "./queries";
|
||||
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
|
||||
export {
|
||||
useCreateAutopilot,
|
||||
useUpdateAutopilot,
|
||||
@@ -14,7 +7,4 @@ export {
|
||||
useCreateAutopilotTrigger,
|
||||
useUpdateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
useRotateAutopilotTriggerWebhookToken,
|
||||
useReplayAutopilotDelivery,
|
||||
} from "./mutations";
|
||||
export { buildAutopilotWebhookUrl } from "./webhook";
|
||||
|
||||
@@ -128,32 +128,3 @@ export function useDeleteAutopilotTrigger() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRotateAutopilotTriggerWebhookToken() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
|
||||
api.rotateAutopilotTriggerWebhookToken(autopilotId, triggerId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Replay re-dispatches a previously-recorded delivery. The server creates
|
||||
// a new delivery row (with `replayed_from_delivery_id`) and synchronously
|
||||
// kicks off a new autopilot run. We invalidate both deliveries and runs so
|
||||
// the new delivery and any resulting run show up immediately.
|
||||
export function useReplayAutopilotDelivery() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, deliveryId }: { autopilotId: string; deliveryId: string }) =>
|
||||
api.replayAutopilotDelivery(autopilotId, deliveryId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.deliveries(wsId, vars.autopilotId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,12 +8,6 @@ export const autopilotKeys = {
|
||||
[...autopilotKeys.all(wsId), "detail", id] as const,
|
||||
runs: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", id] as const,
|
||||
run: (wsId: string, autopilotId: string, runId: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", autopilotId, runId] as const,
|
||||
deliveries: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "deliveries", id] as const,
|
||||
delivery: (wsId: string, autopilotId: string, deliveryId: string) =>
|
||||
[...autopilotKeys.all(wsId), "deliveries", autopilotId, deliveryId] as const,
|
||||
};
|
||||
|
||||
export function autopilotListOptions(wsId: string) {
|
||||
@@ -38,52 +32,3 @@ export function autopilotRunsOptions(wsId: string, id: string) {
|
||||
select: (data) => data.runs,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotRunOptions fetches a single run with its full trigger_payload.
|
||||
// The list endpoint (autopilotRunsOptions) omits trigger_payload to keep
|
||||
// list responses small; callers (e.g. the run-detail dialog) use this
|
||||
// query on demand when the user opens a run.
|
||||
export function autopilotRunOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
runId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.run(wsId, autopilotId, runId),
|
||||
queryFn: () => api.getAutopilotRun(autopilotId, runId),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotDeliveriesOptions powers the Deliveries section in the autopilot
|
||||
// detail page. The list is slim — raw_body / selected_headers / response_body
|
||||
// are omitted server-side. Detail rows are fetched on-demand when the user
|
||||
// expands a row (see autopilotDeliveryOptions).
|
||||
export function autopilotDeliveriesOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.deliveries(wsId, autopilotId),
|
||||
queryFn: () => api.listAutopilotDeliveries(autopilotId),
|
||||
select: (data) => data.deliveries,
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotDeliveryOptions fetches the full delivery row including raw_body
|
||||
// and headers subset. Used by the detail dialog opened from a list row.
|
||||
export function autopilotDeliveryOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.delivery(wsId, autopilotId, deliveryId),
|
||||
queryFn: () => api.getAutopilotDelivery(autopilotId, deliveryId),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAutopilotWebhookUrl } from "./webhook";
|
||||
import type { AutopilotTrigger } from "../types";
|
||||
|
||||
const baseTrigger: AutopilotTrigger = {
|
||||
id: "t1",
|
||||
autopilot_id: "a1",
|
||||
kind: "webhook",
|
||||
enabled: true,
|
||||
cron_expression: null,
|
||||
timezone: null,
|
||||
next_run_at: null,
|
||||
webhook_token: "awt_abc",
|
||||
webhook_path: "/api/webhooks/autopilots/awt_abc",
|
||||
webhook_url: null,
|
||||
label: null,
|
||||
last_fired_at: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
describe("buildAutopilotWebhookUrl", () => {
|
||||
it("returns the server-provided webhook_url verbatim when present", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, webhook_url: "https://custom.example/api/webhooks/autopilots/awt_abc" },
|
||||
}),
|
||||
).toBe("https://custom.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("composes from apiBaseUrl + webhook_path", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example" }),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("strips trailing slash on apiBaseUrl", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example/" }),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("falls back to currentOrigin when apiBaseUrl is empty", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: baseTrigger,
|
||||
apiBaseUrl: "",
|
||||
currentOrigin: "https://app.example",
|
||||
}),
|
||||
).toBe("https://app.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("composes from token when webhook_path is missing", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, webhook_path: null },
|
||||
apiBaseUrl: "https://api.example",
|
||||
}),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("returns null for non-webhook trigger", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, kind: "schedule", webhook_token: null, webhook_path: null },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns relative path when no base or origin available", () => {
|
||||
expect(buildAutopilotWebhookUrl({ trigger: baseTrigger })).toBe("/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { AutopilotTrigger } from "../types";
|
||||
|
||||
/**
|
||||
* Compose a usable absolute webhook URL for a webhook trigger.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. trigger.webhook_url — present only when MULTICA_PUBLIC_URL is set on the
|
||||
* server. This is the authoritative form when available.
|
||||
* 2. apiBaseUrl + webhook_path — desktop apps and self-host setups where the
|
||||
* server didn't mint an absolute URL but the client knows its API origin.
|
||||
* 3. currentOrigin + webhook_path — browser fallback when getBaseUrl() is
|
||||
* empty (e.g. same-origin Next.js dev).
|
||||
*
|
||||
* Returns null when the trigger has no token / path yet (a new trigger that
|
||||
* hasn't been written back to the cache, or a non-webhook trigger).
|
||||
*/
|
||||
export function buildAutopilotWebhookUrl(params: {
|
||||
trigger: Pick<AutopilotTrigger, "kind" | "webhook_token" | "webhook_path" | "webhook_url">;
|
||||
apiBaseUrl?: string;
|
||||
currentOrigin?: string;
|
||||
}): string | null {
|
||||
const { trigger, apiBaseUrl, currentOrigin } = params;
|
||||
|
||||
if (trigger.kind !== "webhook") return null;
|
||||
|
||||
if (typeof trigger.webhook_url === "string" && trigger.webhook_url) {
|
||||
return trigger.webhook_url;
|
||||
}
|
||||
|
||||
const path =
|
||||
(typeof trigger.webhook_path === "string" && trigger.webhook_path) ||
|
||||
(trigger.webhook_token ? `/api/webhooks/autopilots/${trigger.webhook_token}` : null);
|
||||
if (!path) return null;
|
||||
|
||||
const base = stripTrailingSlash(apiBaseUrl) || stripTrailingSlash(currentOrigin);
|
||||
if (!base) return path; // last resort — relative path will still work in-browser
|
||||
return base + path;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(s: string | undefined): string {
|
||||
if (!s) return "";
|
||||
return s.endsWith("/") ? s.slice(0, -1) : s;
|
||||
}
|
||||
@@ -64,45 +64,6 @@ export function useMarkChatSessionRead() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a chat session. Optimistically swaps the title in the cached
|
||||
* list so the dropdown reflects the new label immediately; rolls back on
|
||||
* error. The matching `chat:session_updated` WS event keeps other
|
||||
* tabs/devices in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useUpdateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { sessionId: string; title: string }) => {
|
||||
logger.info("updateChatSession.start", {
|
||||
sessionId: data.sessionId,
|
||||
titleLength: data.title.length,
|
||||
});
|
||||
return api.updateChatSession(data.sessionId, { title: data.title });
|
||||
},
|
||||
onMutate: async ({ sessionId, title }) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
|
||||
const patch = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, title } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
|
||||
|
||||
return { prevSessions };
|
||||
},
|
||||
onError: (err, vars, ctx) => {
|
||||
logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from the
|
||||
* sessions list so the dropdown updates instantly; rolls back on error.
|
||||
|
||||
@@ -22,8 +22,6 @@ export const dashboardKeys = {
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
|
||||
};
|
||||
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
@@ -72,17 +70,3 @@ export function dashboardAgentRunTimeOptions(
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from "./queries";
|
||||
export * from "./pull-request-status";
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
derivePullRequestStatusKind,
|
||||
derivePullRequestProgressSegments,
|
||||
shouldShowPullRequestStats,
|
||||
type PullRequestStatusInput,
|
||||
} from "./pull-request-status";
|
||||
|
||||
const base: PullRequestStatusInput = { state: "open" };
|
||||
|
||||
describe("derivePullRequestStatusKind", () => {
|
||||
it("closed beats every other signal", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "closed",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 99,
|
||||
checks_pending: 99,
|
||||
checks_passed: 99,
|
||||
}),
|
||||
).toBe("closed");
|
||||
});
|
||||
|
||||
it("merged beats every other signal except closed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "merged",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 5,
|
||||
}),
|
||||
).toBe("merged");
|
||||
});
|
||||
|
||||
it("dirty conflicts wins over check signals", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "dirty",
|
||||
checks_passed: 3,
|
||||
}),
|
||||
).toBe("conflicts");
|
||||
});
|
||||
|
||||
it("any failed check beats pending and passed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 3,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_failed");
|
||||
});
|
||||
|
||||
it("pending beats passed when no failure", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_pending: 1,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_pending");
|
||||
});
|
||||
|
||||
it("all-passed is checks_passed regardless of mergeable=clean", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "clean",
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_passed");
|
||||
});
|
||||
|
||||
it("clean + no suites is ready-to-merge", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("opaque mergeable values render as unknown", () => {
|
||||
for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
|
||||
expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePullRequestProgressSegments", () => {
|
||||
it("returns null for terminal PRs (merged / closed)", () => {
|
||||
expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
|
||||
expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no suite has been observed", () => {
|
||||
expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
|
||||
expect(
|
||||
derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("orders segments failed → pending → passed (failure leftmost)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 2,
|
||||
checks_passed: 3,
|
||||
});
|
||||
expect(segs).not.toBeNull();
|
||||
expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
|
||||
});
|
||||
|
||||
it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 0,
|
||||
checks_pending: 0,
|
||||
checks_passed: 4,
|
||||
});
|
||||
expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
|
||||
});
|
||||
|
||||
it("ratios sum to ~1 across segments", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 1,
|
||||
checks_passed: 2,
|
||||
})!;
|
||||
const total = segs.reduce((acc, s) => acc + s.ratio, 0);
|
||||
expect(total).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowPullRequestStats", () => {
|
||||
it("hides when every field is 0 or missing (legacy backend)", () => {
|
||||
expect(shouldShowPullRequestStats({})).toBe(false);
|
||||
expect(shouldShowPullRequestStats({ additions: 0, deletions: 0, changed_files: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows when at least one number is non-zero", () => {
|
||||
expect(shouldShowPullRequestStats({ additions: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ deletions: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ changed_files: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ additions: 437, deletions: 6, changed_files: 6 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
|
||||
// Status kinds rendered in the PR sidebar row's detail line. Order in the
|
||||
// pass-through table matters — the first matching rule wins. The order is
|
||||
// chosen so terminal PR states (closed / merged) short-circuit before any
|
||||
// transient CI/conflict signal, since those signals are no longer actionable
|
||||
// on a terminal PR.
|
||||
//
|
||||
// Priority (high → low):
|
||||
// 1. closed (not merged) → status_closed
|
||||
// 2. merged → status_merged
|
||||
// 3. mergeable_state = "dirty" → status_conflicts
|
||||
// 4. any failed suite → status_checks_failed
|
||||
// 5. any pending suite → status_checks_pending
|
||||
// 6. any passed suite → status_checks_passed
|
||||
// 7. no suite + mergeable=clean → status_ready
|
||||
// 8. otherwise → status_unknown
|
||||
//
|
||||
// Note: this table is the single source of truth for the sidebar PR row. The
|
||||
// older row-with-badges implementation used a separate "hide status row for
|
||||
// terminal PRs" branch — the current row renders
|
||||
// with status_closed / status_merged text, never falling through to a
|
||||
// conflicts / checks line on a terminal PR. Keep this priority order in sync
|
||||
// with the i18n keys `pull_request_card_status_*` and with the progress-strip
|
||||
// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
|
||||
// solid bar; the rest map onto the per-suite counts).
|
||||
export type PullRequestStatusKind =
|
||||
| "closed"
|
||||
| "merged"
|
||||
| "conflicts"
|
||||
| "checks_failed"
|
||||
| "checks_pending"
|
||||
| "checks_passed"
|
||||
| "ready"
|
||||
| "unknown";
|
||||
|
||||
export interface PullRequestStatusInput {
|
||||
state: GitHubPullRequest["state"];
|
||||
mergeable_state?: string | null;
|
||||
checks_failed?: number;
|
||||
checks_pending?: number;
|
||||
checks_passed?: number;
|
||||
}
|
||||
|
||||
export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
|
||||
if (input.state === "closed") return "closed";
|
||||
if (input.state === "merged") return "merged";
|
||||
if (input.mergeable_state === "dirty") return "conflicts";
|
||||
if ((input.checks_failed ?? 0) > 0) return "checks_failed";
|
||||
if ((input.checks_pending ?? 0) > 0) return "checks_pending";
|
||||
if ((input.checks_passed ?? 0) > 0) return "checks_passed";
|
||||
if (input.mergeable_state === "clean") return "ready";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export interface PullRequestProgressSegment {
|
||||
kind: "failed" | "pending" | "passed";
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
// Segmented progress bar input. Returns null when:
|
||||
// - the PR is terminal (closed/merged) — the card paints a solid bar
|
||||
// in a state-specific color, no segmentation needed;
|
||||
// - no check_suite has been observed (total === 0) — the card hides
|
||||
// the bar entirely.
|
||||
// Otherwise emits the segments left-to-right: failed → pending → passed.
|
||||
// "Failure first" is intentional: problems should be visible before signal
|
||||
// that everything is fine.
|
||||
export function derivePullRequestProgressSegments(
|
||||
input: PullRequestStatusInput,
|
||||
): PullRequestProgressSegment[] | null {
|
||||
if (input.state === "closed" || input.state === "merged") return null;
|
||||
const failed = input.checks_failed ?? 0;
|
||||
const pending = input.checks_pending ?? 0;
|
||||
const passed = input.checks_passed ?? 0;
|
||||
const total = failed + pending + passed;
|
||||
if (total === 0) return null;
|
||||
const segments: PullRequestProgressSegment[] = [];
|
||||
if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
|
||||
if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
|
||||
if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
|
||||
return segments;
|
||||
}
|
||||
|
||||
export interface PullRequestStatsInput {
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changed_files?: number;
|
||||
}
|
||||
|
||||
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
|
||||
// when the backend that served this PR row doesn't know about the stats
|
||||
// columns yet, every numeric field defaults to 0. Rendering "+0 −0 · 0 files"
|
||||
// in that case would be a lie (the PR almost certainly has real changes),
|
||||
// so we hide the entire stats row until at least one signal is non-zero.
|
||||
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
|
||||
const a = input.additions ?? 0;
|
||||
const d = input.deletions ?? 0;
|
||||
const f = input.changed_files ?? 0;
|
||||
return a + d + f > 0;
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import type { ApiClient } from "../api/client";
|
||||
import type { Attachment } from "../types";
|
||||
import { MAX_FILE_SIZE } from "../constants/upload";
|
||||
|
||||
// Carries the full Attachment so editors that need preview metadata
|
||||
// (`content_type`, `download_url`) get it directly; `link` is kept as an
|
||||
// alias for `url` because many callers persist it into Markdown / avatar
|
||||
// fields by that name.
|
||||
export type UploadResult = Attachment & { link: string };
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface UploadContext {
|
||||
issueId?: string;
|
||||
@@ -36,7 +36,7 @@ export function useFileUpload(
|
||||
commentId: ctx?.commentId,
|
||||
chatSessionId: ctx?.chatSessionId,
|
||||
});
|
||||
return { ...att, link: att.url };
|
||||
return { id: att.id, filename: att.filename, link: att.url };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import {
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type { Issue, ListIssuesCache } from "../types";
|
||||
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
||||
import { issueKeys } from "./queries";
|
||||
|
||||
export type DeletedIssueCacheMetadata = {
|
||||
parentIssueIds: string[];
|
||||
};
|
||||
|
||||
function collectParentId(
|
||||
parentIssueIds: Set<string>,
|
||||
parentId: string | null | undefined,
|
||||
) {
|
||||
if (parentId) parentIssueIds.add(parentId);
|
||||
}
|
||||
|
||||
function collectParentFromListCache(
|
||||
parentIssueIds: Set<string>,
|
||||
data: ListIssuesCache | undefined,
|
||||
issueId: string,
|
||||
) {
|
||||
const parentId = data
|
||||
? findIssueLocation(data, issueId)?.issue.parent_issue_id
|
||||
: undefined;
|
||||
collectParentId(parentIssueIds, parentId);
|
||||
}
|
||||
|
||||
function parentIdFromChildrenKey(key: QueryKey) {
|
||||
const parentId = key[key.length - 1];
|
||||
return typeof parentId === "string" ? parentId : null;
|
||||
}
|
||||
|
||||
export function collectDeletedIssueCacheMetadata(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
): DeletedIssueCacheMetadata {
|
||||
const parentIssueIds = new Set<string>();
|
||||
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
||||
collectParentId(parentIssueIds, detail?.parent_issue_id);
|
||||
|
||||
collectParentFromListCache(
|
||||
parentIssueIds,
|
||||
qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId)),
|
||||
issueId,
|
||||
);
|
||||
|
||||
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
})) {
|
||||
collectParentFromListCache(parentIssueIds, data, issueId);
|
||||
}
|
||||
|
||||
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
})) {
|
||||
const child = data?.find((issue) => issue.id === issueId);
|
||||
if (!child) continue;
|
||||
collectParentId(parentIssueIds, child.parent_issue_id);
|
||||
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
|
||||
}
|
||||
|
||||
return { parentIssueIds: Array.from(parentIssueIds) };
|
||||
}
|
||||
|
||||
export function pruneDeletedIssueFromListCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
|
||||
for (const [key] of qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
})) {
|
||||
qc.setQueryData<ListIssuesCache>(key, (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneDeletedIssueFromParentChildrenCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata: DeletedIssueCacheMetadata,
|
||||
) {
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((issue) => issue.id !== issueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueParentCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
metadata: DeletedIssueCacheMetadata,
|
||||
) {
|
||||
if (metadata.parentIssueIds.length === 0) return;
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueDependentCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateIssueScopedCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
}
|
||||
|
||||
export function cleanupDeletedIssueCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
|
||||
) {
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
|
||||
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
|
||||
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient, type QueryKey } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
import {
|
||||
@@ -12,20 +11,12 @@ import {
|
||||
findIssueLocation,
|
||||
getBucket,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
setBucket,
|
||||
} from "./cache-helpers";
|
||||
import {
|
||||
cleanupDeletedIssueCaches,
|
||||
collectDeletedIssueCacheMetadata,
|
||||
invalidateDeletedIssueDependentCaches,
|
||||
invalidateDeletedIssueParentCaches,
|
||||
invalidateIssueScopedCaches,
|
||||
pruneDeletedIssueFromListCaches,
|
||||
pruneDeletedIssueFromParentChildrenCaches,
|
||||
} from "./delete-cache";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
|
||||
import type { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
@@ -103,58 +94,6 @@ export function useLoadMoreByStatus(
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
export function useLoadMoreByAssigneeGroup(
|
||||
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
||||
queryKey: QueryKey,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const cache = qc.getQueryData<GroupedIssuesResponse>(queryKey);
|
||||
const cachedGroup = cache?.groups.find((g) => g.id === group.id);
|
||||
const loaded = cachedGroup?.issues.length ?? 0;
|
||||
const total = cachedGroup?.total ?? 0;
|
||||
const hasMore = loaded < total;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listGroupedIssues({
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
...filter,
|
||||
group_assignee_type: group.assignee_type ?? "none",
|
||||
group_assignee_id: group.assignee_id ?? undefined,
|
||||
});
|
||||
const nextGroup = res.groups[0];
|
||||
if (!nextGroup) return;
|
||||
|
||||
qc.setQueryData<GroupedIssuesResponse>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
groups: old.groups.map((existing) => {
|
||||
if (existing.id !== nextGroup.id) return existing;
|
||||
const existingIds = new Set(existing.issues.map((issue) => issue.id));
|
||||
const appended = nextGroup.issues.filter((issue) => !existingIds.has(issue.id));
|
||||
return {
|
||||
...existing,
|
||||
issues: [...existing.issues, ...appended],
|
||||
total: nextGroup.total,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -179,8 +118,6 @@ export function useCreateIssue() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -255,15 +192,6 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
// Refresh the issue's attachments cache when the description editor
|
||||
// bound new uploads — the description editor reads `issueAttachments`
|
||||
// to resolve text-preview Eye gates, and unlike other mutations this
|
||||
// payload mutates the attachment join table.
|
||||
if (vars.attachment_ids?.length) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) });
|
||||
}
|
||||
// Invalidate old parent's children cache
|
||||
if (ctx?.parentId) {
|
||||
qc.invalidateQueries({
|
||||
@@ -289,58 +217,24 @@ export function useDeleteIssue() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await Promise.all([
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
||||
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
|
||||
]);
|
||||
const metadata = collectDeletedIssueCacheMetadata(qc, wsId, id);
|
||||
await Promise.all(
|
||||
metadata.parentIssueIds.map((parentId) =>
|
||||
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
|
||||
),
|
||||
);
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
});
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
prevChildren.set(
|
||||
parentId,
|
||||
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
|
||||
);
|
||||
}
|
||||
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, id);
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
|
||||
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, id) : old,
|
||||
);
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { id, metadata, prevList, prevMyLists, prevDetail, prevChildren };
|
||||
return { prevList, parentIssueId: deleted?.parent_issue_id };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevMyLists) {
|
||||
for (const [key, snapshot] of ctx.prevMyLists) {
|
||||
qc.setQueryData(key, snapshot);
|
||||
}
|
||||
}
|
||||
if (ctx?.prevDetail) {
|
||||
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||
}
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, id, ctx) => {
|
||||
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadata);
|
||||
},
|
||||
onSettled: (_data, _err, _id, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
|
||||
if (ctx?.parentIssueId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -397,8 +291,6 @@ export function useBatchUpdateIssues() {
|
||||
},
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
@@ -417,94 +309,57 @@ export function useBatchDeleteIssues() {
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await Promise.all([
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
||||
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
|
||||
]);
|
||||
const metadataById = new Map(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
collectDeletedIssueCacheMetadata(qc, wsId, id),
|
||||
]),
|
||||
);
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const parentIssueIds = new Set<string>();
|
||||
for (const metadata of metadataById.values()) {
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
parentIssueIds.add(parentId);
|
||||
if (prevList) {
|
||||
for (const id of ids) {
|
||||
const loc = findIssueLocation(prevList, id);
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
await Promise.all(
|
||||
Array.from(parentIssueIds).map((parentId) =>
|
||||
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
|
||||
),
|
||||
);
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
// Children cache may be the only place sub-issues live when the user
|
||||
// operates from a parent's detail page. Collect affected parents and
|
||||
// optimistically filter the deleted ids out of each children cache so
|
||||
// the row disappears immediately, mirroring the list-cache behaviour.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
for (const parentId of parentIssueIds) {
|
||||
prevChildren.set(
|
||||
parentId,
|
||||
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
parentIssueIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((c) => !idSet.has(c.id)),
|
||||
);
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
const metadata = metadataById.get(id);
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, id);
|
||||
if (metadata) {
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
|
||||
}
|
||||
}
|
||||
return { prevList, prevMyLists, prevChildren, parentIssueIds, metadataById };
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
});
|
||||
return { prevList, prevChildren, parentIssueIds };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevMyLists) {
|
||||
for (const [key, snapshot] of ctx.prevMyLists) {
|
||||
qc.setQueryData(key, snapshot);
|
||||
}
|
||||
}
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (data, ids, ctx) => {
|
||||
if (data.deleted === ids.length) {
|
||||
for (const id of ids) {
|
||||
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadataById.get(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevMyLists) {
|
||||
for (const [key, snapshot] of ctx.prevMyLists) {
|
||||
qc.setQueryData(key, snapshot);
|
||||
}
|
||||
}
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
invalidateIssueScopedCaches(qc, wsId, id);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
|
||||
invalidateDeletedIssueParentCaches(qc, wsId, {
|
||||
parentIssueIds: Array.from(ctx.parentIssueIds),
|
||||
});
|
||||
for (const parentId of ctx.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -566,8 +421,8 @@ export function useCreateComment(issueId: string) {
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
|
||||
api.updateComment(commentId, content, attachmentIds),
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type {
|
||||
GroupedIssuesResponse,
|
||||
IssueStatus,
|
||||
ListGroupedIssuesParams,
|
||||
ListIssuesParams,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
@@ -12,22 +10,11 @@ import { BOARD_STATUSES } from "./config";
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||||
assigneeGroupsAll: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "assignee-groups"] as const,
|
||||
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
|
||||
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
|
||||
/** All "my issues" queries — use for bulk invalidation. */
|
||||
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
|
||||
/** Per-scope "my issues" list with filter identity baked into the key. */
|
||||
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
|
||||
[...issueKeys.myAll(wsId), scope, filter] as const,
|
||||
myAssigneeGroupsAll: (wsId: string) =>
|
||||
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
|
||||
myAssigneeGroups: (
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
children: (wsId: string, id: string) =>
|
||||
@@ -58,11 +45,6 @@ export type MyIssuesFilter = Pick<
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
>;
|
||||
|
||||
export type AssigneeGroupedIssuesFilter = Omit<
|
||||
ListGroupedIssuesParams,
|
||||
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
|
||||
>;
|
||||
|
||||
/** Page size per status column. */
|
||||
export const ISSUE_PAGE_SIZE = 50;
|
||||
|
||||
@@ -110,22 +92,6 @@ export function issueListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function issueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) {
|
||||
return queryOptions<GroupedIssuesResponse>({
|
||||
queryKey: issueKeys.assigneeGroups(wsId, filter),
|
||||
queryFn: () =>
|
||||
api.listGroupedIssues({
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-filtered issue list for the My Issues page.
|
||||
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
||||
@@ -142,23 +108,6 @@ export function myIssueListOptions(
|
||||
});
|
||||
}
|
||||
|
||||
export function myIssueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) {
|
||||
return queryOptions<GroupedIssuesResponse>({
|
||||
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
|
||||
queryFn: () =>
|
||||
api.listGroupedIssues({
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueDetailOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.detail(wsId, id),
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
type IssueViewState,
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
mergeViewStatePersisted,
|
||||
} from "./view-store";
|
||||
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
|
||||
export type ActorIssuesScope = "assigned" | "created";
|
||||
|
||||
export interface ActorIssuesViewState extends IssueViewState {
|
||||
scope: ActorIssuesScope;
|
||||
setScope: (scope: ActorIssuesScope) => void;
|
||||
}
|
||||
|
||||
const basePersist = viewStorePersistOptions("multica_actor_issues_view");
|
||||
|
||||
const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
|
||||
scope: "assigned" as ActorIssuesScope,
|
||||
setScope: (scope: ActorIssuesScope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: basePersist.name,
|
||||
storage: basePersist.storage,
|
||||
partialize: (state: ActorIssuesViewState) => ({
|
||||
...basePersist.partialize(state),
|
||||
scope: state.scope,
|
||||
}),
|
||||
merge: mergeViewStatePersisted<ActorIssuesViewState>,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const actorIssuesViewStore: StoreApi<ActorIssuesViewState> =
|
||||
_actorIssuesViewStore;
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
_actorIssuesViewStore.persist.rehydrate(),
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
openCreateIssueWithPreference,
|
||||
useCreateModeStore,
|
||||
} from "./create-mode-store";
|
||||
import { useModalStore } from "../../modals";
|
||||
|
||||
describe("openCreateIssueWithPreference", () => {
|
||||
const initialMode = useCreateModeStore.getState().lastMode;
|
||||
|
||||
beforeEach(() => {
|
||||
useModalStore.getState().close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useCreateModeStore.getState().setLastMode(initialMode);
|
||||
useModalStore.getState().close();
|
||||
});
|
||||
|
||||
it("opens quick-create-issue when last mode is agent", () => {
|
||||
useCreateModeStore.getState().setLastMode("agent");
|
||||
openCreateIssueWithPreference();
|
||||
expect(useModalStore.getState().modal).toBe("quick-create-issue");
|
||||
expect(useModalStore.getState().data).toBeNull();
|
||||
});
|
||||
|
||||
it("opens create-issue when last mode is manual", () => {
|
||||
useCreateModeStore.getState().setLastMode("manual");
|
||||
openCreateIssueWithPreference();
|
||||
expect(useModalStore.getState().modal).toBe("create-issue");
|
||||
});
|
||||
|
||||
it("forwards seed data to whichever modal is opened", () => {
|
||||
useCreateModeStore.getState().setLastMode("manual");
|
||||
openCreateIssueWithPreference({ project_id: "p1" });
|
||||
expect(useModalStore.getState().modal).toBe("create-issue");
|
||||
expect(useModalStore.getState().data).toEqual({ project_id: "p1" });
|
||||
|
||||
useCreateModeStore.getState().setLastMode("agent");
|
||||
openCreateIssueWithPreference({ project_id: "p2" });
|
||||
expect(useModalStore.getState().modal).toBe("quick-create-issue");
|
||||
expect(useModalStore.getState().data).toEqual({ project_id: "p2" });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
import { useModalStore } from "../../modals";
|
||||
|
||||
/**
|
||||
* Last create-issue mode the user landed on. Drives the global `c` shortcut
|
||||
@@ -35,18 +34,3 @@ export const useCreateModeStore = create<CreateModeState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Open the create-issue flow in whichever mode the user landed on last.
|
||||
* Generic entry points (sidebar button, command palette, `c` shortcut) call
|
||||
* this so the persisted preference actually takes effect; entry points that
|
||||
* pre-seed manual-only fields (status, parent_issue_id) keep opening
|
||||
* "create-issue" directly because agent mode can't honour those seeds.
|
||||
*/
|
||||
export function openCreateIssueWithPreference(
|
||||
data?: Record<string, unknown> | null,
|
||||
) {
|
||||
const lastMode = useCreateModeStore.getState().lastMode;
|
||||
const modal = lastMode === "manual" ? "create-issue" : "quick-create-issue";
|
||||
useModalStore.getState().open(modal, data ?? null);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ const RESET_STATE = {
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
|
||||
@@ -11,7 +11,6 @@ interface IssueDraft {
|
||||
priority: IssuePriority;
|
||||
assigneeType?: IssueAssigneeType;
|
||||
assigneeId?: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
@@ -22,7 +21,6 @@ const EMPTY_DRAFT: IssueDraft = {
|
||||
priority: "none",
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export {
|
||||
useCreateModeStore,
|
||||
openCreateIssueWithPreference,
|
||||
type CreateMode,
|
||||
} from "./create-mode-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export {
|
||||
useRecentIssuesStore,
|
||||
@@ -23,11 +19,6 @@ export {
|
||||
type MyIssuesViewState,
|
||||
type MyIssuesScope,
|
||||
} from "./my-issues-view-store";
|
||||
export {
|
||||
actorIssuesViewStore,
|
||||
type ActorIssuesViewState,
|
||||
type ActorIssuesScope,
|
||||
} from "./actor-issues-view-store";
|
||||
export {
|
||||
useIssueViewStore,
|
||||
createIssueViewStore,
|
||||
|
||||
@@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useQuickCreateStore } from "./quick-create-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
lastActorType: null,
|
||||
lastActorId: null,
|
||||
lastAgentId: null,
|
||||
lastProjectId: null,
|
||||
prompt: "",
|
||||
keepOpen: false,
|
||||
@@ -35,20 +34,4 @@ describe("quick create store", () => {
|
||||
setLastProjectId(null);
|
||||
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers the last actor (agent or squad) and clears both fields together", () => {
|
||||
const { setLastActor } = useQuickCreateStore.getState();
|
||||
|
||||
setLastActor("agent", "agent-1");
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBe("agent");
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBe("agent-1");
|
||||
|
||||
setLastActor("squad", "squad-1");
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBe("squad");
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBe("squad-1");
|
||||
|
||||
setLastActor(null, null);
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBeNull();
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,26 +5,17 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type QuickCreateActorType = "agent" | "squad";
|
||||
|
||||
// Per-workspace memory of the last actor (agent or squad) and project the
|
||||
// user picked in the Quick Create modal. Defaulted to those values on next
|
||||
// open so frequent users skip the pickers entirely — without this, anyone
|
||||
// targeting a single project ends up retyping "in project A" on every
|
||||
// prompt. Persisted with the workspace-aware StateStorage so switching
|
||||
// workspaces shows the right default automatically. Per-user scoping comes
|
||||
// for free from localStorage being browser-profile-local — matches how
|
||||
// draft-store / issues-scope-store / comment-collapse-store already
|
||||
// namespace themselves.
|
||||
//
|
||||
// lastActorType + lastActorId replace the prior `lastAgentId` field once
|
||||
// squads became selectable. Users who had a persisted agent preference
|
||||
// land back on whatever the picker shows first; a one-time re-pick is
|
||||
// preferable to the type-tag ambiguity of overloading a single UUID.
|
||||
// Per-workspace memory of the last agent and project the user picked in the
|
||||
// Quick Create modal. Defaulted to those values on next open so frequent
|
||||
// users skip the pickers entirely — without this, anyone targeting a single
|
||||
// project ends up retyping "in project A" on every prompt. Persisted with
|
||||
// the workspace-aware StateStorage so switching workspaces shows the right
|
||||
// default automatically. Per-user scoping comes for free from localStorage
|
||||
// being browser-profile-local — matches how draft-store /
|
||||
// issues-scope-store / comment-collapse-store already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastActorType: QuickCreateActorType | null;
|
||||
lastActorId: string | null;
|
||||
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
lastProjectId: string | null;
|
||||
setLastProjectId: (id: string | null) => void;
|
||||
prompt: string;
|
||||
@@ -37,9 +28,8 @@ interface QuickCreateState {
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastActorType: null,
|
||||
lastActorId: null,
|
||||
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
lastProjectId: null,
|
||||
setLastProjectId: (id) => set({ lastProjectId: id }),
|
||||
prompt: "",
|
||||
|
||||
@@ -10,15 +10,13 @@ import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "..
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface CardProperties {
|
||||
priority: boolean;
|
||||
description: boolean;
|
||||
assignee: boolean;
|
||||
startDate: boolean;
|
||||
dueDate: boolean;
|
||||
project: boolean;
|
||||
childProgress: boolean;
|
||||
@@ -26,29 +24,22 @@ export interface CardProperties {
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
type: "member" | "agent" | "squad";
|
||||
type: "member" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: "position", label: "Manual" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
{ value: "start_date", label: "Start date" },
|
||||
{ value: "due_date", label: "Due date" },
|
||||
{ value: "created_at", label: "Created date" },
|
||||
{ value: "title", label: "Title" },
|
||||
];
|
||||
|
||||
export const GROUPING_OPTIONS: { value: IssueGrouping; label: string }[] = [
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "assignee", label: "Assignee" },
|
||||
];
|
||||
|
||||
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
|
||||
{ key: "priority", label: "Priority" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "assignee", label: "Assignee" },
|
||||
{ key: "startDate", label: "Start date" },
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
{ key: "project", label: "Project" },
|
||||
{ key: "labels", label: "Labels" },
|
||||
@@ -57,7 +48,6 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
|
||||
|
||||
export interface IssueViewState {
|
||||
viewMode: ViewMode;
|
||||
grouping: IssueGrouping;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
@@ -71,7 +61,6 @@ export interface IssueViewState {
|
||||
cardProperties: CardProperties;
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGrouping: (grouping: IssueGrouping) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
toggleAssigneeFilter: (value: ActorFilterValue) => void;
|
||||
@@ -91,7 +80,6 @@ export interface IssueViewState {
|
||||
|
||||
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
|
||||
viewMode: "board",
|
||||
grouping: "status",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
@@ -106,7 +94,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
startDate: true,
|
||||
dueDate: true,
|
||||
project: true,
|
||||
childProgress: true,
|
||||
@@ -115,7 +102,6 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGrouping: (grouping) => set({ grouping }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
@@ -219,7 +205,6 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state: IssueViewState) => ({
|
||||
viewMode: state.viewMode,
|
||||
grouping: state.grouping,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
|
||||
@@ -1,34 +1,17 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
|
||||
import { onIssueLabelsChanged } from "./ws-updaters";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type {
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
AgentTask,
|
||||
Attachment,
|
||||
Issue,
|
||||
IssueReaction,
|
||||
IssueLabelsResponse,
|
||||
IssueSubscriber,
|
||||
IssueUsageSummary,
|
||||
Label,
|
||||
ListIssuesCache,
|
||||
TimelineEntry,
|
||||
} from "../types";
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
const ISSUE_ID = "issue-1";
|
||||
const OTHER_ISSUE_ID = "issue-2";
|
||||
const PARENT_ISSUE_ID = "parent-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
const labelA: Label = {
|
||||
id: "label-a",
|
||||
@@ -64,54 +47,12 @@ const baseIssue: Issue = {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
labels: [labelA],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const parentedIssue: Issue = {
|
||||
...baseIssue,
|
||||
parent_issue_id: PARENT_ISSUE_ID,
|
||||
};
|
||||
|
||||
const otherIssue: Issue = {
|
||||
...baseIssue,
|
||||
id: OTHER_ISSUE_ID,
|
||||
identifier: "MUL-2",
|
||||
title: "Other",
|
||||
};
|
||||
|
||||
function makeListCache(...issues: Issue[]): ListIssuesCache {
|
||||
return {
|
||||
byStatus: {
|
||||
todo: { issues, total: issues.length },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(issueId = ISSUE_ID): AgentTask {
|
||||
return {
|
||||
id: `task-${issueId}`,
|
||||
agent_id: AGENT_ID,
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: issueId,
|
||||
status: "completed",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: "2025-01-01T00:00:00Z",
|
||||
completed_at: "2025-01-01T00:01:00Z",
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function expectInvalidated(qc: QueryClient, queryKey: readonly unknown[]) {
|
||||
expect(qc.getQueryState(queryKey)?.isInvalidated).toBe(true);
|
||||
}
|
||||
|
||||
describe("onIssueLabelsChanged", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
@@ -152,243 +93,3 @@ describe("onIssueLabelsChanged", () => {
|
||||
expect(detail?.labels).toEqual([labelB]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueDeleted", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
});
|
||||
|
||||
it("removes every cache entry scoped directly to the deleted issue", () => {
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
|
||||
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(ISSUE_ID), [
|
||||
{
|
||||
type: "activity",
|
||||
id: "activity-1",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
action: "created",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<IssueReaction[]>(issueKeys.reactions(ISSUE_ID), [
|
||||
{
|
||||
id: "reaction-1",
|
||||
issue_id: ISSUE_ID,
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
emoji: "+1",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<IssueSubscriber[]>(issueKeys.subscribers(ISSUE_ID), [
|
||||
{
|
||||
issue_id: ISSUE_ID,
|
||||
user_type: "member",
|
||||
user_id: "user-1",
|
||||
reason: "manual",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), {
|
||||
total_input_tokens: 10,
|
||||
total_output_tokens: 20,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
task_count: 1,
|
||||
});
|
||||
qc.setQueryData<Attachment[]>(issueKeys.attachments(ISSUE_ID), [
|
||||
{
|
||||
id: "attachment-1",
|
||||
workspace_id: WS_ID,
|
||||
issue_id: ISSUE_ID,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
filename: "evidence.png",
|
||||
url: "s3://bucket/evidence.png",
|
||||
download_url: "https://example.test/evidence.png",
|
||||
content_type: "image/png",
|
||||
size_bytes: 1,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [otherIssue]);
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
|
||||
labels: [labelA],
|
||||
});
|
||||
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, OTHER_ISSUE_ID), otherIssue);
|
||||
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(OTHER_ISSUE_ID), []);
|
||||
qc.setQueryData<IssueLabelsResponse>(
|
||||
labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID),
|
||||
{ labels: [labelB] },
|
||||
);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.timeline(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.reactions(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.subscribers(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.attachments(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
|
||||
expect(qc.getQueryData(issueKeys.detail(WS_ID, OTHER_ISSUE_ID))).toEqual(
|
||||
otherIssue,
|
||||
);
|
||||
expect(qc.getQueryData(issueKeys.timeline(OTHER_ISSUE_ID))).toEqual([]);
|
||||
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID))).toEqual({
|
||||
labels: [labelB],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the deleted issue from workspace and my-issues list caches immediately", () => {
|
||||
const myFilter = { assignee_id: AGENT_ID };
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
issueKeys.list(WS_ID),
|
||||
makeListCache(baseIssue, otherIssue),
|
||||
);
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
makeListCache(baseIssue, otherIssue),
|
||||
);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
|
||||
const myList = qc.getQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
);
|
||||
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
OTHER_ISSUE_ID,
|
||||
]);
|
||||
expect(list?.byStatus.todo?.total).toBe(1);
|
||||
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
OTHER_ISSUE_ID,
|
||||
]);
|
||||
expect(myList?.byStatus.todo?.total).toBe(1);
|
||||
expectInvalidated(qc, issueKeys.list(WS_ID));
|
||||
expectInvalidated(qc, issueKeys.myList(WS_ID, "assigned", myFilter));
|
||||
});
|
||||
|
||||
it("invalidates parent progress when the parent id only exists in detail cache", () => {
|
||||
qc.setQueryData<Issue>(
|
||||
issueKeys.detail(WS_ID, ISSUE_ID),
|
||||
parentedIssue,
|
||||
);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
|
||||
parentedIssue,
|
||||
otherIssue,
|
||||
]);
|
||||
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const parentChildren = qc.getQueryData<Issue[]>(
|
||||
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
|
||||
);
|
||||
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
|
||||
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates parent progress when the deleted issue is only present in a children cache", () => {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
|
||||
parentedIssue,
|
||||
otherIssue,
|
||||
]);
|
||||
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const parentChildren = qc.getQueryData<Issue[]>(
|
||||
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
|
||||
);
|
||||
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
|
||||
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates parent progress when the parent id only exists in a my-issues cache", () => {
|
||||
const myFilter = { assignee_id: AGENT_ID };
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
makeListCache(parentedIssue, otherIssue),
|
||||
);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
|
||||
otherIssue,
|
||||
]);
|
||||
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const myList = qc.getQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
);
|
||||
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
OTHER_ISSUE_ID,
|
||||
]);
|
||||
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates child progress when the deleted issue is itself a parent", () => {
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [
|
||||
{
|
||||
...otherIssue,
|
||||
parent_issue_id: ISSUE_ID,
|
||||
},
|
||||
]);
|
||||
qc.setQueryData(
|
||||
issueKeys.childProgress(WS_ID),
|
||||
new Map([[ISSUE_ID, { done: 0, total: 1 }]]),
|
||||
);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates agent task and activity caches that can reference the deleted issue", () => {
|
||||
qc.setQueryData<AgentTask[]>(
|
||||
agentTaskSnapshotKeys.list(WS_ID),
|
||||
[makeTask()],
|
||||
);
|
||||
qc.setQueryData<AgentActivityBucket[]>(
|
||||
agentActivityKeys.last30d(WS_ID),
|
||||
[
|
||||
{
|
||||
agent_id: AGENT_ID,
|
||||
bucket_at: "2025-01-01T00:00:00Z",
|
||||
task_count: 1,
|
||||
failed_count: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
qc.setQueryData<AgentRunCount[]>(agentRunCountsKeys.last30d(WS_ID), [
|
||||
{ agent_id: AGENT_ID, run_count: 1 },
|
||||
]);
|
||||
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
|
||||
makeTask(),
|
||||
]);
|
||||
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
|
||||
expectInvalidated(qc, agentActivityKeys.last30d(WS_ID));
|
||||
expectInvalidated(qc, agentRunCountsKeys.last30d(WS_ID));
|
||||
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
|
||||
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import { cleanupDeletedIssueCaches } from "./delete-cache";
|
||||
import type { Issue, IssueLabelsResponse, Label } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
@@ -19,8 +19,6 @@ export function onIssueCreated(
|
||||
old ? addIssueToBuckets(old, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
@@ -50,8 +48,6 @@ export function onIssueUpdated(
|
||||
old ? patchIssueInBuckets(old, issue.id, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
@@ -104,8 +100,6 @@ export function onIssueLabelsChanged(
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
@@ -113,7 +107,21 @@ export function onIssueDeleted(
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
cleanupDeletedIssueCaches(qc, wsId, issueId);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
if (deleted?.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ type ModalType =
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "create-squad"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
| "issue-add-child"
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"./api": "./api/index.ts",
|
||||
"./api/client": "./api/client.ts",
|
||||
"./api/schema": "./api/schema.ts",
|
||||
"./api/schemas": "./api/schemas.ts",
|
||||
"./api/ws-client": "./api/ws-client.ts",
|
||||
"./config": "./config/index.ts",
|
||||
"./auth": "./auth/index.ts",
|
||||
@@ -55,9 +54,6 @@
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./agents/visibility-label": "./agents/visibility-label.ts",
|
||||
"./agents/stores": "./agents/stores/index.ts",
|
||||
"./squads": "./squads/index.ts",
|
||||
"./squads/stores": "./squads/stores/index.ts",
|
||||
"./permissions": "./permissions/index.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
@@ -109,11 +105,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||