Compare commits

..

2 Commits

Author SHA1 Message Date
Jiang Bohan
26698d0f58 fix(github): re-evaluate auto-close on closed-without-merge events too
GPT-Boy review on #2470: gating only the `state == "merged"` branch left
one ordering hole. PR-A merges first → issue stays in_progress because
PR-B is open; PR-B later closes WITHOUT merging → no event ever re-runs
the auto-close check, so the issue is stuck in_progress.

Generalise the trigger to every terminal PR event (`merged` or `closed`)
and advance the issue only when:
- the issue is not already terminal (done / cancelled);
- no sibling PR is still in flight (open / draft);
- at least one linked PR — current or sibling — actually merged.

Rule (3) preserves "user closed every PR without merging → leave the
issue alone": if no work was delivered, the user decides what to do.

Replace `CountOpenSiblingPullRequestsForIssue` with
`GetSiblingPullRequestStateCountsForIssue`, which returns both the
in-flight count and the merged count in a single roundtrip.

Adds `TestWebhook_ClosedSiblingAfterMerge` (the regression GPT-Boy
flagged) and `TestWebhook_AllClosedWithoutMerge` (the negative case
guarding rule 3). Refactors the multi-PR webhook helper out of the
existing two-merge test so all three multi-PR scenarios share it.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:23:45 +08:00
Jiang Bohan
837528e80a fix(github): only auto-close issue when all linked PRs have resolved
Previously, the webhook handler unconditionally moved an issue to `done`
as soon as a single linked PR was merged. If a second PR was also linked
to the same issue and still open / draft, the issue would close before
the work was actually finished.

Add `CountOpenSiblingPullRequestsForIssue` and gate the auto-status
transition on it: a merged PR advances its linked issues only when no
sibling PR linked to the same issue is still in flight. Issues stay put
while siblings are open or draft, and the merge that resolves the last
in-flight PR is the one that closes the issue.

Adds an integration test that opens two PRs against the same issue,
merges the first, asserts the issue stays in_progress, then merges the
second and asserts the issue advances to done.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 15:07:49 +08:00
437 changed files with 2778 additions and 34965 deletions

View File

@@ -48,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
@@ -116,13 +101,6 @@ ALLOWED_ORIGINS=
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → Integrations "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
# Frontend
FRONTEND_PORT=3000
FRONTEND_ORIGIN=http://localhost:3000

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
**English | [简体中文](README.zh-CN.md)**
@@ -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.

View File

@@ -20,7 +20,7 @@
[![CI](https://github.com/multica-ai/multica/actions/workflows/ci.yml/badge.svg)](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
[![GitHub stars](https://img.shields.io/github/stars/multica-ai/multica?style=flat)](https://github.com/multica-ai/multica/stargazers)
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
**[English](README.md) | 简体中文**
@@ -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 一起高效完成项目。**
## 架构
```

View File

@@ -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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -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

View File

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

View File

@@ -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.",

View File

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

View File

@@ -5,7 +5,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
import fixPath from "fix-path";
import { setupAutoUpdater } from "./updater";
import { setupDaemonManager } from "./daemon-manager";
import { openExternalSafely, downloadURLSafely } from "./external-url";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
@@ -133,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}`],
},
});
@@ -233,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 -----------------------------------------------
@@ -317,14 +288,6 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
ipcMain.handle("file:download-url", (_event, url: string) => {
if (!mainWindow) {
console.warn("[download] ignored file:download-url — mainWindow torn down");
return;
}
downloadURLSafely(mainWindow, url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer

View File

@@ -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);

View File

@@ -19,9 +19,6 @@ interface DesktopAPI {
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Download a file by URL through Electron's native download system.
* Shows a native save dialog. On non-desktop platforms this is undefined. */
downloadURL: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */
@@ -84,9 +81,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<

View File

@@ -89,11 +89,6 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Download a file by URL through Electron's native download system.
* Shows a save dialog and saves to disk. Unlike openExternal, this
* avoids browser rendering of HTML files on Linux.
* On non-desktop platforms this property is undefined. */
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),
@@ -207,11 +202,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);
},

View File

@@ -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>
);
}

View File

@@ -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&apos;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&apos;ll be notified when it&apos;s ready to install.
v{state.latestVersion} is available see the download prompt
in the corner.
</p>
)}
{state.status === "error" && (

View File

@@ -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} />;
}

View File

