Compare commits
9 Commits
fix/email-
...
template-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f59fda229d | ||
|
|
5e96909ea8 | ||
|
|
126df05671 | ||
|
|
e68976ce18 | ||
|
|
5f2222b83d | ||
|
|
68edf57f64 | ||
|
|
31ec36146f | ||
|
|
6fafd86ecc | ||
|
|
f435dd2cd4 |
21
.env.example
@@ -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
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -7,10 +7,10 @@ body:
|
||||
id: deployment
|
||||
attributes:
|
||||
label: Deployment type
|
||||
description: Are you using the Official App (multica.ai) or a self-hosted instance?
|
||||
description: Are you using the hosted version or a self-hosted instance?
|
||||
options:
|
||||
- Official App
|
||||
- self-host
|
||||
- multica.ai (hosted)
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
3
.gitignore
vendored
@@ -58,3 +58,6 @@ server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
18
README.md
@@ -32,8 +32,6 @@ Multica turns coding agents into real teammates. Assign issues to an agent like
|
||||
|
||||
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
|
||||
|
||||
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
|
||||
</p>
|
||||
@@ -55,7 +53,6 @@ Like Multics before it, the bet is on multiplexing: a small team shouldn't feel
|
||||
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
|
||||
|
||||
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
|
||||
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
|
||||
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
|
||||
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
|
||||
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
|
||||
@@ -131,6 +128,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
|
||||
| **User model** | Multi-user teams with roles & permissions | Single board operator |
|
||||
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
|
||||
| **Deployment** | Cloud-first | Local-first |
|
||||
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
|
||||
| **Extensibility** | Skills system | Skills + Plugin system |
|
||||
|
||||
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
|
||||
|
||||
---
|
||||
|
||||
## CLI
|
||||
|
||||
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.
|
||||
|
||||
@@ -32,8 +32,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
|
||||
|
||||
不再需要复制粘贴 prompt,不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**。
|
||||
|
||||
面向更大的团队,Squads(小队)提供稳定的路由层:把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
|
||||
</p>
|
||||
@@ -55,7 +53,6 @@ Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
|
||||
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
|
||||
|
||||
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
|
||||
- **Squads(小队)** — 把多个 Agent(以及人类成员)组合成由 leader agent 带队的小队,直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`。
|
||||
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
|
||||
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
|
||||
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI,实时监控。
|
||||
@@ -134,6 +131,19 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
|
||||
|
||||
---
|
||||
|
||||
## Multica vs Paperclip
|
||||
|
||||
| | Multica | Paperclip |
|
||||
|---|---------|-----------|
|
||||
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
|
||||
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
|
||||
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
|
||||
| **部署** | 云端优先 | 本地优先 |
|
||||
| **管理深度** | 轻量(Issue / Project / Labels) | 重度(组织架构 / 审批 / 预算) |
|
||||
| **扩展** | Skills 系统 | Skills + 插件系统 |
|
||||
|
||||
**简单来说:Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
|
||||
@@ -25,30 +25,14 @@ These have sensible defaults and only need to be set when tuning a large or cons
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica supports two email backends. `SMTP_HOST` takes priority when set; otherwise `RESEND_API_KEY` is used. With neither configured, verification codes are printed to the server log — copy them from there to log in.
|
||||
|
||||
#### Option A: Resend (recommended for cloud deployments)
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
#### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this option when your deployment cannot reach the public internet or you already have an internal mail relay (e.g. Exchange, Postfix, SendGrid on-prem).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `SMTP_HOST` | SMTP relay hostname (setting this activates SMTP mode) | - |
|
||||
| `SMTP_PORT` | SMTP port | `25` |
|
||||
| `SMTP_USERNAME` | SMTP username (leave empty for unauthenticated relay) | - |
|
||||
| `SMTP_PASSWORD` | SMTP password | - |
|
||||
| `SMTP_TLS_INSECURE` | Set `true` to skip TLS certificate verification (self-signed / private CA certs) | `false` |
|
||||
|
||||
STARTTLS is used automatically when advertised by the server. Port 465 (SMTPS / implicit TLS) is not currently supported - use ports 25 or 587 with STARTTLS.
|
||||
|
||||
> **Note:** If neither Resend nor SMTP is configured, generated verification codes are printed to backend logs — copy them from there to log in. A fixed local testing code (e.g. `888888`) is **opt-in only**: set `MULTICA_DEV_VERIFICATION_CODE=888888` in `.env` and keep `APP_ENV` non-production. The Docker self-host stack pins `APP_ENV=production`, so the shortcut is ignored there. **Never enable a fixed code on a publicly reachable instance.**
|
||||
> **Note:** If Resend is not configured, generated verification codes are printed to backend logs. A fixed local testing code is disabled by default; to opt in on a private test instance, set `APP_ENV=development` and `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value. It is ignored when `APP_ENV=production`.
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 782 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -46,31 +46,20 @@ linux:
|
||||
# Yaru). Forcing `multica` makes every Linux identity slot agree and
|
||||
# matches `StartupWMClass=Multica` (productName-derived).
|
||||
executableName: multica
|
||||
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
|
||||
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
|
||||
# ASAR's `package.json` — `productName` if present, otherwise `name`.
|
||||
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
|
||||
# directly; it does not. With our source package.json carrying only
|
||||
# `name: "@multica/desktop"`, packaged Electron emitted
|
||||
# `WM_CLASS=@multica/desktop`, which broke association with this entry
|
||||
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
|
||||
# outside this file — `productName: "Multica"` on the source
|
||||
# package.json (so the ASAR carries it) and `app.setName("Multica")`
|
||||
# in the production branch of `src/main/index.ts` (belt-and-braces).
|
||||
# Keep `StartupWMClass: Multica` pinned here so any future drift in
|
||||
# those two anchors shows up as a diff against this declaration.
|
||||
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
|
||||
# window prints `Multica` for both fields.
|
||||
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
|
||||
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
|
||||
# builds resolves to `productName` (`Multica`). Without an explicit
|
||||
# `StartupWMClass`, electron-builder writes `productName` as the default
|
||||
# — making this declaration redundant with current settings — but
|
||||
# pinning the value here turns a silent future drift (e.g. if anyone
|
||||
# renames productName or sets app.setName at boot) into a visible diff
|
||||
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
|
||||
# GNOME associate the running window with the `.desktop` entry and
|
||||
# therefore render the right icon. The post-build verification step in
|
||||
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
|
||||
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
|
||||
# auto-generation silently shipped only the 1024×1024 source in our
|
||||
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
|
||||
# with no usable size and falling back to the theme default. Shipping
|
||||
# the sizes from source removes the toolchain dependency entirely.
|
||||
icon: build/icons
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"productName": "Multica",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
|
||||
@@ -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 -----------------------------------------------
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
// downloaded and ready to install on next quit.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
@@ -29,39 +26,8 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Single-flight guard around checkForUpdates(). With autoDownload=true the
|
||||
// startup, periodic, and manual triggers can all kick off downloads, and
|
||||
// overlapping calls have caused duplicate download warnings in the past
|
||||
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
|
||||
// callers onto the same in-flight promise.
|
||||
let inFlightCheck: Promise<unknown> | null = null;
|
||||
function checkForUpdatesOnce(): Promise<unknown> {
|
||||
if (inFlightCheck) return inFlightCheck;
|
||||
const p = autoUpdater
|
||||
.checkForUpdates()
|
||||
.then((result) => {
|
||||
// checkForUpdates resolves as soon as metadata is fetched; the actual
|
||||
// download (when autoDownload=true) is exposed on result.downloadPromise.
|
||||
// Without a handler a download failure becomes an unhandled rejection
|
||||
// in the main process — Node may terminate it on future versions.
|
||||
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
|
||||
(err) => {
|
||||
console.error("Failed to download update:", err);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightCheck === p) inFlightCheck = null;
|
||||
});
|
||||
inFlightCheck = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
@@ -76,20 +42,15 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
// Retained for IPC back-compat with older renderer bundles. With
|
||||
// autoDownload=true the renderer no longer triggers this path.
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
@@ -100,9 +61,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = (await checkForUpdatesOnce()) as
|
||||
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
|
||||
| null;
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
@@ -126,7 +85,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -134,7 +93,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
|
||||
4
apps/desktop/src/preload/index.d.ts
vendored
@@ -84,9 +84,7 @@ interface DaemonAPI {
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
|
||||
@@ -207,11 +207,8 @@ const updaterAPI = {
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
|
||||
callback(info);
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
|
||||
@@ -1,27 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
|
||||
// Downloads run silently in the background (main process has
|
||||
// autoDownload=true). The renderer only renders UI once the package is fully
|
||||
// downloaded and waiting for a restart.
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "ready"; version: string };
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = window.updater.onUpdateDownloaded((info) => {
|
||||
setState({ status: "ready", version: info.version });
|
||||
setDismissed(false);
|
||||
});
|
||||
return cleanup;
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed) return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
@@ -32,31 +60,78 @@ export function UpdateNotification() {
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} will be applied on next launch.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.updater.installUpdate()}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
Download update
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ export function UpdatesSettingsTab() {
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch, downloading them in the background. You'll
|
||||
be prompted to restart once an update is ready.
|
||||
shortly after launch.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
@@ -51,8 +50,7 @@ export function UpdatesSettingsTab() {
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates download in the background and show a
|
||||
restart prompt when ready.
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
@@ -63,8 +61,8 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is downloading in the background —
|
||||
you'll be notified when it's ready to install.
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MemberDetailPage as SharedMemberDetailPage } from "@multica/views/members";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
|
||||
export function MemberDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const member = members.find((m) => m.user_id === id) ?? null;
|
||||
|
||||
useDocumentTitle(member?.name ?? "Member");
|
||||
|
||||
if (!id) return null;
|
||||
return <SharedMemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { MemberDetailPage } from "./pages/member-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
@@ -21,7 +20,6 @@ import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
@@ -148,17 +146,6 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{
|
||||
path: "members/:id",
|
||||
element: <MemberDetailPage />,
|
||||
handle: { title: "Member" },
|
||||
},
|
||||
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
|
||||
{
|
||||
path: "squads/:id",
|
||||
element: <SquadDetailPageView />,
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "usage",
|
||||
|
||||
@@ -45,5 +45,4 @@ New agents default to **private**. To make one available to the whole workspace,
|
||||
|
||||
- [Create and configure an agent](/agents-create) — how to build one
|
||||
- [Skills](/skills) — attach knowledge packs to an agent
|
||||
- [Squads](/squads) — group agents under a leader so the right one picks up the right issue
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run
|
||||
|
||||
@@ -45,5 +45,4 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
|
||||
- [Skills](/skills) —— 给智能体挂上专业知识包
|
||||
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Hand an issue to an agent and it takes over as the official assigne
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths. The same flow also accepts a [squad](/squads) as the assignee — Multica then triggers the squad's **leader agent** instead.
|
||||
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
|
||||
|
||||
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -18,7 +18,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
|
||||
|
||||
## Assign from the UI
|
||||
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace, all non-archived agents, and every non-archived [squad](/squads). Pick an agent (or squad) and the issue is assigned right away.
|
||||
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
|
||||
|
||||
A few rules:
|
||||
|
||||
@@ -78,6 +78,5 @@ But **different agents can work on the same issue in parallel** — for example,
|
||||
## Next
|
||||
|
||||
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
|
||||
- [**Squads**](/squads) — assign to a group of agents and let the leader decide who picks it up
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 把 issue 交给智能体,它作为正式负责人一直工作到
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。同样的流程也接受 [小队(squad)](/squads) 作为 assignee——这种情况下 Multica 会触发小队的**队长智能体**。
|
||||
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
|
||||
|
||||
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|
||||
|---|---|---|---|---|---|
|
||||
@@ -18,7 +18,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 在界面里分配
|
||||
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员、未归档的智能体、以及未归档的 [小队](/squads)。选一个智能体(或小队),issue 立刻分配。
|
||||
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员和未归档的智能体。选一个智能体,issue 立刻分给它。
|
||||
|
||||
几条规则:
|
||||
|
||||
@@ -78,6 +78,5 @@ multica issue assign MUL-42 --unassign
|
||||
## 下一步
|
||||
|
||||
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
|
||||
- [**小队**](/squads) —— 把 issue 分给一组智能体,由队长决定谁接手
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
|
||||
@@ -12,11 +12,9 @@ For the list of environment variables referenced below, see [Environment variabl
|
||||
|
||||
## How email + verification code sign-in works
|
||||
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. Two delivery backends are supported — pick whichever fits your deployment:
|
||||
The user enters an email on the sign-in page → the server sends a 6-digit code → the user enters it → the server verifies it → a JWT cookie is issued. Standard flow. It requires [Resend](https://resend.com/) as the email provider:
|
||||
|
||||
### Option A: Resend (recommended for cloud / public-internet deployments)
|
||||
|
||||
1. Create a [Resend](https://resend.com/) account and verify your domain
|
||||
1. Create a Resend account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
@@ -27,22 +25,7 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
4. Restart the server
|
||||
|
||||
### Option B: SMTP relay (for self-hosted / on-premise deployments)
|
||||
|
||||
Use this when the deployment can't reach `api.resend.com` or you already have an internal mail relay (Exchange, Postfix, on-prem SendGrid, etc.). `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # default 25; use 587 for STARTTLS submission
|
||||
SMTP_USERNAME=multica # leave empty for unauthenticated relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # set true only for self-signed / private CA
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # reused as the From: header
|
||||
```
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTPS / implicit TLS) is **not** currently supported — use port 25 or 587.
|
||||
|
||||
**What happens if you set neither**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
**What happens if you don't set `RESEND_API_KEY`**: the server doesn't error, but **every email that should have been sent is written to the server's stdout only**. Handy for local development (copy the code from the logs); in production it's a black hole.
|
||||
|
||||
## Fixed local testing codes
|
||||
|
||||
@@ -51,7 +34,7 @@ STARTTLS is upgraded automatically when the server advertises it. Port 465 (SMTP
|
||||
|
||||
The old behavior where non-production instances accepted `888888` by default has been removed. Unless you explicitly configure it, typing `888888` is treated like any other wrong code.
|
||||
|
||||
Local development without any email backend configured (no Resend, no SMTP) should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
Local development without Resend should use the generated code printed in server logs. If you need deterministic local/private automation, set `MULTICA_DEV_VERIFICATION_CODE` to a 6-digit value such as `888888`, and keep `APP_ENV` non-production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -12,11 +12,9 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一:
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
|
||||
### Option A:Resend(公网/云端部署推荐)
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
@@ -27,22 +25,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
4. 重启 server
|
||||
|
||||
### Option B:SMTP relay(内网/自部署)
|
||||
|
||||
适合内网无法访问 `api.resend.com`,或者已经有内部邮件中继(Exchange、Postfix、自部署 SendGrid 等)的场景。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
```bash
|
||||
SMTP_HOST=smtp.internal.example.com
|
||||
SMTP_PORT=587 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
SMTP_TLS_INSECURE=false # 仅在私有 CA / 自签证书时改成 true
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。**暂不支持** 465(SMTPS / 隐式 TLS),请使用 25 或 587。
|
||||
|
||||
**两种都不配**:server 不报错,但所有本该发出去的邮件**只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
@@ -51,7 +34,7 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
没配任何邮件后端(Resend 和 SMTP 都没设)的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -79,20 +79,6 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica squad list` | List squads in the workspace |
|
||||
| `multica squad get <id>` | Show a single squad |
|
||||
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
|
||||
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
|
||||
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
|
||||
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
|
||||
|
||||
See [Squads](/squads) for the full model.
|
||||
|
||||
## Autopilots
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
@@ -79,20 +79,6 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | 列出工作区里的小队 |
|
||||
| `multica squad get <id>` | 查看一个小队 |
|
||||
| `multica squad create --name "..." --leader <agent>` | 创建小队(owner / admin)|
|
||||
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
|
||||
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
|
||||
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
|
||||
|
||||
完整模型见 [小队](/squads)。
|
||||
|
||||
## Autopilots
|
||||
|
||||
| 命令 | 用途 |
|
||||
|
||||
@@ -160,7 +160,6 @@ Chinese term reference:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -160,7 +160,6 @@ Multica 的产品名词分两类:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -35,28 +35,14 @@ These are the core variables you must think about before deploying — some have
|
||||
|
||||
## Email configuration
|
||||
|
||||
Multica supports two delivery backends — [Resend](https://resend.com/) for cloud deployments, or an SMTP relay for internal / on-premise networks. `SMTP_HOST` takes priority over `RESEND_API_KEY` when both are set.
|
||||
|
||||
### Resend
|
||||
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | empty | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account; also reused as the `From:` header when SMTP is in use) |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | Sender address (must be a domain verified in your Resend account) |
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | empty | SMTP relay hostname. Setting this activates SMTP mode and overrides Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP port. Use `587` for STARTTLS submission; **port 465 (SMTPS / implicit TLS) is not supported** |
|
||||
| `SMTP_USERNAME` | empty | SMTP username. Leave empty for unauthenticated relay |
|
||||
| `SMTP_PASSWORD` | empty | SMTP password |
|
||||
| `SMTP_TLS_INSECURE` | `false` | Set `true` to skip TLS certificate verification (private CA / self-signed only) |
|
||||
|
||||
STARTTLS is upgraded automatically when the server advertises it. The dial timeout is 10s and the whole SMTP session has a 30s deadline, so a black-holed relay can't hang the auth handler.
|
||||
|
||||
**Behavior when neither is set**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
**Behavior when `RESEND_API_KEY` is unset**: the server does not error, but every email that should have been sent (verification codes, invite links) **is written to the server's stdout only**. Convenient for local development — copy the code out of the server logs; **in production, forgetting to set this creates a silent black hole**, with users never receiving email and no error surfaced.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
|
||||
@@ -35,28 +35,14 @@ Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量
|
||||
|
||||
## 怎么配邮件
|
||||
|
||||
Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合公网部署,SMTP relay 适合内网/自部署。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
### Resend
|
||||
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头)|
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
|
||||
|
||||
### SMTP relay
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `SMTP_HOST` | 空 | SMTP relay 主机名。设置后即启用 SMTP 模式并覆盖 Resend |
|
||||
| `SMTP_PORT` | `25` | SMTP 端口。STARTTLS 提交端口用 `587`;**暂不支持 465(SMTPS / 隐式 TLS)** |
|
||||
| `SMTP_USERNAME` | 空 | SMTP 用户名。留空表示未认证 relay |
|
||||
| `SMTP_PASSWORD` | 空 | SMTP 密码 |
|
||||
| `SMTP_TLS_INSECURE` | `false` | 设为 `true` 跳过 TLS 证书校验(仅限私有 CA / 自签证书)|
|
||||
|
||||
服务端 advertise STARTTLS 时会自动升级。dial 超时 10s,整个 SMTP 会话有 30s deadline,避免 relay 黑洞把 auth handler 挂死。
|
||||
|
||||
**两种都不设的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发方便(你从 server 日志里抄验证码);**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
**不设 `RESEND_API_KEY` 时的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
|
||||
@@ -16,10 +16,6 @@ Same as mentioning a member — type `@` to open the picker and select an agent.
|
||||
|
||||
The `@mention` Markdown syntax, the picker, and `@all` semantics are covered in [**Comments**](/comments).
|
||||
|
||||
<Callout type="info">
|
||||
**You can also `@`-mention a [squad](/squads) in a comment.** The same picker surfaces squads alongside members and agents; selecting one inserts `[@SquadName](mention://squad/<uuid>)` and triggers the squad's **leader agent** to coordinate a response — assignee and status stay untouched.
|
||||
</Callout>
|
||||
|
||||
## How it differs from assignment
|
||||
|
||||
Both put the agent to work, but the mechanics are entirely different:
|
||||
@@ -57,7 +53,6 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
|
||||
|
||||
## Next
|
||||
|
||||
- [**Squads**](/squads) — `@`-mention a squad to have the leader route the question to the right member
|
||||
- [**Chat**](/chat) — one-to-one conversation outside any issue
|
||||
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
|
||||
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics
|
||||
|
||||
@@ -16,10 +16,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
`@mention` 的 Markdown 语法、picker 的用法、`@all` 的语义见 [**评论**](/comments)。
|
||||
|
||||
<Callout type="info">
|
||||
**`@` 也可以指向 [小队(squad)](/squads)。** picker 里小队和成员、智能体并列;选中后会插入 `[@SquadName](mention://squad/<uuid>)`,触发小队的**队长智能体**来协调响应——assignee 和 status 都不会变。
|
||||
</Callout>
|
||||
|
||||
## 和分配的差别
|
||||
|
||||
同样是让智能体工作,但机制完全不同:
|
||||
@@ -57,7 +53,6 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 下一步
|
||||
|
||||
- [**小队**](/squads) —— `@` 一个小队,由队长把问题派给合适的成员
|
||||
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
|
||||
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
|
||||
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---智能体怎么运行---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
|
||||
@@ -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,7 +108,7 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
## Common issues
|
||||
|
||||
- **Backend won't start**: check container logs with `docker compose -f docker-compose.selfhost.yml logs backend`; usually it's a bad `DATABASE_URL` or `JWT_SECRET` in `.env`
|
||||
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **Verification code not received**: Resend isn't configured → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -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 # 默认 25;STARTTLS 提交端口用 587
|
||||
SMTP_USERNAME=multica # 留空则使用未认证 relay
|
||||
SMTP_PASSWORD=...
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
```
|
||||
|
||||
之后重启:`docker compose -f docker-compose.selfhost.yml restart backend`。
|
||||
|
||||
更多 auth 配置(OAuth、注册白名单)以及完整的 SMTP 变量说明见 [登录与注册配置](/auth-setup) 和 [环境变量](/environment-variables)。
|
||||
更多 auth 配置(OAuth、注册白名单)见 [登录与注册配置](/auth-setup)。
|
||||
|
||||
## 4. 首次登录 + 创建工作区
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
@@ -121,7 +107,7 @@ 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-连不上)
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: Squads
|
||||
description: "A squad is a group of agents (and optionally human members) led by one designated leader agent. Assign an issue to a squad and the leader decides who picks it up."
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A squad is a **named group of [agents](/agents) and human [members](/members-roles)**, with one designated **leader agent**. The squad is itself a first-class assignee: pick it from any **Assignee** picker and the leader takes the trigger, reads the issue, then `@`-mentions the squad member best suited to do the work. Squads let you assemble specialists once and dispatch them **by topic instead of by name** — the team grows, the routing stays the same.
|
||||
|
||||
## What a squad is, in mechanics
|
||||
|
||||
- **One leader, many members.** The leader must be an agent; members can be agents or human members. A squad with only the leader is allowed (the leader briefing notes "no other members"), and the same agent can sit in multiple squads.
|
||||
- **Assignable everywhere a person is.** Squads appear in the Assignee picker, the @-mention picker, and the quick-create modal — anywhere you'd pick an agent or member, you can pick a squad.
|
||||
- **Soft-deleted via archive.** Archive a squad and it disappears from pickers and lists; any issue currently assigned to it is **transferred to the leader agent** so the work doesn't go silent. Archived squads can't be assigned to new issues.
|
||||
|
||||
## When to use a squad versus a single agent
|
||||
|
||||
| Pick a squad when… | Pick a single agent when… |
|
||||
|---|---|
|
||||
| You have several specialists and don't know which one fits this issue in advance | The work is well-scoped to one specialty and you know who should do it |
|
||||
| You want one stable assignee (the squad) while the actual responder changes per issue | You want the agent's name on the issue and clear individual accountability |
|
||||
| You want a `@FrontendTeam` style routing target in comments | One-on-one `@agent-name` is enough |
|
||||
|
||||
The squad doesn't add capability — it adds **routing**. The members are still ordinary agents; the leader's only job is to pick the right one.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Action | Who can do it |
|
||||
|---|---|
|
||||
| Create / update / archive a squad | Workspace **owner** or **admin** |
|
||||
| Add or remove members, change roles | Workspace **owner** or **admin** |
|
||||
| Assign an issue to a squad | Any workspace member (same as assigning to an agent) |
|
||||
| `@`-mention a squad in a comment | Any workspace member |
|
||||
| Record a squad-leader evaluation | The squad leader agent only (via CLI) |
|
||||
|
||||
The full role matrix lives in [Members and roles](/members-roles).
|
||||
|
||||
## Create a squad
|
||||
|
||||
In the sidebar, open **Squads → New squad** and fill in:
|
||||
|
||||
- **Name** — e.g. `Frontend Team`, `Bug Triage`. Doesn't need to be unique within the workspace.
|
||||
- **Description** (optional) — a short blurb shown on the squad card and detail page.
|
||||
- **Leader** — pick an existing agent. The leader is added to the squad automatically with role `leader`.
|
||||
|
||||
After creation, open the squad's detail page to:
|
||||
|
||||
- **Add members** — pick agents or human members, optionally give each a short role description (e.g. "owns the migrations", "reviewer of last resort"). The leader uses these roles when deciding who to delegate to.
|
||||
- **Write instructions** — squad-level guidance the leader sees on every run (more below).
|
||||
- **Set an avatar** — picked from the same picker used for agents.
|
||||
|
||||
CLI equivalent:
|
||||
|
||||
```bash
|
||||
multica squad create --name "Frontend Team" --leader frontend-lead-agent
|
||||
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
|
||||
```
|
||||
|
||||
## How a squad-assigned issue runs
|
||||
|
||||
When a non-Backlog issue is assigned to a squad, Multica immediately enqueues a `task` for the **leader agent** (not for every member). The flow then looks like this:
|
||||
|
||||
1. **Leader claims the task.** The agent runtime picks up the task on its next poll, same as any other agent assignment.
|
||||
2. **Leader is briefed.** On claim, Multica appends three sections to the leader's system prompt — see [What the leader sees on every turn](#what-the-leader-sees-on-every-turn) below.
|
||||
3. **Leader posts one delegation comment.** The comment `@`-mentions the chosen member(s) using the exact mention markdown from the roster — that mention triggers a new `task` for each mentioned agent.
|
||||
4. **Leader records its evaluation** via `multica squad activity <issue-id> action --reason "..."`. This writes an entry to the issue's activity timeline so humans can see the leader actually evaluated the trigger.
|
||||
5. **Leader stops.** The leader does not do the implementation itself. When the delegated member posts back, the leader is re-triggered to read the update and either delegate the next step, escalate, or stay silent.
|
||||
|
||||
If the issue is in **Backlog**, the leader is not triggered — Backlog is a parking lot, same rule as for direct agent assignment.
|
||||
|
||||
### What the leader sees on every turn
|
||||
|
||||
On each squad-leader run, three blocks are appended to the leader's instructions:
|
||||
|
||||
- **Squad Operating Protocol** — a hard-coded rule set: read the issue, delegate by `@`-mention, be terse (don't restate the issue body — the assignee can read it), record an evaluation every turn, and **stop after dispatching**. This protocol is system-managed and not editable.
|
||||
- **Squad Roster** — the leader's self-row plus one row per non-archived member. Each row carries the exact mention markdown (`[@Name](mention://agent/<uuid>)` or `[@Name](mention://member/<uuid>)`) the leader should paste — typing a plain `@name` won't trigger anyone.
|
||||
- **Squad Instructions** — your custom guidance for this squad (set on the squad detail page or via `multica squad update --instructions`). Use this for routing rules ("send DB work to Alice, frontend to Bob"), escalation policies, or anything else the leader needs to know that isn't already in the issue.
|
||||
|
||||
## When the leader is re-triggered
|
||||
|
||||
After the first dispatch, the leader is woken up automatically by **most subsequent comments** on the issue. The exact rules:
|
||||
|
||||
| Event | Leader triggered? |
|
||||
|---|---|
|
||||
| A non-member (human reporter, external agent) posts a comment | **Yes** |
|
||||
| A squad member posts a progress update with no `@mention` | **Yes** — the leader re-evaluates whether the next step is needed |
|
||||
| Anyone posts a comment that explicitly `@`-mentions another agent / member / squad / `@all` | **No** — the explicit `@` is the routing signal; the leader gets out of the way |
|
||||
| The leader's own comment (self-trigger) | **No** — guarded to prevent a loop |
|
||||
| A comment containing only an issue cross-reference (`[MUL-123](mention://issue/...)`) | **Yes** — issue references aren't routing |
|
||||
|
||||
Dedup applies on top of these rules: if the leader already has a `queued` or `dispatched` task on this issue, a new trigger won't enqueue a duplicate.
|
||||
|
||||
<Callout type="info">
|
||||
**Why the leader doesn't trigger when a member posts an `@`-mention.** Once a squad member directly `@`s someone, that comment is a deliberate hand-off — having the leader wake up to "observe" the routing would just produce a no-op turn and clutter the timeline. Agent-authored comments are the exception: when an agent posts a result that `@`s another agent, the leader still wakes up so it can coordinate the thread.
|
||||
</Callout>
|
||||
|
||||
## `@`-mention a squad in a comment
|
||||
|
||||
Squads appear in the `@` picker alongside members and agents. Mentioning a squad inserts `[@SquadName](mention://squad/<uuid>)` and triggers the **squad leader** as if you had assigned the issue to the squad — without changing the assignee or the status. Use this when you want the squad to pick someone for a question or sub-task while keeping the current owner.
|
||||
|
||||
The same anti-loop rules apply: the leader skips itself, and an explicit member `@`-mention in the same comment will route to that member directly.
|
||||
|
||||
## Reassign or archive a squad
|
||||
|
||||
**Reassigning an issue away from a squad** behaves like any other assignee change: all of the issue's active tasks (including the leader's) are cancelled, and the new assignee — agent, member, or another squad — is enqueued. There is no separate "remove squad without changing assignee" action; pick a different assignee.
|
||||
|
||||
**Archiving a squad** (`multica squad delete <id>`, or the Archive button on the detail page):
|
||||
|
||||
1. **Transfers issues currently assigned to the squad to the leader agent**, so the work continues against a concrete agent instead of going silent.
|
||||
2. Marks the squad with `archived_at` / `archived_by` — the row is preserved so historical activity entries still resolve, but the squad disappears from lists, pickers, and the @-mention dropdown.
|
||||
3. **Rejects future assignments** to this squad with `cannot assign to an archived squad`.
|
||||
|
||||
There is currently no unarchive command; create a new squad if you need the routing back.
|
||||
|
||||
## Squad operations from the CLI
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `multica squad list` | List squads in the workspace |
|
||||
| `multica squad get <id>` | Show one squad's name, leader, description, instructions |
|
||||
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
|
||||
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | Update one or more fields |
|
||||
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
|
||||
| `multica squad member list <id>` | List a squad's members |
|
||||
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | Add a member (owner / admin) |
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
|
||||
|
||||
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
|
||||
|
||||
## Next
|
||||
|
||||
- [Assign issues to agents](/assigning-issues) — same flow, applies to squad assignees too
|
||||
- [`@`-mention agents in comments](/mentioning-agents) — the `@` picker also surfaces squads
|
||||
- [Agents](/agents) — what an agent is, the building block of every squad
|
||||
- [Members and roles](/members-roles) — the full owner / admin / member permission matrix
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
title: 小队
|
||||
description: 小队(squad)是一组智能体(可选附带成员),由一名指定的"队长"智能体(leader)领导。把 issue 分配给小队,队长来决定谁接手。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
小队(squad)是一组 [智能体](/agents) 和 [人类成员](/members-roles) 的**命名集合**,其中有一名指定的**队长(leader),必须是智能体**。小队本身是一等可分配对象——在任意 **Assignee** 选择器里直接挑它,触发会落到队长身上:队长读 issue、判断谁最合适,然后用 `@` 提及把活派给那个成员。小队让你把一组专家**一次性编好队**,之后**按主题派活,而不是按名字派活**——队伍扩展,路由不变。
|
||||
|
||||
## 小队的运转机制
|
||||
|
||||
- **一个队长,多名成员。** 队长必须是智能体;成员可以是智能体或人类成员。只有队长一个人的小队也是允许的(队长 briefing 会注明"没有其他成员"),同一个智能体也能加入多个小队。
|
||||
- **任何能选人的地方都能选小队。** Assignee picker、@ 提及 picker、快速创建 modal——只要能选智能体或成员的位置,小队都会出现。
|
||||
- **删除走"归档"软删除。** 归档一个小队后,它会从 picker 和列表里消失;当前分配给它的 issue 会被**自动转给队长智能体**,让工作不至于卡住。归档的小队不能再被分配新 issue。
|
||||
|
||||
## 什么时候用小队,什么时候用单个智能体
|
||||
|
||||
| 用小队的场景 | 用单个智能体的场景 |
|
||||
|---|---|
|
||||
| 有几个专家,但事先不知道这条 issue 该归谁 | 工作范围很明确,明确知道该谁干 |
|
||||
| 想让 assignee(小队)稳定,实际响应人按 issue 变 | 希望 issue 上挂的是这个智能体的名字,责任清晰 |
|
||||
| 想要一个 `@FrontendTeam` 那样的路由目标 | 一对一 `@agent-name` 就够用 |
|
||||
|
||||
小队不增加能力——它增加**路由**。成员还是那些智能体,队长唯一的工作是**挑对人**。
|
||||
|
||||
## 权限
|
||||
|
||||
| 操作 | 谁能做 |
|
||||
|---|---|
|
||||
| 创建 / 更新 / 归档小队 | 工作区 **owner** 或 **admin** |
|
||||
| 增删成员、改成员角色 | 工作区 **owner** 或 **admin** |
|
||||
| 把 issue 分配给小队 | 任何工作区成员(和分配给智能体一样)|
|
||||
| 在评论里 `@` 小队 | 任何工作区成员 |
|
||||
| 记录小队队长的 evaluation | 只有队长智能体本人(通过 CLI)|
|
||||
|
||||
完整角色权限对照见 [成员与权限](/members-roles)。
|
||||
|
||||
## 创建小队
|
||||
|
||||
在侧边栏打开 **Squads → New squad**,填几个字段:
|
||||
|
||||
- **名字(Name)** —— 例如 `Frontend Team`、`Bug Triage`。在工作区里**不要求唯一**。
|
||||
- **描述(Description,可选)** —— 一句话简介,展示在小队卡片和详情页上。
|
||||
- **队长(Leader)** —— 选一个已有的智能体。创建后队长会自动以 `leader` 角色加入小队。
|
||||
|
||||
创建完打开小队详情页可以:
|
||||
|
||||
- **加成员** —— 选智能体或人类成员;可以给每个成员加一句"角色描述"(例如 "owns the migrations"、"reviewer of last resort")。队长派活时会参考这些角色。
|
||||
- **写 instructions** —— 小队级别的指令,队长每次执行都能看到(见下文)。
|
||||
- **设头像** —— 用和智能体一样的头像选择器。
|
||||
|
||||
CLI 等价命令:
|
||||
|
||||
```bash
|
||||
multica squad create --name "Frontend Team" --leader frontend-lead-agent
|
||||
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
|
||||
```
|
||||
|
||||
## 分配给小队的 issue 是怎么跑的
|
||||
|
||||
非 Backlog 状态的 issue 一旦分配给小队,Multica 会立刻给**队长智能体**入队一个 `task`(不是给每个成员都入一个)。整个流程是这样的:
|
||||
|
||||
1. **队长领走 task。** 队长所在的 daemon 在下次轮询时把 task 领走,和普通智能体的分配流程一样。
|
||||
2. **队长拿到 briefing。** 领走的瞬间,Multica 会在队长的系统提示后面追加三段内容——详见下文 [队长每次执行看到的内容](#队长每次执行看到的内容)。
|
||||
3. **队长发一条"派活"评论。** 评论里用 roster 里给好的 mention markdown `@` 选中的成员——这个 `@` 会触发被派的成员入队新 `task`。
|
||||
4. **队长记录 evaluation:** `multica squad activity <issue-id> action --reason "..."`。这一行会写进 issue 的 activity 时间线,方便人类回溯队长确实评估过这一次触发。
|
||||
5. **队长停下。** 派完活,队长**不动手干活**。当被派的成员有回复时,队长会被自动唤醒,决定下一步:继续派活、上抛给人类、还是保持沉默。
|
||||
|
||||
如果 issue 是 **Backlog** 状态,队长不会被触发——Backlog 是停泊场,规则和直接分配给智能体一样。
|
||||
|
||||
### 队长每次执行看到的内容
|
||||
|
||||
每次队长被触发,三段内容会被附加到它的 instructions 上:
|
||||
|
||||
- **Squad Operating Protocol(小队工作规范)** —— 一段硬编码的规则集:读 issue → 用 `@` 派活 → 简洁(**不要**复述 issue 内容,被派的成员自己能读)→ 每次都记 evaluation → **派完就停**。这段是系统管理的,不可编辑。
|
||||
- **Squad Roster(小队花名册)** —— 队长自己一行 + 每个未归档成员一行。每一行带上**确切可用**的 mention markdown(`[@Name](mention://agent/<uuid>)` 或 `[@Name](mention://member/<uuid>)`)让队长直接复制——纯文本 `@name` 是**不会**触发任何人的。
|
||||
- **Squad Instructions(小队自定义指令)** —— 你为这个小队写的私货(在详情页里编辑,或用 `multica squad update --instructions`)。用来写路由规则("DB 相关派给 Alice,前端派给 Bob")、上报策略,或者任何 issue 本身不会有的背景。
|
||||
|
||||
## 队长什么时候会被再次触发
|
||||
|
||||
第一次派活完之后,**大多数后续评论**都会自动唤醒队长。具体规则:
|
||||
|
||||
| 事件 | 触发队长?|
|
||||
|---|---|
|
||||
| 非小队成员(人类 reporter、外部智能体)发评论 | **会** |
|
||||
| 小队成员发"进展更新",**不带任何** `@mention` | **会**——队长重新评估是否需要下一步 |
|
||||
| 任何人发的评论里**显式 `@`** 智能体 / 成员 / 小队 / `@all` | **不会**——显式 `@` 就是路由信号,队长让位 |
|
||||
| 队长自己发的评论 | **不会**——硬编码防自触发 |
|
||||
| 评论里只有 issue 互链 `[MUL-123](mention://issue/...)` | **会**——issue 引用不算路由 |
|
||||
|
||||
以上规则之上还有去重:如果队长在这个 issue 上已经有 `queued` 或 `dispatched` 的 task,新一次触发不会重复入队。
|
||||
|
||||
<Callout type="info">
|
||||
**为什么成员发的 `@` 评论不会唤醒队长。** 小队成员一旦直接 `@` 谁,那条评论就是**有意识的交接**——再让队长唤醒一次"观察"路由,只会产出一次空回合、把时间线搞乱。智能体作者的评论是个例外:当某个智能体发出一条结果还顺手 `@` 了另一个智能体时,队长仍然会被唤醒,以便协调整条线程。
|
||||
</Callout>
|
||||
|
||||
## 在评论里 `@` 一个小队
|
||||
|
||||
小队会出现在 `@` picker 里,和成员、智能体并列。点选小队会插入 `[@SquadName](mention://squad/<uuid>)`,效果等同于把这个 issue 分配给小队触发的**队长**——但**不改 assignee、不改 status**。适合"我想让小队挑个人回答一下/做一小步,但 issue 还归原来的人"这种场景。
|
||||
|
||||
防循环规则同样适用:队长跳过自己;同一条评论里如果还显式 `@` 了某个成员,路由会直接落到那个成员。
|
||||
|
||||
## 重新分配或归档一个小队
|
||||
|
||||
**把分配人从小队改成别的**,行为和换 assignee 完全一致:当前 issue 上所有活跃 task(包括队长的)会被取消,新的 assignee(智能体、成员、或另一个小队)被入队。没有"不改 assignee 只移除小队"的单独操作;要换就选新的 assignee。
|
||||
|
||||
**归档小队**(`multica squad delete <id>`,或详情页的 Archive 按钮):
|
||||
|
||||
1. **当前分配给这个小队的 issue 会被自动转给队长智能体**,让工作落到一个具体智能体上,避免无人接手。
|
||||
2. 在 squad 表上写入 `archived_at` / `archived_by`——记录被保留下来,历史的 activity 还能解析;但从列表、picker、`@` 下拉里它都消失。
|
||||
3. **拒绝后续分配**——`cannot assign to an archived squad`。
|
||||
|
||||
目前没有"反归档"命令;要恢复路由,重新建一个小队即可。
|
||||
|
||||
## CLI 命令
|
||||
|
||||
| 命令 | 用途 |
|
||||
|---|---|
|
||||
| `multica squad list` | 列出工作区里的小队 |
|
||||
| `multica squad get <id>` | 查看小队的名字、队长、描述、instructions |
|
||||
| `multica squad create --name "..." --leader <agent>` | 创建小队(owner / admin)|
|
||||
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | 修改一个或多个字段 |
|
||||
| `multica squad delete <id>` | 归档(软删除)——同时把当前分配给小队的 issue 转给队长 |
|
||||
| `multica squad member list <id>` | 列出小队成员 |
|
||||
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 加成员(owner / admin)|
|
||||
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
|
||||
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
|
||||
|
||||
`--leader` 接受智能体名字或 UUID;其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
|
||||
|
||||
## 下一步
|
||||
|
||||
- [分配 issue 给智能体](/assigning-issues) —— 流程相同,对小队 assignee 也适用
|
||||
- [在评论里 `@` 智能体](/mentioning-agents) —— `@` picker 同样能选到小队
|
||||
- [智能体](/agents) —— 小队的"零件"
|
||||
- [成员与权限](/members-roles) —— owner / admin / member 的完整权限对照
|
||||
@@ -1,13 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { MemberDetailPage } from "@multica/views/members";
|
||||
|
||||
export default function MemberDetailRoute({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
return <MemberDetailPage userId={id} />;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { SquadDetailPage as default } from "@multica/views/squads";
|
||||
@@ -1 +0,0 @@
|
||||
export { SquadsPage as default } from "@multica/views/squads";
|
||||
@@ -284,54 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
title: "Squads & Attachment Previews",
|
||||
changes: [],
|
||||
features: [
|
||||
"Squads let teams assign work to a group, with a leader agent coordinating the next step",
|
||||
"Attachments can be previewed in place for PDFs, audio, video, markdown, code, logs, and plain text",
|
||||
"Chinese names can be found by pinyin across mentions, assignees, subscribers, agents, projects, and squads",
|
||||
],
|
||||
improvements: [
|
||||
"Squad pages now include member management, faster agent creation from a squad, clearer row actions, and a wider detail layout",
|
||||
"Quick-create and picker flows are easier to search and now include squad-aware routing",
|
||||
"Usage charts can switch between cost and token views, with the same timezone controls used by runtimes",
|
||||
"Workspace operators get command-line controls for managing squads and stopping a runaway issue run",
|
||||
"Shared interface labels are translated more consistently in English and Chinese",
|
||||
],
|
||||
fixes: [
|
||||
"Squad leaders stay quiet when a human already routed the conversation to someone specific",
|
||||
"Mentioning a squad now wakes the right leader while preserving private-agent access rules",
|
||||
"Issue lists stay fresher after deletes and follow-up comments no longer trigger stale Done replies",
|
||||
"Attachment previews keep working for files added while writing or editing issues and comments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.32",
|
||||
date: "2026-05-13",
|
||||
title: "Usage Insights, Chat Renaming & Smoother Desktop Flows",
|
||||
changes: [],
|
||||
features: [
|
||||
"Usage now shows workspace and project token activity, runtime trends, and per-agent rankings in one place",
|
||||
"Chat sessions can be renamed directly from the chat header",
|
||||
"Feedback reports can include screenshots or files so teams have the context they need",
|
||||
],
|
||||
improvements: [
|
||||
"The Usage page has clearer naming and a more dynamic agent leaderboard",
|
||||
"New chats and completed chat responses update more smoothly with fewer loading flashes",
|
||||
"Self-hosted GitHub setup is easier to configure and the setup docs point to the right cloud URL",
|
||||
"User-installed Codex skills are available automatically when new tasks run",
|
||||
],
|
||||
fixes: [
|
||||
"Empty successful agent responses are marked completed instead of blocked",
|
||||
"Pasted mentions in instruction editors keep their mention links",
|
||||
"Desktop attachment downloads use the native Linux flow and tab closing no longer loops",
|
||||
"Gemini and Windows runtime startup checks are more reliable in unattended runs",
|
||||
"Long GitHub repository lists stay usable when adding project resources",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
|
||||
@@ -284,54 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
useAgentsViewStore,
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
@@ -1,96 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useAgentsViewStore } from "./view-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAgentsViewStore.setState({ scope: "mine" });
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("useAgentsViewStore", () => {
|
||||
it("defaults to 'mine'", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("setScope mutates the store", () => {
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
|
||||
const raw = localStorage.getItem("multica_agents_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ scope: "all" });
|
||||
});
|
||||
|
||||
it("rehydrates a different saved scope on workspace switch", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:beta",
|
||||
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type AgentsScope = "mine" | "all";
|
||||
|
||||
export interface AgentsViewState {
|
||||
scope: AgentsScope;
|
||||
setScope: (scope: AgentsScope) => void;
|
||||
}
|
||||
|
||||
export const useAgentsViewStore = create<AgentsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: "multica_agents_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the default "mine" instead of leaving the previous workspace's in-
|
||||
// memory scope in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak "all" across workspaces.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, scope: "mine" };
|
||||
return { ...current, ...(persisted as Partial<AgentsViewState>) };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useAgentsViewStore.persist.rehydrate());
|
||||
@@ -1,22 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentListOptions, squadListOptions } from "../workspace/queries";
|
||||
import { agentListOptions } from "../workspace/queries";
|
||||
import { runtimeListOptions } from "../runtimes/queries";
|
||||
import { agentTaskSnapshotOptions } from "./queries";
|
||||
|
||||
// Subscribe to the queries that power agent presence and the @mention
|
||||
// suggestion list so they're warm by the time any hover card / inline
|
||||
// indicator / mention popup first renders. Without this warm-up, surfaces
|
||||
// that don't otherwise touch the snapshot (inbox, issues, chat) flash a
|
||||
// skeleton on first hover while the fetch is in flight, and the @mention
|
||||
// list may show incomplete results (e.g. missing squads).
|
||||
// Subscribe to the three queries that power agent presence so they're warm
|
||||
// by the time any hover card / inline indicator first renders. Without this
|
||||
// warm-up, surfaces that don't otherwise touch the snapshot (inbox, issues,
|
||||
// chat) flash a skeleton on first hover while the fetch is in flight.
|
||||
//
|
||||
// useRealtimeSync (WS task / agent / daemon / squad invalidations) and the
|
||||
// 30s presence tick keep these caches fresh after the initial fetch — this
|
||||
// hook only collapses the cold-start window.
|
||||
// useRealtimeSync (WS task / agent / daemon invalidations) and the 30s
|
||||
// presence tick keep these caches fresh after the initial fetch — this hook
|
||||
// only collapses the cold-start window.
|
||||
//
|
||||
// All queries are workspace-scoped; the queryKeys include wsId so workspace
|
||||
// All three are workspace-scoped; the queryKeys include wsId so workspace
|
||||
// switch automatically refetches the new workspace's data with no extra
|
||||
// wiring here. The workspace-scoped layouts on both apps gate rendering on
|
||||
// "workspace resolved", so callers can safely pass useWorkspaceId() — by the
|
||||
@@ -25,5 +23,4 @@ export function useWorkspacePresencePrefetch(wsId: string | undefined): void {
|
||||
useQuery({ ...agentListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...runtimeListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...agentTaskSnapshotOptions(wsId ?? ""), enabled: !!wsId });
|
||||
useQuery({ ...squadListOptions(wsId ?? ""), enabled: !!wsId });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -91,8 +91,6 @@ import type {
|
||||
GitHubPullRequest,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -208,27 +206,6 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown by getAttachmentTextContent when the server refuses to inline a
|
||||
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
|
||||
// download" affordance with the Download CTA still available.
|
||||
export class PreviewTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("attachment too large for inline preview");
|
||||
this.name = "PreviewTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown by getAttachmentTextContent when the server's text whitelist
|
||||
// rejects the content type. Normally the client's isPreviewable() guard
|
||||
// catches this earlier, but the two whitelists can drift — surfacing the
|
||||
// 415 as a typed error makes the drift visible.
|
||||
export class PreviewUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super("attachment type not supported for inline preview");
|
||||
this.name = "PreviewUnsupportedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
@@ -303,23 +280,15 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Sends the request with the standard headers (auth, CSRF, request id,
|
||||
// client identity) and runs the shared error path (401 → handleUnauthorized,
|
||||
// structured ApiError, status-aware log level). Returns the raw Response so
|
||||
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
|
||||
// path, plain text for the attachment-preview proxy, etc.
|
||||
private async fetchRaw(
|
||||
path: string,
|
||||
init?: RequestInit & { extraHeaders?: Record<string, string> },
|
||||
): Promise<Response> {
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": rid,
|
||||
...this.authHeaders(),
|
||||
...(init?.extraHeaders ?? {}),
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
|
||||
@@ -340,18 +309,12 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
return res;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await this.fetchRaw(path, {
|
||||
...init,
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -500,12 +463,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: {
|
||||
agent_id?: string;
|
||||
squad_id?: string;
|
||||
prompt: string;
|
||||
project_id?: string | null;
|
||||
}): Promise<{ task_id: string }> {
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -592,10 +550,10 @@ export class ApiClient {
|
||||
return this.fetch("/api/assignee-frequency");
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1235,13 +1193,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||||
}
|
||||
@@ -1296,38 +1247,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Fetches the raw bytes of a text-previewable attachment.
|
||||
//
|
||||
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
||||
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
||||
// of which would otherwise prevent the renderer from getting the body.
|
||||
// The server always replies with `text/plain; charset=utf-8` for safety;
|
||||
// the original MIME ships back in the `X-Original-Content-Type` header so
|
||||
// the preview dispatcher can choose between markdown / html / plain code.
|
||||
//
|
||||
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
||||
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
||||
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
||||
// the modal can render specific fallbacks instead of generic failure.
|
||||
async getAttachmentTextContent(
|
||||
id: string,
|
||||
): Promise<{ text: string; originalContentType: string }> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 413) throw new PreviewTooLargeError();
|
||||
if (err.status === 415) throw new PreviewUnsupportedError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
text: await res.text(),
|
||||
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// Projects
|
||||
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
@@ -1450,43 +1369,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Squads
|
||||
async listSquads(): Promise<Squad[]> {
|
||||
return this.fetch(`/api/squads`);
|
||||
}
|
||||
|
||||
async getSquad(id: string): Promise<Squad> {
|
||||
return this.fetch(`/api/squads/${id}`);
|
||||
}
|
||||
|
||||
async createSquad(data: { name: string; description?: string; leader_id: string; avatar_url?: string }): Promise<Squad> {
|
||||
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
||||
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async deleteSquad(id: string): Promise<void> {
|
||||
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
|
||||
return this.fetch(`/api/squads/${squadId}/members`);
|
||||
}
|
||||
|
||||
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
|
||||
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
|
||||
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
|
||||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// Autopilots
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -5,11 +5,11 @@ import type { ApiClient } from "../api/client";
|
||||
import type { Attachment } from "../types";
|
||||
import { MAX_FILE_SIZE } from "../constants/upload";
|
||||
|
||||
// Carries the full Attachment so editors that need preview metadata
|
||||
// (`content_type`, `download_url`) get it directly; `link` is kept as an
|
||||
// alias for `url` because many callers persist it into Markdown / avatar
|
||||
// fields by that name.
|
||||
export type UploadResult = Attachment & { link: string };
|
||||
export interface UploadResult {
|
||||
id: string;
|
||||
filename: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface UploadContext {
|
||||
issueId?: string;
|
||||
@@ -36,7 +36,7 @@ export function useFileUpload(
|
||||
commentId: ctx?.commentId,
|
||||
chatSessionId: ctx?.chatSessionId,
|
||||
});
|
||||
return { ...att, link: att.url };
|
||||
return { id: att.id, filename: att.filename, link: att.url };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import {
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type { Issue, ListIssuesCache } from "../types";
|
||||
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
||||
import { issueKeys } from "./queries";
|
||||
|
||||
export type DeletedIssueCacheMetadata = {
|
||||
parentIssueIds: string[];
|
||||
};
|
||||
|
||||
function collectParentId(
|
||||
parentIssueIds: Set<string>,
|
||||
parentId: string | null | undefined,
|
||||
) {
|
||||
if (parentId) parentIssueIds.add(parentId);
|
||||
}
|
||||
|
||||
function collectParentFromListCache(
|
||||
parentIssueIds: Set<string>,
|
||||
data: ListIssuesCache | undefined,
|
||||
issueId: string,
|
||||
) {
|
||||
const parentId = data
|
||||
? findIssueLocation(data, issueId)?.issue.parent_issue_id
|
||||
: undefined;
|
||||
collectParentId(parentIssueIds, parentId);
|
||||
}
|
||||
|
||||
function parentIdFromChildrenKey(key: QueryKey) {
|
||||
const parentId = key[key.length - 1];
|
||||
return typeof parentId === "string" ? parentId : null;
|
||||
}
|
||||
|
||||
export function collectDeletedIssueCacheMetadata(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
): DeletedIssueCacheMetadata {
|
||||
const parentIssueIds = new Set<string>();
|
||||
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
||||
collectParentId(parentIssueIds, detail?.parent_issue_id);
|
||||
|
||||
collectParentFromListCache(
|
||||
parentIssueIds,
|
||||
qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId)),
|
||||
issueId,
|
||||
);
|
||||
|
||||
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
})) {
|
||||
collectParentFromListCache(parentIssueIds, data, issueId);
|
||||
}
|
||||
|
||||
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
})) {
|
||||
const child = data?.find((issue) => issue.id === issueId);
|
||||
if (!child) continue;
|
||||
collectParentId(parentIssueIds, child.parent_issue_id);
|
||||
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
|
||||
}
|
||||
|
||||
return { parentIssueIds: Array.from(parentIssueIds) };
|
||||
}
|
||||
|
||||
export function pruneDeletedIssueFromListCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
|
||||
for (const [key] of qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
})) {
|
||||
qc.setQueryData<ListIssuesCache>(key, (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneDeletedIssueFromParentChildrenCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata: DeletedIssueCacheMetadata,
|
||||
) {
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((issue) => issue.id !== issueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueParentCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
metadata: DeletedIssueCacheMetadata,
|
||||
) {
|
||||
if (metadata.parentIssueIds.length === 0) return;
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueDependentCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateIssueScopedCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
}
|
||||
|
||||
export function cleanupDeletedIssueCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
|
||||
) {
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
|
||||
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
|
||||
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
}
|
||||
@@ -11,17 +11,9 @@ 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 { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
@@ -200,13 +192,6 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(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({
|
||||
@@ -232,56 +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) });
|
||||
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) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -356,92 +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) });
|
||||
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) });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -503,8 +421,8 @@ export function useCreateComment(issueId: string) {
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
|
||||
api.updateComment(commentId, content, attachmentIds),
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
type IssueViewState,
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
mergeViewStatePersisted,
|
||||
} from "./view-store";
|
||||
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
|
||||
export type ActorIssuesScope = "assigned" | "created";
|
||||
|
||||
export interface ActorIssuesViewState extends IssueViewState {
|
||||
scope: ActorIssuesScope;
|
||||
setScope: (scope: ActorIssuesScope) => void;
|
||||
}
|
||||
|
||||
const basePersist = viewStorePersistOptions("multica_actor_issues_view");
|
||||
|
||||
const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
|
||||
scope: "assigned" as ActorIssuesScope,
|
||||
setScope: (scope: ActorIssuesScope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: basePersist.name,
|
||||
storage: basePersist.storage,
|
||||
partialize: (state: ActorIssuesViewState) => ({
|
||||
...basePersist.partialize(state),
|
||||
scope: state.scope,
|
||||
}),
|
||||
merge: mergeViewStatePersisted<ActorIssuesViewState>,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const actorIssuesViewStore: StoreApi<ActorIssuesViewState> =
|
||||
_actorIssuesViewStore;
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
_actorIssuesViewStore.persist.rehydrate(),
|
||||
);
|
||||
@@ -1,44 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
openCreateIssueWithPreference,
|
||||
useCreateModeStore,
|
||||
} from "./create-mode-store";
|
||||
import { useModalStore } from "../../modals";
|
||||
|
||||
describe("openCreateIssueWithPreference", () => {
|
||||
const initialMode = useCreateModeStore.getState().lastMode;
|
||||
|
||||
beforeEach(() => {
|
||||
useModalStore.getState().close();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
useCreateModeStore.getState().setLastMode(initialMode);
|
||||
useModalStore.getState().close();
|
||||
});
|
||||
|
||||
it("opens quick-create-issue when last mode is agent", () => {
|
||||
useCreateModeStore.getState().setLastMode("agent");
|
||||
openCreateIssueWithPreference();
|
||||
expect(useModalStore.getState().modal).toBe("quick-create-issue");
|
||||
expect(useModalStore.getState().data).toBeNull();
|
||||
});
|
||||
|
||||
it("opens create-issue when last mode is manual", () => {
|
||||
useCreateModeStore.getState().setLastMode("manual");
|
||||
openCreateIssueWithPreference();
|
||||
expect(useModalStore.getState().modal).toBe("create-issue");
|
||||
});
|
||||
|
||||
it("forwards seed data to whichever modal is opened", () => {
|
||||
useCreateModeStore.getState().setLastMode("manual");
|
||||
openCreateIssueWithPreference({ project_id: "p1" });
|
||||
expect(useModalStore.getState().modal).toBe("create-issue");
|
||||
expect(useModalStore.getState().data).toEqual({ project_id: "p1" });
|
||||
|
||||
useCreateModeStore.getState().setLastMode("agent");
|
||||
openCreateIssueWithPreference({ project_id: "p2" });
|
||||
expect(useModalStore.getState().modal).toBe("quick-create-issue");
|
||||
expect(useModalStore.getState().data).toEqual({ project_id: "p2" });
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
import { useModalStore } from "../../modals";
|
||||
|
||||
/**
|
||||
* Last create-issue mode the user landed on. Drives the global `c` shortcut
|
||||
@@ -35,18 +34,3 @@ export const useCreateModeStore = create<CreateModeState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Open the create-issue flow in whichever mode the user landed on last.
|
||||
* Generic entry points (sidebar button, command palette, `c` shortcut) call
|
||||
* this so the persisted preference actually takes effect; entry points that
|
||||
* pre-seed manual-only fields (status, parent_issue_id) keep opening
|
||||
* "create-issue" directly because agent mode can't honour those seeds.
|
||||
*/
|
||||
export function openCreateIssueWithPreference(
|
||||
data?: Record<string, unknown> | null,
|
||||
) {
|
||||
const lastMode = useCreateModeStore.getState().lastMode;
|
||||
const modal = lastMode === "manual" ? "create-issue" : "quick-create-issue";
|
||||
useModalStore.getState().open(modal, data ?? null);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export {
|
||||
useCreateModeStore,
|
||||
openCreateIssueWithPreference,
|
||||
type CreateMode,
|
||||
} from "./create-mode-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export {
|
||||
useRecentIssuesStore,
|
||||
@@ -23,11 +19,6 @@ export {
|
||||
type MyIssuesViewState,
|
||||
type MyIssuesScope,
|
||||
} from "./my-issues-view-store";
|
||||
export {
|
||||
actorIssuesViewStore,
|
||||
type ActorIssuesViewState,
|
||||
type ActorIssuesScope,
|
||||
} from "./actor-issues-view-store";
|
||||
export {
|
||||
useIssueViewStore,
|
||||
createIssueViewStore,
|
||||
|
||||
@@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useQuickCreateStore } from "./quick-create-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
lastActorType: null,
|
||||
lastActorId: null,
|
||||
lastAgentId: null,
|
||||
lastProjectId: null,
|
||||
prompt: "",
|
||||
keepOpen: false,
|
||||
@@ -35,20 +34,4 @@ describe("quick create store", () => {
|
||||
setLastProjectId(null);
|
||||
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers the last actor (agent or squad) and clears both fields together", () => {
|
||||
const { setLastActor } = useQuickCreateStore.getState();
|
||||
|
||||
setLastActor("agent", "agent-1");
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBe("agent");
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBe("agent-1");
|
||||
|
||||
setLastActor("squad", "squad-1");
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBe("squad");
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBe("squad-1");
|
||||
|
||||
setLastActor(null, null);
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBeNull();
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,26 +5,17 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type QuickCreateActorType = "agent" | "squad";
|
||||
|
||||
// Per-workspace memory of the last actor (agent or squad) and project the
|
||||
// user picked in the Quick Create modal. Defaulted to those values on next
|
||||
// open so frequent users skip the pickers entirely — without this, anyone
|
||||
// targeting a single project ends up retyping "in project A" on every
|
||||
// prompt. Persisted with the workspace-aware StateStorage so switching
|
||||
// workspaces shows the right default automatically. Per-user scoping comes
|
||||
// for free from localStorage being browser-profile-local — matches how
|
||||
// draft-store / issues-scope-store / comment-collapse-store already
|
||||
// namespace themselves.
|
||||
//
|
||||
// lastActorType + lastActorId replace the prior `lastAgentId` field once
|
||||
// squads became selectable. Users who had a persisted agent preference
|
||||
// land back on whatever the picker shows first; a one-time re-pick is
|
||||
// preferable to the type-tag ambiguity of overloading a single UUID.
|
||||
// Per-workspace memory of the last agent and project the user picked in the
|
||||
// Quick Create modal. Defaulted to those values on next open so frequent
|
||||
// users skip the pickers entirely — without this, anyone targeting a single
|
||||
// project ends up retyping "in project A" on every prompt. Persisted with
|
||||
// the workspace-aware StateStorage so switching workspaces shows the right
|
||||
// default automatically. Per-user scoping comes for free from localStorage
|
||||
// being browser-profile-local — matches how draft-store /
|
||||
// issues-scope-store / comment-collapse-store already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastActorType: QuickCreateActorType | null;
|
||||
lastActorId: string | null;
|
||||
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
lastProjectId: string | null;
|
||||
setLastProjectId: (id: string | null) => void;
|
||||
prompt: string;
|
||||
@@ -37,9 +28,8 @@ interface QuickCreateState {
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastActorType: null,
|
||||
lastActorId: null,
|
||||
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
lastProjectId: null,
|
||||
setLastProjectId: (id) => set({ lastProjectId: id }),
|
||||
prompt: "",
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface CardProperties {
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
type: "member" | "agent" | "squad";
|
||||
type: "member" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -107,5 +107,21 @@ export function onIssueDeleted(
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
cleanupDeletedIssueCaches(qc, wsId, issueId);
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
if (deleted?.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ type ModalType =
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "create-squad"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
| "issue-add-child"
|
||||
|
||||
@@ -54,9 +54,6 @@
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./agents/visibility-label": "./agents/visibility-label.ts",
|
||||
"./agents/stores": "./agents/stores/index.ts",
|
||||
"./squads": "./squads/index.ts",
|
||||
"./squads/stores": "./squads/stores/index.ts",
|
||||
"./permissions": "./permissions/index.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
|
||||
@@ -22,12 +22,10 @@ describe("paths.workspace() shape", () => {
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"squads",
|
||||
"inbox",
|
||||
"myIssues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"squads",
|
||||
"settings",
|
||||
]),
|
||||
);
|
||||
@@ -43,12 +41,10 @@ describe("paths.workspace() shape", () => {
|
||||
["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>;
|
||||
|
||||
@@ -13,14 +13,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");
|
||||
});
|
||||
|
||||
|
||||
@@ -27,9 +27,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`,
|
||||
|
||||
@@ -79,7 +79,6 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"squads",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"usage",
|
||||
|
||||
@@ -171,14 +171,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
|
||||
@@ -267,7 +259,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
|
||||
@@ -733,33 +724,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
|
||||
@@ -820,7 +784,6 @@ export function useRealtimeSync(
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
unsubChatSessionDeleted();
|
||||
unsubChatSessionUpdated();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./stores";
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
useSquadsViewStore,
|
||||
type SquadsScope,
|
||||
type SquadsViewState,
|
||||
} from "./view-store";
|
||||
@@ -1,96 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSquadsViewStore } 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();
|
||||
useSquadsViewStore.setState({ scope: "mine" });
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("useSquadsViewStore", () => {
|
||||
it("defaults to 'mine'", () => {
|
||||
expect(useSquadsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("setScope mutates the store", () => {
|
||||
useSquadsViewStore.getState().setScope("all");
|
||||
expect(useSquadsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useSquadsViewStore.getState().setScope("all");
|
||||
|
||||
const raw = localStorage.getItem("multica_squads_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_squads_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"multica_squads_view:beta",
|
||||
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_squads_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_squads_view:acme")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type SquadsScope = "mine" | "all";
|
||||
|
||||
export interface SquadsViewState {
|
||||
scope: SquadsScope;
|
||||
setScope: (scope: SquadsScope) => void;
|
||||
}
|
||||
|
||||
export const useSquadsViewStore = create<SquadsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: "multica_squads_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<SquadsViewState>) };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useSquadsViewStore.persist.rehydrate());
|
||||
@@ -27,10 +27,6 @@ export interface UpdateIssueRequest {
|
||||
due_date?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
project_id?: string | null;
|
||||
/** Attachment IDs to bind to this issue alongside the description update.
|
||||
* Used by the description editor to register newly uploaded files so they
|
||||
* surface in `issueAttachments` and keep their preview Eye on refresh. */
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
|
||||
export interface ListIssuesParams {
|
||||
|
||||
@@ -54,13 +54,9 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "chat:session_deleted"
|
||||
| "chat:session_updated"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
| "squad:created"
|
||||
| "squad:updated"
|
||||
| "squad:deleted"
|
||||
| "label:created"
|
||||
| "label:updated"
|
||||
| "label:deleted"
|
||||
|
||||
@@ -100,16 +100,3 @@ export type {
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
} from "./autopilot";
|
||||
export type {
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberType,
|
||||
SquadActivityLog,
|
||||
SquadActivityOutcome,
|
||||
CreateSquadRequest,
|
||||
UpdateSquadRequest,
|
||||
AddSquadMemberRequest,
|
||||
RemoveSquadMemberRequest,
|
||||
UpdateSquadMemberRoleRequest,
|
||||
CreateSquadActivityLogRequest,
|
||||
} from "./squad";
|
||||
|
||||
@@ -11,7 +11,7 @@ export type IssueStatus =
|
||||
|
||||
export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none";
|
||||
|
||||
export type IssueAssigneeType = "member" | "agent" | "squad";
|
||||
export type IssueAssigneeType = "member" | "agent";
|
||||
|
||||
export interface IssueReaction {
|
||||
id: string;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
export type SquadMemberType = "agent" | "member";
|
||||
|
||||
export type SquadActivityOutcome = "action" | "no_action" | "failed";
|
||||
|
||||
export interface Squad {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
avatar_url: string | null;
|
||||
leader_id: string;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
export interface SquadMember {
|
||||
id: string;
|
||||
squad_id: string;
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SquadActivityLog {
|
||||
id: string;
|
||||
squad_id: string;
|
||||
issue_id: string;
|
||||
trigger_comment_id: string | null;
|
||||
leader_id: string;
|
||||
outcome: SquadActivityOutcome;
|
||||
details: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateSquadRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
leader_id: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSquadRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
leader_id?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface AddSquadMemberRequest {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface RemoveSquadMemberRequest {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
}
|
||||
|
||||
export interface UpdateSquadMemberRoleRequest {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface CreateSquadActivityLogRequest {
|
||||
squad_id: string;
|
||||
issue_id: string;
|
||||
trigger_comment_id?: string;
|
||||
outcome: SquadActivityOutcome;
|
||||
details?: unknown;
|
||||
}
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
|
||||
import { memberListOptions, agentListOptions } from "./queries";
|
||||
|
||||
export function useActorName() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
|
||||
const getMemberName = (userId: string) => {
|
||||
const m = members.find((m) => m.user_id === userId);
|
||||
@@ -20,15 +19,9 @@ export function useActorName() {
|
||||
return a?.name ?? "Unknown Agent";
|
||||
};
|
||||
|
||||
const getSquadName = (squadId: string) => {
|
||||
const s = squads.find((s) => s.id === squadId);
|
||||
return s?.name ?? "Unknown Squad";
|
||||
};
|
||||
|
||||
const getActorName = (type: string, id: string) => {
|
||||
if (type === "member") return getMemberName(id);
|
||||
if (type === "agent") return getAgentName(id);
|
||||
if (type === "squad") return getSquadName(id);
|
||||
if (type === "system") return "Multica";
|
||||
return "System";
|
||||
};
|
||||
@@ -46,9 +39,8 @@ export function useActorName() {
|
||||
const getActorAvatarUrl = (type: string, id: string): string | null => {
|
||||
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
|
||||
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
|
||||
if (type === "squad") return squads.find((s) => s.id === id)?.avatar_url ?? null;
|
||||
return null;
|
||||
};
|
||||
|
||||
return { getMemberName, getAgentName, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { Agent, Squad, Workspace } from "../types";
|
||||
import type { Agent, Workspace } from "../types";
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||
@@ -9,7 +9,6 @@ export const workspaceKeys = {
|
||||
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
|
||||
myInvitations: () => ["invitations", "mine"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
||||
};
|
||||
@@ -44,14 +43,6 @@ export function agentListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function squadListOptions(wsId: string) {
|
||||
return queryOptions<Squad[]>({
|
||||
queryKey: workspaceKeys.squads(wsId),
|
||||
queryFn: () => api.listSquads(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
}
|
||||
|
||||
export function skillListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.skills(wsId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot, Users } from "lucide-react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
@@ -11,7 +11,6 @@ interface ActorAvatarProps {
|
||||
avatarUrl?: string | null;
|
||||
isAgent?: boolean;
|
||||
isSystem?: boolean;
|
||||
isSquad?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
@@ -22,12 +21,12 @@ function ActorAvatar({
|
||||
avatarUrl,
|
||||
isAgent,
|
||||
isSystem,
|
||||
isSquad,
|
||||
size = 20,
|
||||
className,
|
||||
}: ActorAvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Reset error state when URL changes (e.g. user uploads new avatar)
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
}, [avatarUrl]);
|
||||
@@ -36,10 +35,7 @@ function ActorAvatar({
|
||||
<div
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center font-medium overflow-hidden",
|
||||
// Squads (a group, non-human) get a square tile so they don't read as
|
||||
// a single person; everyone else stays round.
|
||||
isSquad ? "rounded-md" : "rounded-full",
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
|
||||
"bg-muted text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
@@ -57,8 +53,6 @@ function ActorAvatar({
|
||||
<MulticaIcon noSpin style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : isAgent ? (
|
||||
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : isSquad ? (
|
||||
<Users style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
@@ -19,9 +18,7 @@ function FileUploadButton({
|
||||
className,
|
||||
size = "default",
|
||||
}: FileUploadButtonProps) {
|
||||
const { t } = useTranslation("ui");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const attachLabel = t(($) => $.attach_file);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -39,8 +36,8 @@ function FileUploadButton({
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label={attachLabel}
|
||||
title={attachLabel}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
btnSize,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
@@ -68,10 +67,9 @@ function PaginationPrevious({
|
||||
text = "Previous",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
const { t } = useTranslation("ui")
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={t(($) => $.pagination_previous)}
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("pl-1.5!", className)}
|
||||
{...props}
|
||||
@@ -87,10 +85,9 @@ function PaginationNext({
|
||||
text = "Next",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
const { t } = useTranslation("ui")
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label={t(($) => $.pagination_next)}
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("pr-1.5!", className)}
|
||||
{...props}
|
||||
|
||||
@@ -4,7 +4,6 @@ import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
@@ -266,7 +265,6 @@ function SidebarTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { t } = useTranslation("ui")
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -282,15 +280,13 @@ function SidebarTrigger({
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">{t(($) => $.toggle_sidebar)}</span>
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar, setWidth, setIsResizing } = useSidebar()
|
||||
const { t } = useTranslation("ui")
|
||||
const toggleLabel = t(($) => $.toggle_sidebar)
|
||||
const didDragRef = React.useRef(false)
|
||||
const dragRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
|
||||
@@ -334,11 +330,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label={toggleLabel}
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
onMouseDown={onMouseDown}
|
||||
title={toggleLabel}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",
|
||||
|
||||
@@ -43,19 +43,11 @@ export function useScrollFade(
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
// ResizeObserver only fires on the container's own box. When children
|
||||
// grow inside a flex/auto-height parent (e.g. async-loaded list items,
|
||||
// collapsibles), scrollHeight changes but clientHeight does not — the
|
||||
// mask would stay "none" until the user scrolls. MutationObserver on
|
||||
// childList catches those content insertions.
|
||||
const mo = new MutationObserver(update);
|
||||
mo.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [ref, update]);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from 'react'
|
||||
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
@@ -62,7 +61,6 @@ export function CodeBlock({
|
||||
className,
|
||||
mode = 'full'
|
||||
}: CodeBlockProps): React.JSX.Element {
|
||||
const { t } = useTranslation("ui")
|
||||
const [highlighted, setHighlighted] = React.useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
@@ -180,7 +178,7 @@ export function CodeBlock({
|
||||
{/* Language label + copy button */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
|
||||
<span className="text-muted-foreground font-medium uppercase tracking-wide">
|
||||
{resolvedLang !== 'text' ? resolvedLang : t(($) => $.plain_text)}
|
||||
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -190,7 +188,7 @@ export function CodeBlock({
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
||||
aria-label={t(($) => $.copy_code)}
|
||||
aria-label="Copy code"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-success" />
|
||||
@@ -200,7 +198,7 @@ export function CodeBlock({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{t(($) => $.copy_code)}</TooltipContent>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import remarkMath from 'remark-math'
|
||||
import { FileText, Download } from 'lucide-react'
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
import { CodeBlock, InlineCode } from './CodeBlock'
|
||||
import { isAllowedFileCardHref, preprocessFileCards } from './file-cards'
|
||||
import { preprocessFileCards } from './file-cards'
|
||||
import { preprocessLinks } from './linkify'
|
||||
import { preprocessMentionShortcodes } from './mentions'
|
||||
import 'katex/dist/katex.min.css'
|
||||
@@ -120,7 +120,8 @@ function createComponents(
|
||||
const dataType = node?.properties?.dataType as string | undefined
|
||||
if (dataType === 'fileCard') {
|
||||
const rawHref = (node?.properties?.dataHref as string) || ''
|
||||
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
|
||||
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
|
||||
const href = /^https?:\/\//i.test(rawHref) ? rawHref : ''
|
||||
const filename = (node?.properties?.dataFilename as string) || ''
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
||||
|
||||
@@ -15,28 +15,8 @@
|
||||
|
||||
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
|
||||
|
||||
/**
|
||||
* URL alternation accepted inside `!file[name](url)` markdown.
|
||||
*
|
||||
* Restricted to:
|
||||
* - `/uploads/...` site-relative paths (LocalStorage backend with no LOCAL_UPLOAD_BASE_URL)
|
||||
* - `http(s)://...` absolute URLs (S3 / CloudFront / hosted)
|
||||
*
|
||||
* Anything else — `javascript:`, `data:`, protocol-relative `//host/x`, other
|
||||
* APIs `/api/…`, path-traversal `/../…` — is rejected so a stored file-card
|
||||
* cannot be turned into an out-of-band navigation.
|
||||
*/
|
||||
export const FILE_CARD_URL_PATTERN = /\/uploads\/[^)]*|https?:\/\/[^)]+/
|
||||
|
||||
/** Prefix test applied by renderers to validate `data-href` before opening it. */
|
||||
export function isAllowedFileCardHref(href: string): boolean {
|
||||
return /^(https?:\/\/|\/uploads\/)/i.test(href)
|
||||
}
|
||||
|
||||
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
|
||||
const NEW_FILE_CARD_RE = new RegExp(
|
||||
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
|
||||
)
|
||||
const NEW_FILE_CARD_RE = /^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)$/
|
||||
|
||||
/** Legacy syntax: [name](cdnUrl) on its own line — matched by CDN hostname. */
|
||||
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/
|
||||
|
||||
@@ -3,10 +3,4 @@ export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
|
||||
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
|
||||
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
|
||||
export { preprocessMentionShortcodes } from './mentions'
|
||||
export {
|
||||
preprocessFileCards,
|
||||
isCdnUrl,
|
||||
isFileCardUrl,
|
||||
isAllowedFileCardHref,
|
||||
FILE_CARD_URL_PATTERN,
|
||||
} from './file-cards'
|
||||
export { preprocessFileCards, isCdnUrl, isFileCardUrl } from './file-cards'
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"./hooks/*": "./hooks/*.ts",
|
||||
"./lib/utils": "./lib/utils.ts",
|
||||
"./lib/data-table": "./lib/data-table.ts",
|
||||
"./i18n-types": "./types/i18next.ts",
|
||||
"./styles/tokens.css": "./styles/tokens.css",
|
||||
"./styles/base.css": "./styles/base.css"
|
||||
},
|
||||
@@ -53,10 +52,8 @@
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-i18next": "catalog:"
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import "i18next";
|
||||
|
||||
// Local slice of the i18next augmentation that owns the `ui` namespace.
|
||||
// The base augmentation lives in packages/views/i18n/resources-types.ts and
|
||||
// declares everything else; this file contributes only the `ui` entry via
|
||||
// declaration merging on the global `I18nResources` interface so
|
||||
// packages/ui can typecheck the selector form standalone without depending
|
||||
// on @multica/views.
|
||||
//
|
||||
// When both files are loaded together (in a consumer's typecheck program),
|
||||
// the two augmentations compose: views contributes common/auth/... and ui
|
||||
// contributes `ui`. No properties overlap, so the merge is conflict-free.
|
||||
//
|
||||
// The resource shape is mirrored from packages/views/locales/{en,zh-Hans}/ui.json.
|
||||
// Drift between the JSON and these types is not caught by the locale parity
|
||||
// test — if you add a key to ui.json, mirror it here.
|
||||
declare global {
|
||||
interface I18nResources {
|
||||
ui: {
|
||||
attach_file: string;
|
||||
toggle_sidebar: string;
|
||||
pagination_previous: string;
|
||||
pagination_next: string;
|
||||
copy_code: string;
|
||||
plain_text: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
resources: I18nResources;
|
||||
enableSelector: true;
|
||||
}
|
||||
}
|
||||