mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-19 04:38:50 +02:00
Compare commits
86 Commits
agent/lamb
...
revert/exe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51108a7bb0 | ||
|
|
9c5302ff2f | ||
|
|
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 | ||
|
|
ee48e58b8f | ||
|
|
464201ba0d | ||
|
|
9517536d49 | ||
|
|
4d6b5ad06f | ||
|
|
8572a79950 | ||
|
|
f82a6adde9 | ||
|
|
675ed02aa6 | ||
|
|
9da52add15 |
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;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { autoUpdater, UpdateDownloadedEvent } from "electron-updater";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
autoUpdater.autoDownload = false;
|
||||
// Silent background updates: electron-updater downloads on its own as soon
|
||||
// as `update-available` fires; we only surface UI when the package is fully
|
||||
// downloaded and ready to install on next quit.
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Windows arm64 ships its own update metadata channel because
|
||||
@@ -26,8 +29,39 @@ export type ManualUpdateCheckResult =
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
// Single-flight guard around checkForUpdates(). With autoDownload=true the
|
||||
// startup, periodic, and manual triggers can all kick off downloads, and
|
||||
// overlapping calls have caused duplicate download warnings in the past
|
||||
// (see electronjs.org/docs/latest/api/auto-updater). Coalesce concurrent
|
||||
// callers onto the same in-flight promise.
|
||||
let inFlightCheck: Promise<unknown> | null = null;
|
||||
function checkForUpdatesOnce(): Promise<unknown> {
|
||||
if (inFlightCheck) return inFlightCheck;
|
||||
const p = autoUpdater
|
||||
.checkForUpdates()
|
||||
.then((result) => {
|
||||
// checkForUpdates resolves as soon as metadata is fetched; the actual
|
||||
// download (when autoDownload=true) is exposed on result.downloadPromise.
|
||||
// Without a handler a download failure becomes an unhandled rejection
|
||||
// in the main process — Node may terminate it on future versions.
|
||||
void (result as { downloadPromise?: Promise<unknown> } | null)?.downloadPromise?.catch(
|
||||
(err) => {
|
||||
console.error("Failed to download update:", err);
|
||||
},
|
||||
);
|
||||
return result;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlightCheck === p) inFlightCheck = null;
|
||||
});
|
||||
inFlightCheck = p;
|
||||
return p;
|
||||
}
|
||||
|
||||
export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): void {
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
// Forwarded for renderer-side state tracking only; the notification UI
|
||||
// does not render an "available" affordance with autoDownload=true.
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-available", {
|
||||
version: info.version,
|
||||
@@ -42,15 +76,20 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
autoUpdater.on("update-downloaded", (info: UpdateDownloadedEvent) => {
|
||||
const win = getMainWindow();
|
||||
win?.webContents.send("updater:update-downloaded");
|
||||
win?.webContents.send("updater:update-downloaded", {
|
||||
version: info.version,
|
||||
releaseNotes: info.releaseNotes,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-updater error:", err);
|
||||
});
|
||||
|
||||
// Retained for IPC back-compat with older renderer bundles. With
|
||||
// autoDownload=true the renderer no longer triggers this path.
|
||||
ipcMain.handle("updater:download", () => {
|
||||
return autoUpdater.downloadUpdate();
|
||||
});
|
||||
@@ -61,7 +100,9 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
ipcMain.handle("updater:check", async (): Promise<ManualUpdateCheckResult> => {
|
||||
try {
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
const result = (await checkForUpdatesOnce()) as
|
||||
| { updateInfo: { version: string }; isUpdateAvailable?: boolean }
|
||||
| null;
|
||||
const currentVersion = app.getVersion();
|
||||
// Trust electron-updater's own decision rather than re-deriving it from
|
||||
// a version-string compare. The two diverge for pre-release channels,
|
||||
@@ -85,7 +126,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
|
||||
// Initial check shortly after startup so we don't block boot.
|
||||
setTimeout(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
console.error("Failed to check for updates:", err);
|
||||
});
|
||||
}, STARTUP_CHECK_DELAY_MS);
|
||||
@@ -93,7 +134,7 @@ export function setupAutoUpdater(getMainWindow: () => BrowserWindow | null): voi
|
||||
// Background poll so long-running sessions still pick up new releases
|
||||
// without requiring the user to restart the app.
|
||||
setInterval(() => {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
checkForUpdatesOnce().catch((err) => {
|
||||
console.error("Periodic update check failed:", err);
|
||||
});
|
||||
}, PERIODIC_CHECK_INTERVAL_MS);
|
||||
|
||||
4
apps/desktop/src/preload/index.d.ts
vendored
4
apps/desktop/src/preload/index.d.ts
vendored
@@ -84,7 +84,9 @@ interface DaemonAPI {
|
||||
interface UpdaterAPI {
|
||||
onUpdateAvailable: (callback: (info: { version: string; releaseNotes?: string }) => void) => () => void;
|
||||
onDownloadProgress: (callback: (progress: { percent: number }) => void) => () => void;
|
||||
onUpdateDownloaded: (callback: () => void) => () => void;
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => () => void;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
checkForUpdates: () => Promise<
|
||||
|
||||
@@ -207,8 +207,11 @@ const updaterAPI = {
|
||||
ipcRenderer.on("updater:download-progress", handler);
|
||||
return () => ipcRenderer.removeListener("updater:download-progress", handler);
|
||||
},
|
||||
onUpdateDownloaded: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
onUpdateDownloaded: (
|
||||
callback: (info: { version: string; releaseNotes?: string }) => void,
|
||||
) => {
|
||||
const handler = (_: unknown, info: { version: string; releaseNotes?: string }) =>
|
||||
callback(info);
|
||||
ipcRenderer.on("updater:update-downloaded", handler);
|
||||
return () => ipcRenderer.removeListener("updater:update-downloaded", handler);
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,55 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDownToLine, RefreshCw, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { RefreshCw, X } from "lucide-react";
|
||||
|
||||
// Downloads run silently in the background (main process has
|
||||
// autoDownload=true). The renderer only renders UI once the package is fully
|
||||
// downloaded and waiting for a restart.
|
||||
type UpdateState =
|
||||
| { status: "idle" }
|
||||
| { status: "available"; version: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "ready" };
|
||||
| { status: "ready"; version: string };
|
||||
|
||||
export function UpdateNotification() {
|
||||
const [state, setState] = useState<UpdateState>({ status: "idle" });
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateAvailable((info) => {
|
||||
setState({ status: "available", version: info.version });
|
||||
setDismissed(false);
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onDownloadProgress((progress) => {
|
||||
setState({ status: "downloading", percent: progress.percent });
|
||||
}),
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
window.updater.onUpdateDownloaded(() => {
|
||||
setState({ status: "ready" });
|
||||
}),
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
const cleanup = window.updater.onUpdateDownloaded((info) => {
|
||||
setState({ status: "ready", version: info.version });
|
||||
setDismissed(false);
|
||||
});
|
||||
return cleanup;
|
||||
}, []);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
// Prevent double-click: immediately transition to downloading state
|
||||
if (state.status !== "available") return;
|
||||
setState({ status: "downloading", percent: 0 });
|
||||
window.updater.downloadUpdate();
|
||||
}, [state.status]);
|
||||
|
||||
const handleInstall = useCallback(() => {
|
||||
window.updater.installUpdate();
|
||||
}, []);
|
||||
|
||||
// Only allow dismiss when update is available (not during download or ready)
|
||||
if (state.status === "idle") return null;
|
||||
if (dismissed && state.status === "available") return null;
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-80 rounded-lg border border-border bg-background p-4 shadow-lg animate-in slide-in-from-bottom-2 fade-in duration-300">
|
||||
@@ -60,78 +32,31 @@ export function UpdateNotification() {
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{state.status === "available" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">New version available</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} is ready to download
|
||||
</p>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
v{state.version} will be applied on next launch.
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
onClick={() => setDismissed(true)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
Download update
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.updater.installUpdate()}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "downloading" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-primary/10 p-1.5">
|
||||
<ArrowDownToLine className="size-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Downloading update...</p>
|
||||
<div className="mt-2 h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${Math.round(state.percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{Math.round(state.percent)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === "ready" && (
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 rounded-md bg-success/10 p-1.5">
|
||||
<RefreshCw className="size-4 text-success" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium">Update ready</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Restart to apply the update
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{/* Secondary "See changes" — gives the user a reason to
|
||||
restart by surfacing what they're about to get. Opens
|
||||
in the default browser via the shared openExternal
|
||||
bridge so the URL hits the same allow-list as every
|
||||
other outbound link. */}
|
||||
<button
|
||||
onClick={() => window.desktopAPI.openExternal("https://multica.ai/changelog")}
|
||||
className="inline-flex items-center rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium text-foreground hover:bg-accent transition-colors"
|
||||
>
|
||||
See changes
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInstall}
|
||||
className="inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,8 @@ export function UpdatesSettingsTab() {
|
||||
<h2 className="text-lg font-semibold">Updates</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
The desktop app checks for new versions automatically once an hour and
|
||||
shortly after launch.
|
||||
shortly after launch, downloading them in the background. You'll
|
||||
be prompted to restart once an update is ready.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 divide-y">
|
||||
@@ -50,7 +51,8 @@ export function UpdatesSettingsTab() {
|
||||
<p className="text-sm font-medium">Check for updates</p>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Trigger a check now instead of waiting for the next automatic
|
||||
poll. Available updates appear as a notification in the corner.
|
||||
poll. Available updates download in the background and show a
|
||||
restart prompt when ready.
|
||||
</p>
|
||||
{state.status === "up-to-date" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
@@ -61,8 +63,8 @@ export function UpdatesSettingsTab() {
|
||||
{state.status === "available" && (
|
||||
<p className="text-sm text-muted-foreground mt-2 inline-flex items-center gap-1.5">
|
||||
<ArrowDownToLine className="size-3.5 text-primary" />
|
||||
v{state.latestVersion} is available — see the download prompt
|
||||
in the corner.
|
||||
v{state.latestVersion} is downloading in the background —
|
||||
you'll be notified when it's ready to install.
|
||||
</p>
|
||||
)}
|
||||
{state.status === "error" && (
|
||||
|
||||
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,6 +11,7 @@ import { ProjectDetailPage } from "./pages/project-detail-page";
|
||||
import { AutopilotDetailPage } from "./pages/autopilot-detail-page";
|
||||
import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { MemberDetailPage } from "./pages/member-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
@@ -147,6 +148,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",
|
||||
|
||||
@@ -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,7 +16,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
- **Priority** — inherited by the `task` it produces (same semantics as issue priority)
|
||||
- **Description / prompt** — the work description the agent receives each run
|
||||
- **Execution mode** — see below
|
||||
- **Triggers** — at least one `schedule` (cron + timezone)
|
||||
- **Triggers** — at least one `schedule` (cron + timezone) or `webhook`
|
||||
|
||||
## Pick an execution mode
|
||||
|
||||
@@ -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,7 +16,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
- **优先级** — 继承给它产生的 `task`(语义同 issue 优先级)
|
||||
- **描述 / Prompt** — 智能体每次执行拿到的工作说明
|
||||
- **执行模式** — 见下节
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)
|
||||
- **触发器** — 至少加一条 `schedule`(cron + 时区)或 `webhook`
|
||||
|
||||
## 选择执行模式
|
||||
|
||||
@@ -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}`)的限流器。两个限流器各自读各自的列表,部署在代理后面的实例需要两个都配上。
|
||||
|
||||
## 守护进程的调节参数
|
||||
|
||||
守护进程跑在用户本地机器上,配置也是读本地环境变量。常用的几个:
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -284,6 +284,60 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title:
|
||||
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
|
||||
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
|
||||
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
|
||||
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
|
||||
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"Agent runs recover more reliably from stuck commands, idle sessions, and long-running work",
|
||||
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
|
||||
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
|
||||
"Search results are ranked more usefully and include better snippets",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
|
||||
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
|
||||
"Squad pages scroll correctly and show which members are already working",
|
||||
"Desktop zoom shortcuts work again across the common keyboard combinations",
|
||||
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "Faster Navigation, Background Updates & More Reliable Squads",
|
||||
changes: [],
|
||||
features: [
|
||||
"Member and agent detail pages now show related tasks so teams can review who is working on what",
|
||||
"The desktop app downloads updates in the background so a new version is ready when you are",
|
||||
"Self-hosted deployments can send email through SMTP as an alternative to Resend",
|
||||
"Create Squad has a clearer setup flow with member selection that works better for team coordination",
|
||||
],
|
||||
improvements: [
|
||||
"Page transitions are faster, with issue pages prepared ahead of time and smoother loading states",
|
||||
"Long issue activity blocks collapse so comments and conclusions are easier to scan",
|
||||
"Agents and Squads remember the Mine/All view when you return to the list",
|
||||
"Repository setup accepts more SSH URL formats across settings, projects, and quick create",
|
||||
"Squad handoffs are more dependable when agents have multiple roles or delegate to a specific member",
|
||||
],
|
||||
fixes: [
|
||||
"Self-hosted local file cards render and preview correctly",
|
||||
"Agent-run tasks are more dependable when local tools or skills need to be found automatically",
|
||||
"Claude usage totals match more of the model names reported by connected tools",
|
||||
"After switching workspaces, live updates come from the correct workspace and show the right source",
|
||||
"Chat session menus and runtime names hold their shape in narrower spaces",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -284,6 +284,59 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
|
||||
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
|
||||
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
|
||||
"Skills 支持从本地 runtime 批量复制到 workspace,团队初始化更快",
|
||||
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"Agent 运行在遇到卡住的命令、空闲会话和长时间任务时更容易恢复",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
|
||||
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
|
||||
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
|
||||
"桌面端缩放快捷键在常见组合下恢复正常",
|
||||
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
title: "更快的导航、后台更新与更可靠的小队协作",
|
||||
changes: [],
|
||||
features: [
|
||||
"成员和 agent 详情页现在可以看到关联任务,方便回看每个人和每个 agent 正在推进的工作",
|
||||
"桌面端会在后台提前下载新版本,等你准备好时再安装更新",
|
||||
"自托管部署可以使用 SMTP 发送邮件,不再只依赖 Resend",
|
||||
"创建 Squad 的流程更清晰,成员选择和初始设置更适合团队协作",
|
||||
],
|
||||
improvements: [
|
||||
"页面切换更快,Issue 页面会提前准备内容,并在加载时展示更自然的过渡状态",
|
||||
"Issue 时间线会把较长的活动记录收起,重点评论和结论更容易扫读",
|
||||
"Agents 和 Squads 页会记住你上次选择的 Mine/All 视图,返回列表时不再重置",
|
||||
"仓库设置、项目资源和快速创建流程更好地支持 SSH 形式的仓库地址",
|
||||
"小队分工更稳定,leader 能正确接续双角色 agent 的回复,也会更明确地把任务交给指定成员",
|
||||
],
|
||||
fixes: [
|
||||
"自托管本地文件卡片可以正常展示和预览",
|
||||
"Agent 在自动寻找本地工具、加载技能以及无人值守运行时更可靠",
|
||||
"Claude 用量统计能识别更多接入工具上报的模型名称",
|
||||
"切换 workspace 后,实时更新会来自正确的 workspace,消息来源也更准确",
|
||||
"聊天会话下拉菜单和 runtime 名称展示在窄空间里更稳定",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.0",
|
||||
date: "2026-05-14",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
5
packages/core/agents/stores/index.ts
Normal file
5
packages/core/agents/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
useAgentsViewStore,
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
96
packages/core/agents/stores/view-store.test.ts
Normal file
96
packages/core/agents/stores/view-store.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useAgentsViewStore } from "./view-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useAgentsViewStore.setState({ scope: "mine" });
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("useAgentsViewStore", () => {
|
||||
it("defaults to 'mine'", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("setScope mutates the store", () => {
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
|
||||
const raw = localStorage.getItem("multica_agents_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ scope: "all" });
|
||||
});
|
||||
|
||||
it("rehydrates a different saved scope on workspace switch", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:beta",
|
||||
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
40
packages/core/agents/stores/view-store.ts
Normal file
40
packages/core/agents/stores/view-store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type AgentsScope = "mine" | "all";
|
||||
|
||||
export interface AgentsViewState {
|
||||
scope: AgentsScope;
|
||||
setScope: (scope: AgentsScope) => void;
|
||||
}
|
||||
|
||||
export const useAgentsViewStore = create<AgentsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: "multica_agents_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the default "mine" instead of leaving the previous workspace's in-
|
||||
// memory scope in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak "all" across workspaces.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, scope: "mine" };
|
||||
return { ...current, ...(persisted as Partial<AgentsViewState>) };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useAgentsViewStore.persist.rehydrate());
|
||||
@@ -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),
|
||||
|
||||
46
packages/core/issues/stores/actor-issues-view-store.ts
Normal file
46
packages/core/issues/stores/actor-issues-view-store.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||
import { persist } from "zustand/middleware";
|
||||
import {
|
||||
type IssueViewState,
|
||||
viewStoreSlice,
|
||||
viewStorePersistOptions,
|
||||
mergeViewStatePersisted,
|
||||
} from "./view-store";
|
||||
import { registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
|
||||
export type ActorIssuesScope = "assigned" | "created";
|
||||
|
||||
export interface ActorIssuesViewState extends IssueViewState {
|
||||
scope: ActorIssuesScope;
|
||||
setScope: (scope: ActorIssuesScope) => void;
|
||||
}
|
||||
|
||||
const basePersist = viewStorePersistOptions("multica_actor_issues_view");
|
||||
|
||||
const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
|
||||
scope: "assigned" as ActorIssuesScope,
|
||||
setScope: (scope: ActorIssuesScope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: basePersist.name,
|
||||
storage: basePersist.storage,
|
||||
partialize: (state: ActorIssuesViewState) => ({
|
||||
...basePersist.partialize(state),
|
||||
scope: state.scope,
|
||||
}),
|
||||
merge: mergeViewStatePersisted<ActorIssuesViewState>,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const actorIssuesViewStore: StoreApi<ActorIssuesViewState> =
|
||||
_actorIssuesViewStore;
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
_actorIssuesViewStore.persist.rehydrate(),
|
||||
);
|
||||
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",
|
||||
@@ -54,6 +55,9 @@
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
|
||||
"./agents/visibility-label": "./agents/visibility-label.ts",
|
||||
"./agents/stores": "./agents/stores/index.ts",
|
||||
"./squads": "./squads/index.ts",
|
||||
"./squads/stores": "./squads/stores/index.ts",
|
||||
"./permissions": "./permissions/index.ts",
|
||||
"./projects": "./projects/index.ts",
|
||||
"./projects/queries": "./projects/queries.ts",
|
||||
@@ -105,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");
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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));
|
||||
|
||||
1
packages/core/squads/index.ts
Normal file
1
packages/core/squads/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./stores";
|
||||
5
packages/core/squads/stores/index.ts
Normal file
5
packages/core/squads/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
useSquadsViewStore,
|
||||
type SquadsScope,
|
||||
type SquadsViewState,
|
||||
} from "./view-store";
|
||||
96
packages/core/squads/stores/view-store.test.ts
Normal file
96
packages/core/squads/stores/view-store.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSquadsViewStore } from "./view-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
useSquadsViewStore.setState({ scope: "mine" });
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
describe("useSquadsViewStore", () => {
|
||||
it("defaults to 'mine'", () => {
|
||||
expect(useSquadsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("setScope mutates the store", () => {
|
||||
useSquadsViewStore.getState().setScope("all");
|
||||
expect(useSquadsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useSquadsViewStore.getState().setScope("all");
|
||||
|
||||
const raw = localStorage.getItem("multica_squads_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ scope: "all" });
|
||||
});
|
||||
|
||||
it("rehydrates a different saved scope on workspace switch", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_squads_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
localStorage.setItem(
|
||||
"multica_squads_view:beta",
|
||||
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("mine");
|
||||
});
|
||||
|
||||
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_squads_view:acme",
|
||||
JSON.stringify({ state: { scope: "all" }, version: 0 }),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("all");
|
||||
|
||||
setCurrentWorkspace("beta", "ws_b");
|
||||
await flush();
|
||||
await flush();
|
||||
expect(useSquadsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_squads_view:acme")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
40
packages/core/squads/stores/view-store.ts
Normal file
40
packages/core/squads/stores/view-store.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type SquadsScope = "mine" | "all";
|
||||
|
||||
export interface SquadsViewState {
|
||||
scope: SquadsScope;
|
||||
setScope: (scope: SquadsScope) => void;
|
||||
}
|
||||
|
||||
export const useSquadsViewStore = create<SquadsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
}),
|
||||
{
|
||||
name: "multica_squads_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the default "mine" instead of leaving the previous workspace's in-
|
||||
// memory scope in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak "all" across workspaces.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, scope: "mine" };
|
||||
return { ...current, ...(persisted as Partial<SquadsViewState>) };
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useSquadsViewStore.persist.rehydrate());
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
useWorkspaceActivityMap,
|
||||
useWorkspacePresenceMap,
|
||||
} from "@multica/core/agents";
|
||||
import { useAgentsViewStore } from "@multica/core/agents/stores";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
@@ -100,10 +101,10 @@ export function AgentsPage() {
|
||||
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
|
||||
|
||||
const [view, setView] = useState<View>("active");
|
||||
// Default to "mine" — matches runtimes page convention and the visual
|
||||
// ordering (Mine first). All is one click away when users want the
|
||||
// workspace-wide view.
|
||||
const [scope, setScope] = useState<Scope>("mine");
|
||||
// Scope (Mine/All) is persisted per workspace so it survives list →
|
||||
// detail → back navigation. Default is "mine" on first visit.
|
||||
const scope = useAgentsViewStore((s) => s.scope);
|
||||
const setScope = useAgentsViewStore((s) => s.setScope);
|
||||
const [availabilityFilter, setAvailabilityFilter] =
|
||||
useState<AvailabilityFilter>("all");
|
||||
const [sort, setSort] = useState<SortKey>("recent");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,64 @@ 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;
|
||||
|
||||
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 +329,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,6 +346,32 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
|
||||
</div>
|
||||
)}
|
||||
{isWebhook && webhookUrl && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -217,6 +405,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 +440,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 +491,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 +640,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 +756,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 +780,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)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user