@@ -11,17 +11,14 @@ 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";
import { DashboardPage } from "@multica/views/dashboard";
import { AutopilotsPage } from "@multica/views/autopilots/components";
import { MyIssuesPage } from "@multica/views/my-issues";
import { SkillsPage } from "@multica/views/skills";
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
import { 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,23 +145,7 @@ 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",
element: <DashboardPage />,
handle: { title: "Usage" },
},
{
path: "settings",
element: (

View File

@@ -180,61 +180,6 @@ describe("useTabStore actions", () => {
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("defers disposing the closed tab router until after the store update", () => {
vi.useFakeTimers();
try {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
const closingTab = useTabStore
.getState()
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
const dispose = vi.mocked(closingTab!.router.dispose);
store.closeTab(closedTabId);
expect(dispose).not.toHaveBeenCalled();
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
vi.runAllTimers();
expect(dispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("ignores router-sync updates from a tab after it has been closed", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
store.closeTab(closedTabId);
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
store.updateTabHistory(closedTabId, 1, 2);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
});
it("does not replace the tab group for no-op router-sync updates", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");

View File

@@ -350,10 +350,7 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const closing = group.tabs[index];
const disposeClosingRouter = () => {
// Let React unmount the tab's RouterProvider before disposing it.
window.setTimeout(() => closing.router.dispose(), 0);
};
closing.router.dispose();
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
@@ -366,7 +363,6 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
disposeClosingRouter();
return;
}
@@ -382,7 +378,6 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
disposeClosingRouter();
},
setActiveTab(tabId) {
@@ -407,13 +402,6 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
if (
next.path === current.path &&
next.title === current.title &&
next.icon === current.icon
) {
return;
}
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
@@ -430,12 +418,6 @@ export const useTabStore = create<TabStore>()(
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
if (
current.historyIndex === historyIndex &&
current.historyLength === historyLength
) {
return;
}
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;

View File

@@ -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

View File

@@ -45,5 +45,4 @@ import { Callout } from "fumadocs-ui/components/callout";
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
- [Skills](/skills) —— 给智能体挂上专业知识包
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么

View File

@@ -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

View File

@@ -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) —— 让智能体定时自动开工

View File

@@ -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

View File

@@ -12,11 +12,9 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
## Email + 验证码登录怎么工作
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务
### Option AResend公网/云端部署推荐)
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
1. 在 Resend 建账号、验证你的域名
2. 创建 API key
3. 设环境变量:
@@ -27,22 +25,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
4. 重启 server
### Option BSMTP relay内网/自部署)
适合内网无法访问 `api.resend.com`或者已经有内部邮件中继Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
```bash
SMTP_HOST=smtp.internal.example.com
SMTP_PORT=587 # 默认 25STARTTLS 提交端口用 587
SMTP_USERNAME=multica # 留空则使用未认证 relay
SMTP_PASSWORD=...
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
```
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465SMTPS / 隐式 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

View File

@@ -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 |

View File

@@ -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
| 命令 | 用途 |

View File

@@ -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 | 主题 / 语言 |

View File

@@ -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 | 主题 / 语言 |

View File

@@ -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
@@ -155,22 +141,6 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
</Callout>
## GitHub integration
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
| Variable | Default | Description |
|---|---|---|
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
**Behavior when either is unset:**
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
## Usage analytics
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
@@ -184,6 +154,5 @@ By default, the server reports to Multica's official PostHog instance. To opt ou
## Next
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
- [GitHub integration](/github-integration) — how to set up the GitHub App that backs `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET`
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do

View File

@@ -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`**暂不支持 465SMTPS / 隐式 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
@@ -155,22 +141,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
**`FRONTEND_ORIGIN` 不设就有两个静默失败**1邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例2WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
</Callout>
## GitHub 集成
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
| 环境变量 | 默认值 | 说明 |
|---|---|---|
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug`https://github.com/apps/<slug>` 的尾部。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
**任一变量未设时:**
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
## 用量统计
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
@@ -184,6 +154,5 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
## 下一步
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
- [GitHub 集成](/github-integration) —— `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` 背后的 GitHub App 怎么建
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义

View File

@@ -1,183 +0,0 @@
---
title: GitHub integration
description: Connect a GitHub App once, then PRs whose branch, title, or body reference an issue identifier auto-attach to that issue — and merging the PR moves the issue to Done.
---
import { Callout } from "fumadocs-ui/components/callout";
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
There is no per-issue setup. The whole flow is identifier-driven.
## What the integration does
| Surface | Behavior |
|---|---|
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
## How identifiers are matched
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:
- Case-insensitive — `mul-123`, `MUL-123`, `Mul-123` all match.
- Bounded — a `\b` on the left and a digit anchor on the right keep it from grabbing version numbers like `v1.2-3` or email-style strings.
- Workspace-scoped — only matches the workspace's own [issue prefix](/workspaces). `FOO-1` in a workspace whose prefix is `MUL` is ignored, even if the integer matches another issue.
- Deduplicated — listing `MUL-1, MUL-1` in the body links the issue once.
You can reference **multiple issues** in one PR. `Closes MUL-1, MUL-2` links the PR to both, and merging it advances both to `Done`.
## The auto-merge-to-Done rule
When a PR's `merged` field flips to `true`, every linked issue is evaluated:
| Issue current status | Result |
|---|---|
| `done` | No change (already terminal). |
| `cancelled` | **No change** — cancelled means the user explicitly abandoned the work; the integration does not override that signal. |
| Anything else (`todo`, `in_progress`, `in_review`, `blocked`, `backlog`) | Moved to `done`. |
Closing a PR **without** merging it only updates the PR card's state to `Closed`. The linked issues stay where they were — the user is the one who decides what closing-without-merge means.
<Callout type="info">
The action is attributed to the `system` actor on the timeline. Subscribers of the issue receive an inbox notification for the status change, the same way they would if a human had moved it.
</Callout>
## What's not auto-linked
- **Identifiers in commit messages** — only branch / title / body are scanned. A commit titled `MUL-123: fix login` does not auto-link unless the same string also appears in the PR title or body.
- **Identifiers in PR comments** — only the PR's own metadata is scanned; later GitHub comments are ignored.
- **PRs in repos the App isn't installed on** — without the App, Multica never receives the webhook.
- **Manually linking a PR to an issue** — there is no UI for this yet. If your team's convention puts identifiers in a place Multica isn't reading, add them to the PR title or body.
## Disconnecting
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
## Permissions and visibility
- **Connect / disconnect** require workspace **owner or admin**. Members see the card description but no Connect button.
- The **Pull requests** sidebar on an issue is visible to anyone who can read the issue — same permissions as the rest of issue detail.
- The GitHub App requests **read-only** access to pull requests and metadata. Multica never pushes commits, comments, or status checks back to GitHub.
## Self-host setup
If you're running Multica on Multica Cloud, the integration is already configured — skip this section.
For self-host, you create one GitHub App, point it at your server, and set two environment variables. The whole flow is below.
### 1. Create a GitHub App
Go to one of:
- Personal account → `https://github.com/settings/apps/new`
- Organization → `https://github.com/organizations/<org>/settings/apps/new`
Fill in:
| Field | Value |
|---|---|
| **GitHub App name** | Anything recognizable, e.g. `Multica` or `Multica (staging)`. |
| **Homepage URL** | Your Multica frontend, e.g. `https://multica.example.com`. |
| **Callback URL** | Leave blank — Multica doesn't use OAuth user identity. |
| **Setup URL** | `https://<api-host>/api/github/setup`. **Check "Redirect on update"**. |
| **Webhook → Active** | Enabled. |
| **Webhook URL** | `https://<api-host>/api/webhooks/github`. |
| **Webhook secret** | Generate a long random string (e.g. `openssl rand -hex 32`). You'll paste the same value into Multica's env in step 2. |
| **Permissions → Repository → Pull requests** | **Read-only**. |
| **Permissions → Repository → Metadata** | Read-only (mandatory). |
| **Subscribe to events** | Tick **Pull request**. |
| **Where can this GitHub App be installed?** | Your choice. `Only on this account` is fine for single-org setups. |
After **Create GitHub App**, note two things from the App's detail page:
- The **public link** at the top — its tail is the slug. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
- The **webhook secret** you just generated (you can't read it back from GitHub later — save it now).
<Callout type="warning">
**Webhook secret ≠ Client secret.** The App settings page has both fields stacked together. The **Webhook secret** is what signs `pull_request` payloads — that's the one Multica needs. The **Client secret** is for OAuth and is not used by this integration. Mixing them up produces a confusing `401 invalid signature` on every webhook delivery.
</Callout>
### 2. Set environment variables
On the API server:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
```
Both variables are required. If either is missing:
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
Restart the API after setting the env vars.
### 3. Run migrations
The integration ships its tables in migration `079_github_integration`. If you're upgrading an older deployment:
```bash
make migrate-up
```
Three tables get created: `github_installation`, `github_pull_request`, `issue_pull_request`. They cascade-delete with their workspace, so removing a workspace cleans them up automatically.
### 4. Connect from the UI
In Multica:
1. Open **Settings → Integrations** as an owner or admin.
2. Click **Connect GitHub**. GitHub opens in a new tab.
3. Pick the repositories to grant access to and **Install**.
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
### 5. Verify with a curl probe
If GitHub's **Recent Deliveries** page reports `401 invalid signature` after install, the two sides have different secrets. The fastest way to find out which side is wrong is to bypass GitHub:
```bash
SECRET="<the value you put in GITHUB_WEBHOOK_SECRET>"
BODY='{"zen":"test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i -X POST https://<api-host>/api/webhooks/github \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: ping" \
-H "Content-Type: application/json" \
-d "$BODY"
```
| HTTP status | Meaning | Fix |
|---|---|---|
| `200` `{"ok":"pong"}` | Server's loaded secret matches your `$SECRET`. The mismatch is on GitHub. | Edit the App → Webhook secret → **paste the same value** → **Save changes** (clicking out of the field without Save keeps the old secret). Redeliver. |
| `401 invalid signature` | Server's loaded secret is **not** what you think it is. | Confirm the env var landed in the running process (e.g. `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" | wc -c`). Re-deploy. |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` is empty in the process. | Set the env var, restart the API. |
## Limitations
A few rough edges to be aware of today:
- **No manual link UI yet** — the only way to link a PR is to have the identifier in its branch, title, or body.
- **No CI / check state** — only the PR itself is mirrored. Build status, review comments, and reviewers are not surfaced in Multica.
- **No workspace-level config** for the merge → Done rule — it's a fixed default (`merged → done`, unless `cancelled`). Workspace-customizable mappings are a future addition.
- **Multi-PR-to-one-issue is conservative on merge** — if two PRs both reference `MUL-123` and the first one merges, the issue is moved to `Done` immediately. A follow-up change to wait for all linked PRs to resolve before advancing is in progress.
## Next
- [Issues](/issues) — the issue identifiers (`MUL-123`) referenced from PRs
- [Workspaces](/workspaces) — where the workspace-specific issue prefix is set
- [Environment variables](/environment-variables) — full env reference, including the GitHub variables above

View File

@@ -1,183 +0,0 @@
---
title: GitHub 集成
description: 一次性连接 GitHub App之后 PR 的分支名、标题或正文里写了 issue 编号(例如 MUL-123就会自动挂到那个 issue 上——PR 合并时 issue 自动转 Done。
---
import { Callout } from "fumadocs-ui/components/callout";
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时issue 自动转 **Done**。
没有 per-issue 的配置,整个流程是「编号驱动」的。
## 集成做了什么
| 出现位置 | 行为 |
|---|---|
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR含标题、仓库、状态`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
| **Webhook后台** | 每次 `pull_request` 事件触发upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
## 编号是怎么匹配的
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:
- 大小写不敏感——`mul-123`、`MUL-123`、`Mul-123` 都能匹配
- 有边界——左侧 `\b`、右侧只接数字,避免误抓 `v1.2-3`、email 地址等
- 限定到本工作区——只匹配本工作区的 [issue prefix](/workspaces)。前缀是 `MUL` 的工作区里PR 出现 `FOO-1` 不会匹配,即使数字撞另一个 issue 也不会
- 自动去重——`Closes MUL-1, MUL-1` 只关联一次
一个 PR 里**可以同时引用多个 issue**。比如 `Closes MUL-1, MUL-2`PR 同时关联两个 issue合并时两个 issue 都会转 `Done`。
## Merge 自动转 Done 的规则
PR 的 `merged` 字段翻成 `true` 时,逐个评估关联的 issue
| Issue 当前状态 | 结果 |
|---|---|
| `done` | 不变(已经是终态)|
| `cancelled` | **不变**——cancelled 是用户明确放弃工作的信号,集成不覆盖 |
| 其他(`todo` / `in_progress` / `in_review` / `blocked` / `backlog`| 转成 `done` |
PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`issue 状态不变。"关闭但不合并"语义因团队而异Multica 不替用户做决定。
<Callout type="info">
状态变更的 actor 是 `system`。订阅了该 issue 的成员会收到 inbox 通知,和成员手动改状态时一致。
</Callout>
## 哪些情况不会自动关联
- **Commit message 里的编号**——只扫 PR 的分支 / 标题 / 正文。一个 commit message 写 `MUL-123: fix login` 不会触发关联,除非同样的字符串也出现在 PR 标题或正文里
- **PR 评论里的编号**——只扫 PR 自己的元数据,后续的 GitHub comment 不读
- **App 没安装的仓库里的 PR**——没 AppMultica 收不到 webhook
- **手动把 PR 关联到 issue**——暂时没有这个 UI。如果你们的约定把编号放到 Multica 不扫的地方,请改放到 PR 标题或正文里
## 断开连接
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR但来自这个 installation 的新 webhook 事件不再被接受。
## 权限和可见性
- **Connect / Disconnect** 需要工作区 **owner 或 admin**。普通成员能看到卡片描述但看不到 Connect 按钮
- **Pull requests** 侧栏对所有能看到该 issue 的成员可见——和 issue 详情页其他部分权限一致
- GitHub App 申请的是 PR 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check
## Self-Host 配置
如果你在 Multica Cloud 上,集成已经配好——跳过本节。
Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境变量。完整流程如下。
### 1. 创建一个 GitHub App
到下面其中一个页面:
- 个人账号 → `https://github.com/settings/apps/new`
- 组织 → `https://github.com/organizations/<org>/settings/apps/new`
按下表填写:
| 字段 | 值 |
|---|---|
| **GitHub App name** | 任何能辨识的名字,例如 `Multica` 或 `Multica (staging)` |
| **Homepage URL** | 你的 Multica 前端,例如 `https://multica.example.com` |
| **Callback URL** | 留空——本集成不使用 OAuth 用户身份 |
| **Setup URL** | `https://<api-host>/api/github/setup`。**勾选 "Redirect on update"** |
| **Webhook → Active** | 启用 |
| **Webhook URL** | `https://<api-host>/api/webhooks/github` |
| **Webhook secret** | 生成一个长随机字符串(例如 `openssl rand -hex 32`)。这个值会同样填到 step 2 的 env 里 |
| **Permissions → Repository → Pull requests** | **Read-only** |
| **Permissions → Repository → Metadata** | Read-only必填|
| **Subscribe to events** | 勾选 **Pull request** |
| **Where can this GitHub App be installed?** | 自选。单组织部署建议选 `Only on this account` |
点 **Create GitHub App** 之后,从详情页记下两件事:
- 顶部 **public link** 的尾部即 slug。`https://github.com/apps/multica-acme` → slug = `multica-acme`
- 你刚生成的 **webhook secret**GitHub 之后不会再让你读取这个值——现在就保存好)
<Callout type="warning">
**Webhook secret ≠ Client secret。** App 设置页里两个字段紧挨着。**Webhook secret** 用于签 `pull_request` payload这才是 Multica 需要的那个;**Client secret** 是 OAuth 用的,和本集成无关。混淆这两个会得到「每条 webhook 都 `401 invalid signature`」的诡异症状。
</Callout>
### 2. 配置环境变量
API server 上:
```dotenv
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
```
两个都必填。任何一个缺失:
- Settings 里 `Connect GitHub` 按钮会被 **disable**并显示「not configured」提示
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
设完 env 重启 API。
### 3. 执行 migration
集成的表在 migration `079_github_integration` 里。如果是升级既有部署:
```bash
make migrate-up
```
会创建三张表:`github_installation`、`github_pull_request`、`issue_pull_request`。三张表都 cascade 跟随 workspace——删工作区会自动清理。
### 4. 在 UI 里连接
到 Multica
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
2. 点 **Connect GitHub**GitHub 在新 tab 打开
3. 选择要授权的仓库,点 **Install**
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
### 5. 用 curl 自检
如果 GitHub 的 **Recent Deliveries** 里第一次 PR 事件就报 `401 invalid signature`,说明两边的 secret 不一致。绕过 GitHub 直接测 server 是最快的定位方法:
```bash
SECRET="<你填给 GITHUB_WEBHOOK_SECRET 的值>"
BODY='{"zen":"test"}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
curl -i -X POST https://<api-host>/api/webhooks/github \
-H "X-Hub-Signature-256: sha256=$SIG" \
-H "X-GitHub-Event: ping" \
-H "Content-Type: application/json" \
-d "$BODY"
```
| HTTP 状态 | 含义 | 修法 |
|---|---|---|
| `200` `{"ok":"pong"}` | server 加载的 secret 和你 `$SECRET` 一致——GitHub 那边的 secret 才是错的 | 编辑 App → Webhook secret 字段**粘相同的值** → **必须点 Save changes**(不点 Save 等于没改)→ Redeliver |
| `401 invalid signature` | server 加载的 secret **不是**你以为的那个 | 进容器确认 env 实际生效(例如 `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`),重新部署 |
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` 在进程里是空的 | 配上 env重启 API |
## 已知限制
目前还没做的几个边界:
- **手动 link UI 暂未提供**——关联 PR 的唯一方法是把 issue 编号写到 PR 分支 / 标题 / 正文
- **不读 CI / check 状态**——只镜像 PR 本身构建状态、reviewer 评论、reviewer 列表都没接进 Multica
- **没有工作区级别的 merge → status 映射配置**——默认固定是 `merged → done`cancelled 除外)。可配置映射是后续迭代
- **同 issue 多 PR 时merge 行为偏激进**——两个 PR 都引用 `MUL-123` 时,第一个 merge 就把 issue 转 Done。"等所有关联 PR 都解决再推进 issue 状态"的优化已经在做了
## 下一步
- [Issues](/issues) —— PR 引用的 issue 编号(`MUL-123`)的来源
- [工作区](/workspaces) —— 工作区 issue prefix 的设置位置
- [环境变量](/environment-variables) —— 完整 env 清单,包含上面提到的 GitHub 变量

View File

@@ -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

View File

@@ -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` 的语义

View File

@@ -16,7 +16,6 @@
"agents",
"agents-create",
"skills",
"squads",
"---How agents run---",
"daemon-runtimes",
"tasks",
@@ -28,8 +27,6 @@
"autopilots",
"---Inbox---",
"inbox",
"---Integrations---",
"github-integration",
"---Self-hosting & ops---",
"environment-variables",
"auth-setup",

View File

@@ -15,7 +15,6 @@
"agents",
"agents-create",
"skills",
"squads",
"---智能体怎么运行---",
"daemon-runtimes",
"tasks",
@@ -27,8 +26,6 @@
"autopilots",
"---收件箱---",
"inbox",
"---集成---",
"github-integration",
"---自部署运维---",
"environment-variables",
"auth-setup",

View File

@@ -59,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
@@ -72,28 +70,16 @@ 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
@@ -122,13 +108,12 @@ 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
- [Environment variables](/environment-variables) — full env reference
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
- [GitHub integration](/github-integration) — connect a GitHub App so PRs auto-link to issues and merging closes them
- [Troubleshooting](/troubleshooting) — start here when things go wrong
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path

View File

@@ -58,9 +58,7 @@ make selfhost
如果不配邮件用户无法通过邮件收到验证码server 会把生成的验证码打印到 stdout。
支持两种发送通道,按部署环境二选一
**Option A — Resend公网/云端部署):**
要真的发验证码邮件
1. 在 [Resend](https://resend.com/) 注册并拿一个 API key
2. 验证一个你控制的发件域名
@@ -71,28 +69,16 @@ 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 # 默认 25STARTTLS 提交端口用 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` 后它才会生效
- 登录后创建第一个工作区
@@ -121,13 +107,12 @@ multica setup self-host
## 常见问题
- **后端起不来**:看容器日志 `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-连不上)
## 下一步
- [环境变量](/environment-variables) —— 完整 env 清单
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
- [GitHub 集成](/github-integration) —— 连一个 GitHub App让 PR 自动关联 issue、merge 时自动转 Done
- [故障排查](/troubleshooting) —— 遇到问题先来这里
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 DesktopWeb 前端 + CLI 仍然是最快的自部署路径

View File

@@ -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

View File

@@ -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 的完整权限对照

View File

@@ -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:"

View File

@@ -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>
);
}

View File

@@ -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} />;
}

View File

@@ -1 +0,0 @@
export { SquadDetailPage as default } from "@multica/views/squads";

View File

@@ -1 +0,0 @@
export { SquadsPage as default } from "@multica/views/squads";

View File

@@ -1 +0,0 @@
export { DashboardPage as default } from "@multica/views/dashboard";

View File

@@ -284,106 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
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",
title: "GitHub Integration, Chat Attachments & Safer Issue Navigation",
changes: [],
features: [
"Connect GitHub so linked pull requests appear on Multica issues, sync their status, and close the Multica issue automatically when the PR closes",
"Chat messages can include file attachments and image previews",
"Agents and runtimes can now be kept public or private for clearer team access",
"Stopping a single agent task now asks for confirmation before it is terminated",
"New GitHub integration docs cover both hosted and self-hosted setup",
],
improvements: [
"Issue links land more reliably on the exact comment or activity you opened",
"Long issue timelines scroll more smoothly",
"The feedback dialog now points contributors toward GitHub discussions and issues",
"Self-hosted Caddy guidance now calls out real-time connection requirements",
"Linux desktop packages show the Multica app icon again",
],
fixes: [
"Downloaded attachments keep their original filenames",
"Local attachments are served more reliably, and upload controls stay disabled until files are ready",
"Issue creation dialogs keep their text fields at the correct height",
"Runtime documentation links point to the correct page",
],
},
{
version: "0.2.30",
date: "2026-05-11",

View File

@@ -284,106 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
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",
title: "GitHub 集成、聊天附件与 Issue 定位优化",
changes: [],
features: [
"接入 GitHub 后,关联的 Pull Request 会显示在 Multica Issue 中,状态会同步到 Multica关闭 PR 后会自动关闭对应 Issue",
"聊天消息支持添加文件附件和图片预览",
"Agent 和 runtime 可以设置公开或私有,方便控制团队可见范围",
"停止单个 agent 任务前会先弹出确认,避免误操作",
"新增 GitHub 集成文档,覆盖托管版和自托管配置",
],
improvements: [
"打开 Issue 链接时,会更稳定地定位到指定评论或动态",
"很长的 Issue 时间线滚动更顺畅",
"反馈入口更明确地引导用户到 GitHub 参与讨论和反馈",
"自托管 Caddy 配置文档补充实时连接要求",
"Linux 桌面端安装包恢复显示 Multica 应用图标",
],
fixes: [
"下载附件时保留原始文件名",
"本地附件访问更稳定,上传按钮会等文件准备好后再可用",
"创建 Issue 弹窗里的文本框高度显示正确",
"Runtime 文档入口跳转到正确页面",
],
},
{
version: "0.2.30",
date: "2026-05-11",

View File

@@ -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",

View File

@@ -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>;

View File

@@ -46,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}
@@ -63,11 +58,9 @@ services:
APP_ENV: ${APP_ENV:-production}
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
restart: unless-stopped
frontend:

View File

@@ -1,555 +0,0 @@
# Agent 快速创建 — 三阶段实施计划
> Status: Draft (设计阶段,未动工)
> Owner: TBD
> Last updated: 2026-05-13
## TL;DR
- **目标**:降低用户创建 Agent 的门槛,从「手工填表 + 一个个挑 skill」演进到「一键模板」「AI 推荐 skill」「AI 直接创建 agent」三档
- **三阶段**:Template(必做、独立)→ Skill Finder(AI 推荐 skill)→ AI Create Agent(AI 直接创建)
- **架构关键**:Phase 2/3 复用现有 Quick-create Issue 基础设施(派任务给 agent + tool calling + inbox 通知),不引入新 LLM 调用路径
- **不需要新基础设施**:无 SSE、无 server-side LLM、无新 WS channel
- **soft blocker**:两处 routine 重构(`createSkillWithFiles` TX 拆分、skill 同名 find-or-create)
- **不做**:接入 Anthropic 官方 marketplace(plugin 体系跟单体 skill 形态不匹配)、接入 ClawHub(战略对位错误 + 实际使用率低,见 §5)
---
## 1. 背景与目标
### 1.1 当前现状
当前用户创建一个 Agent 需要走的步骤:
1.`/agents` 页面 → 点 "Create Agent"
2. 手工填 name / description / runtime / model
3. 手工写 instructions(空白文本框,用户自己思考措辞)
4. 创建完后进 Agent 详情页 → 点 "Add Skill" → 一个一个挑 skill 关联
5. 如果 workspace 还没有需要的 skill,得先去别处建/导入 skill(`POST /api/skills/import` 支持 skills.sh / GitHub / ClawHub 三种 URL)
**痛点**:
- 用户得**预先知道**自己需要哪些 skill,这要求他对 skill 生态熟悉
- 写 instructions 是空白文本编辑,大多数用户不知道写什么
- 跨多页操作,体感上"创建一个能用的 Agent"是个项目,不是个动作
### 1.2 三阶段方案
| Phase | 提供给用户的能力 | 是否需要 AI | 独立可发布 |
|---|---|---|---|
| **1. Template** | 选模板 → 自动 import 模板带的 skill + 预填 instructions | 否 | ✅ |
| **2. Skill Finder** | 描述需求 → AI 推荐 skill 列表 → 一键导入到 workspace | ✅ | ✅(独立功能,任何场景都能用) |
| **3. AI Create Agent** | 描述需求 → AI 自己 find skill + 写 instructions + 创建 agent | ✅ | 依赖 Phase 2 |
每个 phase **本身有用户价值**,不需要等下一个 phase 才能用:
- Phase 1 用户能用模板创建 agent,即使后两阶段没做
- Phase 2 用户能在任何地方"用 AI 找 skill"(创建 agent 时、给现有 agent 加 skill 时、单纯逛 skill 时)
- Phase 3 是 1+2 的组合
### 1.3 不在范围内
明确不做的事(及理由,见 §5):
- 接入 Anthropic 官方 plugin marketplace(`anthropics/claude-plugins-official`)
- 接入 ClawHub 的"发现/搜索"层(import 路径已经存在,但是死代码,建议下线)
- 让 AI 直接装 skill 到用户本地 `~/.claude/skills/`(npx skills CLI 行为)
- Server-side LLM 调用(后端目前没有 LLM SDK,这条路引入新基础设施,而 Quick-create 模式可以避开)
---
## 2. 关键概念回顾
> 这一节给没参与前期讨论的同事看。已经熟悉 skill 系统的可跳到 §3。
### 2.1 Skill 是什么
Skill 是一个**按需加载的能力包**,本质是 SKILL.md 文件 + 可选附件。Anthropic 2025-12 把它发布为开放标准(agentskills.io),Cursor / OpenAI / GitHub Copilot 等都已采纳——同一份 SKILL.md 跨多个 agent 工具都能用。
每个 runtime(Claude Code / Cursor / Codex 等)启动时**自动扫**自己约定的目录(`~/.claude/skills/``.cursor/skills/` 等),读 SKILL.md 的 frontmatter 形成"我手上有这些 skill"的清单注入 system prompt。具体 skill 正文只在被触发时才进 context。
### 2.2 Multica 的 Skill 数据模型
3 张表(migration `008_structured_skills.up.sql`):
| 表 | 关键字段 |
|---|---|
| `skill` | `id, workspace_id, name, description, content (=SKILL.md 正文), config (含 origin 元数据)` |
| `skill_file` | `skill_id, path, content`(SKILL.md 的附件,如 examples/*.md、scripts/*.py) |
| `agent_skill` | `agent_id, skill_id`(M:N 关联) |
**关键约束**:`UNIQUE(workspace_id, name)` — 同 workspace 内 skill 名字必须唯一。
### 2.3 Skill 流转链路(数据库 → runtime)
任务运行时,skill 从 PG 到 runtime 的完整路径:
```
1. 数据库:skill + skill_file + agent_skill 三张表的行
2. Daemon claim 任务:
POST /api/runtimes/{runtimeId}/tasks/claim
handler/daemon.go:1018-1098 (ClaimTaskByRuntime)
→ service/task.go:1447-1463 (LoadAgentSkills)
→ 把 agent 关联的所有 skill 全文塞进 HTTP 响应
3. Daemon 算工作目录:
server/internal/daemon/execenv/execenv.go:114, 124
workDir = {WorkspacesRoot}/{wsID}/{shortTaskID}/workdir
4. Daemon 按 runtime 算 skill 目录:
server/internal/daemon/execenv/context.go:121-158 (resolveSkillsDir)
claude → {workDir}/.claude/skills
cursor → {workDir}/.cursor/skills
codex → 特殊:{codexHome}/skills
5. Daemon 把字符串写成磁盘文件:
context.go:175-204 (writeSkillFiles)
核心就两行 os.WriteFile
6. Daemon 启动 runtime,cwd = workDir
runtime 自己扫 .claude/skills/(等)→ 加载 frontmatter
7. 任务结束:os.RemoveAll(workDir)
PG 是真相源,workDir 是每次任务临时复印件
```
**核心 invariant**:Multica 不教 runtime 怎么用 skill,只把文件摆到 runtime 已经会扫的位置。
### 2.4 Template = Instructions + Skill 引用
Template 是个**静态 JSON 定义**,包含:
- 预写好的 instructions
- 一组 skill 引用(用 URL 指向 skills.sh / GitHub)
用户选模板时,后端:
1. 对每个 skill 引用,**复用现有 `/api/skills/import` 的 fetcher**(`fetchFromSkillsSh` / `fetchFromGitHub`)拉内容
2. 物化到 workspace(同名复用 / 新建)
3. CreateAgent + setAgentSkills
4. 整个流程一个事务
skill 引用为什么用 URL 而不是内联 SKILL.md 内容:
- 复用现有 import 基础设施,零新代码
- skill 内容跟 GitHub 同步,不需要 vendoring 进 multica 仓库
- 模板 JSON 体积小,git review 友好
### 2.5 Quick-create Issue 模式(Phase 2/3 复用的基础设施)
当前 `POST /api/issues/quick-create`(handler/issue.go:877-982)的流程:
```
1. 后端 enqueue 任务:
- agent_task_queue 加一行,issue_id = NULL,context JSONB = {type: "quick-create", prompt: ...}
- 立即返回 202 Accepted + task_id
2. Daemon claim 任务时识别 quick-create:
- 检查 task.Context != nil AND !task.IssueID.Valid
- 解析为 QuickCreateContext (service/task.go:1810-1811)
3. Daemon 构造 prompt:
- daemon/prompt.go:45-106 (buildQuickCreatePrompt)
- 把用户的自然语言 prompt 作为语义核心
- 加上"调用 multica issue create CLI 命令"的指令
4. Agent 跑 LLM + tool calling:
- LLM 输出形如 `multica issue create --title="..." --description="..."` 的命令
- daemon 执行 CLI 命令,CLI 调 POST /api/issues 创建 issue
- CLI 自动在请求里带上 MULTICA_QUICK_CREATE_TASK_ID env(daemon/daemon.go:2081)
→ 让创建出来的 issue 带 origin_type='quick_create' + origin_id=<task_id>
5. 后端 link + 通知:
- 完成检测:GetIssueByOrigin(workspace_id, "quick_create", task_id)
- LinkTaskToIssue(task_id, issue_id) 把任务行的 issue_id 补上
- 写 inbox_item 通知用户(notifyQuickCreateCompleted, service/task.go:1908-1920)
```
**关键洞察**:这个模式**完全通用化**了。复用它只需要:
1. 新的 context JSONB type(比如 `"skill-find"``"agent-create"`)
2. 新的 prompt builder
3. 新的"完成检测 + inbox 通知"
不需要任何 daemon / 任务队列层面的改动。
---
## 3. 三阶段详细设计
### Phase 1:Agent Template
**目标**:用户选模板 → 一键得到一个可用的 agent(自带 skill + instructions),不需要 AI 参与。
#### 设计
- **Template 定义存放**:静态 JSON,commit 在 `server/internal/agenttmpl/templates/*.json`
- **Template JSON 形态**:
```json
{
"slug": "code-reviewer",
"name": "Code Reviewer",
"description": "审代码用的 agent",
"instructions": "你审代码,关注 N+1 查询、错误处理、类型安全...",
"skills": [
{ "source_url": "https://skills.sh/obra/superpowers/tdd" },
{ "source_url": "https://github.com/foo/bar/tree/main/skills/code-style" }
]
}
```
- **新 endpoint**:`POST /api/agents/from-template`
- 请求:`{template_slug, name, runtime_id, ...overrides}`
- 后端流程(**全部在一个事务里**):
1. 加载 template JSON
2. 对每个 skill source_url:
- 调用 `detectImportSource(url)`(skill.go:586-617)分发到对应 fetcher
- 通过 GetSkillByWorkspaceAndName 检查 workspace 是否已有同名 skill
- 有 → 复用现有 skill_id
- 无 → 调 `createSkillWithFilesInTx`(待重构,见 §4)物化
3. `CreateAgent`(复用 agent.go:CreateAgent 的内部逻辑)
4. 批量 `AddAgentSkill` 关联
- 响应:`{agent: {...}, imported_skill_ids: [...], reused_skill_ids: [...]}`
- **前端**:`CreateAgentDialog`(packages/views/agents/components/create-agent-dialog.tsx)加 "From template" 模式,跟现有 manual / duplicate 模式并列
- 模板选择器 → 预览(instructions + skill 列表)→ 提交调新 endpoint
- 响应里的 `reused_skill_ids` 用 toast 提示"以下 skill 已存在,沿用了 workspace 现有版本"
#### 起步模板清单(初版,可调)
- `code-reviewer` — 代码审查
- `tdd-pair` — TDD 配对编程
- `db-reviewer` — 数据库 / SQL 审查
- `pr-summarizer` — PR 摘要
- `docs-writer` — 文档撰写
具体每个模板选哪些 skill URL,在 Phase 1 启动时单独决定(需要逛 skills.sh 选高质量 skill)。
#### Phase 1 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/agenttmpl/`(新包) | 加载 JSON 模板的代码 |
| `server/internal/agenttmpl/templates/*.json`(新文件) | 5 个起步模板 |
| `server/internal/handler/agent.go` | 新 handler `CreateAgentFromTemplate` |
| `server/internal/handler/skill_create.go` | **重构**:拆出 `createSkillWithFilesInTx` 变体(见 §4) |
| `server/pkg/db/queries/skill.sql` | 加 `GetSkillByWorkspaceAndName`(见 §4) |
| `server/cmd/server/router.go` | 注册新 endpoint |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 template 模式 |
| `packages/core/api/agent.ts` | 加 `createAgentFromTemplate` API 调用 |
| `packages/views/agents/components/template-picker.tsx`(新文件) | 模板选择器组件 |
### Phase 2:Skill Finder
**目标**:用户用自然语言描述需求(如"我想审 SQL"),AI 推荐一组 skill,用户勾选一键导入到 workspace。
#### 设计
- **架构选型**:走 quick-create 模式,**不是后端直接调 LLM**
- **新 endpoint**:`POST /api/skills/find`
- 请求:`{prompt, agent_id}`(agent_id 是用来跑这个 LLM 任务的 agent,跟 Quick-create Issue 一样要求预先有 agent)
- 后端流程:
1. enqueue 任务:`agent_task_queue` 加一行,context JSONB = `{type: "skill-find", prompt}`
2. 返回 202 + task_id
- **Daemon prompt builder**:`daemon/prompt.go` 加 `buildSkillFindPrompt`(类比 buildQuickCreatePrompt)
- 喂给 agent 的 prompt 大致:
```
用户需求:{user_prompt}
你的任务:从以下 curated skill 清单里选 3-5 个最相关的推荐给用户。
可选 skill 清单(JSON):
{curated_skill_index}
输出:调用 `multica skill find --output-results '<JSON>'` 命令,
JSON 形态为 [{name, description, source_url, reason}, ...]
```
- **CLI 命令**(新):`multica skill find --output-results <JSON>`
- 不发起 HTTP 请求,只把 JSON 写到 daemon 通过 env 指定的临时文件
- daemon 读这个文件,把内容塞进 inbox notification 的 payload
- **Curated skill 索引**:`server/internal/agenttmpl/skill_index.json`(新文件)
- 几十到上百条精选 skill,每条:`{name, description, source_url, tags, install_count}`
- 维护方式:工程师/产品手工维护,代码 review 卡内容质量
- MVP **不做**实时 GitHub Code Search 或 skills.sh 爬虫
- **完成通知**:写 inbox_item,type = `skill_find_done`,payload 含推荐结果数组
- **前端**:
- 独立"Find Skill"页面(`/skills/find` 或 `/skills?ai=true`)
- skill list page 上"用 AI 找 skill"按钮入口
- 用户输入 prompt → 提交 → 等通知 → inbox item 里展示 skill 卡片(name + description + source_url + reason)
- 用户勾选 → 一键批量调现有 `POST /api/skills/import`(每个 skill 一次,可考虑加 batch endpoint 但 MVP 不必要)
#### Phase 2 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/skill.go` | 新 handler `FindSkill`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueSkillFindTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildSkillFindPrompt` |
| `server/internal/daemon/daemon.go` | 加 `SkillFindContext` 识别 + env 注入 |
| `server/cmd/multica/cmd_skill.go` | 加 `find --output-results` 子命令 |
| `server/internal/agenttmpl/skill_index.json`(新文件) | curated 清单 |
| `packages/views/skills/components/find-skills-dialog.tsx`(新文件) | UI |
| `packages/core/api/skill.ts` | 加 `findSkills` API |
| `packages/views/inbox/items/skill-find-result.tsx`(新文件) | inbox item 渲染 |
### Phase 3:AI Create Agent
**目标**:用户描述需求,AI 自己 find skill + 写 instructions + 创建 agent。
#### 设计
- **架构选型**:走 quick-create 模式,**组合 Phase 2 的 find 能力 + 新的 agent create CLI**
- **新 endpoint**:`POST /api/agents/ai-draft`
- 请求:`{prompt, host_agent_id}`(host_agent_id 是跑这个元任务的 agent)
- 后端:enqueue 任务,context = `{type: "agent-create", prompt}`,返回 202 + task_id
- **Daemon prompt builder**:`buildAgentCreatePrompt` 指挥 agent 三步走:
```
1. 调用 `multica skill find --output-results ...` 选 skill
(或直接看 curated 清单选)
2. 基于选定 skill 写 instructions
3. 调用 `multica agent create --name ... --instructions ... --skill-ids ...`
创建 agent 并关联 skill
```
- **CLI 命令**(新):`multica agent create`
- 后端 handler 已存在(handler/agent.go:CreateAgent),只需要绑 CLI(~50 行)
- 创建时带 `MULTICA_AI_DRAFT_TASK_ID` env,服务端用它做 origin 标记 + LinkTaskToAgent
- **完成通知**:inbox_item type = `agent_draft_done`,payload 含 agent_id + 摘要
- **前端**:`CreateAgentDialog` 加 "AI" 模式
- 输入需求 → 提交 → 等通知 → inbox 通知里点击 → 跳新 agent 详情页(用户在那儿编辑/调整)
#### Phase 3 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/agent.go` | 新 handler `AIDraftAgent`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueAgentDraftTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildAgentCreatePrompt` |
| `server/cmd/multica/cmd_agent.go` | 加 `create` 子命令(handler 已有) |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 "AI" 模式 |
| `packages/core/api/agent.ts` | 加 `aiDraftAgent` API |
| `packages/views/inbox/items/agent-draft-result.tsx`(新文件) | inbox item 渲染 |
---
## 4. Blocker 清单与修复方案
### 4.1 [SOFT] `createSkillWithFiles` 不可组合事务
**问题**:`server/internal/handler/skill_create.go:21-71` 这个函数自己 `Begin()` 一个事务,执行完 `Commit()`。Phase 1 需要在外层事务里**多次**调用它(import N 个 skill + createAgent + setAgentSkills 都在一个 TX),但现在没法这么用。
**影响范围**:Phase 1
**修复方案**:
```go
// 拆成两个函数(保持原 API 向后兼容):
// 新增:接受外部 qtx,不管事务
func createSkillWithFilesInTx(
ctx context.Context,
qtx *db.Queries,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
// 不 Begin/Commit,只调 qtx.CreateSkill + qtx.UpsertSkillFile loop
}
// 改造:原函数变成包装层,内部调 InTx 版
func (h *Handler) createSkillWithFiles(
ctx context.Context,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
tx, _ := h.TxStarter.Begin(ctx)
defer tx.Rollback()
qtx := h.Queries.WithTx(tx)
result, err := createSkillWithFilesInTx(ctx, qtx, input)
if err != nil { return nil, err }
tx.Commit()
return result, nil
}
```
旧调用方完全不变。Phase 1 新 endpoint 自己 Begin,然后多次调 `*InTx` 变体,最后统一 Commit。
**工作量**:小(< 100 行重构)
### 4.2 [SOFT] Skill 同名冲突
**问题**:`skill` 表有 `UNIQUE(workspace_id, name)` 约束。Phase 1 模板导入时,如果模板里的 skill 跟 workspace 已有 skill 同名,INSERT 会报 PG 错误 23505,整个 from-template 流程挂掉。
**影响范围**:Phase 1
**修复方案**:加 find-or-create 模式:
1. 新 query `GetSkillByWorkspaceAndName`(`server/pkg/db/queries/skill.sql`)
2. Phase 1 流程改成:
- 对每个模板 skill,先查 workspace 是否已有同名
- 有 → 复用现有 skill_id,跳过 import
- 无 → 调 `createSkillWithFilesInTx` 物化
3. 响应里返回 `reused_skill_ids: [...]`,前端 toast "以下 skill 已存在,沿用现有版本"
**不选择"覆盖"或"加后缀"的原因**:用户可能已经改过本地版本,覆盖会丢用户修改;加后缀污染 skill 列表。
**工作量**:小(< 50 行 + 1 条 sqlc query)
### 4.3 [SOFT] 缺 `multica skill find` CLI
**影响范围**:Phase 2
**方案**:加一个 CLI 子命令,模仿 `multica skill import` 的实现(`server/cmd/multica/cmd_skill.go:55-60, 323-357`)。**注意**:这个命令不发 HTTP 请求,只是 LLM agent 用来"输出推荐结果"的 channel——它把 LLM 推荐的 JSON 写到 daemon 指定的临时文件,daemon 读完塞进 inbox notification。
**工作量**:小(~80 行)
### 4.4 [SOFT] 缺 `multica agent create` CLI
**影响范围**:Phase 3
**方案**:后端 handler 已有(`handler/agent.go:CreateAgent`),只需在 `server/cmd/multica/cmd_agent.go` 加 `create` 子命令。
**工作量**:小(~50 行)
### 4.5 [非 blocker] System Agent 问题
**之前误判为 hard blocker,实际不是**:
Quick-create Issue 当前的设计就要求用户**预先有一个 agent** 才能用——AI 路径不为"零 agent 起步"服务。Phase 2/3 沿用这个前提,所以**新 workspace 没 agent 时 AI 功能不可用**是符合现有产品模型的,不需要 bootstrap 一个 system agent。
产品自然解锁路径:
1. 新用户进 workspace
2. 用 **Phase 1 Template**(无需 AI、无需现有 agent)创建第一个 agent
3. 之后 Phase 2/3 即可用,host_agent 就用刚创建的那个
---
## 5. 关键设计决策(及理由)
### 5.1 为什么不接 Anthropic 官方 marketplace?
**结构错配**。Anthropic 官方 marketplace(`anthropics/claude-plugins-official`)是 **plugin 体系**:每个 plugin 是个 bundle,包含 `.claude-plugin/plugin.json` + `skills/` + `agents/` + `hooks/` + `.mcp.json`。
Multica 只有**单体 skill**(SKILL.md + skill_file),没有 plugin / bundle 概念。要接入得新写 plugin parser + 拆分逻辑,工作量大,而 skills.sh 已经覆盖了同一批高质量内容(skills.sh 后端就是 GitHub raw,绝大多数 skill 作者就在 GitHub 上,Anthropic plugin 体系里的 skill 通常也在作者的 GitHub repo 里有单体副本)。
### 5.2 为什么走 quick-create 模式而不是后端直接调 LLM?
代码事实:`server/` 目前**完全没有任何 LLM SDK**(grep `anthropic-sdk-go` / `openai-go` / 任何 LLM provider 都是 0 命中)。所有 LLM 调用都通过 daemon → runtime → CLI 这条路。
走 quick-create 模式的优势:
- **不引入新基础设施**(SSE / LLM client / API key 管理)
- **复用 agent 的 instructions / model / runtime 配置**(用户已经在某个 agent 里配置过的偏好自动生效)
- **统一计费 / 用量监控**(LLM 调用都计在用户 agent 的 quota 里)
代价:
- 用户得**预先有一个 agent**(参见 §4.5,这跟 Quick-create Issue 现状一致)
- LLM 调用通过 daemon 多一跳,延迟略增(但不阻塞 202 响应)
### 5.3 为什么 Skill Finder 是 endpoint 不是 SKILL.md?
**Skill Finder 名字里的 "Skill" 是它的产物(找的是 skill),不是它自己实现成 SKILL.md**。
如果做成 SKILL.md 文件:
- 它得装进某个 agent 里才能用 → 单点功能变得需要前置配置
- skill 教 agent 调什么?调 `npx skills`(装到本地,目标错)?调 Multica API(那要写 tool channel,绕一大圈)
- AI 创建 Agent(Phase 3)那条路要"启动 agent → agent 调 skill → skill 调 tool",链路复杂三倍
做成 endpoint:
- 用户独立可用(独立 UI 入口)
- AI 创建 Agent 后端直接调 endpoint,两个功能共用一段逻辑
- 简单
### 5.4 Curated Skill 索引 vs 实时搜索
**MVP 用 curated 清单**(几十条精选 URL + 摘要 commit 在 repo 里)。理由:
- 质量可控
- 不踩 GitHub Code Search rate limit
- 不被 LLM 编 URL(LLM 知识 cutoff + hallucinate URL 是真问题)
- 维护成本低
进阶可加 `search_skills(query)` tool 实时打 GitHub Code Search,等用户反馈"清单太窄"再做。
### 5.5 不做 ClawHub(顺手清理建议)
**现状**:`POST /api/skills/import` 当前支持 3 个 source(`fetchFromClawHub` skill.go:642-744、`fetchFromSkillsSh` skill.go:757-879、`fetchFromGitHub` skill.go:1363-1463)。ClawHub 是个独立 HTTP 客户端,不复用 GitHub 基础设施。
**判断**(详见之前讨论):
- ClawHub 服务的是 OpenClaw 平台(Multica 同生态位竞品的内容生态)
- UI 没有发现/搜索层,用户只能粘 URL,而 ClawHub 装机量远低于 skills.sh,用户主动逛的概率极低
- 独立代码路径,API 演进时单独跟进
**建议**(独立于本计划,可以一起做也可以延后):
- 跑 `SELECT count(*) FROM skill WHERE config->'origin'->>'type' = 'clawhub'` 看实际使用量
- 接近 0 → 渐进下线(先去 UI SourceCard,后续 release 删 fetcher)
- 有量 → 留着,但仍不为它做新功能
---
## 6. 实施依赖与排期
```
[Phase 1] Template
└── 独立,无依赖
└── 包含 2 个 soft blocker 的修复(§4.1 §4.2)
[Phase 2] Skill Finder
└── 依赖 Phase 1 中的 skill import 路径(已存在,沿用)
└── 含 1 个 soft blocker(§4.3)
[Phase 3] AI Create Agent
└── 依赖 Phase 2(复用 find skill 能力)
└── 含 1 个 soft blocker(§4.4)
```
**真实排期建议**:
- Phase 1 可单独发版,有独立价值
- Phase 2 独立可发版(找 skill 是高频独立场景)
- Phase 3 等 Phase 2 ready 后开始
每个 phase 启动时单独开 PR 设计 doc,本文档只是路线图。
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| GitHub rate limit(模板 import 多个 skill 时) | 已有 `GITHUB_TOKEN` env 支持(skill.go:1163-1166),5000/h 配额够用。生产环境确保配置 |
| 模板里引用的 skill repo 被作者删除 | from-template handler 容错:某个 skill fetch 失败 → 整个事务回滚,前端展示具体哪个 URL 挂了。模板自己也定期 review |
| LLM 推荐编造 URL(Phase 2) | 用 curated 清单作为 context,**不让 LLM 自由发挥 URL**,推荐范围限定在清单内 |
| Phase 3 LLM 写出离谱 instructions | 用户在 inbox 通知里点击 → 跳新 agent 详情页**编辑模式**,不直接进入"已就绪"状态。用户必须确认 |
| 模板格式后续要演进(加字段) | Template JSON 加 `version` 字段,后端按 version 兼容老格式 |
| Curated skill 清单过时(作者改 repo / 删 skill) | 加 CI 任务定期跑一遍清单 URL,挂掉的报警通知维护者 |
---
## 8. 不在本文档范围(已识别的下一步话题)
- 跨 workspace 模板共享 / marketplace 化(用户能把自己的 agent 存成模板分享)
- 实时 GitHub Code Search tool(Phase 2 进阶)
- Server-side LLM 调用基础设施(如果未来需要 streaming 等场景)
- ClawHub 下线决策(独立讨论,见 §5.5)
- Skill 版本管理(workspace skill 版本号 / 升级提示)
---
## 附录 A:代码索引
> 给接手开发的同事的快速参考。每条 file:line 都在本计划里被引用过,记录在这里方便跳转。
| 主题 | 位置 |
|---|---|
| Skill DB 模型 | `server/migrations/008_structured_skills.up.sql:4-32` |
| Skill 创建 handler + 事务 | `server/internal/handler/skill.go:143-162` + `skill_create.go:21-71` |
| Skill import 入口(支持 3 个 source) | `server/internal/handler/skill.go:1538` |
| Skill import source 分发 | `server/internal/handler/skill.go:586-617` (`detectImportSource`) |
| Skills.sh fetcher | `server/internal/handler/skill.go:757-879` (`fetchFromSkillsSh`) |
| GitHub fetcher | `server/internal/handler/skill.go:1363-1463` (`fetchFromGitHub`) |
| ClawHub fetcher | `server/internal/handler/skill.go:642-744` (`fetchFromClawHub`) |
| Agent 创建 handler | `server/internal/handler/agent.go:380-399` (request) + `:422-564` (CreateAgent) |
| Agent 创建 sqlc | `server/pkg/db/queries/agent.sql:19-25` |
| Agent-Skill 关联 sqlc | `server/pkg/db/queries/agent.sql:86-103` |
| 当前 Agent Duplication(前端模式) | `packages/views/agents/components/agents-page.tsx:286-301`(post-create skill copy) |
| Agent 创建 dialog | `packages/views/agents/components/create-agent-dialog.tsx` |
| Skill add dialog | `packages/views/agents/components/skill-add-dialog.tsx` |
| Quick-create Issue handler | `server/internal/handler/issue.go:877-982` (`QuickCreateIssue`) |
| Quick-create task enqueue | `server/internal/service/task.go:488+` (`EnqueueQuickCreateTask`) |
| Daemon claim + load skills | `server/internal/handler/daemon.go:1018-1098` + `service/task.go:1447-1463` |
| Daemon prompt build | `server/internal/daemon/prompt.go:17-36` (dispatch) + `:45-106` (`buildQuickCreatePrompt`) |
| Daemon execenv prepare | `server/internal/daemon/execenv/execenv.go:103-176` |
| Skill 目录约定(runtime mapping) | `server/internal/daemon/execenv/context.go:121-158` (`resolveSkillsDir`) |
| Skill 文件落盘 | `server/internal/daemon/execenv/context.go:175-204` (`writeSkillFiles`) |
| Quick-create 完成检测 + inbox | `server/internal/service/task.go:1810-1949` |
| LinkTaskToIssue | `server/internal/handler/agent.go:97-105` |
| Quick-create Issue 前端 modal | `packages/views/modals/quick-create-issue.tsx:48-570+` |
| Multica CLI 入口 | `server/cmd/multica/main.go:62-79` |
| Skill CLI 命令 | `server/cmd/multica/cmd_skill.go:17-96`(已有 import,无 find) |
| Agent CLI 命令 | `server/cmd/multica/cmd_agent.go:101-112`(已有 list/get,无 create) |

View File

@@ -82,30 +82,3 @@ export function agentTasksOptions(wsId: string, agentId: string) {
refetchOnWindowFocus: true,
});
}
// Agent templates are workspace-independent: a static catalog served from
// the server's embedded JSON. Cache effectively forever — the only way the
// list / detail change is a server deploy, and a hard reload picks that up.
export const agentTemplateKeys = {
all: () => ["agent-templates"] as const,
list: () => [...agentTemplateKeys.all(), "list"] as const,
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
};
export function agentTemplateListOptions() {
return queryOptions({
queryKey: agentTemplateKeys.list(),
queryFn: () => api.listAgentTemplates(),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}
export function agentTemplateDetailOptions(slug: string) {
return queryOptions({
queryKey: agentTemplateKeys.detail(slug),
queryFn: () => api.getAgentTemplate(slug),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}

View File

@@ -1,5 +0,0 @@
export {
useAgentsViewStore,
type AgentsScope,
type AgentsViewState,
} from "./view-store";

View File

@@ -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();
});
});

View File

@@ -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());

View File

@@ -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 });
}

View File

@@ -200,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(

View File

@@ -2,7 +2,6 @@ import type {
Issue,
CreateIssueRequest,
UpdateIssueRequest,
GroupedIssuesResponse,
ListIssuesResponse,
SearchIssuesResponse,
SearchProjectsResponse,
@@ -10,13 +9,8 @@ import type {
CreateMemberRequest,
UpdateMemberRequest,
ListIssuesParams,
ListGroupedIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
UpdateAgentRequest,
AgentTask,
AgentActivityBucket,
@@ -44,10 +38,6 @@ import type {
RuntimeHourlyActivity,
RuntimeUsageByAgent,
RuntimeUsageByHour,
DashboardUsageDaily,
DashboardUsageByAgent,
DashboardAgentRunTime,
DashboardRunTimeDaily,
RuntimeUpdate,
RuntimeModelListRequest,
RuntimeLocalSkillListRequest,
@@ -94,8 +84,6 @@ import type {
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
Squad,
SquadMember,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -103,24 +91,12 @@ import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
ChildIssuesResponseSchema,
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_TIMELINE_ENTRIES,
GroupedIssuesResponseSchema,
ListIssuesResponseSchema,
SubscribersListSchema,
TimelineEntriesSchema,
@@ -214,27 +190,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;
@@ -309,23 +264,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>) ?? {}),
};
@@ -346,18 +293,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>;
}
@@ -479,36 +420,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));
@@ -536,12 +447,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),
@@ -628,10 +534,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 }),
});
}
@@ -722,51 +628,6 @@ export class ApiClient {
});
}
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
const raw = await this.fetch<unknown>("/api/agent-templates");
return parseWithFallback(
raw,
AgentTemplateSummaryListSchema,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
{ endpoint: "GET /api/agent-templates" },
);
}
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
const raw = await this.fetch<unknown>(
`/api/agent-templates/${encodeURIComponent(slug)}`,
);
// Round-trip the requested slug into the fallback so a malformed
// detail response still produces a navigable record matching the URL
// the user clicked.
return parseWithFallback(
raw,
AgentTemplateSchema,
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
{ endpoint: "GET /api/agent-templates/:slug" },
);
}
/** Creates an agent from a curated template. The server fetches every
* referenced skill URL in parallel, materializes them into the workspace
* (find-or-create by name), and writes the agent + skill bindings in a
* single transaction. On any upstream fetch failure, the entire write is
* rolled back and the API returns 422 with `failed_urls`. */
async createAgentFromTemplate(
data: CreateAgentFromTemplateRequest,
): Promise<CreateAgentFromTemplateResponse> {
const raw = await this.fetch<unknown>("/api/agents/from-template", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
CreateAgentFromTemplateResponseSchema,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
{ endpoint: "POST /api/agents/from-template" },
);
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
@@ -839,73 +700,6 @@ export class ApiClient {
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
}
// ---------------------------------------------------------------------------
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
// Each accepts an optional `project_id` to narrow the scope to one project.
// Cost is computed client-side from the model pricing table (same contract
// as the per-runtime endpoints above).
// ---------------------------------------------------------------------------
async getDashboardUsageDaily(
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageDaily[]> {
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/usage/daily?${search}`);
return parseWithFallback<DashboardUsageDaily[]>(
raw,
DashboardUsageDailyListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/daily" },
);
}
async getDashboardUsageByAgent(
params: { days?: number; project_id?: string | null },
): Promise<DashboardUsageByAgent[]> {
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/usage/by-agent?${search}`);
return parseWithFallback<DashboardUsageByAgent[]>(
raw,
DashboardUsageByAgentListSchema,
[],
{ endpoint: "GET /api/dashboard/usage/by-agent" },
);
}
async getDashboardAgentRunTime(
params: { days?: number; project_id?: string | null },
): Promise<DashboardAgentRunTime[]> {
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/agent-runtime?${search}`);
return parseWithFallback<DashboardAgentRunTime[]>(
raw,
DashboardAgentRunTimeListSchema,
[],
{ endpoint: "GET /api/dashboard/agent-runtime" },
);
}
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,
@@ -1286,13 +1080,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`);
}
@@ -1347,38 +1134,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();
@@ -1501,43 +1256,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) });
}
// Autopilots
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
const search = new URLSearchParams();

View File

@@ -1,9 +1,4 @@
export {
ApiClient,
ApiError,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "./client";
export { ApiClient, ApiError } from "./client";
export type {
ApiClientOptions,
ImportStarterContentPayload,

View File

@@ -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" });
@@ -126,108 +117,6 @@ describe("ApiClient schema fallback", () => {
expect(res).toEqual({ issues: [] });
});
});
// Agent template catalog is hit by the desktop create-agent picker.
// Installed desktop builds outlive any given server, so the shape MUST
// survive future field renames / wrapping without crashing. Each test
// here mirrors a concrete future drift we want to absorb.
describe("listAgentTemplates", () => {
it("falls back to [] when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toEqual([]);
});
it("defaults skills to [] when the field is missing from a template", async () => {
// Future server: drops `skills` because the picker no longer reads
// them. Picker code calls `template.skills.length` — must not throw.
stubFetchJson([{ slug: "x", name: "X" }]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.skills).toEqual([]);
});
it("accepts the bare-array shape (current contract)", async () => {
stubFetchJson([
{ slug: "a", name: "A", description: "", skills: [] },
{ slug: "b", name: "B", description: "", skills: [] },
]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
});
it("accepts a future {templates: [...]} envelope without breaking", async () => {
// Server migrates to a paginated envelope. We unwrap so the picker
// keeps working on the older bare-array consumer.
stubFetchJson({
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.slug).toBe("a");
});
});
describe("getAgentTemplate", () => {
it("falls back to a minimal record carrying the requested slug", async () => {
// Slug is part of the URL the user clicked — the fallback round-
// trips it so the page header still makes sense after a parse miss.
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.slug).toBe("code-reviewer");
expect(detail.skills).toEqual([]);
expect(detail.instructions).toBe("");
});
it("defaults instructions to '' when the field is missing", async () => {
stubFetchJson({
slug: "code-reviewer",
name: "Code Reviewer",
description: "",
skills: [],
});
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.instructions).toBe("");
});
});
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
// parse the response — UI code reads `agent.id === ""` and skips
// the navigation step rather than landing on `/agents/`.
stubFetchJson({ unexpected: "shape" });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
stubFetchJson({ agent: { id: "agent-1" } });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("agent-1");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
});
});
// Direct tests for the helper, decoupled from any specific endpoint —

