mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 20:58:56 +02:00
Compare commits
88 Commits
pr-2632
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2d8ef97f2 | ||
|
|
e0a6a39a47 | ||
|
|
6f5fbb7813 | ||
|
|
baedc48f59 | ||
|
|
933f417dac | ||
|
|
e6cf5a6eca | ||
|
|
d9ae891064 | ||
|
|
ffba2607aa | ||
|
|
b97cc3cb6e | ||
|
|
b58ab2cc48 | ||
|
|
eabfb8f3d1 | ||
|
|
e8d4b9a0a2 | ||
|
|
fe1ccb19c9 | ||
|
|
5f1ced867c | ||
|
|
4d8b6ddb84 | ||
|
|
692570f41a | ||
|
|
84d75cdd1e | ||
|
|
fab0671332 | ||
|
|
46c1e2c889 | ||
|
|
c78bfbcf17 | ||
|
|
1796ef6dff | ||
|
|
ceb967aefa | ||
|
|
d04b00b32e | ||
|
|
a4a18605eb | ||
|
|
dfe2a57361 | ||
|
|
6621231237 | ||
|
|
433cd1aaf5 | ||
|
|
8cc48b1176 | ||
|
|
2d501322e9 | ||
|
|
60bae62622 | ||
|
|
c328c402d8 | ||
|
|
2323b72710 | ||
|
|
20c2f45b4a | ||
|
|
15152c6ccd | ||
|
|
eb5c6d7547 | ||
|
|
e50bfc88da | ||
|
|
e8fb0efe3d | ||
|
|
d42fbcb794 | ||
|
|
79dd066363 | ||
|
|
58a76f6d96 | ||
|
|
9418d2a2c1 | ||
|
|
7c3dab695f | ||
|
|
f1c9617b5e | ||
|
|
113c4f4e90 | ||
|
|
44d2fc1946 | ||
|
|
3645bdb5b6 | ||
|
|
668cab6022 | ||
|
|
431006e7d6 | ||
|
|
9bd17058f8 | ||
|
|
e00b94b0f9 | ||
|
|
4c7a990a25 | ||
|
|
380c6b5122 | ||
|
|
0079a73430 | ||
|
|
3698fd85d5 | ||
|
|
d43961ed7a | ||
|
|
bfe9bf3eea | ||
|
|
8e88156356 | ||
|
|
d8635ad580 | ||
|
|
fcd13aece9 | ||
|
|
57be69517f | ||
|
|
f64d182fd1 | ||
|
|
2d21f5258d | ||
|
|
5ad1641b72 | ||
|
|
1cb926d52d | ||
|
|
2980ead4c7 | ||
|
|
e8d6c912c4 | ||
|
|
319b23eb39 | ||
|
|
b7a58c06ac | ||
|
|
bb32be0e50 | ||
|
|
3137feecdf | ||
|
|
461be83970 | ||
|
|
a23856bae3 | ||
|
|
75dc70686b | ||
|
|
9b6b8f5877 | ||
|
|
7c8cf929d1 | ||
|
|
35e9a7f0f6 | ||
|
|
4c1fd60215 | ||
|
|
2f0e5b589e | ||
|
|
e6e9a9f77d | ||
|
|
f29bd93444 | ||
|
|
2acc454ea5 | ||
|
|
25182995c6 | ||
|
|
8d872b7521 | ||
|
|
968ef1ca84 | ||
|
|
833032ed9c | ||
|
|
e7db644563 | ||
|
|
da7b33561e | ||
|
|
cc3a510952 |
63
.env.example
63
.env.example
@@ -29,6 +29,22 @@ PORT=8080
|
||||
JWT_SECRET=change-me-in-production
|
||||
MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
MULTICA_APP_URL=http://localhost:3000
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers. Leave unset behind a same-origin reverse proxy or for plain
|
||||
# localhost dev — the frontend will compose the URL from
|
||||
# window.origin + webhook_path in that case. Headers are intentionally
|
||||
# not used to derive this value, to avoid Host / X-Forwarded-Host
|
||||
# spoofing when a self-hosted reverse proxy is not hardened.
|
||||
MULTICA_PUBLIC_URL=
|
||||
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
|
||||
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
|
||||
# Empty (the default) means "trust no headers" — the limiter uses
|
||||
# r.RemoteAddr only, which is the safe shape when the backend is
|
||||
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
|
||||
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
|
||||
# announced ranges for cloud deployments.
|
||||
MULTICA_TRUSTED_PROXIES=
|
||||
MULTICA_DAEMON_CONFIG=
|
||||
MULTICA_WORKSPACE_ID=
|
||||
MULTICA_DAEMON_ID=
|
||||
@@ -48,11 +64,26 @@ MULTICA_IMAGE_TAG=latest
|
||||
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
|
||||
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
|
||||
|
||||
# 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.
|
||||
# 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).
|
||||
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
|
||||
@@ -88,8 +119,30 @@ LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
# Security
|
||||
# Comma-separated list of allowed origins for CORS and WebSocket connections.
|
||||
# Defaults to localhost dev origins when unset.
|
||||
# Example: ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
ALLOWED_ORIGINS=
|
||||
# Example: CORS_ALLOWED_ORIGINS=https://app.multica.ai,https://staging.multica.ai
|
||||
CORS_ALLOWED_ORIGINS=
|
||||
|
||||
# ==================== Rate limiting (optional Redis) ====================
|
||||
# Per-IP fixed-window rate limiter on the public auth endpoints
|
||||
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
|
||||
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
|
||||
# backend logs "rate limiting disabled: REDIS_URL not configured" at
|
||||
# startup. The same REDIS_URL is reused by the realtime fan-out hub,
|
||||
# the PAT cache, and the daemon-token cache.
|
||||
# REDIS_URL=redis://localhost:6379/0
|
||||
# Max requests per IP per minute. Defaults are 5 for send-code/google
|
||||
# and 20 for verify-code.
|
||||
# RATE_LIMIT_AUTH=5
|
||||
# RATE_LIMIT_AUTH_VERIFY=20
|
||||
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
|
||||
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
|
||||
# REQUIRED behind a reverse proxy — otherwise every real user shares
|
||||
# the proxy IP and the whole deployment lands in one bucket, turning
|
||||
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
|
||||
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
|
||||
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
|
||||
# governs the autopilot webhook limiter).
|
||||
# RATE_LIMIT_TRUSTED_PROXIES=
|
||||
|
||||
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
|
||||
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
|
||||
|
||||
@@ -25,14 +25,30 @@ These have sensible defaults and only need to be set when tuning a large or cons
|
||||
|
||||
### Email (Required for Authentication)
|
||||
|
||||
Multica uses email-based magic link authentication via [Resend](https://resend.com).
|
||||
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)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `RESEND_API_KEY` | Your Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | Sender email address (default: `noreply@multica.ai`) |
|
||||
|
||||
> **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`.
|
||||
#### 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.**
|
||||
|
||||
### Google OAuth (Optional)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
@@ -189,19 +190,13 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, mainWindow!.webContents)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
152
apps/desktop/src/main/keyboard-shortcuts.test.ts
Normal file
152
apps/desktop/src/main/keyboard-shortcuts.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
|
||||
|
||||
function makeWc(initialLevel = 0) {
|
||||
let level = initialLevel;
|
||||
return {
|
||||
getZoomLevel: vi.fn(() => level),
|
||||
setZoomLevel: vi.fn((next: number) => {
|
||||
level = next;
|
||||
}),
|
||||
currentLevel: () => level,
|
||||
};
|
||||
}
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleAppShortcut — reload blocking", () => {
|
||||
it("swallows Cmd+R on macOS", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows Ctrl+R on Linux/Windows", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows F5 regardless of modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-keyDown events", () => {
|
||||
const wc = makeWc();
|
||||
expect(
|
||||
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom in", () => {
|
||||
it("zooms in on Cmd+= (unshifted)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Cmd++ (Shift+=)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Ctrl+= on non-mac", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps zoom-in at the upper bound", () => {
|
||||
const wc = makeWc(4.5);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
|
||||
it("zooms out on Cmd+- (unshifted)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Cmd+_ (Shift+-)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Ctrl+- on non-mac", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("undoes a prior Cmd+= so the user can return to 100%", () => {
|
||||
const wc = makeWc(0);
|
||||
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps zoom-out at the lower bound", () => {
|
||||
const wc = makeWc(-3);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(-3);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — reset zoom", () => {
|
||||
it("resets to 0 on Cmd+0", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("resets to 0 on Ctrl+0", () => {
|
||||
const wc = makeWc(-1.5);
|
||||
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores plain 0 without modifier", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
it("does not capture plain letters", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
74
apps/desktop/src/main/keyboard-shortcuts.ts
Normal file
74
apps/desktop/src/main/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
// Shape of the input subset we read from Electron's `before-input-event`.
|
||||
// Modeled as a structural type so the handler is unit-testable without a
|
||||
// real Electron Input instance.
|
||||
export type ShortcutInput = {
|
||||
type: string;
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
|
||||
|
||||
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
|
||||
// per step). Clamp to a range that keeps the UI legible — values outside
|
||||
// this band turn the workspace into either confetti or a microfiche.
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 4.5;
|
||||
|
||||
/**
|
||||
* Inspect a `before-input-event` key and apply (or block) the matching
|
||||
* window-level shortcut. Returns `true` when the caller should call
|
||||
* `event.preventDefault()` — that both swallows the renderer keydown and
|
||||
* prevents the application menu accelerator from firing, so we don't
|
||||
* double-trigger zoom on macOS where the default menu also binds these
|
||||
* keys.
|
||||
*
|
||||
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
|
||||
* default `Cmd+-` accelerator does not fire reliably across keyboard
|
||||
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
|
||||
// (tabs, drafts, WS connections) with no URL bar to recover from.
|
||||
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!cmdOrCtrl) return false;
|
||||
|
||||
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
|
||||
if (input.key === "=" || input.key === "+") {
|
||||
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
|
||||
if (input.key === "-" || input.key === "_") {
|
||||
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + 0 → reset zoom to 100%.
|
||||
if (input.key === "0") {
|
||||
webContents.setZoomLevel(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
@@ -12,15 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,24 +23,13 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
* Desktop-only controls for the daemon embedded in this Electron app. The
|
||||
* shared runtimes page renders this inside the selected local machine header.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
export function DaemonRuntimeActions() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -57,14 +37,8 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
@@ -76,10 +50,6 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
@@ -108,9 +78,6 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
@@ -119,8 +86,6 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
@@ -136,9 +101,6 @@ export function DaemonRuntimeCard() {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
@@ -162,106 +124,64 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,9 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export function AttachmentPreviewRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const filename = searchParams.get("name") ?? undefined;
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
18
apps/desktop/src/renderer/src/pages/member-detail-page.tsx
Normal file
18
apps/desktop/src/renderer/src/pages/member-detail-page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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,9 @@ 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 { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { DashboardPage } from "@multica/views/dashboard";
|
||||
@@ -147,6 +149,11 @@ 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",
|
||||
@@ -154,6 +161,11 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "attachments/:id/preview",
|
||||
element: <AttachmentPreviewRoute />,
|
||||
handle: { title: "Attachment" },
|
||||
},
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
|
||||
@@ -12,9 +12,11 @@ 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. It requires [Resend](https://resend.com/) as the email provider:
|
||||
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:
|
||||
|
||||
1. Create a Resend account and verify your domain
|
||||
### Option A: Resend (recommended for cloud / public-internet deployments)
|
||||
|
||||
1. Create a [Resend](https://resend.com/) account and verify your domain
|
||||
2. Create an API key
|
||||
3. Set the environment variables:
|
||||
|
||||
@@ -25,7 +27,22 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
4. Restart the server
|
||||
|
||||
**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.
|
||||
### 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.
|
||||
|
||||
## Fixed local testing codes
|
||||
|
||||
@@ -34,7 +51,7 @@ The user enters an email on the sign-in page → the server sends a 6-digit code
|
||||
|
||||
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 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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -12,9 +12,11 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
## Email + 验证码登录怎么工作
|
||||
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。需要 [Resend](https://resend.com/) 作为邮件发送服务:
|
||||
用户在登录页输邮箱 → server 发 6 位验证码 → 用户填回 → server 验证 → 签发 JWT cookie。是标准流程。支持两种邮件发送通道,按部署环境二选一:
|
||||
|
||||
1. 在 Resend 建账号、验证你的域名
|
||||
### Option A:Resend(公网/云端部署推荐)
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 建账号、验证你的域名
|
||||
2. 创建 API key
|
||||
3. 设环境变量:
|
||||
|
||||
@@ -25,7 +27,22 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
4. 重启 server
|
||||
|
||||
**不配 `RESEND_API_KEY` 的后果**:server 不报错,但**所有本该发出去的邮件只打到 server 的 stdout**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
### 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**。本地开发方便(你从日志抄验证码),生产环境等于黑洞。
|
||||
|
||||
## 固定本地测试验证码
|
||||
|
||||
@@ -34,7 +51,7 @@ Multica 支持两种登录方式:**Email + 验证码**(默认)和 **Google
|
||||
|
||||
旧版「非 production 默认接受 `888888`」的行为已经移除。除非你显式配置,否则输入 `888888` 会和普通错误验证码一样被拒绝。
|
||||
|
||||
不配 Resend 的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
没配任何邮件后端(Resend 和 SMTP 都没设)的本地开发,应使用 server 日志里打印的随机验证码。如果你需要确定性的本地/私有自动化测试,可以把 `MULTICA_DEV_VERIFICATION_CODE` 设成一个 6 位数字,比如 `888888`,并保持 `APP_ENV` 为非 production:
|
||||
|
||||
```bash
|
||||
APP_ENV=development
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: Let agents start work on a cron schedule — or trigger once manually via the UI or CLI.
|
||||
description: Let agents start work on a cron schedule, an inbound webhook, or trigger once manually via the UI or CLI.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,13 +16,13 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
## Run it on a schedule
|
||||
@@ -50,15 +50,109 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
A manual trigger goes through the exact same execution flow as a `schedule` trigger — only the `source` field on the run record is marked `manual`.
|
||||
|
||||
## Trigger from a webhook
|
||||
|
||||
Autopilots can also fire on inbound HTTP webhooks. Add a **Webhook** trigger
|
||||
on the autopilot detail page; Multica generates a unique URL of the shape:
|
||||
|
||||
```
|
||||
https://<your-multica-host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
POST any JSON to that URL — Multica records a run with `source = webhook`,
|
||||
stores the body as the run's `trigger_payload`, and dispatches the agent
|
||||
exactly the way a schedule trigger would.
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
In **create issue mode**, the inbound payload is appended to the new issue's
|
||||
description so the agent can read it inline. In **run-only mode**, the
|
||||
payload is part of the run context the daemon hands the agent.
|
||||
|
||||
### Payload shape
|
||||
|
||||
You can send your own envelope:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
…or any JSON object/array. Multica normalizes it into an internal envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<inferred>",
|
||||
"eventPayload": <your body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
When you don't provide an `event` field, Multica infers it from common
|
||||
headers and body fields (`X-GitHub-Event` + body `action`,
|
||||
`X-Gitlab-Event`, `X-Event-Type`, body `event`/`type`/`action`). When
|
||||
nothing matches, the event is `webhook.received`.
|
||||
|
||||
When configuring GitHub or similar sources, set the content type to
|
||||
`application/json` — form-encoded webhook payloads are not accepted.
|
||||
|
||||
### URL is a bearer secret
|
||||
|
||||
The generated URL **is** the credential. Anyone with it can fire the
|
||||
autopilot. Treat it like a token:
|
||||
|
||||
- **Don't paste it into public issue threads, screenshots, or chat history.**
|
||||
- **Rotate it if it leaks** — click "Rotate URL" on the trigger row, or run
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`. The
|
||||
old URL stops working immediately.
|
||||
- For sources that require strong source authentication, wait for
|
||||
per-trigger HMAC signature verification; this v1 URL is bearer-only.
|
||||
- Workspace members who can view the autopilot can read its webhook URLs
|
||||
for now — tighter per-role secret visibility is a follow-up.
|
||||
|
||||
### Status-code semantics
|
||||
|
||||
Multica returns `200 OK` with a `status` field for normal no-op outcomes so
|
||||
your provider's webhook-retry machinery doesn't keep hammering the URL:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
— a run was dispatched.
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
— the assignee's runtime is offline; recorded as a `skipped` run.
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` — the trigger is disabled.
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` — the autopilot is paused.
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` — the autopilot is archived.
|
||||
|
||||
Non-2xx responses cover real failures:
|
||||
|
||||
- `400` — invalid JSON, scalar body, or empty body.
|
||||
- `404` — unknown token (`{"error":"webhook not found"}`).
|
||||
- `413` — payload exceeded 256 KiB.
|
||||
- `429` — per-token rate limit exceeded (defaults to 60 req/min).
|
||||
|
||||
### Self-hosted: configure your public URL
|
||||
|
||||
When `MULTICA_PUBLIC_URL` is set on the server (e.g. `https://multica.example.com`),
|
||||
the trigger response includes an absolute `webhook_url` and the UI shows a
|
||||
ready-to-copy URL. Without it, the UI composes the URL from the client's
|
||||
API origin — which is fine for desktop and same-origin web, but not for
|
||||
custom self-hosted reverse proxies. Multica deliberately does not derive
|
||||
the public host from `Host` / `X-Forwarded-Host` headers so a misconfigured
|
||||
reverse proxy cannot trick the server into minting webhook URLs pointing at
|
||||
an attacker-controlled host.
|
||||
|
||||
## View run history
|
||||
|
||||
Every trigger produces a **run record**, visible on the "History" tab of the autopilot detail page:
|
||||
|
||||
- Trigger source (`schedule` / `manual`)
|
||||
- Trigger source (`schedule` / `manual` / `webhook`)
|
||||
- Start time, completion time
|
||||
- Status (`issue_created` / `running` / `completed` / `failed`)
|
||||
- Status (`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- The linked issue (create issue mode) or `task` (run-only mode)
|
||||
- Failure reason (if failed)
|
||||
- Failure reason (if failed or skipped)
|
||||
|
||||
## What happens when an autopilot fails
|
||||
|
||||
@@ -72,7 +166,11 @@ Why no auto-retry: autopilots are already periodic, so adding system-level retri
|
||||
|
||||
## What's not yet available
|
||||
|
||||
**Webhook and API triggers are not available yet.** The autopilot trigger schema reserves `webhook` and `api` types, but **they are not wired up to any ingress route** — the UI can create triggers of either type, but they will not actually fire. Today, **only `schedule` and manual triggers are end-to-end usable.**
|
||||
**API-kind triggers are not wired up.** The trigger schema reserves an `api`
|
||||
kind, but no ingress route fires it; the UI shows a Deprecated badge for
|
||||
existing rows and offers no copy/rotate affordances. Per-trigger HMAC
|
||||
signature verification, IP allowlists, and provider-specific event presets
|
||||
are tracked as follow-ups; v1 URLs are bearer-only.
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Autopilots
|
||||
description: 让智能体按 cron 定时自己开工——或通过 UI / CLI 手动触发一次。
|
||||
description: 让智能体按 cron 定时自己开工,或在 webhook 到来时被触发——也可以通过 UI / CLI 手动触发一次。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
@@ -16,13 +16,13 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)或 `webhook`
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
@@ -50,15 +50,105 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
手动触发走和 `schedule` 触发完全相同的执行流程,只是运行记录里 `source` 字段标为 `manual`。
|
||||
|
||||
## 通过 Webhook 触发
|
||||
|
||||
Autopilot 也可以由入站 HTTP webhook 触发。在详情页添加一个 **Webhook**
|
||||
触发器,Multica 会生成一个唯一的 URL:
|
||||
|
||||
```
|
||||
https://<你的 Multica host>/api/webhooks/autopilots/awt_…
|
||||
```
|
||||
|
||||
向这个 URL POST 任意 JSON——Multica 会记录一条 `source = webhook` 的
|
||||
run,把请求体保存为 run 的 `trigger_payload`,然后按和 schedule 触发器
|
||||
完全一致的方式派发给智能体。
|
||||
|
||||
```bash
|
||||
curl -X POST "$MULTICA_WEBHOOK_URL" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event":"demo.received","eventPayload":{"message":"hello"}}'
|
||||
```
|
||||
|
||||
在**先建 issue 模式**下,入站 payload 会附加在新 issue 的描述里供智能体
|
||||
直接读到;**直跑模式**下,payload 也会随 run 一并交给 daemon。
|
||||
|
||||
### Payload 形态
|
||||
|
||||
可以发自己的封装:
|
||||
|
||||
```json
|
||||
{ "event": "github.pull_request.opened", "eventPayload": { } }
|
||||
```
|
||||
|
||||
也可以直接发任意 JSON 对象 / 数组。Multica 会规范化为内部封装:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "<推断>",
|
||||
"eventPayload": <你的 body>,
|
||||
"request": { "receivedAt": "<rfc3339>", "contentType": "application/json" }
|
||||
}
|
||||
```
|
||||
|
||||
不带 `event` 字段时,Multica 会按以下顺序从常见 header 和 body 字段
|
||||
推断:`X-GitHub-Event` + body `action`,`X-Gitlab-Event`、
|
||||
`X-Event-Type`、body 里的 `event` / `type` / `action`。都不命中时事件
|
||||
名退化为 `webhook.received`。
|
||||
|
||||
配置 GitHub 之类的来源时,请把 content type 设为 `application/json`——
|
||||
表单编码的 webhook payload 在 v1 里不接受。
|
||||
|
||||
### URL 即 bearer secret
|
||||
|
||||
生成的 URL **就是凭证**,谁拿到都能触发这个 Autopilot。请按 token 对待:
|
||||
|
||||
- **不要贴到公开 issue 评论、截图、聊天记录里。**
|
||||
- **泄漏后立即重新生成**——在触发器上点"重新生成 URL",或运行
|
||||
`multica autopilot trigger-rotate-url <autopilot-id> <trigger-id>`。
|
||||
旧 URL 立即失效。
|
||||
- 对需要强来源认证的源,等 per-trigger HMAC 签名校验上线;v1 URL 仅
|
||||
bearer。
|
||||
- 当前能查看 Autopilot 的工作区成员都能看到它的 webhook URL——更细的
|
||||
权限可见性是后续工作。
|
||||
|
||||
### 状态码语义
|
||||
|
||||
正常的 no-op 路径都返回 `200 OK` 加 `status` 字段,避免外部 webhook 重试
|
||||
机制反复打:
|
||||
|
||||
- `{"status":"accepted","run_id":"…","autopilot_id":"…","trigger_id":"…"}`
|
||||
—— 已派发一次 run。
|
||||
- `{"status":"skipped","run_id":"…","reason":"agent runtime is offline at dispatch time"}`
|
||||
—— 受派智能体的 runtime 离线,记为 `skipped` run。
|
||||
- `{"status":"ignored","reason":"trigger_disabled"}` —— 触发器已禁用。
|
||||
- `{"status":"ignored","reason":"autopilot_paused"}` —— Autopilot 已暂停。
|
||||
- `{"status":"ignored","reason":"autopilot_archived"}` —— Autopilot 已归档。
|
||||
|
||||
非 2xx 是真正的失败:
|
||||
|
||||
- `400` —— 无效 JSON、scalar body、空 body。
|
||||
- `404` —— 未知 token(`{"error":"webhook not found"}`)。
|
||||
- `413` —— 请求体超过 256 KiB。
|
||||
- `429` —— 单 token 速率限制(默认 60 次 / 分钟)。
|
||||
|
||||
### 自托管:配置公开 URL
|
||||
|
||||
服务端设置 `MULTICA_PUBLIC_URL`(例如 `https://multica.example.com`)后,
|
||||
触发器响应里会带绝对的 `webhook_url`,UI 直接显示可复制的 URL。没设
|
||||
时 UI 会用客户端的 API origin 拼出 URL——desktop 和同源 web 没问题,
|
||||
但自定义反向代理就不行了。Multica **故意不**从 `Host` /
|
||||
`X-Forwarded-Host` header 推断公开主机,避免反代配置失误时被诱导生成
|
||||
指向攻击者域名的 webhook URL。
|
||||
|
||||
## 看运行历史
|
||||
|
||||
每次触发都会产生一条**运行记录**(run),可以在 Autopilot 详情页的"历史"tab 看到:
|
||||
|
||||
- 触发源(`schedule` / `manual`)
|
||||
- 触发源(`schedule` / `manual` / `webhook`)
|
||||
- 开始时间、完成时间
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed`)
|
||||
- 状态(`issue_created` / `running` / `completed` / `failed` / `skipped`)
|
||||
- 关联的 issue(先建 issue 模式)或 `task`(直跑模式)
|
||||
- 失败原因(如果失败)
|
||||
- 失败原因(失败或跳过时)
|
||||
|
||||
## Autopilot 失败会怎样
|
||||
|
||||
@@ -72,7 +162,10 @@ multica autopilot trigger <autopilot-id>
|
||||
|
||||
## 暂不可用的能力
|
||||
|
||||
**Webhook 和 API 触发暂不可用**。Autopilot 的触发器类型在 schema 里预留了 `webhook` 和 `api` 两种,但**还没接入站路由**——UI 可以创建这两类触发器,不会真的触发。目前**只有 `schedule` 和手动触发是端到端可用的**。
|
||||
**API 类型触发器尚未接入。** 触发器 schema 里保留了 `api` 类型但没有
|
||||
入站路由会触发它;UI 会给已有的此类记录打 Deprecated 标签,也不显示
|
||||
copy / rotate 操作。Per-trigger HMAC 签名校验、IP allowlist、按提供方
|
||||
的事件预设是后续工作;v1 URL 仅 bearer。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -35,14 +35,28 @@ These are the core variables you must think about before deploying — some have
|
||||
|
||||
## Email configuration
|
||||
|
||||
Multica uses [Resend](https://resend.com/) to send verification codes and invite emails.
|
||||
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
|
||||
|
||||
| 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) |
|
||||
| `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) |
|
||||
|
||||
**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.
|
||||
### 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.
|
||||
|
||||
## Google OAuth configuration
|
||||
|
||||
@@ -114,6 +128,25 @@ Three allowlist layers combine by priority. **If any layer is set to a non-empty
|
||||
|
||||
**Invite flows themselves do not check the signup allowlist** — but the invitee must still be able to **sign in** before accepting the invite. If they already have a Multica account (for example from another workspace), they can accept directly, unaffected by the allowlist; **if they have never signed up**, the first step of sign-in (requesting a verification code) still passes through the allowlist check, and an email rejected by `ALLOW_SIGNUP=false` or by `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` **cannot finish signup, and therefore cannot accept the invite**.
|
||||
|
||||
## Rate limiting (optional Redis)
|
||||
|
||||
Public auth endpoints — `/auth/send-code`, `/auth/verify-code`, `/auth/google` — have per-IP fixed-window rate limiting in front of them. The limiter is backed by Redis. When `REDIS_URL` is unset the middleware is a **no-op** (fail-open) and the backend logs `rate limiting disabled: REDIS_URL not configured` at startup.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | empty | Redis connection URL (for example `redis://localhost:6379/0`). When unset, rate limiting on auth endpoints is disabled. The same Redis is also used by the realtime hub fan-out, the PAT cache, and the daemon-token cache — they all fall back to in-memory / direct-DB mode when unset |
|
||||
| `RATE_LIMIT_AUTH` | `5` | Max requests per IP per minute against `/auth/send-code` and `/auth/google` |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | Max requests per IP per minute against `/auth/verify-code` |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | empty | Comma-separated CIDRs whose `X-Forwarded-For` header the limiter is allowed to trust. Empty (the default) means **never trust XFF** — the limiter only uses the direct connection's `RemoteAddr` |
|
||||
|
||||
When a request is over the limit, the server replies with `429 Too Many Requests`, `Retry-After: 60`, and body `{"error":"too many requests"}`.
|
||||
|
||||
<Callout type="warning">
|
||||
**Behind a reverse proxy you must set `RATE_LIMIT_TRUSTED_PROXIES`.** Otherwise every real user shares the proxy's IP from the backend's point of view, the whole deployment ends up in one bucket, and `/auth/send-code` becomes 5 req/min for the entire site. Typical values: `127.0.0.1/32,::1/128` for a same-host Caddy / Nginx; the CDN's published ranges for Cloudflare / ALB / CloudFront. Only IPs whose `RemoteAddr` falls inside one of these CIDRs may use `X-Forwarded-For` to identify the client.
|
||||
</Callout>
|
||||
|
||||
This separate `RATE_LIMIT_TRUSTED_PROXIES` is **not** the same as `MULTICA_TRUSTED_PROXIES`, which controls the autopilot-webhook limiter (`/api/webhooks/autopilots/{token}`). Each limiter parses its own list, so a deployment behind a proxy should set both.
|
||||
|
||||
## Daemon tuning parameters
|
||||
|
||||
The daemon runs on the user's local machine, and its config is read from local environment variables too. The common ones:
|
||||
|
||||
@@ -35,14 +35,28 @@ Multica 的 [自部署](/self-host-quickstart) 服务器启动时从环境变量
|
||||
|
||||
## 怎么配邮件
|
||||
|
||||
Multica 用 [Resend](https://resend.com/) 发验证码和邀请邮件。
|
||||
Multica 支持两种邮件发送通道——[Resend](https://resend.com/) 适合公网部署,SMTP relay 适合内网/自部署。同时设置时 `SMTP_HOST` 优先级高于 `RESEND_API_KEY`。
|
||||
|
||||
### Resend
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `RESEND_API_KEY` | 空 | Resend API key |
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名)|
|
||||
| `RESEND_FROM_EMAIL` | `noreply@multica.ai` | 发件地址(必须是 Resend 账号已验证的域名;走 SMTP 时同时作为 `From:` 头)|
|
||||
|
||||
**不设 `RESEND_API_KEY` 时的行为**:server 不会报错,但所有本该发出去的邮件(验证码、邀请链接)**只打到 server 的 stdout**。本地开发时方便——你从 server 日志里抄验证码;**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
### 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 日志里抄验证码);**生产环境忘记设就是黑洞**,用户收不到邮件也没任何错误提示。
|
||||
|
||||
## 怎么配 Google OAuth
|
||||
|
||||
@@ -114,6 +128,25 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
|
||||
**邀请流程本身不检查 signup 白名单**——但被邀请人必须先能**登录**才能接受邀请。如果对方已经有 Multica 账号(比如在其他工作区注册过),可以直接接受,不受白名单影响;**如果对方还没注册过**,他们登录的第一步(发送验证码)仍然会过白名单检查,被 `ALLOW_SIGNUP=false` 或 `ALLOWED_EMAILS` / `ALLOWED_EMAIL_DOMAINS` 拒绝的邮箱**无法完成注册,也就没法接受邀请**。
|
||||
|
||||
## 速率限制(可选 Redis)
|
||||
|
||||
公开认证端点——`/auth/send-code`、`/auth/verify-code`、`/auth/google`——前面挂了按 IP 的固定窗口限流。限流器后端是 Redis。`REDIS_URL` 不设时中间件**直通**(fail-open),后端启动会打日志 `rate limiting disabled: REDIS_URL not configured`。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `REDIS_URL` | 空 | Redis 连接 URL(例如 `redis://localhost:6379/0`)。不设时认证端点的限流功能直接关闭。同一个 Redis 也被实时事件 fan-out、PAT 缓存、守护进程 token 缓存复用;不设时这些组件分别回落到内存模式 / 直查 DB |
|
||||
| `RATE_LIMIT_AUTH` | `5` | 单 IP 每分钟对 `/auth/send-code` 和 `/auth/google` 的最大请求数 |
|
||||
| `RATE_LIMIT_AUTH_VERIFY` | `20` | 单 IP 每分钟对 `/auth/verify-code` 的最大请求数 |
|
||||
| `RATE_LIMIT_TRUSTED_PROXIES` | 空 | 逗号分隔的 CIDR 列表,列在内的来源 IP 才允许通过 `X-Forwarded-For` 标识客户端。默认空 = **永不信任 XFF**,限流器只看直连的 `RemoteAddr` |
|
||||
|
||||
被限流的请求会返回 `429 Too Many Requests`,带 `Retry-After: 60` 头和 `{"error":"too many requests"}` 响应体。
|
||||
|
||||
<Callout type="warning">
|
||||
**部署在反向代理后面时必须设 `RATE_LIMIT_TRUSTED_PROXIES`。** 否则在后端看来所有真实用户都共用代理那个 IP,整个部署落到同一个桶里,`/auth/send-code` 会变成全站每分钟只能发 5 次。常见值:本机 Caddy / Nginx 用 `127.0.0.1/32,::1/128`;Cloudflare / ALB / CloudFront 用各家公开的 CDN IP 段。只有 `RemoteAddr` 落在这些 CIDR 内的请求才被允许通过 `X-Forwarded-For` 改写客户端 IP。
|
||||
</Callout>
|
||||
|
||||
这里的 `RATE_LIMIT_TRUSTED_PROXIES` 和 `MULTICA_TRUSTED_PROXIES` **不是同一个**变量——后者控制的是 autopilot webhook 端点(`/api/webhooks/autopilots/{token}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
|
||||
|
||||
## 守护进程的调节参数
|
||||
|
||||
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
|
||||
|
||||
169
apps/docs/content/docs/install-agent-runtime.mdx
Normal file
169
apps/docs/content/docs/install-agent-runtime.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: Install an agent runtime
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
|
||||
This page is the install-side companion to:
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection works
|
||||
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
|
||||
|
||||
<Callout type="info">
|
||||
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
|
||||
</Callout>
|
||||
|
||||
## Before you start
|
||||
|
||||
Two prerequisites apply to **every** tool below:
|
||||
|
||||
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
|
||||
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
|
||||
|
||||
After installing a tool, restart the daemon:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 11 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `claude` |
|
||||
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
|
||||
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
|
||||
| Notes | First-choice recommendation for new users. |
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `codex` |
|
||||
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
|
||||
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `cursor-agent` |
|
||||
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
|
||||
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `copilot` |
|
||||
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
|
||||
| Authentication | Browser-based GitHub login through the CLI. |
|
||||
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `gemini` |
|
||||
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
|
||||
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `opencode` |
|
||||
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
|
||||
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
|
||||
|
||||
### Kiro CLI (Amazon)
|
||||
|
||||
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kiro-cli` |
|
||||
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
|
||||
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
|
||||
|
||||
### Kimi (Moonshot)
|
||||
|
||||
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kimi` |
|
||||
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
|
||||
| Authentication | Moonshot API key, configured per the vendor's docs. |
|
||||
|
||||
### Hermes (Nous Research)
|
||||
|
||||
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `hermes` |
|
||||
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `openclaw` |
|
||||
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
|
||||
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
|
||||
|
||||
### Pi (Inflection AI)
|
||||
|
||||
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `pi` |
|
||||
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
## After installing
|
||||
|
||||
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
|
||||
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
|
||||
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
|
||||
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
|
||||
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
|
||||
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
|
||||
|
||||
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
|
||||
|
||||
## Next
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
|
||||
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks
|
||||
169
apps/docs/content/docs/install-agent-runtime.zh.mdx
Normal file
169
apps/docs/content/docs/install-agent-runtime.zh.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: 安装一个 Agent 运行时
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
|
||||
这一页是装机的入口,和它配套的是:
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
|
||||
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异(会话续接、MCP、模型选择)
|
||||
|
||||
<Callout type="info">
|
||||
Multica 服务器从不接触你的 API key,也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
|
||||
</Callout>
|
||||
|
||||
## 开始前
|
||||
|
||||
下面每一款工具都有两个共同前提:
|
||||
|
||||
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
|
||||
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
|
||||
|
||||
装完一款工具后,重启守护进程:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
|
||||
|
||||
## 11 款支持的工具
|
||||
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
|
||||
|
||||
### Claude Code(Anthropic)
|
||||
|
||||
集成最完整的一款。会话续接好用,MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `claude` |
|
||||
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
|
||||
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
|
||||
| 备注 | 新用户首选。 |
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `codex` |
|
||||
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
|
||||
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `cursor-agent` |
|
||||
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
|
||||
| 认证 | 在 Cursor 编辑器里登录,CLI 复用同一份会话。 |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型,GitHub 决定你拿到哪个模型。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `copilot` |
|
||||
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
|
||||
| 认证 | CLI 里走 GitHub 浏览器登录。 |
|
||||
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
|
||||
|
||||
### Gemini(Google)
|
||||
|
||||
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `gemini` |
|
||||
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
|
||||
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
|
||||
|
||||
### OpenCode(SST)
|
||||
|
||||
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `opencode` |
|
||||
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
|
||||
| 认证 | 按 OpenCode 的文档配你自己的模型供应商(Anthropic、OpenAI 等)。 |
|
||||
|
||||
### Kiro CLI(Amazon)
|
||||
|
||||
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作;skills 拷到 `.kiro/skills/`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kiro-cli` |
|
||||
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
|
||||
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
|
||||
|
||||
### Kimi(Moonshot)
|
||||
|
||||
ACP 协议 agent,主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kimi` |
|
||||
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
|
||||
| 认证 | Moonshot API key,按厂商文档配置。 |
|
||||
|
||||
### Hermes(Nous Research)
|
||||
|
||||
ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `hermes` |
|
||||
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
开源 CLI agent 编排器。**模型绑在 agent 层**(`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `openclaw` |
|
||||
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
|
||||
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
|
||||
|
||||
### Pi(Inflection AI)
|
||||
|
||||
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `pi` |
|
||||
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
|
||||
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
|
||||
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
|
||||
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
|
||||
|
||||
## 排错
|
||||
|
||||
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
|
||||
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`,绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
|
||||
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
|
||||
|
||||
更宽的症状看[排错指南](/zh/troubleshooting)。
|
||||
|
||||
## 接下来
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
|
||||
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
|
||||
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务
|
||||
@@ -19,6 +19,7 @@
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"install-agent-runtime",
|
||||
"tasks",
|
||||
"providers",
|
||||
"---Collaborating with agents---",
|
||||
|
||||
@@ -45,6 +45,10 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
</Callout>
|
||||
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -59,7 +63,9 @@ 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.
|
||||
|
||||
To actually send verification emails:
|
||||
Two delivery backends are supported — pick whichever fits your network:
|
||||
|
||||
**Option A — Resend (cloud / public-internet deployments):**
|
||||
|
||||
1. Sign up at [Resend](https://resend.com/) and get an API key
|
||||
2. Verify a sending domain you control
|
||||
@@ -70,36 +76,80 @@ To actually send verification emails:
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
4. Restart: `docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
**Option B — SMTP relay (internal networks / on-premise):**
|
||||
|
||||
For more auth configuration (OAuth, signup allowlist), see [Auth setup](/auth-setup).
|
||||
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).
|
||||
|
||||
## 4. First login + create a workspace
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- Enter your email
|
||||
- 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)
|
||||
- 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
|
||||
- Do not use `888888` unless you explicitly set `MULTICA_DEV_VERIFICATION_CODE=888888` on a non-production private instance
|
||||
- Log in and create your first workspace
|
||||
|
||||
## 5. Point the CLI at your own server
|
||||
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one. Once installed, **use the self-host variant of the setup command**:
|
||||
The CLI install is the same as in [Cloud quickstart → 2. Install the CLI](/cloud-quickstart#2-install-the-multica-cli) — Homebrew / script / PowerShell, pick one.
|
||||
|
||||
```bash
|
||||
multica setup self-host --server-url http://<your-server-address>:8080 --app-url http://<your-server-address>:3000
|
||||
```
|
||||
### 5a. Same machine
|
||||
|
||||
If you're running everything on one local machine:
|
||||
If the CLI and the server run on the same host, the defaults already work:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
That defaults to `http://localhost:8080` (backend) and `http://localhost:3000` (frontend).
|
||||
That points the CLI at `http://localhost:8080` (backend) and `http://localhost:3000` (frontend), takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
|
||||
`setup self-host` takes you through browser login, stores the PAT locally, and **starts the daemon automatically**.
|
||||
### 5b. Cross-machine: front with a reverse proxy
|
||||
|
||||
Because the compose stack only listens on `127.0.0.1`, a daemon on a different machine cannot reach `http://<server-ip>:8080` directly — and you do not want it to, since the default `JWT_SECRET` would otherwise be reachable from the open internet. Put a reverse proxy on the server that terminates TLS and forwards to `127.0.0.1:8080` (backend) and `127.0.0.1:3000` (frontend), then point the CLI at the public HTTPS URL:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<your-domain> \
|
||||
--app-url https://<your-domain>
|
||||
```
|
||||
|
||||
A minimal Caddyfile that fronts both the frontend and the backend (with WebSocket support, which the daemon and the web app both need) on a single hostname:
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
After bringing the proxy up, set `FRONTEND_ORIGIN=https://multica.example.com` in the server's `.env` and restart the backend — otherwise the WebSocket origin check will reject the browser ([Troubleshooting → WebSocket can't connect](/troubleshooting#websocket-cant-connect)).
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) is another solid option — it gives you TLS and a public hostname without exposing any port on the host at all. An Nginx equivalent (separate `app.` / `api.` hostnames, `proxy_set_header Upgrade` for WebSockets) works just as well; the key requirements are TLS termination and forwarding the `Upgrade` header on `/ws`.
|
||||
|
||||
## 6. Create an agent + assign your first task
|
||||
|
||||
@@ -108,7 +158,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**: Resend isn't configured → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **Verification code not received**: no email backend is configured (neither Resend nor SMTP) → look for `[DEV] Verification code` in `docker compose logs backend`
|
||||
- **WebSocket won't connect**: for public deployments you must set `FRONTEND_ORIGIN` to your real frontend domain; see [Troubleshooting → WebSocket won't connect](/troubleshooting#websocket-wont-connect)
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -44,6 +44,10 @@ make selfhost
|
||||
- **前端**:[http://localhost:3000](http://localhost:3000)
|
||||
- **后端**:[http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**所有端口只监听 `127.0.0.1`。** `docker-compose.selfhost.yml` 把每个 publish 出来的端口都绑到 loopback —— `ss -tlnp` 不会看到 `0.0.0.0:8080`,外网/其它机器默认根本连不上。这是为了避免默认 `JWT_SECRET` 和 Postgres 凭据被直接暴露到公网。要做跨机访问,请用反向代理在前面终结 TLS,详见下方 [Step 5b —— 跨机访问:用反向代理把服务挡在前面](#5b-跨机访问用反向代理把服务挡在前面)。
|
||||
</Callout>
|
||||
|
||||
## 2. 重要:保持生产安全配置
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -58,7 +62,9 @@ make selfhost
|
||||
|
||||
如果不配邮件,用户无法通过邮件收到验证码;server 会把生成的验证码打印到 stdout。
|
||||
|
||||
要真的发验证码邮件:
|
||||
支持两种发送通道,按部署环境二选一:
|
||||
|
||||
**Option A — Resend(公网/云端部署):**
|
||||
|
||||
1. 在 [Resend](https://resend.com/) 注册并拿一个 API key
|
||||
2. 验证一个你控制的发件域名
|
||||
@@ -69,36 +75,80 @@ make selfhost
|
||||
RESEND_FROM_EMAIL=noreply@yourdomain.com
|
||||
```
|
||||
|
||||
4. 重启:`docker compose -f docker-compose.selfhost.yml restart backend`
|
||||
**Option B — SMTP relay(内网/自部署):**
|
||||
|
||||
更多 auth 配置(OAuth、注册白名单)见 [登录与注册配置](/auth-setup)。
|
||||
适合内网无法访问 `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)。
|
||||
|
||||
## 4. 首次登录 + 创建工作区
|
||||
|
||||
打开 [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
- 输入你的邮箱
|
||||
- 从 Resend 邮件里拿验证码(或者前面没配 Resend 的话从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行)
|
||||
- 从你配置的邮件后端(Resend 或 SMTP relay)收到的邮件里拿验证码;两者都没配的话,从 server 容器的 stdout 里抄 `[DEV] Verification code` 这行
|
||||
- 不要直接使用 `888888`;只有在非 production 私有实例上显式设置 `MULTICA_DEV_VERIFICATION_CODE=888888` 后它才会生效
|
||||
- 登录后创建第一个工作区
|
||||
|
||||
## 5. 连接命令行工具到你自己的 server
|
||||
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。装好之后,**用 self-host 版本的 setup 命令**:
|
||||
命令行装法和 [Cloud 快速上手 → 2. 装命令行工具](/cloud-quickstart#2-装-multica-命令行工具) 一样——Homebrew / 脚本 / PowerShell 任选。
|
||||
|
||||
```bash
|
||||
multica setup self-host --server-url http://<你的服务器地址>:8080 --app-url http://<你的服务器地址>:3000
|
||||
```
|
||||
### 5a. 同一台机器
|
||||
|
||||
本地就是一台电脑跑整套的话:
|
||||
CLI 和 server 在同一台机器上时,默认参数就够用:
|
||||
|
||||
```bash
|
||||
multica setup self-host
|
||||
```
|
||||
|
||||
默认连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend)。
|
||||
会自动连 `http://localhost:8080`(backend)+ `http://localhost:3000`(frontend),引导你在浏览器里登录、把 PAT 存到本地、**自动启动守护进程**。
|
||||
|
||||
`setup self-host` 会让你在浏览器里完成登录,把 PAT 存到本地,**自动启动守护进程**。
|
||||
### 5b. 跨机访问:用反向代理把服务挡在前面
|
||||
|
||||
因为 compose 默认只监听 `127.0.0.1`,从别的机器跑的 daemon 是连不上 `http://<server-ip>:8080` 的——这也是有意为之,否则默认 `JWT_SECRET` 等于直接暴露在公网。正确做法是在 server 上跑一个反向代理(Caddy / nginx / Cloudflare Tunnel),由它终结 TLS,再反代到 `127.0.0.1:8080`(backend)和 `127.0.0.1:3000`(frontend)。然后把 CLI 指到公开的 HTTPS 域名:
|
||||
|
||||
```bash
|
||||
multica setup self-host \
|
||||
--server-url https://<你的域名> \
|
||||
--app-url https://<你的域名>
|
||||
```
|
||||
|
||||
最小可用的 Caddyfile,单域名同时挂前后端(带 WebSocket 转发,daemon 和网页端都依赖):
|
||||
|
||||
```nginx
|
||||
multica.example.com {
|
||||
# WebSocket 路由——必须在 catch-all 之前
|
||||
@ws path /ws /ws/*
|
||||
handle @ws {
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Backend API
|
||||
handle /api/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
|
||||
# 其它请求 → 前端
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
代理起好之后,记得在 server 的 `.env` 里把 `FRONTEND_ORIGIN` 设成 `https://multica.example.com` 并重启后端,否则 WebSocket 的 origin 校验会把浏览器拒掉(见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上))。
|
||||
|
||||
[Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) 也是不错的选择——它直接给一个公开域名 + TLS,host 上不用对外暴露任何端口。Nginx 也能做(分 `app.` / `api.` 两个域名 + `proxy_set_header Upgrade` 转 WebSocket),关键就是终结 TLS、并在 `/ws` 上转发 `Upgrade` 头。
|
||||
|
||||
## 6. 创建智能体 + 分配第一个任务
|
||||
|
||||
@@ -107,7 +157,7 @@ multica setup self-host
|
||||
## 常见问题
|
||||
|
||||
- **后端起不来**:看容器日志 `docker compose -f docker-compose.selfhost.yml logs backend`;常见是 `.env` 里 `DATABASE_URL` 或 `JWT_SECRET` 有问题
|
||||
- **验证码收不到**:没配 Resend → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **验证码收不到**:没配任何邮件后端(Resend 和 SMTP 都没设) → 从 `docker compose logs backend` 里找 `[DEV] Verification code`
|
||||
- **WebSocket 连不上**:公网部署必须设 `FRONTEND_ORIGIN` 成你真实的前端域名;见 [故障排查 → WebSocket 连不上](/troubleshooting#websocket-连不上)
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"fumadocs-ui": "^15.5.2",
|
||||
"lucide-react": "catalog:",
|
||||
"mermaid": "^11.14.0",
|
||||
"next": "^15.3.3",
|
||||
"next": "^15.5.16",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
|
||||
20
apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx
Normal file
20
apps/web/app/[workspaceSlug]/(dashboard)/loading.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
|
||||
// Rendered by Next.js as the Suspense fallback during route transitions
|
||||
// inside the (dashboard) segment. Scoped to this segment only — auth /
|
||||
// landing keep their own full-screen fallbacks.
|
||||
export default function DashboardLoading() {
|
||||
return (
|
||||
<div className="flex h-svh w-full flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-3 border-b px-4">
|
||||
<Skeleton className="h-5 w-5 rounded-md" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 p-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"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} />;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
|
||||
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
|
||||
// wants the full viewport for the HTML iframe. Workspace resolution still
|
||||
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
|
||||
export default function AttachmentPreviewWebPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const search = useSearchParams();
|
||||
const filename = search.get("name") ?? undefined;
|
||||
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +284,59 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title:
|
||||
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
|
||||
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
|
||||
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
|
||||
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
|
||||
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
|
||||
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
|
||||
"Search results are ranked more usefully and include better snippets",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
|
||||
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
|
||||
"Squad pages scroll correctly and show which members are already working",
|
||||
"Desktop zoom shortcuts work again across the common keyboard combinations",
|
||||
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "Faster Navigation, Background Updates & More Reliable Squads",
|
||||
changes: [],
|
||||
features: [
|
||||
"Member and agent detail pages now show related tasks so teams can review who is working on what",
|
||||
"The desktop app downloads updates in the background so a new version is ready when you are",
|
||||
"Self-hosted deployments can send email through SMTP as an alternative to Resend",
|
||||
"Create Squad has a clearer setup flow with member selection that works better for team coordination",
|
||||
],
|
||||
improvements: [
|
||||
"Page transitions are faster, with issue pages prepared ahead of time and smoother loading states",
|
||||
"Long issue activity blocks collapse so comments and conclusions are easier to scan",
|
||||
"Agents and Squads remember the Mine/All view when you return to the list",
|
||||
"Repository setup accepts more SSH URL formats across settings, projects, and quick create",
|
||||
"Squad handoffs are more dependable when agents have multiple roles or delegate to a specific member",
|
||||
],
|
||||
fixes: [
|
||||
"Self-hosted local file cards render and preview correctly",
|
||||
"Agent-run tasks are more dependable when local tools or skills need to be found automatically",
|
||||
"Claude usage totals match more of the model names reported by connected tools",
|
||||
"After switching workspaces, live updates come from the correct workspace and show the right source",
|
||||
"Chat session menus and runtime names hold their shape in narrower spaces",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -284,6 +284,58 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
|
||||
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
|
||||
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
|
||||
"Skills 支持从本地 runtime 批量复制到 workspace,团队初始化更快",
|
||||
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
|
||||
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
|
||||
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
|
||||
"桌面端缩放快捷键在常见组合下恢复正常",
|
||||
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "更快的导航、后台更新与更可靠的小队协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"成员和 agent 详情页现在可以看到关联任务,方便回看每个人和每个 agent 正在推进的工作",
|
||||
"桌面端会在后台提前下载新版本,等你准备好时再安装更新",
|
||||
"自托管部署可以使用 SMTP 发送邮件,不再只依赖 Resend",
|
||||
"创建 Squad 的流程更清晰,成员选择和初始设置更适合团队协作",
|
||||
],
|
||||
improvements: [
|
||||
"页面切换更快,Issue 页面会提前准备内容,并在加载时展示更自然的过渡状态",
|
||||
"Issue 时间线会把较长的活动记录收起,重点评论和结论更容易扫读",
|
||||
"Agents 和 Squads 页会记住你上次选择的 Mine/All 视图,返回列表时不再重置",
|
||||
"仓库设置、项目资源和快速创建流程更好地支持 SSH 形式的仓库地址",
|
||||
"小队分工更稳定,leader 能正确接续双角色 agent 的回复,也会更明确地把任务交给指定成员",
|
||||
],
|
||||
fixes: [
|
||||
"自托管本地文件卡片可以正常展示和预览",
|
||||
"Agent 在自动寻找本地工具、加载技能以及无人值守运行时更可靠",
|
||||
"Claude 用量统计能识别更多接入工具上报的模型名称",
|
||||
"切换 workspace 后,实时更新会来自正确的 workspace,消息来源也更准确",
|
||||
"聊天会话下拉菜单和 runtime 名称展示在窄空间里更稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"linkify-it": "^5.0.0",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next": "^16.2.3",
|
||||
"next": "^16.2.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
|
||||
@@ -24,6 +24,12 @@ function NavigationProviderInner({
|
||||
searchParams: new URLSearchParams(searchParams.toString()),
|
||||
getShareableUrl: (path: string) =>
|
||||
typeof window === "undefined" ? path : window.location.origin + path,
|
||||
// router.prefetch is a no-op in dev mode by Next.js design; in production
|
||||
// it warms the RSC payload + route chunk so the next push() commits with
|
||||
// no network round-trip. Safe to call repeatedly — Next dedupes internally.
|
||||
prefetch: (path: string) => {
|
||||
router.prefetch(path);
|
||||
},
|
||||
};
|
||||
|
||||
return <NavigationProvider value={adapter}>{children}</NavigationProvider>;
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Self-hosting Docker Compose — starts PostgreSQL, backend, and frontend.
|
||||
#
|
||||
# Services bind to 127.0.0.1 only. For cross-machine or public access, front
|
||||
# them with a reverse proxy (Caddy / nginx / Cloudflare Tunnel) that terminates
|
||||
# TLS and forwards to 127.0.0.1:8080 (backend) and 127.0.0.1:3000 (frontend).
|
||||
# Do NOT change these bindings to 0.0.0.0 — Docker bypasses host firewalls
|
||||
# (UFW/iptables) by default, so the raw ports would be exposed to the internet
|
||||
# with the default JWT_SECRET and Postgres credentials. See:
|
||||
# apps/docs/content/docs/self-host-quickstart.mdx
|
||||
#
|
||||
# Usage:
|
||||
# cp .env.example .env
|
||||
# # Edit .env — change JWT_SECRET at minimum
|
||||
@@ -18,7 +26,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "${POSTGRES_PORT:-5432}:5432"
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
@@ -34,7 +42,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
- "127.0.0.1:${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
environment:
|
||||
@@ -46,6 +54,11 @@ services:
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-25}
|
||||
SMTP_USERNAME: ${SMTP_USERNAME:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_TLS_INSECURE: ${SMTP_TLS_INSECURE:-false}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||
@@ -63,6 +76,19 @@ services:
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
||||
# Public URL the API is reachable at from the open internet, no
|
||||
# trailing slash. Used to mint absolute webhook URLs for autopilot
|
||||
# webhook triggers. Leave unset behind a same-origin reverse proxy
|
||||
# (e.g. plain localhost dev); the frontend will compose the URL
|
||||
# from window.origin + webhook_path in that case. Headers are
|
||||
# intentionally NOT used to derive this value, to avoid Host /
|
||||
# X-Forwarded-Host spoofing on misconfigured proxies.
|
||||
MULTICA_PUBLIC_URL: ${MULTICA_PUBLIC_URL:-}
|
||||
# Comma-separated CIDRs whose source IP is allowed to set
|
||||
# X-Forwarded-For / X-Real-IP for the webhook per-IP rate limiter.
|
||||
# Empty default = headers ignored, RemoteAddr used. Set e.g.
|
||||
# "127.0.0.1/32" when running behind a same-host reverse proxy.
|
||||
MULTICA_TRUSTED_PROXIES: ${MULTICA_TRUSTED_PROXIES:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
@@ -70,7 +96,7 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("ApiClient", () => {
|
||||
});
|
||||
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
||||
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
||||
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
|
||||
|
||||
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
||||
url,
|
||||
@@ -104,6 +105,10 @@ describe("ApiClient", () => {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
|
||||
method: "POST",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
Issue,
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
SearchIssuesResponse,
|
||||
SearchProjectsResponse,
|
||||
@@ -9,6 +10,7 @@ import type {
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
ListIssuesParams,
|
||||
ListGroupedIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
@@ -45,6 +47,7 @@ import type {
|
||||
DashboardUsageDaily,
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
DashboardRunTimeDaily,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
@@ -86,6 +89,8 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
@@ -93,6 +98,7 @@ import type {
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -107,17 +113,26 @@ import {
|
||||
CommentsListSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_GROUPED_ISSUES_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_SQUAD_MEMBER_STATUS_LIST,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
EMPTY_WEBHOOK_DELIVERY,
|
||||
GroupedIssuesResponseSchema,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -473,6 +488,36 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listGroupedIssues(params: ListGroupedIssuesParams): Promise<GroupedIssuesResponse> {
|
||||
const search = new URLSearchParams({ group_by: params.group_by });
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
if (params.offset) search.set("offset", String(params.offset));
|
||||
if (params.workspace_id) search.set("workspace_id", params.workspace_id);
|
||||
if (params.statuses?.length) search.set("statuses", params.statuses.join(","));
|
||||
if (params.priorities?.length) search.set("priorities", params.priorities.join(","));
|
||||
if (params.assignee_types?.length) search.set("assignee_types", params.assignee_types.join(","));
|
||||
if (params.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.assignee_filters?.length) {
|
||||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
if (params.include_no_assignee) search.set("include_no_assignee", "true");
|
||||
if (params.creator_filters?.length) {
|
||||
search.set("creator_filters", params.creator_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
if (params.project_ids?.length) search.set("project_ids", params.project_ids.join(","));
|
||||
if (params.include_no_project) search.set("include_no_project", "true");
|
||||
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
|
||||
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
|
||||
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
|
||||
return parseWithFallback(raw, GroupedIssuesResponseSchema, EMPTY_GROUPED_ISSUES_RESPONSE, {
|
||||
endpoint: "GET /api/issues/grouped",
|
||||
});
|
||||
}
|
||||
|
||||
async searchIssues(params: { q: string; limit?: number; offset?: number; include_closed?: boolean; signal?: AbortSignal }): Promise<SearchIssuesResponse> {
|
||||
const search = new URLSearchParams({ q: params.q });
|
||||
if (params.limit !== undefined) search.set("limit", String(params.limit));
|
||||
@@ -855,6 +900,21 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardRunTimeDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||||
raw,
|
||||
DashboardRunTimeDailyListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/dashboard/runtime/daily" },
|
||||
);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
@@ -1459,7 +1519,7 @@ export class ApiClient {
|
||||
return this.fetch(`/api/squads/${id}`);
|
||||
}
|
||||
|
||||
async createSquad(data: { name: string; description?: string; leader_id: string }): Promise<Squad> {
|
||||
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) });
|
||||
}
|
||||
|
||||
@@ -1487,6 +1547,17 @@ export class ApiClient {
|
||||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// Per-squad members status snapshot: one row per member with derived
|
||||
// working/idle/offline/unstable plus the issues each agent is currently
|
||||
// running. Parsed with a lenient schema so a new server-side status
|
||||
// value or extra field can't white-screen the Squad page (#2143).
|
||||
async getSquadMemberStatus(squadId: string): Promise<SquadMemberStatusListResponse> {
|
||||
const raw = await this.fetch<unknown>(`/api/squads/${squadId}/members/status`);
|
||||
return parseWithFallback(raw, SquadMemberStatusListResponseSchema, EMPTY_SQUAD_MEMBER_STATUS_LIST, {
|
||||
endpoint: "GET /api/squads/:id/members/status",
|
||||
}) as SquadMemberStatusListResponse;
|
||||
}
|
||||
|
||||
// Autopilots
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
@@ -1527,6 +1598,13 @@ export class ApiClient {
|
||||
return this.fetch(`/api/autopilots/${id}/runs?${search}`);
|
||||
}
|
||||
|
||||
// Returns a single run including its full trigger_payload. List responses
|
||||
// omit trigger_payload to keep them small (a webhook envelope can be
|
||||
// up to 256 KiB × limit rows), so the detail view fetches via this route.
|
||||
async getAutopilotRun(autopilotId: string, runId: string): Promise<AutopilotRun> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/runs/${runId}`);
|
||||
}
|
||||
|
||||
async createAutopilotTrigger(autopilotId: string, data: CreateAutopilotTriggerRequest): Promise<AutopilotTrigger> {
|
||||
return this.fetch(`/api/autopilots/${autopilotId}/triggers`, {
|
||||
method: "POST",
|
||||
@@ -1545,6 +1623,74 @@ export class ApiClient {
|
||||
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async rotateAutopilotTriggerWebhookToken(
|
||||
autopilotId: string,
|
||||
triggerId: string,
|
||||
): Promise<AutopilotTrigger> {
|
||||
return this.fetch(
|
||||
`/api/autopilots/${autopilotId}/triggers/${triggerId}/rotate-webhook-token`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
// Webhook deliveries — list is slim (no raw_body / selected_headers /
|
||||
// response_body); detail returns the full row. Both responses are parsed
|
||||
// through a lenient schema so an unknown server-side `status` /
|
||||
// `signature_status` value degrades to a generic row instead of dropping
|
||||
// the whole list.
|
||||
async listAutopilotDeliveries(
|
||||
autopilotId: string,
|
||||
params?: { limit?: number; offset?: number },
|
||||
): Promise<ListWebhookDeliveriesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", params.limit.toString());
|
||||
if (params?.offset) search.set("offset", params.offset.toString());
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries?${search}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE,
|
||||
{ endpoint: "GET /api/autopilots/:id/deliveries" },
|
||||
);
|
||||
}
|
||||
|
||||
async getAutopilotDelivery(
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
): Promise<WebhookDelivery> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}`,
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
WebhookDeliveryResponseSchema,
|
||||
{ ...EMPTY_WEBHOOK_DELIVERY, id: deliveryId, autopilot_id: autopilotId },
|
||||
{ endpoint: "GET /api/autopilots/:id/deliveries/:deliveryId" },
|
||||
);
|
||||
}
|
||||
|
||||
// Replay creates a NEW delivery row referencing the original via
|
||||
// `replayed_from_delivery_id`. Server rejects replays of
|
||||
// signature-invalid / rejected deliveries with 400 — the UI keeps the
|
||||
// button disabled for those rows, but the server is the source of truth.
|
||||
async replayAutopilotDelivery(
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
): Promise<WebhookDelivery> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/autopilots/${autopilotId}/deliveries/${deliveryId}/replay`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
WebhookDeliveryResponseSchema,
|
||||
{ ...EMPTY_WEBHOOK_DELIVERY, autopilot_id: autopilotId },
|
||||
{ endpoint: "POST /api/autopilots/:id/deliveries/:deliveryId/replay" },
|
||||
);
|
||||
}
|
||||
|
||||
// GitHub integration
|
||||
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
||||
|
||||
@@ -13,6 +13,8 @@ export type {
|
||||
} from "./client";
|
||||
export { parseWithFallback, setSchemaLogger } from "./schema";
|
||||
export type { ParseOptions } from "./schema";
|
||||
export { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
export type { DuplicateIssueErrorBody } from "./schemas";
|
||||
export { WSClient } from "./ws-client";
|
||||
|
||||
import type { ApiClient as ApiClientType } from "./client";
|
||||
|
||||
@@ -91,6 +91,15 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listGroupedIssues", () => {
|
||||
it("falls back to empty groups when the response is malformed", async () => {
|
||||
stubFetchJson({ groups: "not-an-array" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listGroupedIssues({ group_by: "assignee" });
|
||||
expect(res).toEqual({ groups: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("listComments", () => {
|
||||
it("returns [] when the response is not an array", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
@@ -189,6 +198,68 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutopilotDeliveries", () => {
|
||||
it("falls back to an empty list when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res).toEqual({ deliveries: [], total: 0 });
|
||||
});
|
||||
|
||||
it("falls back to an empty list when `deliveries` is not an array", async () => {
|
||||
stubFetchJson({ deliveries: "not-an-array", total: 0 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res).toEqual({ deliveries: [], total: 0 });
|
||||
});
|
||||
|
||||
it("accepts an unknown future status value rather than dropping the row", async () => {
|
||||
// Server-side enum drift (e.g. new `quarantined` state). The list
|
||||
// must still surface the row; downstream UI code's `default` arm
|
||||
// handles unknown values with a generic visual.
|
||||
stubFetchJson({
|
||||
deliveries: [
|
||||
{
|
||||
id: "d-1",
|
||||
workspace_id: "ws-1",
|
||||
autopilot_id: "ap-1",
|
||||
trigger_id: "t-1",
|
||||
provider: "github",
|
||||
event: "pull_request.opened",
|
||||
dedupe_key: "abc",
|
||||
dedupe_source: "x-github-delivery",
|
||||
signature_status: "valid",
|
||||
status: "quarantined",
|
||||
attempt_count: 1,
|
||||
content_type: "application/json",
|
||||
response_status: 200,
|
||||
autopilot_run_id: null,
|
||||
replayed_from_delivery_id: null,
|
||||
error: null,
|
||||
received_at: "2026-01-01T00:00:00Z",
|
||||
last_attempt_at: "2026-01-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilotDeliveries("ap-1");
|
||||
expect(res.deliveries).toHaveLength(1);
|
||||
expect(res.deliveries[0]?.status).toBe("quarantined");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAutopilotDelivery", () => {
|
||||
it("falls back to a placeholder carrying the requested id", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAutopilotDelivery("ap-1", "d-1");
|
||||
expect(detail.id).toBe("d-1");
|
||||
expect(detail.autopilot_id).toBe("ap-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentFromTemplate", () => {
|
||||
it("falls back to an empty agent when the response is malformed", async () => {
|
||||
// The agent was created server-side even though the client can't
|
||||
|
||||
51
packages/core/api/schemas.test.ts
Normal file
51
packages/core/api/schemas.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DuplicateIssueErrorBodySchema } from "./schemas";
|
||||
|
||||
// The duplicate-issue branch in create-issue.tsx feeds ApiError.body
|
||||
// (typed as `unknown`) through this schema. Any future server drift that
|
||||
// loses the contract MUST fail the parse so the UI falls back to a normal
|
||||
// error toast instead of rendering an empty / partial duplicate card.
|
||||
describe("DuplicateIssueErrorBodySchema", () => {
|
||||
const valid = {
|
||||
code: "active_duplicate_issue",
|
||||
error: "An active issue with this title already exists: MUL-12 – Login bug",
|
||||
issue: {
|
||||
id: "11111111-1111-1111-1111-111111111111",
|
||||
identifier: "MUL-12",
|
||||
title: "Login bug",
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts a well-formed body", () => {
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(valid).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts unknown extra fields via .loose()", () => {
|
||||
const forwardCompat = {
|
||||
...valid,
|
||||
hint: "Try a different title",
|
||||
issue: { ...valid.issue, workspace_id: "ws-1", status: "todo" },
|
||||
};
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(forwardCompat).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a renamed code (so renames degrade to the generic toast)", () => {
|
||||
const renamed = { ...valid, code: "duplicate_issue" };
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(renamed).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a missing issue object", () => {
|
||||
const { issue: _omit, ...without } = valid;
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a non-string issue.id", () => {
|
||||
const broken = { ...valid, issue: { ...valid.issue, id: 42 } };
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(broken).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a missing error field (it is optional)", () => {
|
||||
const { error: _omit, ...without } = valid;
|
||||
expect(DuplicateIssueErrorBodySchema.safeParse(without).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,11 @@ import type {
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
CreateAgentFromTemplateResponse,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
TimelineEntry,
|
||||
WebhookDelivery,
|
||||
} from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -147,6 +150,7 @@ const IssueSchema = z.object({
|
||||
parent_issue_id: z.string().nullable(),
|
||||
project_id: z.string().nullable(),
|
||||
position: z.number(),
|
||||
start_date: z.string().nullable(),
|
||||
due_date: z.string().nullable(),
|
||||
reactions: z.array(z.unknown()).optional(),
|
||||
labels: z.array(z.unknown()).optional(),
|
||||
@@ -164,6 +168,22 @@ export const EMPTY_LIST_ISSUES_RESPONSE: ListIssuesResponse = {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const IssueAssigneeGroupSchema = z.object({
|
||||
id: z.string(),
|
||||
assignee_type: z.string().nullable(),
|
||||
assignee_id: z.string().nullable(),
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const GroupedIssuesResponseSchema = z.object({
|
||||
groups: z.array(IssueAssigneeGroupSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_GROUPED_ISSUES_RESPONSE: GroupedIssuesResponse = {
|
||||
groups: [],
|
||||
};
|
||||
|
||||
const SubscriberSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
user_type: z.string(),
|
||||
@@ -221,6 +241,15 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
const DashboardRunTimeDailySchema = z.object({
|
||||
date: z.string(),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const DashboardRunTimeDailyListSchema = z.array(DashboardRunTimeDailySchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
@@ -306,3 +335,140 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
|
||||
imported_skill_ids: [],
|
||||
reused_skill_ids: [],
|
||||
};
|
||||
|
||||
// Squad member status — backs the Squad detail page's Members tab. status
|
||||
// is `string | null` (not the narrow `SquadMemberStatusValue` union) so a
|
||||
// new server-side status doesn't fail the parse; the UI defaults to a
|
||||
// neutral pill for unknown values.
|
||||
const SquadActiveIssueBriefSchema = z.object({
|
||||
issue_id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
issue_status: z.string(),
|
||||
}).loose();
|
||||
|
||||
const SquadMemberStatusSchema = z.object({
|
||||
member_type: z.string(),
|
||||
member_id: z.string(),
|
||||
status: z.string().nullable().optional().transform((v) => v ?? null),
|
||||
active_issues: z.array(SquadActiveIssueBriefSchema).default([]),
|
||||
last_active_at: z.string().nullable().optional().transform((v) => v ?? null),
|
||||
}).loose();
|
||||
|
||||
export const SquadMemberStatusListResponseSchema = z.object({
|
||||
members: z.array(SquadMemberStatusSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_SQUAD_MEMBER_STATUS_LIST = { members: [] };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structured error body — POST /api/workspaces/:wsId/issues 409 conflict.
|
||||
//
|
||||
// When the server detects an active issue with the same title in the same
|
||||
// workspace, it returns `{ code: "active_duplicate_issue", error, issue }`
|
||||
// instead of letting the create through. The UI uses the embedded issue ref
|
||||
// to offer "view existing" rather than dropping the user into a generic
|
||||
// "create failed" toast.
|
||||
//
|
||||
// Strict guarantees:
|
||||
// - `code` is a literal so a future server rename (e.g. `duplicate_issue`)
|
||||
// fails the parse and falls back to a normal error toast — drift never
|
||||
// ships as a broken duplicate UI.
|
||||
// - `issue` is required; without an id/identifier/title the "view existing"
|
||||
// button has nothing to point at, so we'd rather fall back than guess.
|
||||
// - `issue.status` is intentionally OMITTED: the duplicate toast doesn't
|
||||
// render a StatusIcon (which has no fallback for unknown enum values),
|
||||
// so a future server-side rename of `status` must not knock this branch
|
||||
// out. `.loose()` lets the field pass through unchanged for any other
|
||||
// consumer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DuplicateIssueErrorBodySchema = z.object({
|
||||
code: z.literal("active_duplicate_issue"),
|
||||
error: z.string().optional(),
|
||||
issue: z.object({
|
||||
id: z.string(),
|
||||
identifier: z.string(),
|
||||
title: z.string(),
|
||||
}).loose(),
|
||||
}).loose();
|
||||
|
||||
export interface DuplicateIssueErrorBody {
|
||||
code: "active_duplicate_issue";
|
||||
error?: string;
|
||||
issue: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook delivery schemas — backing the Autopilot Deliveries section. Enums
|
||||
// (`status`, `signature_status`, `provider`) are kept as `z.string()` so a
|
||||
// future server-side value (e.g. a Stripe provider, a new dedupe state)
|
||||
// degrades to a generic UI fallback rather than collapsing the list into
|
||||
// the empty array. `.loose()` lets unknown fields pass through, matching
|
||||
// the rule used by every other endpoint here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WebhookDeliverySchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
autopilot_id: z.string(),
|
||||
trigger_id: z.string(),
|
||||
provider: z.string(),
|
||||
event: z.string(),
|
||||
dedupe_key: z.string().nullable(),
|
||||
dedupe_source: z.string().nullable(),
|
||||
signature_status: z.string(),
|
||||
status: z.string(),
|
||||
attempt_count: z.number().default(0),
|
||||
content_type: z.string().nullable(),
|
||||
response_status: z.number().nullable(),
|
||||
autopilot_run_id: z.string().nullable(),
|
||||
replayed_from_delivery_id: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
received_at: z.string(),
|
||||
last_attempt_at: z.string(),
|
||||
created_at: z.string(),
|
||||
// Detail-only fields. The list endpoint omits them; the detail endpoint
|
||||
// populates raw_body / selected_headers / response_body.
|
||||
selected_headers: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
raw_body: z.string().nullable().optional(),
|
||||
response_body: z.string().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const ListWebhookDeliveriesResponseSchema = z.object({
|
||||
deliveries: z.array(WebhookDeliverySchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const WebhookDeliveryResponseSchema = WebhookDeliverySchema;
|
||||
|
||||
export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesResponse = {
|
||||
deliveries: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
autopilot_id: "",
|
||||
trigger_id: "",
|
||||
provider: "",
|
||||
event: "",
|
||||
dedupe_key: null,
|
||||
dedupe_source: null,
|
||||
signature_status: "not_required",
|
||||
status: "queued",
|
||||
attempt_count: 0,
|
||||
content_type: null,
|
||||
response_status: null,
|
||||
autopilot_run_id: null,
|
||||
replayed_from_delivery_id: null,
|
||||
error: null,
|
||||
received_at: "",
|
||||
last_attempt_at: "",
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { WSMessage, WSEventType } from "../types/events";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
|
||||
|
||||
/** Identifies the WS client to the server. Sent as `client_platform`,
|
||||
* `client_version`, and `client_os` query parameters on the upgrade URL —
|
||||
@@ -84,7 +84,7 @@ export class WSClient {
|
||||
const eventHandlers = this.handlers.get(msg.type);
|
||||
if (eventHandlers) {
|
||||
for (const handler of eventHandlers) {
|
||||
handler(msg.payload, msg.actor_id);
|
||||
handler(msg.payload, msg.actor_id, msg.actor_type);
|
||||
}
|
||||
}
|
||||
for (const handler of this.anyHandlers) {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
export { autopilotKeys, autopilotListOptions, autopilotDetailOptions, autopilotRunsOptions } from "./queries";
|
||||
export {
|
||||
autopilotKeys,
|
||||
autopilotListOptions,
|
||||
autopilotDetailOptions,
|
||||
autopilotRunsOptions,
|
||||
autopilotDeliveriesOptions,
|
||||
autopilotDeliveryOptions,
|
||||
} from "./queries";
|
||||
export {
|
||||
useCreateAutopilot,
|
||||
useUpdateAutopilot,
|
||||
@@ -7,4 +14,7 @@ export {
|
||||
useCreateAutopilotTrigger,
|
||||
useUpdateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
useRotateAutopilotTriggerWebhookToken,
|
||||
useReplayAutopilotDelivery,
|
||||
} from "./mutations";
|
||||
export { buildAutopilotWebhookUrl } from "./webhook";
|
||||
|
||||
@@ -128,3 +128,32 @@ export function useDeleteAutopilotTrigger() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRotateAutopilotTriggerWebhookToken() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, triggerId }: { autopilotId: string; triggerId: string }) =>
|
||||
api.rotateAutopilotTriggerWebhookToken(autopilotId, triggerId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.detail(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Replay re-dispatches a previously-recorded delivery. The server creates
|
||||
// a new delivery row (with `replayed_from_delivery_id`) and synchronously
|
||||
// kicks off a new autopilot run. We invalidate both deliveries and runs so
|
||||
// the new delivery and any resulting run show up immediately.
|
||||
export function useReplayAutopilotDelivery() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: ({ autopilotId, deliveryId }: { autopilotId: string; deliveryId: string }) =>
|
||||
api.replayAutopilotDelivery(autopilotId, deliveryId),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.deliveries(wsId, vars.autopilotId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.runs(wsId, vars.autopilotId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,12 @@ export const autopilotKeys = {
|
||||
[...autopilotKeys.all(wsId), "detail", id] as const,
|
||||
runs: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", id] as const,
|
||||
run: (wsId: string, autopilotId: string, runId: string) =>
|
||||
[...autopilotKeys.all(wsId), "runs", autopilotId, runId] as const,
|
||||
deliveries: (wsId: string, id: string) =>
|
||||
[...autopilotKeys.all(wsId), "deliveries", id] as const,
|
||||
delivery: (wsId: string, autopilotId: string, deliveryId: string) =>
|
||||
[...autopilotKeys.all(wsId), "deliveries", autopilotId, deliveryId] as const,
|
||||
};
|
||||
|
||||
export function autopilotListOptions(wsId: string) {
|
||||
@@ -32,3 +38,52 @@ export function autopilotRunsOptions(wsId: string, id: string) {
|
||||
select: (data) => data.runs,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotRunOptions fetches a single run with its full trigger_payload.
|
||||
// The list endpoint (autopilotRunsOptions) omits trigger_payload to keep
|
||||
// list responses small; callers (e.g. the run-detail dialog) use this
|
||||
// query on demand when the user opens a run.
|
||||
export function autopilotRunOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
runId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.run(wsId, autopilotId, runId),
|
||||
queryFn: () => api.getAutopilotRun(autopilotId, runId),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotDeliveriesOptions powers the Deliveries section in the autopilot
|
||||
// detail page. The list is slim — raw_body / selected_headers / response_body
|
||||
// are omitted server-side. Detail rows are fetched on-demand when the user
|
||||
// expands a row (see autopilotDeliveryOptions).
|
||||
export function autopilotDeliveriesOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.deliveries(wsId, autopilotId),
|
||||
queryFn: () => api.listAutopilotDeliveries(autopilotId),
|
||||
select: (data) => data.deliveries,
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
// autopilotDeliveryOptions fetches the full delivery row including raw_body
|
||||
// and headers subset. Used by the detail dialog opened from a list row.
|
||||
export function autopilotDeliveryOptions(
|
||||
wsId: string,
|
||||
autopilotId: string,
|
||||
deliveryId: string,
|
||||
options?: { enabled?: boolean },
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: autopilotKeys.delivery(wsId, autopilotId, deliveryId),
|
||||
queryFn: () => api.getAutopilotDelivery(autopilotId, deliveryId),
|
||||
enabled: options?.enabled ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
73
packages/core/autopilots/webhook.test.ts
Normal file
73
packages/core/autopilots/webhook.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAutopilotWebhookUrl } from "./webhook";
|
||||
import type { AutopilotTrigger } from "../types";
|
||||
|
||||
const baseTrigger: AutopilotTrigger = {
|
||||
id: "t1",
|
||||
autopilot_id: "a1",
|
||||
kind: "webhook",
|
||||
enabled: true,
|
||||
cron_expression: null,
|
||||
timezone: null,
|
||||
next_run_at: null,
|
||||
webhook_token: "awt_abc",
|
||||
webhook_path: "/api/webhooks/autopilots/awt_abc",
|
||||
webhook_url: null,
|
||||
label: null,
|
||||
last_fired_at: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
|
||||
describe("buildAutopilotWebhookUrl", () => {
|
||||
it("returns the server-provided webhook_url verbatim when present", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, webhook_url: "https://custom.example/api/webhooks/autopilots/awt_abc" },
|
||||
}),
|
||||
).toBe("https://custom.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("composes from apiBaseUrl + webhook_path", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example" }),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("strips trailing slash on apiBaseUrl", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({ trigger: baseTrigger, apiBaseUrl: "https://api.example/" }),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("falls back to currentOrigin when apiBaseUrl is empty", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: baseTrigger,
|
||||
apiBaseUrl: "",
|
||||
currentOrigin: "https://app.example",
|
||||
}),
|
||||
).toBe("https://app.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("composes from token when webhook_path is missing", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, webhook_path: null },
|
||||
apiBaseUrl: "https://api.example",
|
||||
}),
|
||||
).toBe("https://api.example/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
|
||||
it("returns null for non-webhook trigger", () => {
|
||||
expect(
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger: { ...baseTrigger, kind: "schedule", webhook_token: null, webhook_path: null },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns relative path when no base or origin available", () => {
|
||||
expect(buildAutopilotWebhookUrl({ trigger: baseTrigger })).toBe("/api/webhooks/autopilots/awt_abc");
|
||||
});
|
||||
});
|
||||
43
packages/core/autopilots/webhook.ts
Normal file
43
packages/core/autopilots/webhook.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { AutopilotTrigger } from "../types";
|
||||
|
||||
/**
|
||||
* Compose a usable absolute webhook URL for a webhook trigger.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. trigger.webhook_url — present only when MULTICA_PUBLIC_URL is set on the
|
||||
* server. This is the authoritative form when available.
|
||||
* 2. apiBaseUrl + webhook_path — desktop apps and self-host setups where the
|
||||
* server didn't mint an absolute URL but the client knows its API origin.
|
||||
* 3. currentOrigin + webhook_path — browser fallback when getBaseUrl() is
|
||||
* empty (e.g. same-origin Next.js dev).
|
||||
*
|
||||
* Returns null when the trigger has no token / path yet (a new trigger that
|
||||
* hasn't been written back to the cache, or a non-webhook trigger).
|
||||
*/
|
||||
export function buildAutopilotWebhookUrl(params: {
|
||||
trigger: Pick<AutopilotTrigger, "kind" | "webhook_token" | "webhook_path" | "webhook_url">;
|
||||
apiBaseUrl?: string;
|
||||
currentOrigin?: string;
|
||||
}): string | null {
|
||||
const { trigger, apiBaseUrl, currentOrigin } = params;
|
||||
|
||||
if (trigger.kind !== "webhook") return null;
|
||||
|
||||
if (typeof trigger.webhook_url === "string" && trigger.webhook_url) {
|
||||
return trigger.webhook_url;
|
||||
}
|
||||
|
||||
const path =
|
||||
(typeof trigger.webhook_path === "string" && trigger.webhook_path) ||
|
||||
(trigger.webhook_token ? `/api/webhooks/autopilots/${trigger.webhook_token}` : null);
|
||||
if (!path) return null;
|
||||
|
||||
const base = stripTrailingSlash(apiBaseUrl) || stripTrailingSlash(currentOrigin);
|
||||
if (!base) return path; // last resort — relative path will still work in-browser
|
||||
return base + path;
|
||||
}
|
||||
|
||||
function stripTrailingSlash(s: string | undefined): string {
|
||||
if (!s) return "";
|
||||
return s.endsWith("/") ? s.slice(0, -1) : s;
|
||||
}
|
||||
@@ -22,6 +22,8 @@ export const dashboardKeys = {
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
|
||||
};
|
||||
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
@@ -70,3 +72,17 @@ export function dashboardAgentRunTimeOptions(
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./queries";
|
||||
export * from "./pull-request-status";
|
||||
|
||||
146
packages/core/github/pull-request-status.test.ts
Normal file
146
packages/core/github/pull-request-status.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
derivePullRequestStatusKind,
|
||||
derivePullRequestProgressSegments,
|
||||
shouldShowPullRequestStats,
|
||||
type PullRequestStatusInput,
|
||||
} from "./pull-request-status";
|
||||
|
||||
const base: PullRequestStatusInput = { state: "open" };
|
||||
|
||||
describe("derivePullRequestStatusKind", () => {
|
||||
it("closed beats every other signal", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "closed",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 99,
|
||||
checks_pending: 99,
|
||||
checks_passed: 99,
|
||||
}),
|
||||
).toBe("closed");
|
||||
});
|
||||
|
||||
it("merged beats every other signal except closed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
state: "merged",
|
||||
mergeable_state: "dirty",
|
||||
checks_failed: 5,
|
||||
}),
|
||||
).toBe("merged");
|
||||
});
|
||||
|
||||
it("dirty conflicts wins over check signals", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "dirty",
|
||||
checks_passed: 3,
|
||||
}),
|
||||
).toBe("conflicts");
|
||||
});
|
||||
|
||||
it("any failed check beats pending and passed", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 3,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_failed");
|
||||
});
|
||||
|
||||
it("pending beats passed when no failure", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
checks_pending: 1,
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_pending");
|
||||
});
|
||||
|
||||
it("all-passed is checks_passed regardless of mergeable=clean", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({
|
||||
...base,
|
||||
mergeable_state: "clean",
|
||||
checks_passed: 5,
|
||||
}),
|
||||
).toBe("checks_passed");
|
||||
});
|
||||
|
||||
it("clean + no suites is ready-to-merge", () => {
|
||||
expect(
|
||||
derivePullRequestStatusKind({ ...base, mergeable_state: "clean" }),
|
||||
).toBe("ready");
|
||||
});
|
||||
|
||||
it("opaque mergeable values render as unknown", () => {
|
||||
for (const m of ["blocked", "behind", "unstable", "has_hooks", "unknown", null, undefined]) {
|
||||
expect(derivePullRequestStatusKind({ ...base, mergeable_state: m })).toBe("unknown");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("derivePullRequestProgressSegments", () => {
|
||||
it("returns null for terminal PRs (merged / closed)", () => {
|
||||
expect(derivePullRequestProgressSegments({ state: "merged", checks_passed: 5 })).toBeNull();
|
||||
expect(derivePullRequestProgressSegments({ state: "closed", checks_failed: 3 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no suite has been observed", () => {
|
||||
expect(derivePullRequestProgressSegments({ ...base })).toBeNull();
|
||||
expect(
|
||||
derivePullRequestProgressSegments({ ...base, checks_failed: 0, checks_pending: 0, checks_passed: 0 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("orders segments failed → pending → passed (failure leftmost)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 2,
|
||||
checks_passed: 3,
|
||||
});
|
||||
expect(segs).not.toBeNull();
|
||||
expect(segs!.map((s) => s.kind)).toEqual(["failed", "pending", "passed"]);
|
||||
});
|
||||
|
||||
it("emits a zero-width segment-free output (no entry with ratio 0)", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 0,
|
||||
checks_pending: 0,
|
||||
checks_passed: 4,
|
||||
});
|
||||
expect(segs).toEqual([{ kind: "passed", ratio: 1 }]);
|
||||
});
|
||||
|
||||
it("ratios sum to ~1 across segments", () => {
|
||||
const segs = derivePullRequestProgressSegments({
|
||||
...base,
|
||||
checks_failed: 1,
|
||||
checks_pending: 1,
|
||||
checks_passed: 2,
|
||||
})!;
|
||||
const total = segs.reduce((acc, s) => acc + s.ratio, 0);
|
||||
expect(total).toBeCloseTo(1, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldShowPullRequestStats", () => {
|
||||
it("hides when every field is 0 or missing (legacy backend)", () => {
|
||||
expect(shouldShowPullRequestStats({})).toBe(false);
|
||||
expect(shouldShowPullRequestStats({ additions: 0, deletions: 0, changed_files: 0 })).toBe(false);
|
||||
});
|
||||
|
||||
it("shows when at least one number is non-zero", () => {
|
||||
expect(shouldShowPullRequestStats({ additions: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ deletions: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ changed_files: 1 })).toBe(true);
|
||||
expect(shouldShowPullRequestStats({ additions: 437, deletions: 6, changed_files: 6 })).toBe(true);
|
||||
});
|
||||
});
|
||||
101
packages/core/github/pull-request-status.ts
Normal file
101
packages/core/github/pull-request-status.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { GitHubPullRequest } from "../types";
|
||||
|
||||
// Status kinds rendered in the PR sidebar row's detail line. Order in the
|
||||
// pass-through table matters — the first matching rule wins. The order is
|
||||
// chosen so terminal PR states (closed / merged) short-circuit before any
|
||||
// transient CI/conflict signal, since those signals are no longer actionable
|
||||
// on a terminal PR.
|
||||
//
|
||||
// Priority (high → low):
|
||||
// 1. closed (not merged) → status_closed
|
||||
// 2. merged → status_merged
|
||||
// 3. mergeable_state = "dirty" → status_conflicts
|
||||
// 4. any failed suite → status_checks_failed
|
||||
// 5. any pending suite → status_checks_pending
|
||||
// 6. any passed suite → status_checks_passed
|
||||
// 7. no suite + mergeable=clean → status_ready
|
||||
// 8. otherwise → status_unknown
|
||||
//
|
||||
// Note: this table is the single source of truth for the sidebar PR row. The
|
||||
// older row-with-badges implementation used a separate "hide status row for
|
||||
// terminal PRs" branch — the current row renders
|
||||
// with status_closed / status_merged text, never falling through to a
|
||||
// conflicts / checks line on a terminal PR. Keep this priority order in sync
|
||||
// with the i18n keys `pull_request_card_status_*` and with the progress-strip
|
||||
// derivation in `derivePullRequestProgressSegments` (terminal kinds get a
|
||||
// solid bar; the rest map onto the per-suite counts).
|
||||
export type PullRequestStatusKind =
|
||||
| "closed"
|
||||
| "merged"
|
||||
| "conflicts"
|
||||
| "checks_failed"
|
||||
| "checks_pending"
|
||||
| "checks_passed"
|
||||
| "ready"
|
||||
| "unknown";
|
||||
|
||||
export interface PullRequestStatusInput {
|
||||
state: GitHubPullRequest["state"];
|
||||
mergeable_state?: string | null;
|
||||
checks_failed?: number;
|
||||
checks_pending?: number;
|
||||
checks_passed?: number;
|
||||
}
|
||||
|
||||
export function derivePullRequestStatusKind(input: PullRequestStatusInput): PullRequestStatusKind {
|
||||
if (input.state === "closed") return "closed";
|
||||
if (input.state === "merged") return "merged";
|
||||
if (input.mergeable_state === "dirty") return "conflicts";
|
||||
if ((input.checks_failed ?? 0) > 0) return "checks_failed";
|
||||
if ((input.checks_pending ?? 0) > 0) return "checks_pending";
|
||||
if ((input.checks_passed ?? 0) > 0) return "checks_passed";
|
||||
if (input.mergeable_state === "clean") return "ready";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export interface PullRequestProgressSegment {
|
||||
kind: "failed" | "pending" | "passed";
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
// Segmented progress bar input. Returns null when:
|
||||
// - the PR is terminal (closed/merged) — the card paints a solid bar
|
||||
// in a state-specific color, no segmentation needed;
|
||||
// - no check_suite has been observed (total === 0) — the card hides
|
||||
// the bar entirely.
|
||||
// Otherwise emits the segments left-to-right: failed → pending → passed.
|
||||
// "Failure first" is intentional: problems should be visible before signal
|
||||
// that everything is fine.
|
||||
export function derivePullRequestProgressSegments(
|
||||
input: PullRequestStatusInput,
|
||||
): PullRequestProgressSegment[] | null {
|
||||
if (input.state === "closed" || input.state === "merged") return null;
|
||||
const failed = input.checks_failed ?? 0;
|
||||
const pending = input.checks_pending ?? 0;
|
||||
const passed = input.checks_passed ?? 0;
|
||||
const total = failed + pending + passed;
|
||||
if (total === 0) return null;
|
||||
const segments: PullRequestProgressSegment[] = [];
|
||||
if (failed > 0) segments.push({ kind: "failed", ratio: failed / total });
|
||||
if (pending > 0) segments.push({ kind: "pending", ratio: pending / total });
|
||||
if (passed > 0) segments.push({ kind: "passed", ratio: passed / total });
|
||||
return segments;
|
||||
}
|
||||
|
||||
export interface PullRequestStatsInput {
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changed_files?: number;
|
||||
}
|
||||
|
||||
// shouldShowPullRequestStats encodes the "old backend → new frontend" guard:
|
||||
// when the backend that served this PR row doesn't know about the stats
|
||||
// columns yet, every numeric field defaults to 0. Rendering "+0 −0 · 0 files"
|
||||
// in that case would be a lie (the PR almost certainly has real changes),
|
||||
// so we hide the entire stats row until at least one signal is non-zero.
|
||||
export function shouldShowPullRequestStats(input: PullRequestStatsInput): boolean {
|
||||
const a = input.additions ?? 0;
|
||||
const d = input.deletions ?? 0;
|
||||
const f = input.changed_files ?? 0;
|
||||
return a + d + f > 0;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQueryClient, type QueryKey } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
import {
|
||||
@@ -24,7 +25,7 @@ import {
|
||||
} from "./delete-cache";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
|
||||
import type {
|
||||
CreateIssueRequest,
|
||||
UpdateIssueRequest,
|
||||
@@ -102,6 +103,58 @@ export function useLoadMoreByStatus(
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
export function useLoadMoreByAssigneeGroup(
|
||||
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
||||
queryKey: QueryKey,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const cache = qc.getQueryData<GroupedIssuesResponse>(queryKey);
|
||||
const cachedGroup = cache?.groups.find((g) => g.id === group.id);
|
||||
const loaded = cachedGroup?.issues.length ?? 0;
|
||||
const total = cachedGroup?.total ?? 0;
|
||||
const hasMore = loaded < total;
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (isLoading || !hasMore) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await api.listGroupedIssues({
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: loaded,
|
||||
...filter,
|
||||
group_assignee_type: group.assignee_type ?? "none",
|
||||
group_assignee_id: group.assignee_id ?? undefined,
|
||||
});
|
||||
const nextGroup = res.groups[0];
|
||||
if (!nextGroup) return;
|
||||
|
||||
qc.setQueryData<GroupedIssuesResponse>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
return {
|
||||
groups: old.groups.map((existing) => {
|
||||
if (existing.id !== nextGroup.id) return existing;
|
||||
const existingIds = new Set(existing.issues.map((issue) => issue.id));
|
||||
const appended = nextGroup.issues.filter((issue) => !existingIds.has(issue.id));
|
||||
return {
|
||||
...existing,
|
||||
issues: [...existing.issues, ...appended],
|
||||
total: nextGroup.total,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filter, group.assignee_id, group.assignee_type, hasMore, isLoading, loaded, qc, queryKey]);
|
||||
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Issue CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -126,6 +179,8 @@ export function useCreateIssue() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -200,6 +255,8 @@ export function useUpdateIssue() {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
// Refresh the issue's attachments cache when the description editor
|
||||
// bound new uploads — the description editor reads `issueAttachments`
|
||||
// to resolve text-preview Eye gates, and unlike other mutations this
|
||||
@@ -281,6 +338,8 @@ export function useDeleteIssue() {
|
||||
},
|
||||
onSettled: (_data, _err, _id, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
|
||||
},
|
||||
});
|
||||
@@ -338,6 +397,8 @@ export function useBatchUpdateIssues() {
|
||||
},
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
@@ -438,6 +499,8 @@ export function useBatchDeleteIssues() {
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
|
||||
invalidateDeletedIssueParentCaches(qc, wsId, {
|
||||
parentIssueIds: Array.from(ctx.parentIssueIds),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type {
|
||||
GroupedIssuesResponse,
|
||||
IssueStatus,
|
||||
ListGroupedIssuesParams,
|
||||
ListIssuesParams,
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
@@ -10,11 +12,22 @@ import { BOARD_STATUSES } from "./config";
|
||||
export const issueKeys = {
|
||||
all: (wsId: string) => ["issues", wsId] as const,
|
||||
list: (wsId: string) => [...issueKeys.all(wsId), "list"] as const,
|
||||
assigneeGroupsAll: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "assignee-groups"] as const,
|
||||
assigneeGroups: (wsId: string, filter: AssigneeGroupedIssuesFilter) =>
|
||||
[...issueKeys.assigneeGroupsAll(wsId), filter] as const,
|
||||
/** All "my issues" queries — use for bulk invalidation. */
|
||||
myAll: (wsId: string) => [...issueKeys.all(wsId), "my"] as const,
|
||||
/** Per-scope "my issues" list with filter identity baked into the key. */
|
||||
myList: (wsId: string, scope: string, filter: MyIssuesFilter) =>
|
||||
[...issueKeys.myAll(wsId), scope, filter] as const,
|
||||
myAssigneeGroupsAll: (wsId: string) =>
|
||||
[...issueKeys.myAll(wsId), "assignee-groups"] as const,
|
||||
myAssigneeGroups: (
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) => [...issueKeys.myAssigneeGroupsAll(wsId), scope, filter] as const,
|
||||
detail: (wsId: string, id: string) =>
|
||||
[...issueKeys.all(wsId), "detail", id] as const,
|
||||
children: (wsId: string, id: string) =>
|
||||
@@ -45,6 +58,11 @@ export type MyIssuesFilter = Pick<
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
>;
|
||||
|
||||
export type AssigneeGroupedIssuesFilter = Omit<
|
||||
ListGroupedIssuesParams,
|
||||
"group_by" | "limit" | "offset" | "group_assignee_type" | "group_assignee_id"
|
||||
>;
|
||||
|
||||
/** Page size per status column. */
|
||||
export const ISSUE_PAGE_SIZE = 50;
|
||||
|
||||
@@ -92,6 +110,22 @@ export function issueListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function issueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) {
|
||||
return queryOptions<GroupedIssuesResponse>({
|
||||
queryKey: issueKeys.assigneeGroups(wsId, filter),
|
||||
queryFn: () =>
|
||||
api.listGroupedIssues({
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-filtered issue list for the My Issues page.
|
||||
* Each scope gets its own cache entry so switching tabs is instant after first load.
|
||||
@@ -108,6 +142,23 @@ export function myIssueListOptions(
|
||||
});
|
||||
}
|
||||
|
||||
export function myIssueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: AssigneeGroupedIssuesFilter,
|
||||
) {
|
||||
return queryOptions<GroupedIssuesResponse>({
|
||||
queryKey: issueKeys.myAssigneeGroups(wsId, scope, filter),
|
||||
queryFn: () =>
|
||||
api.listGroupedIssues({
|
||||
group_by: "assignee",
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: 0,
|
||||
...filter,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function issueDetailOptions(wsId: string, id: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.detail(wsId, id),
|
||||
|
||||
48
packages/core/issues/stores/actor-issues-view-store.ts
Normal file
48
packages/core/issues/stores/actor-issues-view-store.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
"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"]),
|
||||
// Actor tasks panel is list-only; override the slice's "board" default.
|
||||
viewMode: "list",
|
||||
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(),
|
||||
);
|
||||
44
packages/core/issues/stores/create-mode-store.test.ts
Normal file
44
packages/core/issues/stores/create-mode-store.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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,6 +3,7 @@
|
||||
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
|
||||
@@ -34,3 +35,18 @@ export const useCreateModeStore = create<CreateModeState>()(
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Open the create-issue flow in whichever mode the user landed on last.
|
||||
* Generic entry points (sidebar button, command palette, `c` shortcut) call
|
||||
* this so the persisted preference actually takes effect; entry points that
|
||||
* pre-seed manual-only fields (status, parent_issue_id) keep opening
|
||||
* "create-issue" directly because agent mode can't honour those seeds.
|
||||
*/
|
||||
export function openCreateIssueWithPreference(
|
||||
data?: Record<string, unknown> | null,
|
||||
) {
|
||||
const lastMode = useCreateModeStore.getState().lastMode;
|
||||
const modal = lastMode === "manual" ? "create-issue" : "quick-create-issue";
|
||||
useModalStore.getState().open(modal, data ?? null);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const RESET_STATE = {
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
|
||||
@@ -11,6 +11,7 @@ interface IssueDraft {
|
||||
priority: IssuePriority;
|
||||
assigneeType?: IssueAssigneeType;
|
||||
assigneeId?: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
}
|
||||
|
||||
@@ -21,6 +22,7 @@ const EMPTY_DRAFT: IssueDraft = {
|
||||
priority: "none",
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export {
|
||||
useCreateModeStore,
|
||||
openCreateIssueWithPreference,
|
||||
type CreateMode,
|
||||
} from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export {
|
||||
useRecentIssuesStore,
|
||||
@@ -19,6 +23,11 @@ export {
|
||||
type MyIssuesViewState,
|
||||
type MyIssuesScope,
|
||||
} from "./my-issues-view-store";
|
||||
export {
|
||||
actorIssuesViewStore,
|
||||
type ActorIssuesViewState,
|
||||
type ActorIssuesScope,
|
||||
} from "./actor-issues-view-store";
|
||||
export {
|
||||
useIssueViewStore,
|
||||
createIssueViewStore,
|
||||
|
||||
@@ -10,13 +10,15 @@ import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "..
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
|
||||
export interface CardProperties {
|
||||
priority: boolean;
|
||||
description: boolean;
|
||||
assignee: boolean;
|
||||
startDate: boolean;
|
||||
dueDate: boolean;
|
||||
project: boolean;
|
||||
childProgress: boolean;
|
||||
@@ -31,15 +33,22 @@ export interface ActorFilterValue {
|
||||
export const SORT_OPTIONS: { value: SortField; label: string }[] = [
|
||||
{ value: "position", label: "Manual" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
{ value: "start_date", label: "Start date" },
|
||||
{ value: "due_date", label: "Due date" },
|
||||
{ value: "created_at", label: "Created date" },
|
||||
{ value: "title", label: "Title" },
|
||||
];
|
||||
|
||||
export const GROUPING_OPTIONS: { value: IssueGrouping; label: string }[] = [
|
||||
{ value: "status", label: "Status" },
|
||||
{ value: "assignee", label: "Assignee" },
|
||||
];
|
||||
|
||||
export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }[] = [
|
||||
{ key: "priority", label: "Priority" },
|
||||
{ key: "description", label: "Description" },
|
||||
{ key: "assignee", label: "Assignee" },
|
||||
{ key: "startDate", label: "Start date" },
|
||||
{ key: "dueDate", label: "Due date" },
|
||||
{ key: "project", label: "Project" },
|
||||
{ key: "labels", label: "Labels" },
|
||||
@@ -48,6 +57,7 @@ export const CARD_PROPERTY_OPTIONS: { key: keyof CardProperties; label: string }
|
||||
|
||||
export interface IssueViewState {
|
||||
viewMode: ViewMode;
|
||||
grouping: IssueGrouping;
|
||||
statusFilters: IssueStatus[];
|
||||
priorityFilters: IssuePriority[];
|
||||
assigneeFilters: ActorFilterValue[];
|
||||
@@ -61,6 +71,7 @@ export interface IssueViewState {
|
||||
cardProperties: CardProperties;
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGrouping: (grouping: IssueGrouping) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
toggleAssigneeFilter: (value: ActorFilterValue) => void;
|
||||
@@ -80,6 +91,7 @@ export interface IssueViewState {
|
||||
|
||||
export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): IssueViewState => ({
|
||||
viewMode: "board",
|
||||
grouping: "status",
|
||||
statusFilters: [],
|
||||
priorityFilters: [],
|
||||
assigneeFilters: [],
|
||||
@@ -94,6 +106,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
priority: true,
|
||||
description: true,
|
||||
assignee: true,
|
||||
startDate: true,
|
||||
dueDate: true,
|
||||
project: true,
|
||||
childProgress: true,
|
||||
@@ -102,6 +115,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
listCollapsedStatuses: [],
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGrouping: (grouping) => set({ grouping }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
statusFilters: state.statusFilters.includes(status)
|
||||
@@ -205,6 +219,7 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state: IssueViewState) => ({
|
||||
viewMode: state.viewMode,
|
||||
grouping: state.grouping,
|
||||
statusFilters: state.statusFilters,
|
||||
priorityFilters: state.priorityFilters,
|
||||
assigneeFilters: state.assigneeFilters,
|
||||
|
||||
@@ -64,6 +64,7 @@ const baseIssue: Issue = {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
labels: [labelA],
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
|
||||
@@ -19,6 +19,8 @@ export function onIssueCreated(
|
||||
old ? addIssueToBuckets(old, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
if (issue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
@@ -48,6 +50,8 @@ export function onIssueUpdated(
|
||||
old ? patchIssueInBuckets(old, issue.id, issue) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
||||
old ? { ...old, ...issue } : old,
|
||||
);
|
||||
@@ -100,6 +104,8 @@ export function onIssueLabelsChanged(
|
||||
old ? { ...old, labels } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
}
|
||||
|
||||
export function onIssueDeleted(
|
||||
@@ -108,4 +114,6 @@ export function onIssueDeleted(
|
||||
issueId: string,
|
||||
) {
|
||||
cleanupDeletedIssueCaches(qc, wsId, issueId);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"./api": "./api/index.ts",
|
||||
"./api/client": "./api/client.ts",
|
||||
"./api/schema": "./api/schema.ts",
|
||||
"./api/schemas": "./api/schemas.ts",
|
||||
"./api/ws-client": "./api/ws-client.ts",
|
||||
"./config": "./config/index.ts",
|
||||
"./auth": "./auth/index.ts",
|
||||
@@ -108,8 +109,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
"@testing-library/react": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"jsdom": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ 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");
|
||||
@@ -21,6 +22,7 @@ describe("paths.workspace(slug)", () => {
|
||||
expect(ws.squads()).toBe("/acme/squads");
|
||||
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
|
||||
expect(ws.settings()).toBe("/acme/settings");
|
||||
expect(ws.attachmentPreview("att_42")).toBe("/acme/attachments/att_42/preview");
|
||||
});
|
||||
|
||||
it("URL-encodes special characters in ids", () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ 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`,
|
||||
@@ -36,6 +37,7 @@ function workspaceScoped(slug: string) {
|
||||
skills: () => `${ws}/skills`,
|
||||
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
|
||||
settings: () => `${ws}/settings`,
|
||||
attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import type { WSEventType } from "../types";
|
||||
import { useWS } from "./provider";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
|
||||
|
||||
/**
|
||||
* Hook that subscribes to a WebSocket event and calls the handler.
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
import { createLogger } from "../logger";
|
||||
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
|
||||
|
||||
type EventHandler = (payload: unknown, actorId?: string) => void;
|
||||
type EventHandler = (payload: unknown, actorId?: string, actorType?: string) => void;
|
||||
|
||||
interface WSContextValue {
|
||||
subscribe: (event: WSEventType, handler: EventHandler) => () => void;
|
||||
|
||||
122
packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
Normal file
122
packages/core/realtime/use-realtime-sync-ws-instance.test.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { WSClient } from "../api/ws-client";
|
||||
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
|
||||
|
||||
vi.mock("../platform/workspace-storage", () => ({
|
||||
getCurrentWsId: () => "ws-1",
|
||||
getCurrentSlug: () => "test-ws",
|
||||
}));
|
||||
|
||||
vi.mock("../paths", () => ({
|
||||
useHasOnboarded: () => true,
|
||||
resolvePostAuthDestination: () => "/",
|
||||
}));
|
||||
|
||||
function createMockWs(): WSClient {
|
||||
return {
|
||||
on: vi.fn(() => () => {}),
|
||||
onAny: vi.fn(() => () => {}),
|
||||
onReconnect: vi.fn(() => () => {}),
|
||||
} as unknown as WSClient;
|
||||
}
|
||||
|
||||
function createStores(): RealtimeSyncStores {
|
||||
return {
|
||||
authStore: Object.assign(() => ({}), {
|
||||
getState: () => ({ user: { id: "u1" } }),
|
||||
subscribe: () => () => {},
|
||||
setState: () => {},
|
||||
destroy: () => {},
|
||||
}),
|
||||
} as unknown as RealtimeSyncStores;
|
||||
}
|
||||
|
||||
function createWrapper(qc: QueryClient) {
|
||||
// Named function (not arrow) so react/display-name lint rule passes —
|
||||
// anonymous render-fn components break that rule even in test files.
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe("useRealtimeSync — ws instance change", () => {
|
||||
let qc: QueryClient;
|
||||
let stores: RealtimeSyncStores;
|
||||
let invalidateSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
stores = createStores();
|
||||
invalidateSpy = vi.spyOn(qc, "invalidateQueries");
|
||||
});
|
||||
|
||||
it("skips invalidation on first non-null ws instance", () => {
|
||||
const ws = createMockWs();
|
||||
renderHook(() => useRealtimeSync(ws, stores), {
|
||||
wrapper: createWrapper(qc),
|
||||
});
|
||||
|
||||
// The main effect calls invalidateQueries for its own setup, but the
|
||||
// ws-instance-change effect should NOT have fired invalidation.
|
||||
// The only invalidateQueries calls should come from the main effect's
|
||||
// event handlers, not from the instance-change effect.
|
||||
// We verify by checking that no call was made with workspaceKeys.list()
|
||||
// pattern from the instance-change path (it logs a specific message).
|
||||
// Simpler: count calls — first mount with a ws should not trigger the
|
||||
// workspace-scoped bulk invalidation.
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not invalidate when ws goes from instance to null", () => {
|
||||
const ws1 = createMockWs();
|
||||
const { rerender } = renderHook(
|
||||
({ ws }) => useRealtimeSync(ws, stores),
|
||||
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
invalidateSpy.mockClear();
|
||||
rerender({ ws: null });
|
||||
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invalidates exactly once when a new ws instance appears after null gap", () => {
|
||||
const ws1 = createMockWs();
|
||||
const { rerender } = renderHook(
|
||||
({ ws }) => useRealtimeSync(ws, stores),
|
||||
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
// Simulate workspace switch: ws -> null -> new ws
|
||||
invalidateSpy.mockClear();
|
||||
rerender({ ws: null });
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
|
||||
const ws2 = createMockWs();
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
// Should have called invalidateQueries for all workspace-scoped keys
|
||||
// (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("does not re-invalidate when rerendered with the same ws instance", () => {
|
||||
const ws1 = createMockWs();
|
||||
const { rerender } = renderHook(
|
||||
({ ws }) => useRealtimeSync(ws, stores),
|
||||
{ initialProps: { ws: ws1 as WSClient | null }, wrapper: createWrapper(qc) },
|
||||
);
|
||||
|
||||
invalidateSpy.mockClear();
|
||||
// Rerender with same instance
|
||||
rerender({ ws: ws1 });
|
||||
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -107,6 +107,30 @@ export function applyChatDoneToCache(
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates all workspace-scoped queries. Used after reconnect and when a
|
||||
* new WSClient instance is detected (workspace switch) to recover events
|
||||
* missed while disconnected.
|
||||
*/
|
||||
function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
authStore: UseBoundStore<StoreApi<AuthState>>;
|
||||
}
|
||||
@@ -154,7 +178,14 @@ export function useRealtimeSync(
|
||||
},
|
||||
agent: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
// Squad members status is derived per agent, so any agent
|
||||
// change (status flip, archive, runtime swap) needs to refresh
|
||||
// the per-squad members-status cache. Prefix-matches both the
|
||||
// squad list and every squadMemberStatus query.
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
|
||||
}
|
||||
},
|
||||
member: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
@@ -197,7 +228,14 @@ export function useRealtimeSync(
|
||||
},
|
||||
daemon: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
// Runtime online/offline transitions move the derived status
|
||||
// for every agent that hosts on this runtime, which shifts the
|
||||
// working/idle/offline pill on the squad page. Same prefix
|
||||
// invalidation pattern as the agent handler above.
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
|
||||
}
|
||||
},
|
||||
autopilot: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
@@ -239,6 +277,14 @@ export function useRealtimeSync(
|
||||
// every list-of-tasks query stale" so cache stays fresh even
|
||||
// when the relevant component isn't currently mounted.
|
||||
qc.invalidateQueries({ queryKey: ["issues", "tasks"] });
|
||||
// Per-issue token usage card (issue-detail right rail). Same
|
||||
// shape as the tasks invalidation above — any task lifecycle
|
||||
// event shifts the aggregated usage numbers.
|
||||
qc.invalidateQueries({ queryKey: ["issues", "usage"] });
|
||||
// Squad members-status reads the same task lifecycle to flip
|
||||
// working ↔ idle for each agent member. Prefix-matches every
|
||||
// mounted squad-page's members-status query in O(1).
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -833,21 +879,7 @@ export function useRealtimeSync(
|
||||
const unsub = ws.onReconnect(async () => {
|
||||
logger.info("reconnected, refetching all data");
|
||||
try {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
|
||||
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.all(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.all(wsId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
invalidateWorkspaceScopedQueries(qc);
|
||||
} catch (e) {
|
||||
logger.error("reconnect refetch failed", e);
|
||||
}
|
||||
@@ -855,4 +887,22 @@ export function useRealtimeSync(
|
||||
|
||||
return unsub;
|
||||
}, [ws, qc]);
|
||||
|
||||
// New WSClient instance (workspace switch) -> invalidate workspace-scoped
|
||||
// queries to recover events missed while the previous instance was torn down.
|
||||
// Skips the initial assignment to avoid a redundant refetch on first mount.
|
||||
const wsInstanceRef = useRef<WSClient | null>(null);
|
||||
useEffect(() => {
|
||||
if (!ws) return;
|
||||
if (wsInstanceRef.current === null) {
|
||||
// First non-null instance — store and skip invalidation.
|
||||
wsInstanceRef.current = ws;
|
||||
return;
|
||||
}
|
||||
if (wsInstanceRef.current === ws) return;
|
||||
wsInstanceRef.current = ws;
|
||||
|
||||
logger.info("new WSClient instance detected, invalidating workspace queries");
|
||||
invalidateWorkspaceScopedQueries(qc);
|
||||
}, [ws, qc]);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ export const runtimeLocalSkillsKeys = {
|
||||
|
||||
const POLL_INTERVAL_MS = 500;
|
||||
const POLL_TIMEOUT_MS = 30_000;
|
||||
// Import timeout is longer than discovery because old daemons (pre-batch) pop
|
||||
// only one import per heartbeat cycle (~15s). With 10 queued imports the 10th
|
||||
// can wait up to 150s in pending before being claimed, plus up to 60s for
|
||||
// the daemon to actually run the import.
|
||||
//
|
||||
// Timeout invariant: IMPORT_POLL_TIMEOUT_MS must exceed
|
||||
// runtimeLocalSkillPendingTimeout + runtimeLocalSkillRunningTimeout
|
||||
// (server/internal/handler/runtime_local_skills.go).
|
||||
// See also IMPORT_CONCURRENCY in packages/views/.../runtime-local-skill-import-panel.tsx
|
||||
// and maxLocalSkillImportBatch in server/internal/handler/daemon.go.
|
||||
const IMPORT_POLL_TIMEOUT_MS = 4 * 60_000; // 4 minutes
|
||||
|
||||
export async function resolveRuntimeLocalSkills(
|
||||
runtimeId: string,
|
||||
@@ -49,7 +60,7 @@ export async function resolveRuntimeLocalSkillImport(
|
||||
let current = initial;
|
||||
|
||||
while (current.status === "pending" || current.status === "running") {
|
||||
if (Date.now() - start > POLL_TIMEOUT_MS) {
|
||||
if (Date.now() - start > IMPORT_POLL_TIMEOUT_MS) {
|
||||
throw new Error("runtime local skill import timed out");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
||||
|
||||
@@ -397,6 +397,17 @@ export interface DashboardAgentRunTime {
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
// One (date) bucket of terminal-task run-time + counts for the workspace
|
||||
// dashboard. Powers the Time and Tasks metrics on the daily-trend toggle
|
||||
// — same toggle as Tokens / Cost, anchored on completed_at so day buckets
|
||||
// line up with the per-agent run-time card.
|
||||
export interface DashboardRunTimeDaily {
|
||||
date: string;
|
||||
total_seconds: number;
|
||||
task_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export type RuntimeUpdateStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CreateIssueRequest {
|
||||
assignee_id?: string;
|
||||
parent_issue_id?: string;
|
||||
project_id?: string;
|
||||
start_date?: string;
|
||||
due_date?: string;
|
||||
attachment_ids?: string[];
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export interface UpdateIssueRequest {
|
||||
assignee_type?: IssueAssigneeType | null;
|
||||
assignee_id?: string | null;
|
||||
position?: number;
|
||||
start_date?: string | null;
|
||||
due_date?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
project_id?: string | null;
|
||||
@@ -46,12 +48,52 @@ export interface ListIssuesParams {
|
||||
open_only?: boolean;
|
||||
}
|
||||
|
||||
export interface IssueActorRef {
|
||||
type: IssueAssigneeType;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ListGroupedIssuesParams {
|
||||
group_by: "assignee";
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
workspace_id?: string;
|
||||
statuses?: IssueStatus[];
|
||||
priorities?: IssuePriority[];
|
||||
assignee_types?: IssueAssigneeType[];
|
||||
assignee_id?: string;
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
project_id?: string;
|
||||
assignee_filters?: IssueActorRef[];
|
||||
include_no_assignee?: boolean;
|
||||
creator_filters?: IssueActorRef[];
|
||||
project_ids?: string[];
|
||||
include_no_project?: boolean;
|
||||
label_ids?: string[];
|
||||
group_assignee_type?: IssueAssigneeType | "none";
|
||||
group_assignee_id?: string;
|
||||
}
|
||||
|
||||
/** Raw backend response shape for `GET /api/issues`. */
|
||||
export interface ListIssuesResponse {
|
||||
issues: Issue[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface IssueAssigneeGroup {
|
||||
id: string;
|
||||
assignee_type: IssueAssigneeType | null;
|
||||
assignee_id: string | null;
|
||||
issues: Issue[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Raw backend response shape for `GET /api/issues/grouped?group_by=assignee`. */
|
||||
export interface GroupedIssuesResponse {
|
||||
groups: IssueAssigneeGroup[];
|
||||
}
|
||||
|
||||
/** Per-status bucket in the paginated issue cache. `total` is the server count (all pages), not the length of `issues`. */
|
||||
export interface IssueStatusBucket {
|
||||
issues: Issue[];
|
||||
@@ -70,6 +112,8 @@ export interface ListIssuesCache {
|
||||
export interface SearchIssueResult extends Issue {
|
||||
match_source: "title" | "description" | "comment";
|
||||
matched_snippet?: string;
|
||||
matched_description_snippet?: string;
|
||||
matched_comment_snippet?: string;
|
||||
}
|
||||
|
||||
export interface SearchIssuesResponse {
|
||||
|
||||
@@ -4,7 +4,16 @@ export type AutopilotExecutionMode = "create_issue" | "run_only";
|
||||
|
||||
export type AutopilotTriggerKind = "schedule" | "webhook" | "api";
|
||||
|
||||
export type AutopilotRunStatus = "issue_created" | "running" | "completed" | "failed";
|
||||
// `skipped` is emitted by the backend pre-flight admission check
|
||||
// (assignee runtime offline at dispatch time, MUL-1899). The frontend MUST
|
||||
// handle it explicitly — falling through to a generic case used to show
|
||||
// the run as still-pending which masked the no-op.
|
||||
export type AutopilotRunStatus =
|
||||
| "issue_created"
|
||||
| "running"
|
||||
| "completed"
|
||||
| "failed"
|
||||
| "skipped";
|
||||
|
||||
export type AutopilotRunSource = "schedule" | "manual" | "webhook" | "api";
|
||||
|
||||
@@ -33,6 +42,14 @@ export interface AutopilotTrigger {
|
||||
timezone: string | null;
|
||||
next_run_at: string | null;
|
||||
webhook_token: string | null;
|
||||
// webhook_path is computed server-side from webhook_token (always
|
||||
// "/api/webhooks/autopilots/{token}"). Optional so older servers can be
|
||||
// talked to gracefully.
|
||||
webhook_path?: string | null;
|
||||
// webhook_url is only present when MULTICA_PUBLIC_URL is configured
|
||||
// server-side. Clients fall back to composing from getBaseUrl/origin +
|
||||
// webhook_path when this is missing.
|
||||
webhook_url?: string | null;
|
||||
label: string | null;
|
||||
last_fired_at: string | null;
|
||||
created_at: string;
|
||||
@@ -100,3 +117,52 @@ export interface ListAutopilotRunsResponse {
|
||||
runs: AutopilotRun[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Webhook delivery enum is server-canonical. The frontend MUST `default`
|
||||
// any switch on it to a generic fallback — see API Response Compatibility
|
||||
// rules in CLAUDE.md. PR1 collapsed `skipped` into `dispatched` (the run
|
||||
// itself carries the skip state); a future server may add new values.
|
||||
export type WebhookDeliveryStatus =
|
||||
| "queued"
|
||||
| "dispatched"
|
||||
| "rejected"
|
||||
| "ignored"
|
||||
| "failed";
|
||||
|
||||
export type WebhookSignatureStatus =
|
||||
| "not_required"
|
||||
| "valid"
|
||||
| "invalid"
|
||||
| "missing";
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
autopilot_id: string;
|
||||
trigger_id: string;
|
||||
provider: string;
|
||||
event: string;
|
||||
dedupe_key: string | null;
|
||||
dedupe_source: string | null;
|
||||
signature_status: WebhookSignatureStatus;
|
||||
status: WebhookDeliveryStatus;
|
||||
attempt_count: number;
|
||||
content_type: string | null;
|
||||
response_status: number | null;
|
||||
autopilot_run_id: string | null;
|
||||
replayed_from_delivery_id: string | null;
|
||||
error: string | null;
|
||||
received_at: string;
|
||||
last_attempt_at: string;
|
||||
created_at: string;
|
||||
// Detail-only fields. The list endpoint omits these to keep the wire
|
||||
// size bounded (raw_body alone can be up to 256 KiB per delivery).
|
||||
selected_headers?: Record<string, unknown> | null;
|
||||
raw_body?: string | null;
|
||||
response_body?: string | null;
|
||||
}
|
||||
|
||||
export interface ListWebhookDeliveriesResponse {
|
||||
deliveries: WebhookDelivery[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
payload: T;
|
||||
actor_id?: string;
|
||||
actor_type?: string;
|
||||
}
|
||||
|
||||
export interface IssueCreatedPayload {
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
|
||||
|
||||
/** Aggregated CI status for a PR's current head SHA, computed server-side from
|
||||
* the latest check_suite per app. `null` when no completed suite has been seen
|
||||
* yet (e.g. PR just opened, or repository has no CI configured). */
|
||||
export type GitHubPullRequestChecksConclusion = "passed" | "failed" | "pending";
|
||||
|
||||
/** Raw mirror of GitHub's `mergeable_state`. The UI only surfaces `clean` and
|
||||
* `dirty`; the other values (`blocked`, `behind`, `unstable`, `unknown`,
|
||||
* `has_hooks`, `draft`) round-trip but render as unknown to avoid asserting
|
||||
* "conflicts" for blocking reasons that aren't actual conflicts. */
|
||||
export type GitHubMergeableState = string;
|
||||
|
||||
export interface GitHubInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -26,6 +37,20 @@ export interface GitHubPullRequest {
|
||||
closed_at: string | null;
|
||||
pr_created_at: string;
|
||||
pr_updated_at: string;
|
||||
/** Optional; older backends omit this field. */
|
||||
mergeable_state?: GitHubMergeableState | null;
|
||||
/** Optional; older backends omit this field. */
|
||||
checks_conclusion?: GitHubPullRequestChecksConclusion | null;
|
||||
/** Per-suite counts that feed the segmented progress bar. Older backends
|
||||
* omit these; treat absence as 0 (the card renders only when sum > 0). */
|
||||
checks_passed?: number;
|
||||
checks_failed?: number;
|
||||
checks_pending?: number;
|
||||
/** Diff stats from GitHub's `pull_request` payload. Older backends omit
|
||||
* these fields; we treat 0/0/0 as "unknown" and hide the stats row. */
|
||||
additions?: number;
|
||||
deletions?: number;
|
||||
changed_files?: number;
|
||||
}
|
||||
|
||||
export interface ListGitHubInstallationsResponse {
|
||||
|
||||
@@ -8,6 +8,7 @@ export type InboxItemType =
|
||||
| "assignee_changed"
|
||||
| "status_changed"
|
||||
| "priority_changed"
|
||||
| "start_date_changed"
|
||||
| "due_date_changed"
|
||||
| "new_comment"
|
||||
| "mentioned"
|
||||
|
||||
@@ -32,6 +32,7 @@ export type {
|
||||
DashboardUsageDaily,
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
DashboardRunTimeDaily,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
@@ -78,7 +79,9 @@ export type {
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
GitHubInstallation,
|
||||
GitHubMergeableState,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestChecksConclusion,
|
||||
GitHubPullRequestState,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
@@ -99,6 +102,10 @@ export type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
WebhookDelivery,
|
||||
WebhookDeliveryStatus,
|
||||
WebhookSignatureStatus,
|
||||
ListWebhookDeliveriesResponse,
|
||||
} from "./autopilot";
|
||||
export type {
|
||||
Squad,
|
||||
@@ -112,4 +119,8 @@ export type {
|
||||
RemoveSquadMemberRequest,
|
||||
UpdateSquadMemberRoleRequest,
|
||||
CreateSquadActivityLogRequest,
|
||||
SquadMemberStatusValue,
|
||||
SquadActiveIssueBrief,
|
||||
SquadMemberStatus,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "./squad";
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface Issue {
|
||||
parent_issue_id: string | null;
|
||||
project_id: string | null;
|
||||
position: number;
|
||||
start_date: string | null;
|
||||
due_date: string | null;
|
||||
reactions?: IssueReaction[];
|
||||
labels?: Label[];
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface CreateSquadRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
leader_id: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSquadRequest {
|
||||
@@ -75,3 +76,32 @@ export interface CreateSquadActivityLogRequest {
|
||||
outcome: SquadActivityOutcome;
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// SquadMemberStatus mirrors the four-way bucket the back-end derives in
|
||||
// handler/squad.go::deriveSquadMemberStatus. Kept as a string union here
|
||||
// (rather than re-derived from snapshot data) so the squad page can render
|
||||
// the freshest server-side judgement without re-fetching the agent
|
||||
// snapshot / runtime list.
|
||||
export type SquadMemberStatusValue = "working" | "idle" | "offline" | "unstable";
|
||||
|
||||
export interface SquadActiveIssueBrief {
|
||||
issue_id: string;
|
||||
identifier: string;
|
||||
title: string;
|
||||
issue_status: string;
|
||||
}
|
||||
|
||||
export interface SquadMemberStatus {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
// Human members are returned with status === null so the UI can render
|
||||
// them in the same list without showing a status pill (v1 has no
|
||||
// presence signal for humans).
|
||||
status: SquadMemberStatusValue | null;
|
||||
active_issues: SquadActiveIssueBrief[];
|
||||
last_active_at: string | null;
|
||||
}
|
||||
|
||||
export interface SquadMemberStatusListResponse {
|
||||
members: SquadMemberStatus[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
|
||||
@@ -10,30 +11,30 @@ export function useActorName() {
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
|
||||
const getMemberName = (userId: string) => {
|
||||
const getMemberName = useCallback((userId: string) => {
|
||||
const m = members.find((m) => m.user_id === userId);
|
||||
return m?.name ?? "Unknown";
|
||||
};
|
||||
}, [members]);
|
||||
|
||||
const getAgentName = (agentId: string) => {
|
||||
const getAgentName = useCallback((agentId: string) => {
|
||||
const a = agents.find((a) => a.id === agentId);
|
||||
return a?.name ?? "Unknown Agent";
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
const getSquadName = (squadId: string) => {
|
||||
const getSquadName = useCallback((squadId: string) => {
|
||||
const s = squads.find((s) => s.id === squadId);
|
||||
return s?.name ?? "Unknown Squad";
|
||||
};
|
||||
}, [squads]);
|
||||
|
||||
const getActorName = (type: string, id: string) => {
|
||||
const getActorName = useCallback((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";
|
||||
};
|
||||
}, [getAgentName, getMemberName, getSquadName]);
|
||||
|
||||
const getActorInitials = (type: string, id: string) => {
|
||||
const getActorInitials = useCallback((type: string, id: string) => {
|
||||
const name = getActorName(type, id);
|
||||
return name
|
||||
.split(" ")
|
||||
@@ -41,14 +42,31 @@ export function useActorName() {
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
};
|
||||
}, [getActorName]);
|
||||
|
||||
const getActorAvatarUrl = (type: string, id: string): string | null => {
|
||||
const getActorAvatarUrl = useCallback((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;
|
||||
};
|
||||
}, [agents, members, squads]);
|
||||
|
||||
return { getMemberName, getAgentName, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||
return useMemo(
|
||||
() => ({
|
||||
getMemberName,
|
||||
getAgentName,
|
||||
getSquadName,
|
||||
getActorName,
|
||||
getActorInitials,
|
||||
getActorAvatarUrl,
|
||||
}),
|
||||
[
|
||||
getActorAvatarUrl,
|
||||
getActorInitials,
|
||||
getActorName,
|
||||
getAgentName,
|
||||
getMemberName,
|
||||
getSquadName,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ export const workspaceKeys = {
|
||||
myInvitations: () => ["invitations", "mine"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
|
||||
// Per-squad member status. Lives under the workspace key tree so
|
||||
// workspace switches naturally drop the cache, and so a broad
|
||||
// `["workspaces", wsId, "squads"]` invalidation covers it.
|
||||
squadMemberStatus: (wsId: string, squadId: string) =>
|
||||
["workspaces", wsId, "squads", squadId, "members-status"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
||||
};
|
||||
@@ -52,6 +57,20 @@ export function squadListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
// Per-squad members status snapshot. The freshness signal is the WS task /
|
||||
// agent / runtime invalidation wired in use-realtime-sync (which broadly
|
||||
// invalidates `["workspaces", wsId, "squads"]`); the staleTime is a
|
||||
// tab-focus safety net.
|
||||
export function squadMemberStatusOptions(wsId: string, squadId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.squadMemberStatus(wsId, squadId),
|
||||
queryFn: () => api.getSquadMemberStatus(squadId),
|
||||
enabled: !!wsId && !!squadId,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function skillListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.skills(wsId),
|
||||
|
||||
@@ -40,7 +40,7 @@ function ActorAvatar({
|
||||
// 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",
|
||||
"bg-muted text-muted-foreground",
|
||||
(!avatarUrl || imgError) && "bg-muted text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||
|
||||
@@ -127,6 +127,7 @@ function ChartTooltipContent({
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
footer,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
@@ -137,6 +138,16 @@ function ChartTooltipContent({
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
footer?:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
payload: NonNullable<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
TooltipNameType
|
||||
>["payload"]
|
||||
>,
|
||||
) => React.ReactNode)
|
||||
} & Omit<
|
||||
RechartsPrimitive.DefaultTooltipContentProps<
|
||||
TooltipValueType,
|
||||
@@ -266,6 +277,11 @@ function ChartTooltipContent({
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{footer != null && (
|
||||
<div className="mt-0.5 border-t border-border/50 pt-1.5">
|
||||
{typeof footer === "function" ? footer(payload) : footer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ function SelectItem({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 items-center gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
|
||||
@@ -114,6 +114,20 @@
|
||||
animation: chat-text-shimmer 2.5s linear infinite;
|
||||
}
|
||||
|
||||
/* Navigation progress bar: 2px brand-colored indeterminate sweep with a
|
||||
* right-edge glow that shows across the top of the dashboard while a
|
||||
* transition-wrapped push/replace is committing. Driven by useIsNavigating();
|
||||
* independent of the actual network, so it disappears the moment React commits
|
||||
* the new route. */
|
||||
@keyframes nav-progress-sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
.animate-nav-progress-sweep {
|
||||
animation: nav-progress-sweep 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
}
|
||||
|
||||
/* Border beam: a brand-tinted highlight sweeps continuously around the
|
||||
* element's rounded border, drawing the eye to a CTA that would otherwise
|
||||
* blend into the chrome (e.g. the "switch to agent" affordance in manual
|
||||
|
||||
@@ -240,7 +240,7 @@ function AvatarEditor({
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
@@ -271,7 +271,7 @@ function AvatarEditor({
|
||||
type="button"
|
||||
// rounded-lg matches the standard agent avatar treatment used in
|
||||
// list rows. Avoid rounded-full — circles are reserved for humans.
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
aria-label={t(($) => $.inspector.change_avatar_aria)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
BookOpenText,
|
||||
FileText,
|
||||
KeyRound,
|
||||
ListTodo,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import type { Agent, AgentRuntime } from "@multica/core/types";
|
||||
@@ -24,17 +25,20 @@ import { InstructionsTab } from "./tabs/instructions-tab";
|
||||
import { SkillsTab } from "./tabs/skills-tab";
|
||||
import { EnvTab } from "./tabs/env-tab";
|
||||
import { CustomArgsTab } from "./tabs/custom-args-tab";
|
||||
import { ActorIssuesPanel } from "../../common/actor-issues-panel";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
type DetailTab =
|
||||
| "activity"
|
||||
| "tasks"
|
||||
| "instructions"
|
||||
| "skills"
|
||||
| "env"
|
||||
| "custom_args";
|
||||
|
||||
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "instructions" | "skills" | "environment" | "custom_args"> = {
|
||||
const TAB_LABEL_KEY: Record<DetailTab, "activity" | "tasks" | "instructions" | "skills" | "environment" | "custom_args"> = {
|
||||
activity: "activity",
|
||||
tasks: "tasks",
|
||||
instructions: "instructions",
|
||||
skills: "skills",
|
||||
env: "environment",
|
||||
@@ -46,6 +50,7 @@ const detailTabs: {
|
||||
icon: typeof FileText;
|
||||
}[] = [
|
||||
{ id: "activity", icon: Activity },
|
||||
{ id: "tasks", icon: ListTodo },
|
||||
{ id: "instructions", icon: FileText },
|
||||
{ id: "skills", icon: BookOpenText },
|
||||
{ id: "env", icon: KeyRound },
|
||||
@@ -59,10 +64,11 @@ interface AgentOverviewPaneProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-pane on the agent detail page. Five tabs of equal weight:
|
||||
* Right-pane on the agent detail page:
|
||||
*
|
||||
* - Activity (default) — what the agent is doing now / how it's been doing /
|
||||
* what it just finished. The "watch state" surface.
|
||||
* - Tasks — assigned/created issues using the shared issue board/list.
|
||||
* - Instructions / Skills / Env / Custom Args — four editing surfaces.
|
||||
*
|
||||
* The previous Settings tab was deleted because every field on it is now
|
||||
@@ -142,6 +148,11 @@ export function AgentOverviewPane({
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{activeTab === "activity" && <ActivityTab agent={agent} />}
|
||||
{activeTab === "tasks" && (
|
||||
<div className="flex h-full min-h-[520px] flex-col">
|
||||
<ActorIssuesPanel actorType="agent" actorId={agent.id} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "instructions" && (
|
||||
<TabContent>
|
||||
<InstructionsTab
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps)
|
||||
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasValue
|
||||
? "border bg-muted"
|
||||
? "border"
|
||||
: "border border-dashed bg-muted/40 hover:bg-muted",
|
||||
)}
|
||||
aria-label={
|
||||
|
||||
@@ -225,7 +225,7 @@ export function CreateAgentDialog({
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
|
||||
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-5xl !h-[85vh]">
|
||||
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-2xl !h-[85vh]">
|
||||
<DialogHeader className="border-b px-5 py-3 space-y-0">
|
||||
<DialogTitle className="text-base font-semibold">{headerTitle}</DialogTitle>
|
||||
{isDuplicate && template && (
|
||||
|
||||
@@ -71,8 +71,12 @@ export function CustomArgsTab({
|
||||
try {
|
||||
await onSave({ custom_args: currentArgs });
|
||||
toast.success(t(($) => $.tab_body.custom_args.saved_toast));
|
||||
} catch {
|
||||
toast.error(t(($) => $.tab_body.custom_args.save_failed_toast));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.tab_body.custom_args.save_failed_toast),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -114,8 +114,12 @@ export function EnvTab({
|
||||
try {
|
||||
await onSave({ custom_env: currentEnvMap });
|
||||
toast.success(t(($) => $.tab_body.env.saved_toast));
|
||||
} catch {
|
||||
toast.error(t(($) => $.tab_body.env.save_failed_toast));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.tab_body.env.save_failed_toast),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
72
packages/views/attachments/attachment-preview-page.tsx
Normal file
72
packages/views/attachments/attachment-preview-page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentPreviewPage — full-page HTML attachment viewer.
|
||||
*
|
||||
* Destination for `openInNewTab` from HtmlAttachmentPreview's toolbar. The
|
||||
* inline preview (HtmlAttachmentPreview) renders the same content in a 480px
|
||||
* card with a hover toolbar; this is the same content edge-to-edge so the
|
||||
* user can resize / interact with the document at full size.
|
||||
*
|
||||
* Same security posture as the inline preview: iframe sandbox is
|
||||
* "allow-scripts" only — no allow-same-origin, no allow-top-navigation. The
|
||||
* iframe runs in an opaque origin and cannot reach cookies, localStorage,
|
||||
* parent, or top-level navigation.
|
||||
*
|
||||
* The route is workspace-scoped (`/{slug}/attachments/{id}/preview`) for
|
||||
* tenancy isolation; the `/api/attachments/{id}/content` proxy itself is
|
||||
* already auth-checked, so the slug is purely a URL contract.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
|
||||
|
||||
interface AttachmentPreviewPageProps {
|
||||
attachmentId: string;
|
||||
/** Optional display name. Falls back to a generic label and is only used
|
||||
* for the document title — never echoed into the iframe sandbox. */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export function AttachmentPreviewPage({
|
||||
attachmentId,
|
||||
filename,
|
||||
}: AttachmentPreviewPageProps) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
|
||||
// Set document.title so desktop's MutationObserver-based tab title picks
|
||||
// up the filename. Web shows the same string in the browser tab.
|
||||
useEffect(() => {
|
||||
if (filename) document.title = filename;
|
||||
}, [filename]);
|
||||
|
||||
const text = query.data?.text;
|
||||
const isLoading = query.isLoading;
|
||||
const isError = !isLoading && (!!query.error || !text);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
|
||||
data-testid="attachment-preview-page-error"
|
||||
>
|
||||
{t(($) => $.attachment.preview_failed)}
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
sandbox="allow-scripts"
|
||||
title={filename ?? "HTML attachment"}
|
||||
className="flex-1 w-full border-0 bg-background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
packages/views/attachments/index.ts
Normal file
1
packages/views/attachments/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AttachmentPreviewPage } from "./attachment-preview-page";
|
||||
@@ -1,16 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil } from "lucide-react";
|
||||
import {
|
||||
Zap, Play, Clock, Plus, Trash2, CheckCircle2, XCircle, Loader2, Pencil,
|
||||
Ban, ChevronDown, ChevronRight,
|
||||
Webhook, Copy, Check, RotateCw,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions } from "@multica/core/autopilots/queries";
|
||||
import { autopilotDetailOptions, autopilotRunsOptions, autopilotRunOptions } from "@multica/core/autopilots/queries";
|
||||
import {
|
||||
useUpdateAutopilot,
|
||||
useDeleteAutopilot,
|
||||
useTriggerAutopilot,
|
||||
useCreateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
useRotateAutopilotTriggerWebhookToken,
|
||||
} from "@multica/core/autopilots/mutations";
|
||||
import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
@@ -48,6 +55,8 @@ import type { AgentTask } from "@multica/core/types/agent";
|
||||
import { ReadonlyContent } from "../../editor";
|
||||
import { TranscriptButton } from "../../common/task-transcript";
|
||||
import { AutopilotDialog } from "./autopilot-dialog";
|
||||
import { WebhookPayloadPreview } from "./webhook-payload-preview";
|
||||
import { WebhookDeliveriesSection } from "./webhook-deliveries-section";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
function formatDate(date: string): string {
|
||||
@@ -59,15 +68,37 @@ function formatDate(date: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
type RunStatus = "issue_created" | "running" | "completed" | "failed";
|
||||
type RunStatus = "issue_created" | "running" | "skipped" | "completed" | "failed";
|
||||
|
||||
const RUN_VISUAL: Record<RunStatus, { color: string; icon: typeof CheckCircle2; spin?: boolean }> = {
|
||||
issue_created: { color: "text-blue-500", icon: Clock },
|
||||
running: { color: "text-blue-500", icon: Loader2, spin: true },
|
||||
// `skipped` (admission check found the assignee runtime offline,
|
||||
// MUL-1899) is muted so it doesn't read as a failure-ratio inflator.
|
||||
// The row still shows failure_reason which carries the skip context.
|
||||
skipped: { color: "text-muted-foreground", icon: Ban },
|
||||
completed: { color: "text-emerald-500", icon: CheckCircle2 },
|
||||
failed: { color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
// WebhookPayloadSlot lazy-fetches the full run (incl. trigger_payload) once
|
||||
// the parent dialog actually mounts this slot. The list endpoint omits
|
||||
// trigger_payload to keep responses small (worst case 256 KiB × N runs),
|
||||
// so the detail-on-demand fetch lives here.
|
||||
function WebhookPayloadSlot({ autopilotId, runId }: { autopilotId: string; runId: string }) {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data, isLoading } = useQuery(
|
||||
autopilotRunOptions(wsId, autopilotId, runId),
|
||||
);
|
||||
if (isLoading) {
|
||||
return <Skeleton className="h-9 w-full" />;
|
||||
}
|
||||
if (!data || data.trigger_payload == null) {
|
||||
return null;
|
||||
}
|
||||
return <WebhookPayloadPreview payload={data.trigger_payload} />;
|
||||
}
|
||||
|
||||
function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: string; agentName: string }) {
|
||||
const { t } = useT("autopilots");
|
||||
const wsPaths = useWorkspacePaths();
|
||||
@@ -104,7 +135,9 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
|
||||
<span className={cn("w-24 shrink-0 text-xs font-medium", visual.color)}>
|
||||
{t(($) => $.run_status[status])}
|
||||
</span>
|
||||
<span className="w-16 shrink-0 text-xs text-muted-foreground capitalize">{run.source}</span>
|
||||
<span className="w-20 shrink-0 text-xs text-muted-foreground">
|
||||
{t(($) => $.run_source[run.source as "schedule" | "manual" | "webhook" | "api"]) ?? run.source}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{run.issue_id ? (
|
||||
t(($) => $.run.issue_linked)
|
||||
@@ -121,6 +154,11 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
|
||||
agentName={agentName}
|
||||
isLive={run.status === "running"}
|
||||
title={t(($) => $.run.view_log)}
|
||||
headerSlot={
|
||||
run.source === "webhook" ? (
|
||||
<WebhookPayloadSlot autopilotId={run.autopilot_id} runId={run.id} />
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -139,11 +177,85 @@ function RunRow({ run, agentId, agentName }: { run: AutopilotRun; agentId: strin
|
||||
return <div className={rowClass}>{content}</div>;
|
||||
}
|
||||
|
||||
function RunHistoryList({
|
||||
runs,
|
||||
agentId,
|
||||
agentName,
|
||||
}: {
|
||||
runs: AutopilotRun[];
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}) {
|
||||
const visibleRuns = runs.filter((run) => run.status !== "skipped");
|
||||
const skippedRuns = runs.filter((run) => run.status === "skipped");
|
||||
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{visibleRuns.map((run) => (
|
||||
<RunRow key={run.id} run={run} agentId={agentId} agentName={agentName} />
|
||||
))}
|
||||
{skippedRuns.length > 0 && (
|
||||
<SkippedRunsGroup runs={skippedRuns} agentId={agentId} agentName={agentName} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkippedRunsGroup({
|
||||
runs,
|
||||
agentId,
|
||||
agentName,
|
||||
}: {
|
||||
runs: AutopilotRun[];
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const [open, setOpen] = useState(false);
|
||||
const latestRun = runs[0];
|
||||
const ToggleIcon = open ? ChevronDown : ChevronRight;
|
||||
|
||||
return (
|
||||
<div className="border-t bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-accent/30 transition-colors"
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<ToggleIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<Ban className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="w-24 shrink-0 text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.run.skipped_group.label)}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate">
|
||||
{t(($) => $.run.skipped_group.summary, { count: runs.length })}
|
||||
</span>
|
||||
{latestRun && (
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(latestRun.triggered_at || latestRun.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t bg-background">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} agentId={agentId} agentName={agentName} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autopilotId: string }) {
|
||||
const { t } = useT("autopilots");
|
||||
const deleteTrigger = useDeleteAutopilotTrigger();
|
||||
const rotateToken = useRotateAutopilotTriggerWebhookToken();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [rotateOpen, setRotateOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setDeleting(true);
|
||||
@@ -151,19 +263,83 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
await deleteTrigger.mutateAsync({ autopilotId, triggerId: trigger.id });
|
||||
toast.success(t(($) => $.trigger_row.toast_deleted));
|
||||
setConfirmOpen(false);
|
||||
} catch {
|
||||
toast.error(t(($) => $.trigger_row.toast_delete_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.trigger_row.toast_delete_failed),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isWebhook = trigger.kind === "webhook";
|
||||
const isApi = trigger.kind === "api";
|
||||
// Resolve the URL from the server's webhook_url first, then compose
|
||||
// from the API base URL (desktop) or window.origin (web). Falls back
|
||||
// to the relative path if neither is available.
|
||||
const webhookUrl = isWebhook
|
||||
? buildAutopilotWebhookUrl({
|
||||
trigger,
|
||||
apiBaseUrl: api.getBaseUrl(),
|
||||
currentOrigin: typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!webhookUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.trigger_row.url_copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t(($) => $.trigger_row.url_copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
try {
|
||||
await rotateToken.mutateAsync({ autopilotId, triggerId: trigger.id });
|
||||
toast.success(t(($) => $.trigger_row.toast_rotated));
|
||||
setRotateOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.trigger_row.toast_rotate_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isWebhook ? Webhook : isApi ? Zap : Clock;
|
||||
const showWebhookUrlRow = isWebhook && webhookUrl;
|
||||
|
||||
// Delete control extracted so a webhook trigger can render it inline
|
||||
// with Copy / Rotate on the URL action row (where the other action
|
||||
// buttons live), while schedule / api triggers — which have no URL row
|
||||
// — keep it pinned to the row's top-right corner. Without this the
|
||||
// trash icon visually floats above the URL action buttons because the
|
||||
// outer flex uses `items-start`.
|
||||
const deleteButton = (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
title={t(($) => $.trigger_row.delete_dialog.confirm)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium capitalize">{trigger.kind}</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{t(($) => $.trigger_kind[trigger.kind])}</span>
|
||||
{trigger.label && (
|
||||
<span className="text-xs text-muted-foreground">({trigger.label})</span>
|
||||
)}
|
||||
@@ -172,6 +348,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.disabled_badge)}
|
||||
</span>
|
||||
)}
|
||||
{isApi && (
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{t(($) => $.trigger_row.deprecated_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trigger.cron_expression && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
@@ -184,15 +365,35 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
|
||||
</div>
|
||||
)}
|
||||
{showWebhookUrlRow && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1 text-xs font-mono text-foreground">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleCopy}
|
||||
title={t(($) => $.trigger_row.copy_url)}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setRotateOpen(true)}
|
||||
title={t(($) => $.trigger_row.rotate_url)}
|
||||
disabled={rotateToken.isPending}
|
||||
>
|
||||
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{!showWebhookUrlRow && deleteButton}
|
||||
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -217,6 +418,26 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog open={rotateOpen} onOpenChange={(v) => { if (!v && !rotateToken.isPending) setRotateOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t(($) => $.trigger_row.rotate_confirm_title)}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.trigger_row.rotate_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={rotateToken.isPending}>
|
||||
{t(($) => $.trigger_row.rotate_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRotate} disabled={rotateToken.isPending}>
|
||||
{rotateToken.isPending
|
||||
? t(($) => $.trigger_row.rotate_in_progress)
|
||||
: t(($) => $.trigger_row.rotate_confirm_action)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -232,29 +453,47 @@ function AddTriggerDialog({
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const createTrigger = useCreateAutopilotTrigger();
|
||||
const [kind, setKind] = useState<"schedule" | "webhook">("schedule");
|
||||
const [config, setConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
|
||||
const [label, setLabel] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
const cronExpr = toCronExpression(config);
|
||||
if (!cronExpr.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "schedule",
|
||||
cron_expression: cronExpr,
|
||||
timezone: config.timezone || undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
if (kind === "schedule") {
|
||||
const cronExpr = toCronExpression(config);
|
||||
if (!cronExpr.trim()) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "schedule",
|
||||
cron_expression: cronExpr,
|
||||
timezone: config.timezone || undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
toast.success(t(($) => $.add_trigger_dialog.toast_added_schedule));
|
||||
} else {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "webhook",
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
toast.success(t(($) => $.add_trigger_dialog.toast_added_webhook));
|
||||
}
|
||||
onOpenChange(false);
|
||||
setKind("schedule");
|
||||
setConfig(getDefaultTriggerConfig());
|
||||
setLabel("");
|
||||
toast.success(t(($) => $.add_trigger_dialog.toast_added));
|
||||
} catch {
|
||||
toast.error(t(($) => $.add_trigger_dialog.toast_add_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.add_trigger_dialog.toast_add_failed),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -265,7 +504,48 @@ function AddTriggerDialog({
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>{t(($) => $.add_trigger_dialog.title)}</DialogTitle>
|
||||
<div className="space-y-4 pt-2">
|
||||
<TriggerConfigSection config={config} onChange={setConfig} />
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.add_trigger_dialog.type_label)}
|
||||
</label>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 rounded-md bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKind("schedule")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
|
||||
kind === "schedule"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t(($) => $.add_trigger_dialog.type_schedule)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKind("webhook")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
|
||||
kind === "webhook"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Webhook className="h-3.5 w-3.5" />
|
||||
{t(($) => $.add_trigger_dialog.type_webhook)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kind === "schedule" ? (
|
||||
<TriggerConfigSection config={config} onChange={setConfig} />
|
||||
) : (
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t(($) => $.add_trigger_dialog.webhook_help)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.add_trigger_dialog.label_field)}
|
||||
@@ -373,8 +653,12 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
await deleteAutopilot.mutateAsync(autopilotId);
|
||||
toast.success(t(($) => $.detail.toast_deleted));
|
||||
router.push(wsPaths.autopilots());
|
||||
} catch {
|
||||
toast.error(t(($) => $.detail.toast_delete_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.detail.toast_delete_failed),
|
||||
);
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
@@ -485,6 +769,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Webhook deliveries — only renders when at least one webhook
|
||||
trigger is configured. The component does its own fetch so
|
||||
schedule-only autopilots don't pay for an empty list query. */}
|
||||
<WebhookDeliveriesSection
|
||||
autopilotId={autopilotId}
|
||||
hasWebhookTrigger={triggers.some((trig) => trig.kind === "webhook")}
|
||||
/>
|
||||
|
||||
{/* Run History */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
@@ -501,11 +793,11 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
{t(($) => $.detail.no_runs)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{runs.map((run) => (
|
||||
<RunRow key={run.id} run={run} agentId={autopilot.assignee_id} agentName={getActorName("agent", autopilot.assignee_id)} />
|
||||
))}
|
||||
</div>
|
||||
<RunHistoryList
|
||||
runs={runs}
|
||||
agentId={autopilot.assignee_id}
|
||||
agentName={getActorName("agent", autopilot.assignee_id)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TFunction } from "i18next";
|
||||
import { createI18n } from "@multica/core/i18n/react";
|
||||
import enAutopilots from "../../locales/en/autopilots.json";
|
||||
import zhAutopilots from "../../locales/zh-Hans/autopilots.json";
|
||||
import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
|
||||
|
||||
// Contract test for the autopilot-dialog partial-success toast formatting.
|
||||
//
|
||||
// The dialog routes its partial-success branches through
|
||||
// `formatSchedulePartialFailureToast`, so this test drives that exact
|
||||
// helper rather than calling `t(...)` independently. That means a regression
|
||||
// in either side — the JSON template (e.g. `{reason}` instead of `{{reason}}`)
|
||||
// or the call-site variable name (e.g. `{ msg: ... }` instead of
|
||||
// `{ reason: ... }`) — fails this test with the substring assertion.
|
||||
|
||||
describe("autopilot dialog partial-success toast", () => {
|
||||
const reason = "schedule conflict: 09:00 overlaps existing trigger";
|
||||
|
||||
describe("en", () => {
|
||||
const i18n = createI18n("en", { en: { autopilots: enAutopilots } });
|
||||
const t = i18n.getFixedT("en", "autopilots") as TFunction<"autopilots">;
|
||||
|
||||
it("renders create partial-success with the server reason verbatim", () => {
|
||||
const rendered = formatSchedulePartialFailureToast(t, "create", reason);
|
||||
expect(rendered).toContain(reason);
|
||||
expect(rendered).not.toContain("{{");
|
||||
expect(rendered).not.toContain("{reason}");
|
||||
});
|
||||
|
||||
it("renders update partial-success with the server reason verbatim", () => {
|
||||
const rendered = formatSchedulePartialFailureToast(t, "update", reason);
|
||||
expect(rendered).toContain(reason);
|
||||
expect(rendered).not.toContain("{{");
|
||||
expect(rendered).not.toContain("{reason}");
|
||||
});
|
||||
|
||||
it("falls back to the no-reason create string when reason is null", () => {
|
||||
expect(formatSchedulePartialFailureToast(t, "create", null)).toBe(
|
||||
"Autopilot created, but schedule failed to save",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the no-reason update string when reason is null", () => {
|
||||
expect(formatSchedulePartialFailureToast(t, "update", null)).toBe(
|
||||
"Autopilot updated, but schedule failed to save",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zh-Hans", () => {
|
||||
const i18n = createI18n("zh-Hans", {
|
||||
"zh-Hans": { autopilots: zhAutopilots },
|
||||
en: { autopilots: enAutopilots },
|
||||
});
|
||||
const t = i18n.getFixedT("zh-Hans", "autopilots") as TFunction<"autopilots">;
|
||||
|
||||
it("renders create partial-success with the server reason verbatim", () => {
|
||||
const rendered = formatSchedulePartialFailureToast(t, "create", reason);
|
||||
expect(rendered).toContain(reason);
|
||||
expect(rendered).not.toContain("{{");
|
||||
expect(rendered).not.toContain("{reason}");
|
||||
});
|
||||
|
||||
it("renders update partial-success with the server reason verbatim", () => {
|
||||
const rendered = formatSchedulePartialFailureToast(t, "update", reason);
|
||||
expect(rendered).toContain(reason);
|
||||
expect(rendered).not.toContain("{{");
|
||||
expect(rendered).not.toContain("{reason}");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
// Centralizes the partial-success toast formatting so the i18n keys and the
|
||||
// `{ reason }` placeholder live in one tested place. Without this, the
|
||||
// translation contract in `autopilot-dialog-i18n.test.ts` could pass while
|
||||
// the dialog's call-site silently passes the wrong variable name and ships
|
||||
// a literal `{{reason}}` to users.
|
||||
export function formatSchedulePartialFailureToast(
|
||||
t: TFunction<"autopilots">,
|
||||
kind: "create" | "update",
|
||||
reason: string | null,
|
||||
): string {
|
||||
if (reason) {
|
||||
return kind === "create"
|
||||
? t(($) => $.dialog.toast_create_partial_with_reason, { reason })
|
||||
: t(($) => $.dialog.toast_update_partial_with_reason, { reason });
|
||||
}
|
||||
return kind === "create"
|
||||
? t(($) => $.dialog.toast_create_partial)
|
||||
: t(($) => $.dialog.toast_update_partial);
|
||||
}
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Copy,
|
||||
FilePlus2,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Play,
|
||||
Rocket,
|
||||
Webhook,
|
||||
X as XIcon,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -42,6 +44,8 @@ import {
|
||||
useUpdateAutopilot,
|
||||
useUpdateAutopilotTrigger,
|
||||
} from "@multica/core/autopilots/mutations";
|
||||
import { buildAutopilotWebhookUrl } from "@multica/core/autopilots";
|
||||
import { api } from "@multica/core/api";
|
||||
import type {
|
||||
AutopilotExecutionMode,
|
||||
AutopilotTrigger,
|
||||
@@ -58,6 +62,7 @@ import {
|
||||
type TriggerFrequency,
|
||||
} from "./trigger-config";
|
||||
import { useT } from "../../i18n";
|
||||
import { formatSchedulePartialFailureToast } from "./autopilot-dialog-toast";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -264,6 +269,20 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
})();
|
||||
const [triggerConfig, setTriggerConfig] = useState<TriggerConfig>(initialCfg);
|
||||
|
||||
// Trigger kind selector. Only meaningful in create mode — edit mode does
|
||||
// not support converting between kinds inline (PLAN.md calls that
|
||||
// out as "delete old, create new" rather than ambiguous in-place
|
||||
// updates), so the toggle is hidden when editing. The kind is
|
||||
// initialized from the first existing trigger so we render the right
|
||||
// panel without surprising the user.
|
||||
const initialKind: "schedule" | "webhook" = (() => {
|
||||
if (isCreate) return "schedule";
|
||||
const first = props.triggers[0];
|
||||
if (first?.kind === "webhook") return "webhook";
|
||||
return "schedule";
|
||||
})();
|
||||
const [triggerKind, setTriggerKind] = useState<"schedule" | "webhook">(initialKind);
|
||||
|
||||
const initialCronRef = useRef(toCronExpression(initialCfg));
|
||||
const initialTimezoneRef = useRef(initialCfg.timezone);
|
||||
const scheduleDirty =
|
||||
@@ -288,6 +307,12 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
const updateTrigger = useUpdateAutopilotTrigger();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// After a successful webhook-kind create, we don't close the dialog —
|
||||
// we swap to a confirmation state showing the freshly minted URL with
|
||||
// copy / done affordances. This avoids the "now go find your autopilot
|
||||
// and click into it to grab the URL" friction.
|
||||
const [createdWebhookTrigger, setCreatedWebhookTrigger] = useState<AutopilotTrigger | null>(null);
|
||||
|
||||
const canSubmit =
|
||||
title.trim().length > 0 && assigneeId.length > 0 && !submitting;
|
||||
|
||||
@@ -302,20 +327,44 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
assignee_id: assigneeId,
|
||||
execution_mode: executionMode,
|
||||
});
|
||||
let scheduleOk = true;
|
||||
let triggerOk = true;
|
||||
let triggerErrMessage: string | null = null;
|
||||
let webhookTrigger: AutopilotTrigger | null = null;
|
||||
try {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId: autopilot.id,
|
||||
kind: "schedule",
|
||||
cron_expression: toCronExpression(triggerConfig),
|
||||
timezone: triggerConfig.timezone,
|
||||
});
|
||||
} catch {
|
||||
scheduleOk = false;
|
||||
if (triggerKind === "webhook") {
|
||||
webhookTrigger = await createTrigger.mutateAsync({
|
||||
autopilotId: autopilot.id,
|
||||
kind: "webhook",
|
||||
});
|
||||
} else {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId: autopilot.id,
|
||||
kind: "schedule",
|
||||
cron_expression: toCronExpression(triggerConfig),
|
||||
timezone: triggerConfig.timezone,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
triggerOk = false;
|
||||
triggerErrMessage =
|
||||
err instanceof Error && err.message ? err.message : null;
|
||||
}
|
||||
if (triggerKind === "webhook" && webhookTrigger) {
|
||||
// Stay in the dialog and surface the URL inline so the user
|
||||
// can copy it without first navigating to the detail page.
|
||||
setCreatedWebhookTrigger(webhookTrigger);
|
||||
toast.success(t(($) => $.dialog.toast_created));
|
||||
return;
|
||||
}
|
||||
onOpenChange(false);
|
||||
if (scheduleOk) toast.success(t(($) => $.dialog.toast_created));
|
||||
else toast.error(t(($) => $.dialog.toast_create_partial));
|
||||
if (triggerOk) {
|
||||
toast.success(t(($) => $.dialog.toast_created));
|
||||
} else {
|
||||
// Partial success: autopilot saved, schedule failed. Show the
|
||||
// server-provided reason so the user can act on it (cron syntax
|
||||
// error, conflict, etc.) instead of seeing a generic message.
|
||||
toast.error(formatSchedulePartialFailureToast(t, "create", triggerErrMessage));
|
||||
}
|
||||
} else {
|
||||
await updateAutopilot.mutateAsync({
|
||||
id: props.autopilotId,
|
||||
@@ -325,7 +374,11 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
execution_mode: executionMode,
|
||||
});
|
||||
let scheduleOk = true;
|
||||
if (scheduleDirty && !schedulePillDisabled) {
|
||||
let scheduleErrMessage: string | null = null;
|
||||
// Skip the schedule sync when the autopilot's first trigger is a
|
||||
// webhook — there's no cron to update there, and the schedule
|
||||
// panel isn't even rendered for webhook autopilots.
|
||||
if (triggerKind === "schedule" && scheduleDirty && !schedulePillDisabled) {
|
||||
const snapshottedTriggerId = firstTriggerIdRef.current;
|
||||
try {
|
||||
if (snapshottedTriggerId) {
|
||||
@@ -343,19 +396,26 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
timezone: triggerConfig.timezone,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
scheduleOk = false;
|
||||
scheduleErrMessage =
|
||||
err instanceof Error && err.message ? err.message : null;
|
||||
}
|
||||
}
|
||||
onOpenChange(false);
|
||||
if (scheduleOk) toast.success(t(($) => $.dialog.toast_updated));
|
||||
else toast.error(t(($) => $.dialog.toast_update_partial));
|
||||
if (scheduleOk) {
|
||||
toast.success(t(($) => $.dialog.toast_updated));
|
||||
} else {
|
||||
toast.error(formatSchedulePartialFailureToast(t, "update", scheduleErrMessage));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
isCreate
|
||||
? t(($) => $.dialog.toast_create_failed)
|
||||
: t(($) => $.dialog.toast_update_failed),
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: isCreate
|
||||
? t(($) => $.dialog.toast_create_failed)
|
||||
: t(($) => $.dialog.toast_update_failed),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -435,6 +495,16 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdWebhookTrigger ? (
|
||||
<WebhookCreatedPanel
|
||||
trigger={createdWebhookTrigger}
|
||||
onClose={() => {
|
||||
setCreatedWebhookTrigger(null);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Body: two columns (stacks on narrow screens via flex-wrap at container level) */}
|
||||
<div
|
||||
key={contentKey}
|
||||
@@ -486,16 +556,24 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
|
||||
<OutputModeSection mode={executionMode} onChange={setExecutionMode} />
|
||||
|
||||
<ScheduleSection
|
||||
config={triggerConfig}
|
||||
onChange={setTriggerConfig}
|
||||
disabled={schedulePillDisabled}
|
||||
disabledReason={
|
||||
schedulePillDisabled
|
||||
? t(($) => $.dialog.schedule_disabled_reason)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{isCreate && (
|
||||
<TriggerKindSection kind={triggerKind} onChange={setTriggerKind} />
|
||||
)}
|
||||
|
||||
{triggerKind === "schedule" ? (
|
||||
<ScheduleSection
|
||||
config={triggerConfig}
|
||||
onChange={setTriggerConfig}
|
||||
disabled={schedulePillDisabled}
|
||||
disabledReason={
|
||||
schedulePillDisabled
|
||||
? t(($) => $.dialog.schedule_disabled_reason)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<WebhookHelpSection isCreate={isCreate} />
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -520,6 +598,8 @@ export function AutopilotDialog(props: AutopilotDialogProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -780,3 +860,169 @@ function ScheduleSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trigger kind segmented control + webhook help section
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TriggerKindSection({
|
||||
kind,
|
||||
onChange,
|
||||
}: {
|
||||
kind: "schedule" | "webhook";
|
||||
onChange: (kind: "schedule" | "webhook") => void;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>{t(($) => $.dialog.section_trigger_kind)}</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-1 rounded-md bg-muted p-1">
|
||||
<TriggerKindButton
|
||||
active={kind === "schedule"}
|
||||
onClick={() => onChange("schedule")}
|
||||
icon={<Clock className="h-3.5 w-3.5" />}
|
||||
label={t(($) => $.dialog.trigger_kind_schedule)}
|
||||
/>
|
||||
<TriggerKindButton
|
||||
active={kind === "webhook"}
|
||||
onClick={() => onChange("webhook")}
|
||||
icon={<Webhook className="h-3.5 w-3.5" />}
|
||||
label={t(($) => $.dialog.trigger_kind_webhook)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriggerKindButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
|
||||
active
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function WebhookHelpSection({ isCreate }: { isCreate: boolean }) {
|
||||
const { t } = useT("autopilots");
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>{t(($) => $.dialog.section_webhook)}</SectionLabel>
|
||||
<p className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground leading-relaxed">
|
||||
{isCreate
|
||||
? t(($) => $.dialog.webhook_help_create)
|
||||
: t(($) => $.dialog.webhook_help_edit)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-create state for webhook autopilots: shows the freshly minted URL
|
||||
// inline so the user can copy it without leaving the dialog.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function WebhookCreatedPanel({
|
||||
trigger,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: AutopilotTrigger;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Same URL composition the trigger row uses: prefer the server-provided
|
||||
// webhook_url, fall back to apiBaseUrl + webhook_path, then origin + path.
|
||||
const url =
|
||||
buildAutopilotWebhookUrl({
|
||||
trigger,
|
||||
apiBaseUrl: api.getBaseUrl(),
|
||||
currentOrigin: typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
}) ?? "";
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.trigger_row.url_copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t(($) => $.trigger_row.url_copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-8 py-10">
|
||||
<div className="mx-auto max-w-xl space-y-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="inline-flex size-9 items-center justify-center rounded-full bg-primary/15 text-primary">
|
||||
<Webhook className="size-4" />
|
||||
</span>
|
||||
<h2 className="text-lg font-semibold tracking-tight">
|
||||
{t(($) => $.dialog.webhook_created_title)}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t(($) => $.dialog.webhook_created_description)}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.08em] text-muted-foreground uppercase mb-2">
|
||||
{t(($) => $.trigger_row.webhook_url_label)}
|
||||
</div>
|
||||
<div className="flex items-stretch gap-1.5">
|
||||
<code className="flex-1 min-w-0 truncate rounded-md border bg-muted px-3 py-2 text-xs font-mono text-foreground">
|
||||
{url}
|
||||
</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={handleCopy}
|
||||
title={t(($) => $.trigger_row.copy_url)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400 leading-relaxed">
|
||||
{t(($) => $.dialog.webhook_created_warning)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t shrink-0 bg-background">
|
||||
<Button size="sm" onClick={onClose}>
|
||||
{t(($) => $.dialog.webhook_created_done)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Ban,
|
||||
AlertTriangle,
|
||||
ShieldOff,
|
||||
RotateCw,
|
||||
Copy,
|
||||
Check,
|
||||
Webhook,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
autopilotDeliveriesOptions,
|
||||
autopilotDeliveryOptions,
|
||||
useReplayAutopilotDelivery,
|
||||
} from "@multica/core/autopilots";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Badge } from "@multica/ui/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "../../i18n";
|
||||
import type {
|
||||
WebhookDelivery,
|
||||
WebhookDeliveryStatus,
|
||||
WebhookSignatureStatus,
|
||||
} from "@multica/core/types";
|
||||
|
||||
// --- Status visuals -------------------------------------------------------
|
||||
|
||||
// Mapping is exhaustive over the current backend enum but every consumer
|
||||
// site falls back to a generic "unknown" visual when the server adds a new
|
||||
// value — see the API Response Compatibility rules in CLAUDE.md.
|
||||
type StatusVisual = {
|
||||
color: string;
|
||||
icon: typeof CheckCircle2;
|
||||
spin?: boolean;
|
||||
};
|
||||
|
||||
const STATUS_VISUAL: Record<WebhookDeliveryStatus, StatusVisual> = {
|
||||
queued: { color: "text-blue-500", icon: Loader2, spin: true },
|
||||
dispatched: { color: "text-emerald-500", icon: CheckCircle2 },
|
||||
// Signature failures and pre-flight bouncebacks land here. Read as a
|
||||
// failure visually, the dialog footer explains the reason.
|
||||
rejected: { color: "text-destructive", icon: ShieldOff },
|
||||
// Ignored covers paused/disabled/archived autopilots — same payload was
|
||||
// received but no run was created. Muted so it doesn't look like a bug.
|
||||
ignored: { color: "text-muted-foreground", icon: Ban },
|
||||
failed: { color: "text-destructive", icon: XCircle },
|
||||
};
|
||||
|
||||
const UNKNOWN_VISUAL: StatusVisual = {
|
||||
color: "text-muted-foreground",
|
||||
icon: AlertTriangle,
|
||||
};
|
||||
|
||||
function visualForStatus(status: string): StatusVisual {
|
||||
return (STATUS_VISUAL as Record<string, StatusVisual>)[status] ?? UNKNOWN_VISUAL;
|
||||
}
|
||||
|
||||
// --- Helpers --------------------------------------------------------------
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// A delivery is replayable when (a) the server allows it (signature is not
|
||||
// invalid AND the delivery itself wasn't rejected) and (b) we have something
|
||||
// to replay (raw_body / received). We mirror the server's rule rather than
|
||||
// rely on the response — keeping the button disabled saves a 400 round-trip.
|
||||
function canReplay(delivery: WebhookDelivery): boolean {
|
||||
if (delivery.signature_status === "invalid") return false;
|
||||
if (delivery.status === "rejected") return false;
|
||||
// `queued` deliveries are mid-flight on the server; replay would race the
|
||||
// synchronous dispatch path. Once they settle, the user can replay.
|
||||
if (delivery.status === "queued") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Section --------------------------------------------------------------
|
||||
|
||||
export function WebhookDeliveriesSection({
|
||||
autopilotId,
|
||||
hasWebhookTrigger,
|
||||
}: {
|
||||
autopilotId: string;
|
||||
hasWebhookTrigger: boolean;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
const { data: deliveries = [], isLoading } = useQuery(
|
||||
autopilotDeliveriesOptions(wsId, autopilotId, {
|
||||
enabled: hasWebhookTrigger,
|
||||
}),
|
||||
);
|
||||
|
||||
// No webhook trigger configured → the entire section is irrelevant. We hide
|
||||
// it rather than render an empty card to keep the detail page short for
|
||||
// schedule-only autopilots.
|
||||
if (!hasWebhookTrigger) return null;
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{t(($) => $.deliveries.section_title)}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-1">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : deliveries.length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-muted-foreground">
|
||||
{t(($) => $.deliveries.empty)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
{deliveries.map((delivery) => (
|
||||
<DeliveryRow
|
||||
key={delivery.id}
|
||||
delivery={delivery}
|
||||
autopilotId={autopilotId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Row ------------------------------------------------------------------
|
||||
|
||||
function DeliveryRow({
|
||||
delivery,
|
||||
autopilotId,
|
||||
}: {
|
||||
delivery: WebhookDelivery;
|
||||
autopilotId: string;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const visual = visualForStatus(delivery.status);
|
||||
const StatusIcon = visual.icon;
|
||||
const statusLabel =
|
||||
t(($) => $.deliveries.status[delivery.status as WebhookDeliveryStatus]) ??
|
||||
delivery.status;
|
||||
const providerLabel = delivery.provider || "—";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
visual.color,
|
||||
visual.spin && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<span className={cn("w-24 shrink-0 text-xs font-medium", visual.color)}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<span className="w-20 shrink-0 text-xs text-muted-foreground truncate">
|
||||
{providerLabel}
|
||||
</span>
|
||||
<span className="flex-1 min-w-0 text-xs text-muted-foreground truncate font-mono">
|
||||
{delivery.event || t(($) => $.webhook_payload.unknown_event)}
|
||||
</span>
|
||||
{delivery.replayed_from_delivery_id && (
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
<RotateCw className="h-3 w-3" />
|
||||
{t(($) => $.deliveries.row.replay_badge)}
|
||||
</Badge>
|
||||
)}
|
||||
{delivery.attempt_count > 1 && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{t(($) => $.deliveries.row.attempts, {
|
||||
count: delivery.attempt_count,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="w-32 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{formatDate(delivery.received_at || delivery.created_at)}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<DeliveryDetailDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
autopilotId={autopilotId}
|
||||
delivery={delivery}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Detail dialog --------------------------------------------------------
|
||||
|
||||
function DeliveryDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
autopilotId,
|
||||
delivery,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
autopilotId: string;
|
||||
delivery: WebhookDelivery;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: detail, isLoading } = useQuery(
|
||||
autopilotDeliveryOptions(wsId, autopilotId, delivery.id, { enabled: open }),
|
||||
);
|
||||
// Use the detail row when loaded, otherwise the slim row from the list.
|
||||
// The slim row is missing raw_body / response_body / selected_headers; the
|
||||
// dialog renders skeleton placeholders for those sections while detail is
|
||||
// still loading.
|
||||
const full = detail ?? delivery;
|
||||
const visual = visualForStatus(full.status);
|
||||
const StatusIcon = visual.icon;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
{/* max-h + overflow-y-auto: webhook bodies + headers + response can
|
||||
easily exceed viewport height. Without a cap the dialog grows past
|
||||
the screen edge and the bottom (e.g. Replay button) becomes
|
||||
unreachable. 85vh leaves breathing room around the dialog. */}
|
||||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Webhook className="h-4 w-4 text-muted-foreground" />
|
||||
{t(($) => $.deliveries.detail.title)}
|
||||
</DialogTitle>
|
||||
<div className="space-y-4 pt-1">
|
||||
{/* Header row — status / provider / event */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0",
|
||||
visual.color,
|
||||
visual.spin && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
<span className={cn("text-sm font-medium", visual.color)}>
|
||||
{t(($) => $.deliveries.status[full.status as WebhookDeliveryStatus]) ??
|
||||
full.status}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline">{full.provider || "—"}</Badge>
|
||||
<code className="rounded bg-muted px-2 py-0.5 text-xs font-mono">
|
||||
{full.event || t(($) => $.webhook_payload.unknown_event)}
|
||||
</code>
|
||||
<SignatureBadge status={full.signature_status as WebhookSignatureStatus} />
|
||||
</div>
|
||||
|
||||
{/* Meta grid */}
|
||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs">
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.received_at)}
|
||||
value={formatDate(full.received_at)}
|
||||
/>
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.last_attempt_at)}
|
||||
value={formatDate(full.last_attempt_at)}
|
||||
/>
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.attempt_count)}
|
||||
value={String(full.attempt_count)}
|
||||
/>
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.response_status)}
|
||||
value={full.response_status != null ? String(full.response_status) : "—"}
|
||||
/>
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.dedupe_key)}
|
||||
value={full.dedupe_key ?? "—"}
|
||||
mono
|
||||
/>
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.dedupe_source)}
|
||||
value={full.dedupe_source ?? "—"}
|
||||
/>
|
||||
{full.content_type && (
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.content_type)}
|
||||
value={full.content_type}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
{full.replayed_from_delivery_id && (
|
||||
<MetaRow
|
||||
label={t(($) => $.deliveries.detail.replayed_from)}
|
||||
value={full.replayed_from_delivery_id}
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{full.error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
<div className="font-medium">
|
||||
{t(($) => $.deliveries.detail.error_label)}
|
||||
</div>
|
||||
<div className="mt-0.5 font-mono break-all">{full.error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Raw body + response body + headers, all loaded lazily */}
|
||||
<DetailSections detail={detail} isLoading={isLoading} />
|
||||
|
||||
{/* Replay button */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<ReplayHint delivery={full} />
|
||||
<ReplayButton
|
||||
autopilotId={autopilotId}
|
||||
delivery={full}
|
||||
onSuccess={() => onOpenChange(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaRow({
|
||||
label,
|
||||
value,
|
||||
mono = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<dt className="text-muted-foreground">{label}</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
"truncate text-foreground",
|
||||
mono && "font-mono",
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignatureBadge({ status }: { status: WebhookSignatureStatus | string }) {
|
||||
const { t } = useT("autopilots");
|
||||
let variant: "default" | "secondary" | "destructive" | "outline" = "outline";
|
||||
if (status === "valid") variant = "default";
|
||||
else if (status === "invalid") variant = "destructive";
|
||||
else if (status === "missing") variant = "secondary";
|
||||
return (
|
||||
<Badge variant={variant}>
|
||||
{t(($) => $.deliveries.signature[status as WebhookSignatureStatus]) ?? status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSections({
|
||||
detail,
|
||||
isLoading,
|
||||
}: {
|
||||
detail: WebhookDelivery | undefined;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
if (isLoading && !detail) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-24 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!detail) return null;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{detail.raw_body && (
|
||||
<CodeBlock
|
||||
label={t(($) => $.deliveries.detail.raw_body)}
|
||||
value={detail.raw_body}
|
||||
/>
|
||||
)}
|
||||
{detail.selected_headers && Object.keys(detail.selected_headers).length > 0 && (
|
||||
<CodeBlock
|
||||
label={t(($) => $.deliveries.detail.selected_headers)}
|
||||
value={JSON.stringify(detail.selected_headers, null, 2)}
|
||||
/>
|
||||
)}
|
||||
{detail.response_body && (
|
||||
<CodeBlock
|
||||
label={t(($) => $.deliveries.detail.response_body)}
|
||||
value={detail.response_body}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ label, value }: { label: string; value: string }) {
|
||||
const { t } = useT("autopilots");
|
||||
const [copied, setCopied] = useState(false);
|
||||
// Truncate in-DOM display for very large bodies; the Copy button still
|
||||
// yields the full string. 4 KiB is large enough for typical webhook
|
||||
// payloads while keeping the dialog responsive.
|
||||
const TRUNCATE_AT = 4096;
|
||||
const isTruncated = value.length > TRUNCATE_AT;
|
||||
const display = isTruncated ? value.slice(0, TRUNCATE_AT) : value;
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.webhook_payload.copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t(($) => $.webhook_payload.copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// min-w-0 lets this card shrink below the <pre>'s intrinsic min-content
|
||||
// width — without it, a minified single-line JSON body would push the
|
||||
// surrounding grid/flex cell (and the whole DialogContent) past the
|
||||
// viewport edge.
|
||||
<div className="min-w-0 rounded-md border bg-background">
|
||||
<div className="flex items-center justify-between border-b px-3 py-1.5 text-[11px]">
|
||||
<span className="font-medium text-muted-foreground">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1 rounded px-2 py-0.5 hover:bg-accent transition-colors"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
{copied
|
||||
? t(($) => $.webhook_payload.copied_short)
|
||||
: t(($) => $.webhook_payload.copy)}
|
||||
</button>
|
||||
</div>
|
||||
{/* whitespace-pre-wrap keeps pretty-printed indentation but lets
|
||||
long lines wrap; break-all is the only thing that breaks mid-token
|
||||
(necessary for minified JSON, which has no whitespace to break at). */}
|
||||
<pre className="max-h-48 overflow-auto bg-muted/40 px-3 py-2 text-xs font-mono leading-relaxed whitespace-pre-wrap break-all">
|
||||
{display}
|
||||
{isTruncated && (
|
||||
<span className="block pt-2 text-muted-foreground/70">
|
||||
{t(($) => $.webhook_payload.truncated_marker)}
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayHint({ delivery }: { delivery: WebhookDelivery }) {
|
||||
const { t } = useT("autopilots");
|
||||
if (delivery.signature_status === "invalid") {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.deliveries.replay.disabled_invalid_signature)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (delivery.status === "rejected") {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.deliveries.replay.disabled_rejected)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (delivery.status === "queued") {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.deliveries.replay.disabled_queued)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ReplayButton({
|
||||
autopilotId,
|
||||
delivery,
|
||||
onSuccess,
|
||||
}: {
|
||||
autopilotId: string;
|
||||
delivery: WebhookDelivery;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const replay = useReplayAutopilotDelivery();
|
||||
const enabled = canReplay(delivery) && !replay.isPending;
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await replay.mutateAsync({ autopilotId, deliveryId: delivery.id });
|
||||
toast.success(t(($) => $.deliveries.replay.toast_success));
|
||||
onSuccess();
|
||||
} catch (e: unknown) {
|
||||
const message =
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: t(($) => $.deliveries.replay.toast_failed);
|
||||
toast.error(message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleClick}
|
||||
disabled={!enabled}
|
||||
>
|
||||
<RotateCw
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 mr-1",
|
||||
replay.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
{replay.isPending
|
||||
? t(($) => $.deliveries.replay.in_progress)
|
||||
: t(($) => $.deliveries.replay.action)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, beforeAll, vi } from "vitest";
|
||||
import { screen, fireEvent } from "@testing-library/react";
|
||||
import { renderWithI18n } from "../../test/i18n";
|
||||
import { WebhookPayloadPreview } from "./webhook-payload-preview";
|
||||
|
||||
// sonner.toast is a fire-and-forget side-effect we don't want to assert on
|
||||
// in these tests; stub it so the Copy button doesn't blow up on toast
|
||||
// invocation.
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
// jsdom doesn't provide navigator.clipboard by default. Stub it once.
|
||||
beforeAll(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
});
|
||||
});
|
||||
|
||||
const envelope = (event: string, eventPayload: unknown, extras: Record<string, unknown> = {}) => ({
|
||||
event,
|
||||
eventPayload,
|
||||
request: { receivedAt: "2026-05-13T12:34:56Z", contentType: "application/json", ...extras },
|
||||
});
|
||||
|
||||
describe("WebhookPayloadPreview", () => {
|
||||
it("renders the envelope event in the header", () => {
|
||||
renderWithI18n(
|
||||
<WebhookPayloadPreview
|
||||
payload={envelope("github.pull_request.opened", { number: 1 })}
|
||||
defaultOpen
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("github.pull_request.opened")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("falls back gracefully when payload is not an envelope", () => {
|
||||
renderWithI18n(
|
||||
<WebhookPayloadPreview payload={{ hello: "world" }} defaultOpen />,
|
||||
);
|
||||
// The unknown-event placeholder is the i18n key; the body should still
|
||||
// include the raw JSON so nothing is hidden.
|
||||
expect(screen.getByText(/hello/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("truncates display when the payload exceeds 4 KiB but copies full text", async () => {
|
||||
// 5 KiB string field → stringified envelope > 4 KiB.
|
||||
const bigPayload = envelope("demo.big", { blob: "x".repeat(5 * 1024) });
|
||||
renderWithI18n(
|
||||
<WebhookPayloadPreview payload={bigPayload} defaultOpen />,
|
||||
);
|
||||
// Truncation marker (i18n) appears as a tail span — we assert by
|
||||
// partial text rather than coupling to the exact phrasing.
|
||||
expect(screen.getByText(/truncated/i)).toBeInTheDocument();
|
||||
|
||||
// The visible <pre> body must NOT contain the full 5 KiB blob — it is
|
||||
// sliced to the truncate threshold.
|
||||
const pre = document.querySelector("pre");
|
||||
expect(pre).not.toBeNull();
|
||||
expect((pre!.textContent ?? "").length).toBeLessThan(5 * 1024 + 200);
|
||||
|
||||
// Clicking Copy must still hand the FULL payload to the clipboard.
|
||||
fireEvent.click(screen.getByRole("button", { name: /copy/i }));
|
||||
const writeText = navigator.clipboard.writeText as ReturnType<typeof vi.fn>;
|
||||
expect(writeText).toHaveBeenCalled();
|
||||
const lastCall = writeText.mock.calls[writeText.mock.calls.length - 1];
|
||||
if (!lastCall) throw new Error("clipboard.writeText was not called");
|
||||
const written = lastCall[0] as string;
|
||||
expect(written.length).toBeGreaterThan(5 * 1024);
|
||||
expect(written).toContain("xxxxxxxx");
|
||||
});
|
||||
});
|
||||
141
packages/views/autopilots/components/webhook-payload-preview.tsx
Normal file
141
packages/views/autopilots/components/webhook-payload-preview.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { Webhook, ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface WebhookPayloadPreviewProps {
|
||||
payload: unknown;
|
||||
/** Default open vs collapsed. The dialog has limited vertical space, so
|
||||
* we collapse by default and let the user expand. */
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a webhook trigger payload (the WebhookEnvelope shape produced
|
||||
* server-side by normalizeWebhookPayload) inline with the autopilot run
|
||||
* detail. Falls back gracefully when the payload isn't an envelope —
|
||||
* showing whatever JSON is there with a generic header.
|
||||
*
|
||||
* This is intentionally read-only and decoupled from any specific dialog
|
||||
* — it gets dropped into AgentTranscriptDialog's headerSlot.
|
||||
*/
|
||||
export function WebhookPayloadPreview({
|
||||
payload,
|
||||
defaultOpen = false,
|
||||
}: WebhookPayloadPreviewProps) {
|
||||
const { t } = useT("autopilots");
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { event, receivedAt, contentType, fullJSON, displayJSON, isTruncated } = useMemo(() => {
|
||||
let event: string | null = null;
|
||||
let eventPayload: unknown = null;
|
||||
let receivedAt: string | null = null;
|
||||
let contentType: string | null = null;
|
||||
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||
const obj = payload as Record<string, unknown>;
|
||||
if (typeof obj.event === "string") event = obj.event;
|
||||
if ("eventPayload" in obj) eventPayload = obj.eventPayload;
|
||||
const req = obj.request;
|
||||
if (req && typeof req === "object") {
|
||||
const r = req as Record<string, unknown>;
|
||||
if (typeof r.receivedAt === "string") receivedAt = r.receivedAt;
|
||||
if (typeof r.contentType === "string") contentType = r.contentType;
|
||||
}
|
||||
}
|
||||
// If the payload didn't match the envelope shape (caller wrote
|
||||
// directly to trigger_payload, malformed history row, etc.), show
|
||||
// the whole thing as the eventPayload so nothing is hidden.
|
||||
if (eventPayload === null && payload !== null && payload !== undefined) {
|
||||
eventPayload = payload;
|
||||
}
|
||||
const fullJSON = JSON.stringify(eventPayload, null, 2);
|
||||
// Truncate the in-DOM string so the dialog stays responsive even when a
|
||||
// provider sent a 256 KiB envelope. The Copy button still yields the
|
||||
// full string, so the user never loses the data. 4 KiB is large enough
|
||||
// to show the envelope header + first object-level fields of a typical
|
||||
// webhook payload.
|
||||
const TRUNCATE_AT = 4096;
|
||||
const isTruncated = fullJSON.length > TRUNCATE_AT;
|
||||
const displayJSON = isTruncated ? fullJSON.slice(0, TRUNCATE_AT) : fullJSON;
|
||||
return { event, receivedAt, contentType, fullJSON, displayJSON, isTruncated };
|
||||
}, [payload]);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullJSON);
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.webhook_payload.copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t(($) => $.webhook_payload.copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-accent/30 transition-colors"
|
||||
>
|
||||
<Webhook className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t(($) => $.webhook_payload.label)}
|
||||
</span>
|
||||
<code className="truncate font-mono text-muted-foreground">
|
||||
{event ?? t(($) => $.webhook_payload.unknown_event)}
|
||||
</code>
|
||||
{receivedAt && (
|
||||
<span className="ml-auto shrink-0 text-muted-foreground/70">
|
||||
{receivedAt}
|
||||
</span>
|
||||
)}
|
||||
{open ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{contentType
|
||||
? t(($) => $.webhook_payload.content_type, { type: contentType })
|
||||
: t(($) => $.webhook_payload.payload)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-2 py-0.5 hover:bg-accent transition-colors",
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-emerald-500" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
{copied
|
||||
? t(($) => $.webhook_payload.copied_short)
|
||||
: t(($) => $.webhook_payload.copy)}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto bg-muted/40 px-3 py-2 text-xs font-mono leading-relaxed">
|
||||
{displayJSON}
|
||||
{isTruncated && (
|
||||
<span className="block pt-2 text-muted-foreground/70">
|
||||
{t(($) => $.webhook_payload.truncated_marker)}
|
||||
</span>
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -907,7 +907,7 @@ function SessionDropdown({
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
<DropdownMenuTrigger className="flex max-w-96 min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 transition-colors hover:bg-accent aria-expanded:bg-accent">
|
||||
{triggerAgent && (
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
@@ -917,7 +917,7 @@ function SessionDropdown({
|
||||
showStatusDot
|
||||
/>
|
||||
)}
|
||||
<span className="truncate text-sm font-medium">{title}</span>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{title}</span>
|
||||
{otherSessionRunning ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.another_running)}
|
||||
@@ -933,7 +933,10 @@ function SessionDropdown({
|
||||
) : null}
|
||||
<ChevronDown className="size-3 text-muted-foreground shrink-0" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="max-h-96 w-auto min-w-64 max-w-80 overflow-y-auto">
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-96 w-auto min-w-[max(16rem,var(--anchor-width,16rem))] max-w-96 overflow-y-auto"
|
||||
>
|
||||
{sessions.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.window.no_previous)}
|
||||
|
||||
@@ -9,11 +9,12 @@ import {
|
||||
} from "@multica/ui/components/ui/hover-card";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { useAgentPresenceDetail } from "@multica/core/agents";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { AgentProfileCard } from "../agents/components/agent-profile-card";
|
||||
import { AgentLivePeekCard } from "../agents/components/agent-live-peek-card";
|
||||
import { MemberProfileCard } from "../members/member-profile-card";
|
||||
import { availabilityConfig } from "../agents/presence";
|
||||
import { useNavigation } from "../navigation";
|
||||
|
||||
/**
|
||||
* Selects which agent hover-card payload to render when `enableHoverCard` is
|
||||
@@ -54,10 +55,17 @@ interface ActorAvatarProps {
|
||||
* existing call sites keep their identity-card behaviour.
|
||||
*/
|
||||
hoverCardVariant?: AgentHoverCardVariant;
|
||||
/**
|
||||
* Make the avatar click through to the actor page. Defaults on for members
|
||||
* and agents, while picker/menu controls keep their own click behavior.
|
||||
*/
|
||||
profileLink?: boolean;
|
||||
}
|
||||
|
||||
const FOCUSABLE_ANCESTOR_SELECTOR =
|
||||
'a[href], button:not([disabled]), [role="button"]:not([aria-disabled="true"]), [tabindex]:not([tabindex="-1"])';
|
||||
const PROFILE_LINK_CONTROL_SELECTOR =
|
||||
'button, [role^="menuitem"], [role="option"], [data-slot="dropdown-menu-item"], [data-slot="dropdown-menu-checkbox-item"], [data-slot="popover-trigger"]';
|
||||
|
||||
export function ActorAvatar({
|
||||
actorType,
|
||||
@@ -67,8 +75,10 @@ export function ActorAvatar({
|
||||
enableHoverCard,
|
||||
showStatusDot,
|
||||
hoverCardVariant = "profile",
|
||||
profileLink,
|
||||
}: ActorAvatarProps) {
|
||||
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
|
||||
const paths = useWorkspacePaths();
|
||||
const avatar = (
|
||||
<ActorAvatarBase
|
||||
name={getActorName(actorType, actorId)}
|
||||
@@ -95,21 +105,79 @@ export function ActorAvatar({
|
||||
) : (
|
||||
avatar
|
||||
);
|
||||
const shouldLinkToProfile =
|
||||
profileLink ?? (actorType === "member" || actorType === "agent");
|
||||
const profileHref =
|
||||
shouldLinkToProfile && actorType === "member"
|
||||
? paths.memberDetail(actorId)
|
||||
: shouldLinkToProfile && actorType === "agent"
|
||||
? paths.agentDetail(actorId)
|
||||
: null;
|
||||
const content = profileHref ? (
|
||||
<ActorAvatarProfileLink href={profileHref}>{dotted}</ActorAvatarProfileLink>
|
||||
) : (
|
||||
dotted
|
||||
);
|
||||
|
||||
if (!enableHoverCard) {
|
||||
return dotted;
|
||||
return content;
|
||||
}
|
||||
if (actorType === "agent") {
|
||||
return (
|
||||
<AgentAvatarHoverCard agentId={actorId} variant={hoverCardVariant}>
|
||||
{dotted}
|
||||
{content}
|
||||
</AgentAvatarHoverCard>
|
||||
);
|
||||
}
|
||||
if (actorType === "member") {
|
||||
return <MemberAvatarHoverCard userId={actorId}>{dotted}</MemberAvatarHoverCard>;
|
||||
return <MemberAvatarHoverCard userId={actorId}>{content}</MemberAvatarHoverCard>;
|
||||
}
|
||||
return dotted;
|
||||
return content;
|
||||
}
|
||||
|
||||
function ActorAvatarProfileLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { push, openInNewTab } = useNavigation();
|
||||
|
||||
const navigate = (event: React.MouseEvent | React.KeyboardEvent) => {
|
||||
const controlAncestor = event.currentTarget.parentElement?.closest(
|
||||
PROFILE_LINK_CONTROL_SELECTOR,
|
||||
);
|
||||
if (controlAncestor) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (
|
||||
"metaKey" in event &&
|
||||
(event.metaKey || event.ctrlKey || event.shiftKey) &&
|
||||
openInNewTab
|
||||
) {
|
||||
openInNewTab(href);
|
||||
return;
|
||||
}
|
||||
push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<span
|
||||
role="link"
|
||||
tabIndex={-1}
|
||||
className="inline-flex cursor-pointer rounded-full"
|
||||
onClick={navigate}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
navigate(event);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Small presence indicator overlaid on the bottom-right of an agent avatar.
|
||||
|
||||
246
packages/views/common/actor-issues-panel.tsx
Normal file
246
packages/views/common/actor-issues-panel.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { ListTodo, Search } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { BOARD_STATUSES } from "@multica/core/issues/config";
|
||||
import {
|
||||
childIssueProgressOptions,
|
||||
myIssueListOptions,
|
||||
type MyIssuesFilter,
|
||||
} from "@multica/core/issues/queries";
|
||||
import {
|
||||
actorIssuesViewStore,
|
||||
type ActorIssuesScope,
|
||||
} from "@multica/core/issues/stores/actor-issues-view-store";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
|
||||
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
|
||||
import { ListView } from "../issues/components/list-view";
|
||||
import { BatchActionToolbar } from "../issues/components/batch-action-toolbar";
|
||||
import { IssueDisplayControls } from "../issues/components/issues-header";
|
||||
import { filterIssues } from "../issues/utils/filter";
|
||||
import { matchesPinyin } from "../editor/extensions/pinyin-match";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export type TaskActorType = "member" | "agent";
|
||||
|
||||
const SCOPE_VALUES: ActorIssuesScope[] = ["assigned", "created"];
|
||||
|
||||
export function ActorIssuesPanel({
|
||||
actorType,
|
||||
actorId,
|
||||
}: {
|
||||
actorType: TaskActorType;
|
||||
actorId: string;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const wsId = useWorkspaceId();
|
||||
const scope = useStore(actorIssuesViewStore, (s) => s.scope);
|
||||
const setScope = useStore(actorIssuesViewStore, (s) => s.setScope);
|
||||
const setViewMode = useStore(actorIssuesViewStore, (s) => s.setViewMode);
|
||||
const statusFilters = useStore(actorIssuesViewStore, (s) => s.statusFilters);
|
||||
const priorityFilters = useStore(actorIssuesViewStore, (s) => s.priorityFilters);
|
||||
const assigneeFilters = useStore(actorIssuesViewStore, (s) => s.assigneeFilters);
|
||||
const includeNoAssignee = useStore(actorIssuesViewStore, (s) => s.includeNoAssignee);
|
||||
const creatorFilters = useStore(actorIssuesViewStore, (s) => s.creatorFilters);
|
||||
const projectFilters = useStore(actorIssuesViewStore, (s) => s.projectFilters);
|
||||
const includeNoProject = useStore(actorIssuesViewStore, (s) => s.includeNoProject);
|
||||
const labelFilters = useStore(actorIssuesViewStore, (s) => s.labelFilters);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useClearFiltersOnWorkspaceChange(actorIssuesViewStore, wsId);
|
||||
|
||||
// The actor tasks panel is list-only; clear any persisted "board" state
|
||||
// so list-only affordances (e.g. BatchActionToolbar) render correctly.
|
||||
useEffect(() => {
|
||||
setViewMode("list");
|
||||
}, [setViewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [scope, actorType, actorId]);
|
||||
|
||||
const queryFilter: MyIssuesFilter = useMemo(
|
||||
() =>
|
||||
scope === "assigned"
|
||||
? { assignee_id: actorId }
|
||||
: { creator_id: actorId },
|
||||
[scope, actorId],
|
||||
);
|
||||
const queryScope = `${actorType}:${actorId}:${scope}`;
|
||||
|
||||
const rawIssuesQuery = useQuery(myIssueListOptions(wsId, queryScope, queryFilter));
|
||||
const rawIssues = useMemo(
|
||||
() => rawIssuesQuery.data ?? [],
|
||||
[rawIssuesQuery.data],
|
||||
);
|
||||
const isLoading = rawIssuesQuery.isLoading;
|
||||
|
||||
const actorIssues = useMemo(
|
||||
() =>
|
||||
rawIssues.filter((issue) =>
|
||||
scope === "assigned"
|
||||
? issue.assignee_type === actorType && issue.assignee_id === actorId
|
||||
: issue.creator_type === actorType && issue.creator_id === actorId,
|
||||
),
|
||||
[actorId, actorType, rawIssues, scope],
|
||||
);
|
||||
|
||||
const filteredIssues = useMemo(
|
||||
() =>
|
||||
filterIssues(actorIssues, {
|
||||
statusFilters,
|
||||
priorityFilters,
|
||||
assigneeFilters,
|
||||
includeNoAssignee,
|
||||
creatorFilters,
|
||||
projectFilters,
|
||||
includeNoProject,
|
||||
labelFilters,
|
||||
}),
|
||||
[
|
||||
actorIssues,
|
||||
statusFilters,
|
||||
priorityFilters,
|
||||
assigneeFilters,
|
||||
includeNoAssignee,
|
||||
creatorFilters,
|
||||
projectFilters,
|
||||
includeNoProject,
|
||||
labelFilters,
|
||||
],
|
||||
);
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return filteredIssues;
|
||||
return filteredIssues.filter((issue) => {
|
||||
const title = issue.title ?? "";
|
||||
return (
|
||||
title.toLowerCase().includes(query) ||
|
||||
issue.identifier.toLowerCase().includes(query) ||
|
||||
matchesPinyin(title, query)
|
||||
);
|
||||
});
|
||||
}, [filteredIssues, search]);
|
||||
|
||||
const { data: childProgressMap = new Map() } = useQuery(
|
||||
childIssueProgressOptions(wsId),
|
||||
);
|
||||
|
||||
const visibleStatuses = useMemo(() => {
|
||||
if (statusFilters.length > 0) {
|
||||
return BOARD_STATUSES.filter((s) => statusFilters.includes(s));
|
||||
}
|
||||
return BOARD_STATUSES;
|
||||
}, [statusFilters]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ActorIssuesSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ViewStoreProvider store={actorIssuesViewStore}>
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t(($) => $.actor_issues.search_placeholder)}
|
||||
className="h-8 w-64 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPE_VALUES.map((value) => (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
scope === value
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
onClick={() => setScope(value)}
|
||||
>
|
||||
{t(($) => $.actor_issues.scope[value].label)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{t(($) => $.actor_issues.scope[value].description)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<IssueDisplayControls scopedIssues={actorIssues} hideViewToggle />
|
||||
</div>
|
||||
|
||||
{actorIssues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<ListTodo className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">
|
||||
{t(($) => $.actor_issues.empty[scope].title)}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{t(($) => $.actor_issues.empty[scope].description)}
|
||||
</p>
|
||||
</div>
|
||||
) : search.trim() !== "" && issues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Search className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={queryScope}
|
||||
myIssuesFilter={queryFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<BatchActionToolbar />
|
||||
</div>
|
||||
</ViewStoreProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ActorIssuesSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-64 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-2 p-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -43,6 +43,13 @@ interface AgentTranscriptDialogProps {
|
||||
items: TimelineItem[];
|
||||
agentName: string;
|
||||
isLive?: boolean;
|
||||
/**
|
||||
* Optional content rendered between the header chips and the event list.
|
||||
* Used by autopilot run rows to surface the inbound webhook trigger
|
||||
* payload so it's visible regardless of whether the agent echoes it.
|
||||
* The dialog stays generic — slot content is the caller's concern.
|
||||
*/
|
||||
headerSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ─── Color mapping for timeline segments ────────────────────────────────────
|
||||
@@ -162,6 +169,7 @@ export function AgentTranscriptDialog({
|
||||
items,
|
||||
agentName,
|
||||
isLive = false,
|
||||
headerSlot,
|
||||
}: AgentTranscriptDialogProps) {
|
||||
const { t } = useT("agents");
|
||||
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
|
||||
@@ -451,6 +459,13 @@ export function AgentTranscriptDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Optional header slot (e.g. webhook payload preview) ── */}
|
||||
{headerSlot && (
|
||||
<div className="border-b px-4 py-3 shrink-0 bg-muted/20">
|
||||
{headerSlot}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Event list ─────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user