View File

@@ -1,14 +1,5 @@
import { z } from "zod";
import type {
Agent,
AgentTemplate,
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
GroupedIssuesResponse,
ListIssuesResponse,
TimelineEntry,
} from "../types";
import type { Attachment, ListIssuesResponse, TimelineEntry } from "../types";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
@@ -165,22 +156,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(),
@@ -194,141 +169,3 @@ export const SubscribersListSchema = z.array(SubscriberSchema);
export const ChildIssuesResponseSchema = z.object({
issues: z.array(IssueSchema).default([]),
}).loose();
// ---------------------------------------------------------------------------
// Workspace dashboard schemas
//
// The dashboard hits three independent rollup endpoints. Each returns a flat
// array, and every field is consumed by chart / KPI math — a missing number
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
// String fields stay lenient (no enum narrowing) to survive future model /
// agent ID drift.
// ---------------------------------------------------------------------------
const DashboardUsageDailySchema = z.object({
date: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
const DashboardUsageByAgentSchema = z.object({
agent_id: z.string(),
model: z.string(),
input_tokens: z.number().default(0),
output_tokens: z.number().default(0),
cache_read_tokens: z.number().default(0),
cache_write_tokens: z.number().default(0),
task_count: z.number().default(0),
}).loose();
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
const DashboardAgentRunTimeSchema = z.object({
agent_id: z.string(),
total_seconds: z.number().default(0),
task_count: z.number().default(0),
failed_count: z.number().default(0),
}).loose();
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
// reaches these endpoints, and a future server change to the template shape
// would white-screen older installed builds (#2192 pattern) without these
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
// through unchanged.
// ---------------------------------------------------------------------------
const AgentTemplateSkillRefSchema = z.object({
source_url: z.string(),
cached_name: z.string().default(""),
cached_description: z.string().default(""),
}).loose();
const AgentTemplateSummarySchemaBase = z.object({
slug: z.string(),
name: z.string(),
description: z.string().default(""),
category: z.string().optional(),
icon: z.string().optional(),
accent: z.string().optional(),
// skills MUST default to [] — picker code reads `template.skills.length`
// and `.map(...)`, both of which crash on `undefined`. The most common
// future drift (field renamed / wrapped) lands here.
skills: z.array(AgentTemplateSkillRefSchema).default([]),
}).loose();
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
// List endpoint historically returns a bare array. Server could legitimately
// migrate to `{templates: [...]}` later — we accept either shape so an old
// desktop survives the upgrade.
export const AgentTemplateSummaryListSchema = z.union([
z.array(AgentTemplateSummarySchemaBase),
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
.loose()
.transform((v) => v.templates),
]);
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
// Detail-only field. Default "" so a malformed detail still renders the
// header + skill list; the user just sees an empty Instructions block.
instructions: z.string().default(""),
}).loose();
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
// from the URL, so we round-trip the requested one back into the fallback
// at the call site (see `getAgentTemplate` in client.ts).
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
slug: "",
name: "",
description: "",
skills: [],
instructions: "",
};
// `agent` is a full Agent record — schematising every field would duplicate
// a 50-field interface and bit-rot fast. We keep it loose and require only
// `id`, the one field the create-from-template flow consumes (used to
// navigate to the new agent's detail page). Downstream code already
// optional-chains the rest.
const MinimalAgentSchema = z.object({
id: z.string(),
}).loose();
export const CreateAgentFromTemplateResponseSchema = z.object({
agent: MinimalAgentSchema,
imported_skill_ids: z.array(z.string()).default([]),
reused_skill_ids: z.array(z.string()).default([]),
}).loose();
// Fallback when the success response fails to parse. The agent server-side
// has likely been created already, so we can't pretend nothing happened —
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
// `agent.id === ""` and skipping navigation while keeping the list
// invalidation, so the user finds their new agent in the list.
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
agent: { id: "" } as Agent,
imported_skill_ids: [],
reused_skill_ids: [],
};

View File

@@ -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) {

View File

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

View File

@@ -1 +0,0 @@
export * from "./queries";

View File

@@ -1,88 +0,0 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
// Workspace dashboard query options. All three endpoints share the same
// (wsId, days, projectId) key shape so workspace switching, time-range
// changes, and the project filter each invalidate the cache cleanly.
//
// The cache key includes `wsId` explicitly: TanStack Query already isolates
// per workspace via the key, but threading wsId into the queryFn lets
// callers fail fast (return [] on empty wsId) instead of issuing a request
// the server would reject.
//
// `projectId` is normalised to `null` (not undefined / "all") so the
// queryKey shape is stable across renders even when the dropdown sits on
// "all projects".
export const dashboardKeys = {
all: (wsId: string) => ["dashboard", wsId] as const,
daily: (wsId: string, days: number, projectId: string | null) =>
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
byAgent: (wsId: string, days: number, projectId: string | null) =>
[...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-
// driven on the server (5-min rollup cadence) and the dashboard isn't a
// real-time view, so background refetches every minute are plenty.
const STALE_TIME = 60 * 1000;
export function dashboardUsageDailyOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.daily(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}
export function dashboardUsageByAgentOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
queryFn: () =>
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
staleTime: STALE_TIME,
});
}
export function dashboardAgentRunTimeOptions(
wsId: string,
days: number,
projectId: string | null,
) {
return queryOptions({
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
queryFn: () =>
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
enabled: !!wsId,
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,
});
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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));

View File

@@ -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),

View File

@@ -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(),
);

View File

@@ -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" });
});
});

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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();
});
});

View File

@@ -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: "",

View File

@@ -10,7 +10,6 @@ import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "..
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
@@ -25,7 +24,7 @@ export interface CardProperties {
}
export interface ActorFilterValue {
type: "member" | "agent" | "squad";
type: "member" | "agent";
id: string;
}
@@ -37,11 +36,6 @@ export const SORT_OPTIONS: { value: SortField; label: string }[] = [
{ 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" },
@@ -54,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[];
@@ -68,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;
@@ -88,7 +80,6 @@ export interface IssueViewState {
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
viewMode: "board",
grouping: "status",
statusFilters: [],
priorityFilters: [],
assigneeFilters: [],
@@ -111,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)
@@ -215,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,

View File

@@ -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",
@@ -70,47 +53,6 @@ const baseIssue: Issue = {
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;
@@ -151,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();
});
});

View File

@@ -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) });
}
}

View File

@@ -7,7 +7,6 @@ type ModalType =
| "create-issue"
| "quick-create-issue"
| "create-project"
| "create-squad"
| "feedback"
| "issue-set-parent"
| "issue-add-child"

View File

@@ -47,16 +47,11 @@
"./runtimes/mutations": "./runtimes/mutations.ts",
"./runtimes/hooks": "./runtimes/hooks.ts",
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
"./dashboard": "./dashboard/index.ts",
"./dashboard/queries": "./dashboard/queries.ts",
"./agents": "./agents/index.ts",
"./agents/queries": "./agents/queries.ts",
"./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",
@@ -108,11 +103,8 @@
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",
"@testing-library/react": "catalog:",
"@types/react": "catalog:",
"jsdom": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:"
}

View File

@@ -17,17 +17,14 @@ describe("paths.workspace() shape", () => {
expect(new Set(parameterlessRoutes)).toEqual(
new Set([
"root",
"usage",
"issues",
"projects",
"autopilots",
"agents",
"squads",
"inbox",
"myIssues",
"runtimes",
"skills",
"squads",
"settings",
]),
);
@@ -38,17 +35,14 @@ describe("paths.workspace() shape", () => {
// Check that none of the parameterless paths embed a leaked literal
// and that their second URL segment matches the method name's kebab-case.
const expectedSegments: Array<[string, string]> = [
["usage", "usage"],
["issues", "issues"],
["projects", "projects"],
["autopilots", "autopilots"],
["agents", "agents"],
["squads", "squads"],
["inbox", "inbox"],
["myIssues", "my-issues"],
["runtimes", "runtimes"],
["skills", "skills"],
["squads", "squads"],
["settings", "settings"],
];
const wsAsAny = ws as unknown as Record<string, () => string>;

View File

@@ -4,8 +4,7 @@ import { paths, isGlobalPath } from "./paths";
describe("paths.workspace(slug)", () => {
const ws = paths.workspace("acme");
it("builds workspace paths with slug prefix", () => {
expect(ws.usage()).toBe("/acme/usage");
it("builds dashboard paths with slug prefix", () => {
expect(ws.issues()).toBe("/acme/issues");
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
expect(ws.projects()).toBe("/acme/projects");
@@ -13,14 +12,11 @@ describe("paths.workspace(slug)", () => {
expect(ws.autopilots()).toBe("/acme/autopilots");
expect(ws.autopilotDetail("a1")).toBe("/acme/autopilots/a1");
expect(ws.agents()).toBe("/acme/agents");
expect(ws.memberDetail("u1")).toBe("/acme/members/u1");
expect(ws.inbox()).toBe("/acme/inbox");
expect(ws.myIssues()).toBe("/acme/my-issues");
expect(ws.runtimes()).toBe("/acme/runtimes");
expect(ws.skills()).toBe("/acme/skills");
expect(ws.skillDetail("skl_123")).toBe("/acme/skills/skl_123");
expect(ws.squads()).toBe("/acme/squads");
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
expect(ws.settings()).toBe("/acme/settings");
});

View File

@@ -18,7 +18,6 @@ function workspaceScoped(slug: string) {
const ws = `/${encode(slug)}`;
return {
root: () => `${ws}/issues`,
usage: () => `${ws}/usage`,
issues: () => `${ws}/issues`,
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
projects: () => `${ws}/projects`,
@@ -27,9 +26,6 @@ function workspaceScoped(slug: string) {
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
agents: () => `${ws}/agents`,
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
memberDetail: (id: string) => `${ws}/members/${encode(id)}`,
squads: () => `${ws}/squads`,
squadDetail: (id: string) => `${ws}/squads/${encode(id)}`,
inbox: () => `${ws}/inbox`,
myIssues: () => `${ws}/my-issues`,
runtimes: () => `${ws}/runtimes`,

View File

@@ -70,7 +70,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"search",
"members",
// Workspace route segments
// Dashboard / workspace route segments
// Reserving each segment name prevents `/{slug}/{view}` from being visually
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
@@ -79,10 +79,8 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"projects",
"autopilots",
"agents",
"squads",
"inbox",
"my-issues",
"usage",
"runtimes",
"skills",
"settings",

View File

@@ -4,7 +4,7 @@ import { useEffect } from "react";
import type { WSEventType } from "../types";
import { useWS } from "./provider";
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
type EventHandler = (payload: unknown, actorId?: string) => void;
/**
* Hook that subscribes to a WebSocket event and calls the handler.

View File

@@ -21,7 +21,7 @@ import {
import { createLogger } from "../logger";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
type EventHandler = (payload: unknown, actorId?: string) => void;
interface WSContextValue {
subscribe: (event: WSEventType, handler: EventHandler) => () => void;

View File

@@ -1,122 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { WSClient } from "../api/ws-client";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
vi.mock("../platform/workspace-storage", () => ({
getCurrentWsId: () => "ws-1",
getCurrentSlug: () => "test-ws",
}));
vi.mock("../paths", () => ({
useHasOnboarded: () => true,
resolvePostAuthDestination: () => "/",
}));
function createMockWs(): WSClient {
return {
on: vi.fn(() => () => {}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
} as unknown as WSClient;
}
function createStores(): RealtimeSyncStores {
return {
authStore: Object.assign(() => ({}), {
getState: () => ({ user: { id: "u1" } }),
subscribe: () => () => {},
setState: () => {},
destroy: () => {},
}),
} as unknown as RealtimeSyncStores;
}
function createWrapper(qc: QueryClient) {
// Named function (not arrow) so react/display-name lint rule passes —
// anonymous render-fn components break that rule even in test files.
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useRealtimeSync — ws instance change", () => {
let qc: QueryClient;
let stores: RealtimeSyncStores;
let invalidateSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
stores = createStores();
invalidateSpy = vi.spyOn(qc, "invalidateQueries");
});
it("skips invalidation on first non-null ws instance", () => {
const ws = createMockWs();
renderHook(() => useRealtimeSync(ws, stores), {
wrapper: createWrapper(qc),
});
// The main effect calls invalidateQueries for its own setup, but the
// ws-instance-change effect should NOT have fired invalidation.
// The only invalidateQueries calls should come from the main effect's
// event handlers, not from the instance-change effect.
// We verify by checking that no call was made with workspaceKeys.list()
// pattern from the instance-change path (it logs a specific message).
// Simpler: count calls — first mount with a ws should not trigger the
// workspace-scoped bulk invalidation.
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("does not invalidate when ws goes from instance to null", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
});
it("invalidates exactly once when a new ws instance appears after null gap", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
// Simulate workspace switch: ws -> null -> new ws
invalidateSpy.mockClear();
rerender({ ws: null });
expect(invalidateSpy).not.toHaveBeenCalled();
const ws2 = createMockWs();
rerender({ ws: ws2 });
// Should have called invalidateQueries for all workspace-scoped keys
// (11 workspace-scoped + 1 workspaceKeys.list() = 12 calls)
expect(invalidateSpy).toHaveBeenCalledTimes(12);
});
it("does not re-invalidate when rerendered with the same ws instance", () => {
const ws1 = createMockWs();
const { rerender } = renderHook(
({ ws }) => useRealtimeSync(ws, stores),
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
);
invalidateSpy.mockClear();
// Rerender with same instance
rerender({ ws: ws1 });
expect(invalidateSpy).not.toHaveBeenCalled();
});
});

View File

@@ -1,117 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
import { applyChatDoneToCache } from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
const messagesKey = chatKeys.messages(sessionId);
const pendingKey = chatKeys.pendingTask(sessionId);
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function userMessage(): ChatMessage {
return {
id: "msg-user",
chat_session_id: sessionId,
role: "user",
content: "hello",
task_id: null,
created_at: "2026-05-13T05:00:00Z",
};
}
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
return {
chat_session_id: sessionId,
task_id: taskId,
message_id: "msg-assistant",
content: "done",
elapsed_ms: 1234,
created_at: "2026-05-13T05:00:02Z",
...overrides,
};
}
describe("applyChatDoneToCache", () => {
it("writes the assistant message before clearing pending task", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
const setQueryData = vi.spyOn(qc, "setQueryData");
applyChatDoneToCache(qc, donePayload());
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
expect(setQueryData.mock.calls[1]?.[0]).toEqual(pendingKey);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
{
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
},
]);
});
it("does not duplicate a replayed chat done event", () => {
const qc = createQueryClient();
const assistant: ChatMessage = {
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
};
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(qc, donePayload());
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
assistant,
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
it("falls back to invalidation-only when older servers omit message fields", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(
qc,
donePayload({ message_id: undefined, content: undefined }),
);
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import type { WSClient } from "../api/ws-client";
import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
@@ -62,7 +62,6 @@ import type {
TaskFailedPayload,
TaskCancelledPayload,
ChatDonePayload,
ChatMessage,
ChatPendingTask,
InvitationCreatedPayload,
} from "../types";
@@ -71,65 +70,6 @@ const chatWsLogger = createLogger("chat.ws");
const logger = createLogger("realtime-sync");
export function applyChatDoneToCache(
qc: QueryClient,
payload: ChatDonePayload,
) {
const sessionId = payload.chat_session_id;
const taskId = payload.task_id;
const messageId = payload.message_id;
const content = payload.content;
if (messageId && content !== undefined) {
qc.setQueryData<ChatMessage[] | undefined>(
chatKeys.messages(sessionId),
(old) => {
if (!old) return old; // first fetch will pick it up
// Idempotent against reconnect replay.
if (old.some((m) => m.id === messageId)) return old;
const assistant: ChatMessage = {
id: messageId,
chat_session_id: sessionId,
role: "assistant",
content,
task_id: taskId,
created_at: payload.created_at ?? new Date().toISOString(),
elapsed_ms: payload.elapsed_ms ?? null,
};
return [...old, assistant];
},
);
}
// Replacement is in the messages list now; safe to drop pending.
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
// Authoritative refetch reconciles redaction / migrations / clients
// that took the fallback branch above.
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
/**
* Invalidates all workspace-scoped queries. Used after reconnect and when a
* new WSClient instance is detected (workspace switch) to recover events
* missed while disconnected.
*/
function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
}
export interface RealtimeSyncStores {
authStore: UseBoundStore<StoreApi<AuthState>>;
}
@@ -194,14 +134,6 @@ export function useRealtimeSync(
const wsId = getCurrentWsId();
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
},
squad: () => {
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
// squad:deleted triggers assignee transfer — refresh issues too.
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
}
},
label: () => {
// label:created/updated/deleted — also refresh issues, since each
// issue carries a denormalized snapshot of its labels (rename/recolor
@@ -262,10 +194,6 @@ export function useRealtimeSync(
// every list-of-tasks query stale" so cache stays fresh even
// when the relevant component isn't currently mounted.
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
// Per-issue token usage card (issue-detail right rail). Same
// shape as the tasks invalidation above — any task lifecycle
// event shifts the aggregated usage numbers.
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
},
};
@@ -294,7 +222,6 @@ export function useRealtimeSync(
"daemon:heartbeat",
// Chat events are handled explicitly below; do not double-invalidate.
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
"chat:session_updated",
// task:message stays out of the prefix path because it fires per
// streamed message during a long run — invalidating the snapshot on
// every message would flood the network. Specific chat handlers below
@@ -641,21 +568,13 @@ export function useRealtimeSync(
chatWsLogger.info("chat:done (global)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
has_message: !!payload.message_id,
});
// Inline-insert the assistant message into the messages cache BEFORE
// clearing pending-task. Both writes land in the same React render
// tick, so ChatMessageList sees `pendingAlreadyPersisted === true`
// and the live TimelineView unmounts only after AssistantMessage has
// mounted — no flicker window. This applies TkDodo's "combine
// setQueryData (active query) + invalidateQueries (others)" pattern
// (https://tkdodo.eu/blog/using-web-sockets-with-react-query).
//
// Falls back to invalidate-only when the server omits the message
// payload (older builds). Older clients hitting a newer server also
// work: they ignore the extra fields and rely on the invalidate
// below, which keeps the old behavior alive.
applyChatDoneToCache(qc, payload);
// Assistant message was just written and task flipped out of 'running'.
// Clear pending-task cache immediately so the live-timeline-vs-assistant
// race window collapses to zero — the subsequent refetch will confirm.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
// Assistant message just landed → has_unread may have flipped to true.
invalidateSessionLists();
@@ -726,12 +645,9 @@ export function useRealtimeSync(
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
// `chat:done` (broadcast immediately before this event in CompleteTask)
// already wrote the assistant message into the messages cache and
// cleared `chatKeys.pendingTask`. This event is now only responsible
// for refreshing the per-user cross-session aggregate that drives the
// FAB indicator — `chat:done` is per-session and doesn't carry that
// information.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
});
@@ -760,33 +676,6 @@ export function useRealtimeSync(
invalidateSessionLists();
});
// chat:session_updated fires after the creator renames a session in
// any tab/device. Patch the cached row inline so the dropdown reflects
// the new title without a full sessions-list refetch.
const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => {
const payload = p as {
chat_session_id: string;
title?: string;
updated_at?: string;
};
chatWsLogger.info("chat:session_updated (global)", payload);
const id = getCurrentWsId();
if (!id) return;
const patch = (
old?: { id: string; title: string; updated_at: string }[],
) =>
old?.map((s) =>
s.id === payload.chat_session_id
? {
...s,
title: payload.title ?? s.title,
updated_at: payload.updated_at ?? s.updated_at,
}
: s,
);
qc.setQueryData(chatKeys.sessions(id), patch);
});
// chat:session_deleted fires after a hard delete. The originating tab has
// already optimistically dropped the row via useDeleteChatSession; this
// handler keeps OTHER tabs/devices in sync and also clears the active
@@ -847,7 +736,6 @@ export function useRealtimeSync(
unsubTaskFailed();
unsubChatSessionRead();
unsubChatSessionDeleted();
unsubChatSessionUpdated();
timers.forEach(clearTimeout);
timers.clear();
};
@@ -860,7 +748,21 @@ export function useRealtimeSync(
const unsub = ws.onReconnect(async () => {
logger.info("reconnected, refetching all data");
try {
invalidateWorkspaceScopedQueries(qc);
const wsId = getCurrentWsId();
if (wsId) {
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
}
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
} catch (e) {
logger.error("reconnect refetch failed", e);
}
@@ -868,22 +770,4 @@ export function useRealtimeSync(
return unsub;
}, [ws, qc]);
// New WSClient instance (workspace switch) -> invalidate workspace-scoped
// queries to recover events missed while the previous instance was torn down.
// Skips the initial assignment to avoid a redundant refetch on first mount.
const wsInstanceRef = useRef<WSClient | null>(null);
useEffect(() => {
if (!ws) return;
if (wsInstanceRef.current === null) {
// First non-null instance — store and skip invalidation.
wsInstanceRef.current = ws;
return;
}
if (wsInstanceRef.current === ws) return;
wsInstanceRef.current = ws;
logger.info("new WSClient instance detected, invalidating workspace queries");
invalidateWorkspaceScopedQueries(qc);
}, [ws, qc]);
}

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