mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 15:09:22 +02:00
Compare commits
37 Commits
agent/lamb
...
fix/webhoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
948186a069 | ||
|
|
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 |
42
.env.example
42
.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=
|
||||
@@ -103,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)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { handleAppShortcut } from "./keyboard-shortcuts";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
@@ -189,19 +190,13 @@ function createWindow(): void {
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
// Prevent Cmd+R / Ctrl+R / Shift+Cmd+R / Shift+Ctrl+R / F5 from
|
||||
// reloading the page. In a desktop app an accidental reload destroys
|
||||
// in-memory state (tabs, drafts, WS connections) with no URL bar to
|
||||
// navigate back. DevTools refresh (via the DevTools UI) still works.
|
||||
mainWindow.webContents.on("before-input-event", (_event, input) => {
|
||||
if (input.type !== "keyDown") return;
|
||||
const cmdOrCtrl =
|
||||
process.platform === "darwin" ? input.meta : input.control;
|
||||
if (
|
||||
(cmdOrCtrl && input.key.toLowerCase() === "r") ||
|
||||
input.key === "F5"
|
||||
) {
|
||||
_event.preventDefault();
|
||||
// Window-level keyboard shortcuts. Calling preventDefault here prevents
|
||||
// both the renderer keydown AND the application menu accelerator, so
|
||||
// anything we own here (reload-block, zoom) is the sole handler for
|
||||
// that combination — no double-fire with the macOS default View menu.
|
||||
mainWindow.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, mainWindow!.webContents)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
152
apps/desktop/src/main/keyboard-shortcuts.test.ts
Normal file
152
apps/desktop/src/main/keyboard-shortcuts.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { handleAppShortcut, type ShortcutInput } from "./keyboard-shortcuts";
|
||||
|
||||
function makeWc(initialLevel = 0) {
|
||||
let level = initialLevel;
|
||||
return {
|
||||
getZoomLevel: vi.fn(() => level),
|
||||
setZoomLevel: vi.fn((next: number) => {
|
||||
level = next;
|
||||
}),
|
||||
currentLevel: () => level,
|
||||
};
|
||||
}
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
|
||||
describe("handleAppShortcut — reload blocking", () => {
|
||||
it("swallows Cmd+R on macOS", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swallows Ctrl+R on Linux/Windows", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("r", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(handleAppShortcut(key("R", { control: true }), wc, "win32")).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows F5 regardless of modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("F5"), wc, "darwin")).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores non-keyDown events", () => {
|
||||
const wc = makeWc();
|
||||
expect(
|
||||
handleAppShortcut({ ...key("r", { meta: true }), type: "keyUp" }, wc, "darwin"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom in", () => {
|
||||
it("zooms in on Cmd+= (unshifted)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Cmd++ (Shift+=)", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("+", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms in on Ctrl+= on non-mac", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("=", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(0);
|
||||
expect(handleAppShortcut(key("="), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clamps zoom-in at the upper bound", () => {
|
||||
const wc = makeWc(4.5);
|
||||
expect(handleAppShortcut(key("=", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(4.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — zoom out (regression: MUL-2354)", () => {
|
||||
it("zooms out on Cmd+- (unshifted)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Cmd+_ (Shift+-)", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("_", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("zooms out on Ctrl+- on non-mac", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-", { control: true }), wc, "win32")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
});
|
||||
|
||||
it("undoes a prior Cmd+= so the user can return to 100%", () => {
|
||||
const wc = makeWc(0);
|
||||
handleAppShortcut(key("=", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0.5);
|
||||
handleAppShortcut(key("-", { meta: true }), wc, "darwin");
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("clamps zoom-out at the lower bound", () => {
|
||||
const wc = makeWc(-3);
|
||||
expect(handleAppShortcut(key("-", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(-3);
|
||||
});
|
||||
|
||||
it("does nothing without Cmd/Ctrl", () => {
|
||||
const wc = makeWc(1);
|
||||
expect(handleAppShortcut(key("-"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — reset zoom", () => {
|
||||
it("resets to 0 on Cmd+0", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0", { meta: true }), wc, "darwin")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("resets to 0 on Ctrl+0", () => {
|
||||
const wc = makeWc(-1.5);
|
||||
expect(handleAppShortcut(key("0", { control: true }), wc, "linux")).toBe(true);
|
||||
expect(wc.currentLevel()).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores plain 0 without modifier", () => {
|
||||
const wc = makeWc(2);
|
||||
expect(handleAppShortcut(key("0"), wc, "darwin")).toBe(false);
|
||||
expect(wc.setZoomLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
it("does not capture plain letters", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("a", { meta: true }), wc, "darwin")).toBe(false);
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
74
apps/desktop/src/main/keyboard-shortcuts.ts
Normal file
74
apps/desktop/src/main/keyboard-shortcuts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { WebContents } from "electron";
|
||||
|
||||
// Shape of the input subset we read from Electron's `before-input-event`.
|
||||
// Modeled as a structural type so the handler is unit-testable without a
|
||||
// real Electron Input instance.
|
||||
export type ShortcutInput = {
|
||||
type: string;
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
|
||||
|
||||
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
|
||||
// per step). Clamp to a range that keeps the UI legible — values outside
|
||||
// this band turn the workspace into either confetti or a microfiche.
|
||||
const ZOOM_STEP = 0.5;
|
||||
const ZOOM_MIN = -3;
|
||||
const ZOOM_MAX = 4.5;
|
||||
|
||||
/**
|
||||
* Inspect a `before-input-event` key and apply (or block) the matching
|
||||
* window-level shortcut. Returns `true` when the caller should call
|
||||
* `event.preventDefault()` — that both swallows the renderer keydown and
|
||||
* prevents the application menu accelerator from firing, so we don't
|
||||
* double-trigger zoom on macOS where the default menu also binds these
|
||||
* keys.
|
||||
*
|
||||
* Why we don't rely on the menu's `zoomIn` / `zoomOut` roles: on macOS the
|
||||
* default `Cmd+-` accelerator does not fire reliably across keyboard
|
||||
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
// Block reload — accidental Cmd+R / Ctrl+R / F5 destroys in-memory state
|
||||
// (tabs, drafts, WS connections) with no URL bar to recover from.
|
||||
if ((cmdOrCtrl && input.key.toLowerCase() === "r") || input.key === "F5") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!cmdOrCtrl) return false;
|
||||
|
||||
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
|
||||
if (input.key === "=" || input.key === "+") {
|
||||
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + "-" (unshifted) or "_" (Shift+-) → zoom out.
|
||||
if (input.key === "-" || input.key === "_") {
|
||||
const next = Math.max(webContents.getZoomLevel() - ZOOM_STEP, ZOOM_MIN);
|
||||
webContents.setZoomLevel(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + 0 → reset zoom to 100%.
|
||||
if (input.key === "0") {
|
||||
webContents.setZoomLevel(0);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Play,
|
||||
Square,
|
||||
RotateCw,
|
||||
Server,
|
||||
Activity,
|
||||
ScrollText,
|
||||
} from "lucide-react";
|
||||
@@ -12,15 +11,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { runtimeListOptions } from "@multica/core/runtimes";
|
||||
import { agentTaskSnapshotOptions } from "@multica/core/agents";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@multica/ui/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,24 +23,13 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { DaemonPanel } from "./daemon-panel";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
import {
|
||||
DAEMON_STATE_COLORS,
|
||||
DAEMON_STATE_LABELS,
|
||||
daemonStateDescription,
|
||||
formatUptime,
|
||||
} from "../../../shared/daemon-types";
|
||||
import { DAEMON_STATE_LABELS } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
* Header card on the desktop Runtimes page that surfaces the daemon embedded
|
||||
* in this Electron app. The same daemon process registers N runtimes with the
|
||||
* server (one per detected CLI), which appear in the runtime list below — so
|
||||
* this card is the parent control surface for "what's running on this Mac".
|
||||
*
|
||||
* Why this lives only on desktop: web users don't have an embedded daemon;
|
||||
* they bring their own (CLI-launched or remote VM) and just see runtimes in
|
||||
* the list. The `desktop-runtimes-page` wrapper is the only mount point.
|
||||
* Desktop-only controls for the daemon embedded in this Electron app. The
|
||||
* shared runtimes page renders this inside the selected local machine header.
|
||||
*/
|
||||
export function DaemonRuntimeCard() {
|
||||
export function DaemonRuntimeActions() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ state: "stopped" });
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
@@ -57,14 +37,8 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: runtimes = [] } = useQuery(runtimeListOptions(wsId));
|
||||
// Snapshot also includes each agent's latest terminal; the filter below
|
||||
// drops anything that isn't running/dispatched, so terminal rows pass
|
||||
// through harmlessly.
|
||||
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
|
||||
|
||||
// Set of runtime IDs registered by THIS daemon (one per detected CLI).
|
||||
// Used both to count "how many CLIs am I contributing" and to figure
|
||||
// out which active tasks would be impacted by a Stop.
|
||||
const localRuntimeIds = useMemo(() => {
|
||||
if (!status.daemonId) return new Set<string>();
|
||||
return new Set(
|
||||
@@ -76,10 +50,6 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
const runtimeCount = localRuntimeIds.size;
|
||||
|
||||
// Tasks that are actually doing work on this daemon right now —
|
||||
// running or dispatched. Queued tasks haven't claimed a runtime yet,
|
||||
// so stopping the daemon won't break them (they'll wait for any
|
||||
// available daemon). The number drives the Stop-confirmation dialog.
|
||||
const affectedTasks = useMemo(
|
||||
() =>
|
||||
snapshot.filter(
|
||||
@@ -108,9 +78,6 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// The actual stop call, separated from the click handler so we can call
|
||||
// it both from the direct path (no active tasks) and from the confirm
|
||||
// dialog's confirm button.
|
||||
const performStop = useCallback(async () => {
|
||||
setActionLoading(true);
|
||||
const result = await window.daemonAPI.stop();
|
||||
@@ -119,8 +86,6 @@ export function DaemonRuntimeCard() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Click on the Stop button. If there's nothing running, just stop;
|
||||
// otherwise pop a confirm dialog explaining the blast radius.
|
||||
const handleStopClick = useCallback(() => {
|
||||
if (affectedTasks.length === 0) {
|
||||
void performStop();
|
||||
@@ -136,9 +101,6 @@ export function DaemonRuntimeCard() {
|
||||
toast.error("Failed to restart daemon", { description: result.error });
|
||||
return;
|
||||
}
|
||||
// Success feedback — the daemon takes a few seconds to come back online,
|
||||
// and the only other UI signal is the state badge flipping briefly. A
|
||||
// toast confirms the click was received and tells the user what to expect.
|
||||
toast.success("Restarting daemon", {
|
||||
description: "Runtimes will be back online in a few seconds.",
|
||||
});
|
||||
@@ -162,106 +124,64 @@ export function DaemonRuntimeCard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card size="sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="size-4 text-muted-foreground" />
|
||||
Local daemon
|
||||
<span className="inline-flex items-center gap-1.5 rounded-md border bg-background px-1.5 py-0.5 text-xs font-normal">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 rounded-full",
|
||||
DAEMON_STATE_COLORS[status.state],
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"tabular-nums",
|
||||
isRunning ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</span>
|
||||
{isRunning && status.uptime && (
|
||||
<span className="text-muted-foreground">
|
||||
· {formatUptime(status.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{daemonStateDescription(status.state, runtimeCount)}
|
||||
</CardDescription>
|
||||
<CardAction className="self-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPanelOpen(true)}
|
||||
>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-1.5">
|
||||
{isRunning && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => setPanelOpen(true)}>
|
||||
<ScrollText className="size-3.5 mr-1.5" />
|
||||
View logs
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleStopClick}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<Square className="size-3.5 mr-1.5" />
|
||||
Stop
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStart}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
{isStopped && (
|
||||
<Button size="sm" onClick={handleStart} disabled={actionLoading}>
|
||||
{actionLoading ? (
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
) : (
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Start
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
{isCliMissing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRetryInstall}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<RotateCw className="size-3.5 mr-1.5" />
|
||||
Retry setup
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
{(isTransitioning || isInstalling) && (
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Activity className="size-3.5 mr-1.5 animate-pulse" />
|
||||
{DAEMON_STATE_LABELS[status.state]}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DaemonPanel
|
||||
open={panelOpen}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { RuntimesPage } from "@multica/views/runtimes";
|
||||
import { DaemonRuntimeCard } from "./daemon-runtime-card";
|
||||
import { DaemonRuntimeActions } from "./daemon-runtime-card";
|
||||
import type { DaemonStatus } from "../../../shared/daemon-types";
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,9 @@ export function DesktopRuntimesPage() {
|
||||
|
||||
return (
|
||||
<RuntimesPage
|
||||
topSlot={<DaemonRuntimeCard />}
|
||||
localDaemonId={status.daemonId ?? null}
|
||||
localMachineName={status.deviceName ?? null}
|
||||
localMachineActions={<DaemonRuntimeActions />}
|
||||
bootstrapping={bootstrapping}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -128,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:
|
||||
|
||||
@@ -128,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">
|
||||
@@ -99,21 +103,53 @@ Open [http://localhost:3000](http://localhost:3000):
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -98,21 +102,53 @@ RESEND_FROM_EMAIL=noreply@yourdomain.com # 同时作为 SMTP From: 头
|
||||
|
||||
## 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. 创建智能体 + 分配第一个任务
|
||||
|
||||
|
||||
@@ -284,6 +284,34 @@ 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",
|
||||
|
||||
@@ -284,6 +284,33 @@ 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",
|
||||
|
||||
@@ -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:
|
||||
@@ -68,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:
|
||||
@@ -75,7 +96,7 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-3000}:3000"
|
||||
- "127.0.0.1:${FRONTEND_PORT:-3000}:3000"
|
||||
environment:
|
||||
HOSTNAME: "0.0.0.0"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-multica}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multica}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "127.0.0.1:5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("ApiClient", () => {
|
||||
});
|
||||
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
|
||||
await client.deleteAutopilotTrigger("ap-1", "tr-1");
|
||||
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
|
||||
|
||||
const calls = fetchMock.mock.calls.map(([url, init]) => ({
|
||||
url,
|
||||
@@ -104,6 +105,10 @@ describe("ApiClient", () => {
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
},
|
||||
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
|
||||
{
|
||||
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
|
||||
method: "POST",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -89,6 +89,8 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
@@ -96,6 +98,7 @@ import type {
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -119,11 +122,17 @@ import {
|
||||
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.
|
||||
@@ -1538,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();
|
||||
@@ -1578,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",
|
||||
@@ -1596,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";
|
||||
|
||||
@@ -198,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);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
CreateAgentFromTemplateResponse,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
TimelineEntry,
|
||||
WebhookDelivery,
|
||||
} from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,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(),
|
||||
@@ -332,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,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -11,13 +11,14 @@ import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SortField = "position" | "priority" | "due_date" | "created_at" | "title";
|
||||
export type 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;
|
||||
@@ -32,6 +33,7 @@ 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" },
|
||||
@@ -46,6 +48,7 @@ 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" },
|
||||
@@ -103,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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -102,8 +102,8 @@ describe("useRealtimeSync — ws instance change", () => {
|
||||
rerender({ ws: ws2 });
|
||||
|
||||
// Should have called invalidateQueries for all workspace-scoped keys
|
||||
// (11 workspace-scoped + 1 workspaceKeys.list() = 12 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(12);
|
||||
// (12 workspace-scoped + 1 workspaceKeys.list() = 13 calls)
|
||||
expect(invalidateSpy).toHaveBeenCalledTimes(13);
|
||||
});
|
||||
|
||||
it("does not re-invalidate when rerendered with the same ws instance", () => {
|
||||
|
||||
@@ -119,6 +119,7 @@ function invalidateWorkspaceScopedQueries(qc: QueryClient): void {
|
||||
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) });
|
||||
@@ -177,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();
|
||||
@@ -220,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();
|
||||
@@ -262,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) });
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
@@ -110,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" | "skipped" | "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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -79,7 +79,9 @@ export type {
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
GitHubInstallation,
|
||||
GitHubMergeableState,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestChecksConclusion,
|
||||
GitHubPullRequestState,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
@@ -100,6 +102,10 @@ export type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
WebhookDelivery,
|
||||
WebhookDeliveryStatus,
|
||||
WebhookSignatureStatus,
|
||||
ListWebhookDeliveriesResponse,
|
||||
} from "./autopilot";
|
||||
export type {
|
||||
Squad,
|
||||
@@ -113,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[];
|
||||
|
||||
@@ -76,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[];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, Ban, ChevronDown, ChevronRight } 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 {
|
||||
@@ -64,11 +73,32 @@ 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();
|
||||
@@ -105,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)
|
||||
@@ -122,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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -214,8 +251,11 @@ function SkippedRunsGroup({
|
||||
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);
|
||||
@@ -223,19 +263,83 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
await deleteTrigger.mutateAsync({ autopilotId, triggerId: trigger.id });
|
||||
toast.success(t(($) => $.trigger_row.toast_deleted));
|
||||
setConfirmOpen(false);
|
||||
} catch {
|
||||
toast.error(t(($) => $.trigger_row.toast_delete_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.trigger_row.toast_delete_failed),
|
||||
);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isWebhook = trigger.kind === "webhook";
|
||||
const isApi = trigger.kind === "api";
|
||||
// Resolve the URL from the server's webhook_url first, then compose
|
||||
// from the API base URL (desktop) or window.origin (web). Falls back
|
||||
// to the relative path if neither is available.
|
||||
const webhookUrl = isWebhook
|
||||
? buildAutopilotWebhookUrl({
|
||||
trigger,
|
||||
apiBaseUrl: api.getBaseUrl(),
|
||||
currentOrigin: typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!webhookUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(webhookUrl);
|
||||
setCopied(true);
|
||||
toast.success(t(($) => $.trigger_row.url_copied));
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t(($) => $.trigger_row.url_copy_failed));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRotate = async () => {
|
||||
try {
|
||||
await rotateToken.mutateAsync({ autopilotId, triggerId: trigger.id });
|
||||
toast.success(t(($) => $.trigger_row.toast_rotated));
|
||||
setRotateOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.trigger_row.toast_rotate_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isWebhook ? Webhook : isApi ? Zap : Clock;
|
||||
const showWebhookUrlRow = isWebhook && webhookUrl;
|
||||
|
||||
// Delete control extracted so a webhook trigger can render it inline
|
||||
// with Copy / Rotate on the URL action row (where the other action
|
||||
// buttons live), while schedule / api triggers — which have no URL row
|
||||
// — keep it pinned to the row's top-right corner. Without this the
|
||||
// trash icon visually floats above the URL action buttons because the
|
||||
// outer flex uses `items-start`.
|
||||
const deleteButton = (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
title={t(($) => $.trigger_row.delete_dialog.confirm)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Clock className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium capitalize">{trigger.kind}</span>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">{t(($) => $.trigger_kind[trigger.kind])}</span>
|
||||
{trigger.label && (
|
||||
<span className="text-xs text-muted-foreground">({trigger.label})</span>
|
||||
)}
|
||||
@@ -244,6 +348,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.disabled_badge)}
|
||||
</span>
|
||||
)}
|
||||
{isApi && (
|
||||
<span className="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{t(($) => $.trigger_row.deprecated_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{trigger.cron_expression && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
@@ -256,15 +365,35 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
|
||||
</div>
|
||||
)}
|
||||
{showWebhookUrlRow && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1 text-xs font-mono text-foreground">
|
||||
{webhookUrl}
|
||||
</code>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={handleCopy}
|
||||
title={t(($) => $.trigger_row.copy_url)}
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-500" /> : <Copy className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setRotateOpen(true)}
|
||||
title={t(($) => $.trigger_row.rotate_url)}
|
||||
disabled={rotateToken.isPending}
|
||||
>
|
||||
<RotateCw className={cn("h-3.5 w-3.5 text-muted-foreground", rotateToken.isPending && "animate-spin")} />
|
||||
</Button>
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
{!showWebhookUrlRow && deleteButton}
|
||||
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -289,6 +418,26 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<AlertDialog open={rotateOpen} onOpenChange={(v) => { if (!v && !rotateToken.isPending) setRotateOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t(($) => $.trigger_row.rotate_confirm_title)}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t(($) => $.trigger_row.rotate_confirm_description)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={rotateToken.isPending}>
|
||||
{t(($) => $.trigger_row.rotate_confirm_cancel)}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRotate} disabled={rotateToken.isPending}>
|
||||
{rotateToken.isPending
|
||||
? t(($) => $.trigger_row.rotate_in_progress)
|
||||
: t(($) => $.trigger_row.rotate_confirm_action)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -304,29 +453,47 @@ function AddTriggerDialog({
|
||||
}) {
|
||||
const { t } = useT("autopilots");
|
||||
const createTrigger = useCreateAutopilotTrigger();
|
||||
const [kind, setKind] = useState<"schedule" | "webhook">("schedule");
|
||||
const [config, setConfig] = useState<TriggerConfig>(getDefaultTriggerConfig);
|
||||
const [label, setLabel] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting) return;
|
||||
const cronExpr = toCronExpression(config);
|
||||
if (!cronExpr.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "schedule",
|
||||
cron_expression: cronExpr,
|
||||
timezone: config.timezone || undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
if (kind === "schedule") {
|
||||
const cronExpr = toCronExpression(config);
|
||||
if (!cronExpr.trim()) {
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "schedule",
|
||||
cron_expression: cronExpr,
|
||||
timezone: config.timezone || undefined,
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
toast.success(t(($) => $.add_trigger_dialog.toast_added_schedule));
|
||||
} else {
|
||||
await createTrigger.mutateAsync({
|
||||
autopilotId,
|
||||
kind: "webhook",
|
||||
label: label.trim() || undefined,
|
||||
});
|
||||
toast.success(t(($) => $.add_trigger_dialog.toast_added_webhook));
|
||||
}
|
||||
onOpenChange(false);
|
||||
setKind("schedule");
|
||||
setConfig(getDefaultTriggerConfig());
|
||||
setLabel("");
|
||||
toast.success(t(($) => $.add_trigger_dialog.toast_added));
|
||||
} catch {
|
||||
toast.error(t(($) => $.add_trigger_dialog.toast_add_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.add_trigger_dialog.toast_add_failed),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -337,7 +504,48 @@ function AddTriggerDialog({
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>{t(($) => $.add_trigger_dialog.title)}</DialogTitle>
|
||||
<div className="space-y-4 pt-2">
|
||||
<TriggerConfigSection config={config} onChange={setConfig} />
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.add_trigger_dialog.type_label)}
|
||||
</label>
|
||||
<div className="mt-1 grid grid-cols-2 gap-1 rounded-md bg-muted p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKind("schedule")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
|
||||
kind === "schedule"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{t(($) => $.add_trigger_dialog.type_schedule)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setKind("webhook")}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-1.5 rounded px-3 py-1.5 text-sm transition-colors",
|
||||
kind === "webhook"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Webhook className="h-3.5 w-3.5" />
|
||||
{t(($) => $.add_trigger_dialog.type_webhook)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kind === "schedule" ? (
|
||||
<TriggerConfigSection config={config} onChange={setConfig} />
|
||||
) : (
|
||||
<p className="rounded-md bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
{t(($) => $.add_trigger_dialog.webhook_help)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t(($) => $.add_trigger_dialog.label_field)}
|
||||
@@ -445,8 +653,12 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
await deleteAutopilot.mutateAsync(autopilotId);
|
||||
toast.success(t(($) => $.detail.toast_deleted));
|
||||
router.push(wsPaths.autopilots());
|
||||
} catch {
|
||||
toast.error(t(($) => $.detail.toast_delete_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.detail.toast_delete_failed),
|
||||
);
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
@@ -557,6 +769,14 @@ export function AutopilotDetailPage({ autopilotId }: { autopilotId: string }) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Webhook deliveries — only renders when at least one webhook
|
||||
trigger is configured. The component does its own fetch so
|
||||
schedule-only autopilots don't pay for an empty list query. */}
|
||||
<WebhookDeliveriesSection
|
||||
autopilotId={autopilotId}
|
||||
hasWebhookTrigger={triggers.some((trig) => trig.kind === "webhook")}
|
||||
/>
|
||||
|
||||
{/* Run History */}
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -185,7 +185,14 @@ export function ActorIssuesPanel({
|
||||
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error(t(($) => $.page.move_failed)) },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.page.move_failed),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[updateIssueMutation, t],
|
||||
|
||||
@@ -43,6 +43,13 @@ interface AgentTranscriptDialogProps {
|
||||
items: TimelineItem[];
|
||||
agentName: string;
|
||||
isLive?: boolean;
|
||||
/**
|
||||
* Optional content rendered between the header chips and the event list.
|
||||
* Used by autopilot run rows to surface the inbound webhook trigger
|
||||
* payload so it's visible regardless of whether the agent echoes it.
|
||||
* The dialog stays generic — slot content is the caller's concern.
|
||||
*/
|
||||
headerSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
// ─── Color mapping for timeline segments ────────────────────────────────────
|
||||
@@ -162,6 +169,7 @@ export function AgentTranscriptDialog({
|
||||
items,
|
||||
agentName,
|
||||
isLive = false,
|
||||
headerSlot,
|
||||
}: AgentTranscriptDialogProps) {
|
||||
const { t } = useT("agents");
|
||||
const [selectedSeq, setSelectedSeq] = useState<number | null>(null);
|
||||
@@ -451,6 +459,13 @@ export function AgentTranscriptDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Optional header slot (e.g. webhook payload preview) ── */}
|
||||
{headerSlot && (
|
||||
<div className="border-b px-4 py-3 shrink-0 bg-muted/20">
|
||||
{headerSlot}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Event list ─────────────────────────────────────────── */}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
|
||||
@@ -26,6 +26,11 @@ interface TranscriptButtonProps {
|
||||
isLive?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
/**
|
||||
* Optional content rendered above the transcript event list. Used to
|
||||
* surface autopilot webhook payloads inline with the run history.
|
||||
*/
|
||||
headerSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +46,7 @@ export function TranscriptButton({
|
||||
isLive = false,
|
||||
className,
|
||||
title = "View transcript",
|
||||
headerSlot,
|
||||
}: TranscriptButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -105,6 +111,7 @@ export function TranscriptButton({
|
||||
items={items}
|
||||
agentName={agentName}
|
||||
isLive={isLive}
|
||||
headerSlot={headerSlot}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
209
packages/views/editor/attachment-card.test.tsx
Normal file
209
packages/views/editor/attachment-card.test.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// vi.hoisted lets us reference the mock from inside the vi.mock factory
|
||||
// even though the factory hoists above the file's top-level statements.
|
||||
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: { download: "Download" },
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
},
|
||||
file_card: { uploading: "Uploading {{filename}}" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AttachmentCard } from "./attachment-card";
|
||||
|
||||
function render(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return rtlRender(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("AttachmentCard — kind dispatch", () => {
|
||||
it("renders chrome only for non-html kinds (image, video, other)", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="snapshot.png"
|
||||
contentType="image/png"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/snapshot.png"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("snapshot.png")).toBeTruthy();
|
||||
// No inline iframe for an image-kind attachment.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders chrome only for an html URL-only source (no attachmentId)", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
// Without an attachment id we cannot hit the ID-keyed /content proxy,
|
||||
// so the card must fall back to chrome-only.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
expect(screen.getByText("report.html")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Eye button for an html URL-only source (the modal's /content proxy is ID-keyed)", () => {
|
||||
// Regression: a cross-comment / copy-pasted `!file[report.html](url)`
|
||||
// used to surface a dead Eye button — the AttachmentCard allowed
|
||||
// preview when `previewableFromUrl` was true even without an
|
||||
// attachmentId, but the modal's tryOpen rejects URL-only text kinds
|
||||
// and the click became a silent no-op.
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle("Preview")).toBeNull();
|
||||
// Download stays available — the underlying URL is still reachable.
|
||||
expect(screen.getByTitle("Download")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still shows the Eye button for an html source when an attachmentId is available", () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTitle("Preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the Eye button for a URL-only pdf source (modal renders pdfs directly from URL)", () => {
|
||||
// Counterpart to the html regression: media kinds (pdf/video/audio)
|
||||
// ARE URL-previewable because the modal renders them via
|
||||
// <iframe src=url>/<video>/<audio>, not via the /content proxy.
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="manual.pdf"
|
||||
contentType="application/pdf"
|
||||
href="https://cdn.example/manual.pdf"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTitle("Preview")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an inline iframe with sandbox='allow-scripts' for an HTML attachment", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart goes here</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("srcdoc")).toBe("<p>chart goes here</p>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentCard — Eye / Download buttons", () => {
|
||||
it("invokes onPreview when Eye is clicked", () => {
|
||||
const onPreview = vi.fn();
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="manual.pdf"
|
||||
contentType="application/pdf"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/manual.pdf"
|
||||
onPreview={onPreview}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByTitle("Preview"));
|
||||
expect(onPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokes onDownload when Download is clicked", () => {
|
||||
const onDownload = vi.fn();
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="manual.pdf"
|
||||
contentType="application/pdf"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/manual.pdf"
|
||||
onPreview={() => {}}
|
||||
onDownload={onDownload}
|
||||
/>,
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByTitle("Download"));
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hides the Eye button while uploading and skips the inline HTML preview", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
uploading
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle("Preview")).toBeNull();
|
||||
expect(screen.queryByTitle("Download")).toBeNull();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
// The mock `t()` returns the i18n template as-is; the production t-fn
|
||||
// interpolates {{filename}} → "report.html". Asserting the template
|
||||
// proves the uploading branch was selected without depending on the
|
||||
// interpolation behavior of the mock.
|
||||
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
228
packages/views/editor/attachment-card.tsx
Normal file
228
packages/views/editor/attachment-card.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentCard — shared attachment row UI used by every entry point that
|
||||
* renders a non-image attachment in the editor surface.
|
||||
*
|
||||
* Three call sites:
|
||||
* 1. `extensions/file-card.tsx` — Tiptap NodeView for `!file[name](url)`
|
||||
* inline in markdown.
|
||||
* 2. `readonly-content.tsx` — readonly file-card `<div data-type="fileCard">`
|
||||
* branch, rendered through preprocessMarkdown.
|
||||
* 3. `comment-card.tsx` `AttachmentList` — standalone attachments that were
|
||||
* not referenced by URL inside the markdown body.
|
||||
*
|
||||
* Centralizing this avoids the third-instance trap: every previous attempt to
|
||||
* add a feature here had to be added in three places, and dropping one
|
||||
* silently re-introduced the bug — MUL-2330's HTML chart was a standalone
|
||||
* attachment, so the inline HTML preview only works if THIS path is covered.
|
||||
*
|
||||
* HTML kind extension:
|
||||
* - When the attachment is HTML and the caller can provide an
|
||||
* `attachmentId` (i.e. the attachment record is known — required for the
|
||||
* ID-keyed `/api/attachments/{id}/content` proxy), the card mounts an
|
||||
* inline `CodeBlockIframe` underneath the row to render the HTML body
|
||||
* directly. Loading errors and 413/415 cases collapse back to the bare
|
||||
* row + Eye/Download buttons.
|
||||
* - For non-HTML kinds (or HTML where we only have a URL), the card looks
|
||||
* and behaves exactly like the previous handwritten rows.
|
||||
*/
|
||||
|
||||
import { Download, Eye, FileText, Loader2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
import { CodeBlockIframe } from "./code-block-iframe";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline HTML preview body
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fixed height per the V2 plan; auto-resize via postMessage handshake is
|
||||
// explicitly out of scope for V1.
|
||||
const INLINE_HTML_HEIGHT = "h-[480px]";
|
||||
|
||||
function InlineHtmlIframe({
|
||||
attachmentId,
|
||||
filename,
|
||||
}: {
|
||||
attachmentId: string;
|
||||
filename: string;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="mt-1 flex h-[480px] items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Any error path (413 / 415 / transport) — fall back silently. The
|
||||
// surrounding card still offers Eye → modal (which surfaces the typed
|
||||
// error) and Download as escape hatches.
|
||||
if (query.error || !query.data) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-1">
|
||||
<CodeBlockIframe
|
||||
html={query.data.text}
|
||||
title={filename}
|
||||
heightClassName={INLINE_HTML_HEIGHT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Card chrome — icon + filename + optional Eye + Download
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AttachmentCardChromeProps {
|
||||
filename: string;
|
||||
uploading?: boolean;
|
||||
canPreview: boolean;
|
||||
canDownload: boolean;
|
||||
onPreview: () => void;
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
function AttachmentCardChrome({
|
||||
filename,
|
||||
uploading,
|
||||
canPreview,
|
||||
canDownload,
|
||||
onPreview,
|
||||
onDownload,
|
||||
}: AttachmentCardChromeProps) {
|
||||
const { t } = useT("editor");
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">
|
||||
{uploading
|
||||
? t(($) => $.file_card.uploading, { filename })
|
||||
: filename}
|
||||
</p>
|
||||
</div>
|
||||
{!uploading && canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPreview();
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!uploading && canDownload && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDownload();
|
||||
}}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AttachmentCard — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface AttachmentCardProps {
|
||||
/** Filename used for icon label and previewable-kind detection. */
|
||||
filename: string;
|
||||
/** Content type used in addition to filename for previewable-kind detection. */
|
||||
contentType?: string;
|
||||
/**
|
||||
* Attachment id — required for HTML inline rendering (the `/content`
|
||||
* proxy is ID-keyed). Undefined means we only have a URL (e.g. a
|
||||
* cross-comment `!file[]()` reference) — the card still renders, the
|
||||
* HTML iframe just doesn't expand.
|
||||
*/
|
||||
attachmentId?: string;
|
||||
/** Download URL — used purely as a non-null sentinel for the download button. */
|
||||
href?: string;
|
||||
/** True while a synchronous upload is in flight (file-card NodeView only). */
|
||||
uploading?: boolean;
|
||||
/** Pressed when the Eye button is clicked. */
|
||||
onPreview: () => void;
|
||||
/** Pressed when the Download button is clicked. */
|
||||
onDownload: () => void;
|
||||
/**
|
||||
* Set to false to disable the HTML inline preview branch (and behave like
|
||||
* the legacy chrome-only card). Useful for editor NodeViews while a draft
|
||||
* upload is still in flight.
|
||||
*/
|
||||
inlineHtmlEnabled?: boolean;
|
||||
}
|
||||
|
||||
export function AttachmentCard({
|
||||
filename,
|
||||
contentType = "",
|
||||
attachmentId,
|
||||
href,
|
||||
uploading,
|
||||
onPreview,
|
||||
onDownload,
|
||||
inlineHtmlEnabled = true,
|
||||
}: AttachmentCardProps) {
|
||||
const kind = filename ? getPreviewKind(contentType, filename) : null;
|
||||
// Media kinds (pdf/video/audio) are previewable from a URL alone — the
|
||||
// modal renders them as <video>/<audio>/<iframe src=url>. Text kinds
|
||||
// (markdown/html/text) need the ID-keyed `/api/attachments/{id}/content`
|
||||
// proxy, so they only preview when we have an attachmentId — otherwise
|
||||
// the Eye button would call tryOpen, get rejected, and do nothing.
|
||||
const isUrlPreviewableKind =
|
||||
kind === "pdf" || kind === "video" || kind === "audio";
|
||||
const canPreview =
|
||||
!!href && kind !== null && (!!attachmentId || isUrlPreviewableKind);
|
||||
|
||||
// Mount the inline iframe only when we can hit the /content proxy
|
||||
// (attachmentId present) AND kind is HTML AND no upload is in flight.
|
||||
const showInlineHtml =
|
||||
inlineHtmlEnabled &&
|
||||
!uploading &&
|
||||
kind === "html" &&
|
||||
!!attachmentId;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<AttachmentCardChrome
|
||||
filename={filename}
|
||||
uploading={uploading}
|
||||
canPreview={canPreview}
|
||||
canDownload={!!href}
|
||||
onPreview={onPreview}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
{showInlineHtml && (
|
||||
<InlineHtmlIframe attachmentId={attachmentId!} filename={filename} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +159,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
expect(screen.getByTestId("readonly-content").textContent).toContain("# heading");
|
||||
});
|
||||
|
||||
it("renders an iframe with srcdoc + sandbox='' for HTML", async () => {
|
||||
it("renders an iframe with srcdoc + sandbox='allow-scripts' for HTML", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>hi</p>",
|
||||
originalContentType: "text/html",
|
||||
@@ -170,7 +170,10 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame?.getAttribute("sandbox")).toBe("");
|
||||
// `allow-scripts` is required so vanilla-JS chart libraries render
|
||||
// (MUL-2330). The combination with `allow-same-origin` would defeat
|
||||
// the sandbox, so this assertion must stay exact.
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("srcdoc")).toBe("<p>hi</p>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,10 +15,12 @@
|
||||
* - markdown : fetch text via api.getAttachmentTextContent, render via
|
||||
* the existing ReadonlyContent (full mention/mermaid/katex
|
||||
* pipeline included).
|
||||
* - html : fetch text, hand to <iframe srcdoc={text} sandbox="">.
|
||||
* Empty sandbox attribute = max restriction (no scripts,
|
||||
* no forms, no top-nav, no popups, no same-origin) — the
|
||||
* recommended pattern for previewing untrusted HTML.
|
||||
* - html : fetch text, hand to <iframe srcdoc={text}
|
||||
* sandbox="allow-scripts">. The iframe runs in an opaque
|
||||
* origin: scripts execute (chart libraries / vanilla SVG
|
||||
* JS work), but cookie / localStorage / parent access /
|
||||
* top-navigation / popups / forms stay blocked because
|
||||
* `allow-same-origin` is intentionally NOT included.
|
||||
* - text : fetch text, highlight with lowlight if the extension
|
||||
* maps to a known hljs language; otherwise plain <pre>.
|
||||
*
|
||||
@@ -35,17 +37,11 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, FileText, Loader2, X } from "lucide-react";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
api,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "@multica/core/api";
|
||||
import { Download, FileText, Loader2, X } from "lucide-react";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { useT } from "../i18n";
|
||||
import { openExternal } from "../platform";
|
||||
@@ -56,6 +52,8 @@ import {
|
||||
type PreviewKind,
|
||||
} from "./utils/preview";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
|
||||
import { CodeBlockStatic } from "./code-block-static";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview source — full attachment, or URL-only (media types only)
|
||||
@@ -355,7 +353,12 @@ function PreviewContent({
|
||||
render={(text) => (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
sandbox=""
|
||||
// `allow-scripts` without `allow-same-origin` — scripts run
|
||||
// in an opaque origin and cannot read cookies / localStorage
|
||||
// / parent state, nor escape via top-nav / popups / forms.
|
||||
// Required so JS-driven charts (echarts / Plotly / vanilla
|
||||
// SVG injection) render instead of showing a blank `<svg>`.
|
||||
sandbox="allow-scripts"
|
||||
className="h-full w-full bg-background"
|
||||
title={state.filename}
|
||||
/>
|
||||
@@ -368,7 +371,11 @@ function PreviewContent({
|
||||
attachmentId={state.attachmentId!}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<CodeBlock language={extensionToLanguage(state.filename)} body={text} />
|
||||
<CodeBlockStatic
|
||||
language={extensionToLanguage(state.filename)}
|
||||
body={text}
|
||||
className="px-6 py-4"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
@@ -393,19 +400,7 @@ function TextBackedPreview({
|
||||
render: (text: string) => ReactNode;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const query = useQuery({
|
||||
queryKey: ["attachment-content", attachmentId] as const,
|
||||
queryFn: () => api.getAttachmentTextContent(attachmentId),
|
||||
// Errors are surfaced as typed fallbacks, not retried — 413 / 415 won't
|
||||
// become 200 on a retry, and a transient failure is easier to recover
|
||||
// from by closing and reopening the modal than waiting on background
|
||||
// retries that have no UI affordance.
|
||||
retry: false,
|
||||
// 413 / 415 bodies are tiny; keep the result around for the session so
|
||||
// the user can flip away and back without refetching.
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 30 * 60_000,
|
||||
});
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
@@ -443,44 +438,6 @@ function TextBackedPreview({
|
||||
return <>{render(query.data.text)}</>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code block — lowlight, matches readonly-content's hljs CSS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
function CodeBlock({ language, body }: { language: string | undefined; body: string }) {
|
||||
const html = useMemo(() => {
|
||||
const code = body.replace(/\n$/, "");
|
||||
try {
|
||||
const tree = language
|
||||
? lowlight.highlight(language, code)
|
||||
: lowlight.highlightAuto(code);
|
||||
return toHtml(tree) as string;
|
||||
} catch {
|
||||
// Fallthrough to a plain escaped <pre> when lowlight rejects the
|
||||
// language tag. Avoids crashing the preview on an unknown extension.
|
||||
return escapeHtml(code);
|
||||
}
|
||||
}, [body, language]);
|
||||
|
||||
return (
|
||||
<pre className="rich-text-editor m-0 overflow-auto px-6 py-4 text-sm">
|
||||
<code
|
||||
className={cn("hljs", language && `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback — used for 413 / 415 / unknown kinds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -406,8 +406,12 @@ function CreateSubIssueButton({
|
||||
)
|
||||
.run();
|
||||
toast.success(t(($) => $.bubble_menu.sub_issue.created, { identifier: newIssue.identifier }));
|
||||
} catch {
|
||||
toast.error(t(($) => $.bubble_menu.sub_issue.create_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.bubble_menu.sub_issue.create_failed),
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
|
||||
58
packages/views/editor/code-block-iframe.tsx
Normal file
58
packages/views/editor/code-block-iframe.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared HTML preview iframe.
|
||||
*
|
||||
* Used by:
|
||||
* - InlineHtmlIframe inside AttachmentCard (HTML attachments inline preview)
|
||||
* - CodeBlockView for fenced ```html blocks (editable Tiptap NodeView)
|
||||
* - HtmlBlockPreview for fenced ```html blocks (ReadonlyContent)
|
||||
* - AttachmentPreviewModal's full-screen HTML kind
|
||||
*
|
||||
* Sandbox semantics:
|
||||
* sandbox="allow-scripts" (NOT "allow-same-origin")
|
||||
* → iframe runs in an opaque origin: scripts execute (chart JS works),
|
||||
* but cookie / localStorage / parent access / top-nav / popups / forms
|
||||
* remain blocked. This is the standard "preview untrusted HTML" model
|
||||
* (HTML spec §iframe sandbox, MDN, Claude artifacts, v0.dev preview).
|
||||
*
|
||||
* The server-side `text/plain` + `nosniff` defense at
|
||||
* /api/attachments/{id}/content remains untouched — we only feed iframe.srcDoc
|
||||
* the text body we fetched, never point iframe.src at the proxy URL.
|
||||
*/
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface CodeBlockIframeProps {
|
||||
/** Document source for srcDoc. Empty string renders a blank frame. */
|
||||
html: string;
|
||||
/** Iframe title for accessibility. */
|
||||
title: string;
|
||||
className?: string;
|
||||
/** Tailwind height token; defaults to h-[320px]. */
|
||||
heightClassName?: string;
|
||||
}
|
||||
|
||||
export function CodeBlockIframe({
|
||||
html,
|
||||
title,
|
||||
className,
|
||||
heightClassName = "h-[320px]",
|
||||
}: CodeBlockIframeProps) {
|
||||
return (
|
||||
<iframe
|
||||
// srcDoc keeps the body in the parent's process but isolated to an
|
||||
// opaque origin via sandbox. Critical that we never combine
|
||||
// `allow-scripts` with `allow-same-origin` — that pairing defeats the
|
||||
// sandbox per the HTML spec (notes on the sandbox attribute).
|
||||
srcDoc={html}
|
||||
sandbox="allow-scripts"
|
||||
title={title}
|
||||
className={cn(
|
||||
"w-full rounded-md border border-border bg-background",
|
||||
heightClassName,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
packages/views/editor/code-block-static.tsx
Normal file
57
packages/views/editor/code-block-static.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CodeBlockStatic — read-only lowlight-highlighted code block.
|
||||
*
|
||||
* Used by:
|
||||
* - AttachmentPreviewModal's text-kind fallback (extracted from there).
|
||||
* - HtmlBlockPreview's "source" toggle in ReadonlyContent.
|
||||
*
|
||||
* NOT used by Tiptap's editable code-block NodeView: that path must keep
|
||||
* `<NodeViewContent as="code" />` so the user can continue typing into the
|
||||
* code block. Replacing it with a static lowlight component would freeze
|
||||
* the content and desync ProseMirror state from the DOM.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
interface CodeBlockStaticProps {
|
||||
language: string | undefined;
|
||||
body: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CodeBlockStatic({ language, body, className }: CodeBlockStaticProps) {
|
||||
const html = useMemo(() => {
|
||||
const code = body.replace(/\n$/, "");
|
||||
try {
|
||||
const tree = language
|
||||
? lowlight.highlight(language, code)
|
||||
: lowlight.highlightAuto(code);
|
||||
return toHtml(tree) as string;
|
||||
} catch {
|
||||
// Unknown language tag — fall back to escaped plain text so we don't
|
||||
// crash on an esoteric extension.
|
||||
return escapeHtml(code);
|
||||
}
|
||||
}, [body, language]);
|
||||
|
||||
return (
|
||||
<pre className={cn("rich-text-editor m-0 overflow-auto text-sm", className)}>
|
||||
<code
|
||||
className={cn("hljs", language && `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
@@ -2,6 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const mockFocus = vi.hoisted(() => vi.fn());
|
||||
const mockSetContent = vi.hoisted(() => vi.fn());
|
||||
const mockSetTextSelection = vi.hoisted(() => vi.fn());
|
||||
const editorState = vi.hoisted(() => ({
|
||||
isFocused: false,
|
||||
isDestroyed: false,
|
||||
markdown: "",
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({}),
|
||||
@@ -23,24 +30,38 @@ vi.mock("./bubble-menu", () => ({
|
||||
EditorBubbleMenu: () => null,
|
||||
}));
|
||||
|
||||
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
|
||||
const onCreateFired = vi.hoisted(() => ({ value: false }));
|
||||
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditor: () => ({
|
||||
commands: {
|
||||
focus: mockFocus,
|
||||
clearContent: vi.fn(),
|
||||
},
|
||||
getMarkdown: () => "",
|
||||
state: {
|
||||
doc: {
|
||||
content: {
|
||||
size: 0,
|
||||
useEditor: (options: { onCreate?: (args: { editor: unknown }) => void }) => {
|
||||
if (!editorRef.current) {
|
||||
editorRef.current = {
|
||||
get isFocused() {
|
||||
return editorState.isFocused;
|
||||
},
|
||||
},
|
||||
selection: {
|
||||
empty: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
get isDestroyed() {
|
||||
return editorState.isDestroyed;
|
||||
},
|
||||
commands: {
|
||||
focus: mockFocus,
|
||||
clearContent: vi.fn(),
|
||||
setContent: mockSetContent,
|
||||
setTextSelection: mockSetTextSelection,
|
||||
},
|
||||
getMarkdown: () => editorState.markdown,
|
||||
state: {
|
||||
doc: { content: { size: 0 } },
|
||||
selection: { empty: true, from: 0, to: 0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!onCreateFired.value) {
|
||||
onCreateFired.value = true;
|
||||
options?.onCreate?.({ editor: editorRef.current });
|
||||
}
|
||||
return editorRef.current;
|
||||
},
|
||||
EditorContent: ({ className }: { className?: string }) => (
|
||||
<div className={className} data-testid="editor-content">
|
||||
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
|
||||
@@ -53,6 +74,11 @@ import { ContentEditor } from "./content-editor";
|
||||
describe("ContentEditor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
editorState.isFocused = false;
|
||||
editorState.isDestroyed = false;
|
||||
editorState.markdown = "";
|
||||
editorRef.current = null;
|
||||
onCreateFired.value = false;
|
||||
});
|
||||
|
||||
it("focuses the editor when clicking the empty container area", () => {
|
||||
@@ -73,4 +99,89 @@ describe("ContentEditor", () => {
|
||||
|
||||
expect(mockFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
|
||||
editorState.markdown = "old content";
|
||||
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
||||
|
||||
expect(mockSetContent).not.toHaveBeenCalled();
|
||||
|
||||
// Editor still holds the old, in-sync content; external value changes.
|
||||
editorState.markdown = "old content";
|
||||
rerender(<ContentEditor defaultValue="new content from server" />);
|
||||
|
||||
expect(mockSetContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetContent).toHaveBeenCalledWith(
|
||||
"new content from server",
|
||||
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not sync when editor is focused and has unsaved local edits", () => {
|
||||
editorState.markdown = "old content";
|
||||
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
||||
|
||||
// User is typing — focused AND dirty (markdown diverges from
|
||||
// lastEmittedRef, which was seeded with "old content" by onCreate).
|
||||
editorState.isFocused = true;
|
||||
editorState.markdown = "user-typed-content";
|
||||
|
||||
rerender(<ContentEditor defaultValue="incoming external change" />);
|
||||
|
||||
expect(mockSetContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs even when editor is focused, as long as it is clean (focused-but-clean must not be permanently dropped)", () => {
|
||||
// This case is the regression test for the focused-but-clean hole:
|
||||
// user clicks into the editor (focused = true) but types nothing
|
||||
// (markdown still equals lastEmittedRef). An external update arrives.
|
||||
// With an unconditional `if (isFocused) return`, this sync would be lost
|
||||
// forever because onBlur has no replay path.
|
||||
editorState.markdown = "old content";
|
||||
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
||||
|
||||
editorState.isFocused = true;
|
||||
editorState.markdown = "old content"; // clean — no typing happened
|
||||
|
||||
rerender(<ContentEditor defaultValue="new content from server" />);
|
||||
|
||||
expect(mockSetContent).toHaveBeenCalledTimes(1);
|
||||
expect(mockSetContent).toHaveBeenCalledWith(
|
||||
"new content from server",
|
||||
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not sync when editor is unfocused but has unsaved local edits (blur-before-debounce window)", () => {
|
||||
editorState.markdown = "old content";
|
||||
const { rerender } = render(
|
||||
<ContentEditor defaultValue="old content" onUpdate={() => {}} />,
|
||||
);
|
||||
|
||||
// User typed locally, then blurred. Debounce hasn't flushed yet so
|
||||
// lastEmittedRef inside the component still reflects "old content".
|
||||
editorState.isFocused = false;
|
||||
editorState.markdown = "user typed but unsaved";
|
||||
|
||||
rerender(
|
||||
<ContentEditor
|
||||
defaultValue="external update from another agent"
|
||||
onUpdate={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockSetContent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not sync when defaultValue normalizes to the current editor markdown", () => {
|
||||
editorState.markdown = "same content";
|
||||
const { rerender } = render(<ContentEditor defaultValue="same content" />);
|
||||
|
||||
// Different `defaultValue` string forces the effect to re-run (the dep
|
||||
// array sees a new value), but the trailing whitespace normalises away
|
||||
// via `trimEnd()`, so `setContent` must still short-circuit.
|
||||
rerender(<ContentEditor defaultValue={"same content\n"} />);
|
||||
|
||||
expect(mockSetContent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +225,59 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync external `defaultValue` changes into the editor.
|
||||
// Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831);
|
||||
// without this effect, a WS-driven description update keeps the editor
|
||||
// showing stale content until the issue is closed and reopened.
|
||||
useEffect(() => {
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
|
||||
const current = stripBlobUrls(editor.getMarkdown()).trimEnd();
|
||||
// "Dirty" = user has local edits not yet flushed through the debounced
|
||||
// `onUpdate`. `lastEmittedRef` is advanced only after a debounce fire,
|
||||
// so a divergence means the editor holds unsaved bytes.
|
||||
const isDirty =
|
||||
lastEmittedRef.current !== null && current !== lastEmittedRef.current;
|
||||
|
||||
// Guard 1: focused AND dirty — protect bytes the user is actively
|
||||
// typing. Focused-but-clean falls through: applying setContent is safe
|
||||
// (no user input to lose) and necessary, because onBlur has no replay
|
||||
// mechanism and a focused clean editor would otherwise drop this sync
|
||||
// permanently.
|
||||
if (editor.isFocused && isDirty) return;
|
||||
|
||||
// Guard 2: unfocused-but-dirty — blur happened but the debounce window
|
||||
// (debounceMs, 1500ms for description) hasn't flushed yet. The pending
|
||||
// onUpdate will reach the server and the cache will reconcile; skipping
|
||||
// here avoids overwriting unsaved local edits.
|
||||
if (isDirty) return;
|
||||
|
||||
const incoming = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
||||
const incomingNormalized = stripBlobUrls(incoming).trimEnd();
|
||||
// Guard 3: normalized-equal short-circuit. Avoids a no-op transaction
|
||||
// when the cache reflects a write this same editor just emitted.
|
||||
if (incomingNormalized === current) return;
|
||||
|
||||
// Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to
|
||||
// `emitUpdate: true`; without this we would re-trigger onUpdate →
|
||||
// server save → self-write loop.
|
||||
const { from, to } = editor.state.selection;
|
||||
editor.commands.setContent(incoming, {
|
||||
emitUpdate: false,
|
||||
contentType: "markdown",
|
||||
});
|
||||
|
||||
// Clamp prior selection to the new doc size so the caret doesn't snap
|
||||
// to position 0 after ProseMirror replaces the document.
|
||||
const docSize = editor.state.doc.content.size;
|
||||
editor.commands.setTextSelection({
|
||||
from: Math.min(from, docSize),
|
||||
to: Math.min(to, docSize),
|
||||
});
|
||||
|
||||
lastEmittedRef.current = stripBlobUrls(editor.getMarkdown()).trimEnd();
|
||||
}, [defaultValue, editor]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => stripBlobUrls(editor?.getMarkdown() ?? ""),
|
||||
clearContent: () => {
|
||||
|
||||
98
packages/views/editor/extensions/code-block-view.test.tsx
Normal file
98
packages/views/editor/extensions/code-block-view.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
// Tiptap's NodeView primitives are hard to instantiate in jsdom without a
|
||||
// full editor. Stub them so the test can render <CodeBlockView /> as a plain
|
||||
// React component and inspect the resulting DOM shape.
|
||||
vi.mock("@tiptap/react", () => {
|
||||
const NodeViewWrapper = ({ children, ...rest }: any) => (
|
||||
<div data-testid="nvw" {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
// The real NodeViewContent renders an element managed by ProseMirror. For
|
||||
// the test it's enough to surface a sentinel element so we can assert it
|
||||
// remains mounted while CSS-hidden.
|
||||
const NodeViewContent = ({ as = "div", ...rest }: any) => {
|
||||
const Tag = as;
|
||||
return <Tag data-testid="nvc" {...rest} />;
|
||||
};
|
||||
return { NodeViewWrapper, NodeViewContent };
|
||||
});
|
||||
|
||||
vi.mock("../mermaid-diagram", () => ({
|
||||
MermaidDiagram: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
code_block: {
|
||||
copy_code: "Copy code",
|
||||
show_preview: "Show preview",
|
||||
show_source: "Show source",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { CodeBlockView } from "./code-block-view";
|
||||
|
||||
function makeProps(language: string, text: string) {
|
||||
return {
|
||||
node: {
|
||||
attrs: { language },
|
||||
textContent: text,
|
||||
},
|
||||
} as unknown as Parameters<typeof CodeBlockView>[0];
|
||||
}
|
||||
|
||||
describe("CodeBlockView — html language toggle", () => {
|
||||
// Inner async timers in useDebouncedValue make the iframe srcDoc lag by
|
||||
// ~200ms; use fake timers so the test stays deterministic.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("defaults to preview view: renders an iframe with sandbox='allow-scripts' and keeps the <pre> mounted (hidden)", () => {
|
||||
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
const frame = document.querySelector("iframe");
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
// NodeViewContent (and its enclosing <pre>) MUST remain mounted —
|
||||
// unmounting would break Tiptap's bindings and prevent editing.
|
||||
const nvc = screen.getByTestId("nvc");
|
||||
expect(nvc).toBeTruthy();
|
||||
const pre = nvc.closest("pre");
|
||||
expect(pre).toBeTruthy();
|
||||
expect(pre?.className).toContain("sr-only");
|
||||
});
|
||||
|
||||
it("toggles to source view: iframe is removed and the <pre> is no longer hidden", () => {
|
||||
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250);
|
||||
});
|
||||
expect(document.querySelector("iframe")).toBeTruthy();
|
||||
const toggle = screen.getByTitle("Show source");
|
||||
fireEvent.click(toggle);
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
const nvc = screen.getByTestId("nvc");
|
||||
const pre = nvc.closest("pre")!;
|
||||
expect(pre.className).not.toContain("sr-only");
|
||||
});
|
||||
|
||||
it("does not show the toggle or an iframe for a non-html language", () => {
|
||||
render(<CodeBlockView {...makeProps("typescript", "const x = 1;")} />);
|
||||
expect(screen.queryByTitle("Show source")).toBeNull();
|
||||
expect(screen.queryByTitle("Show preview")).toBeNull();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -3,16 +3,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { Code as CodeIcon, Copy, Check, Eye } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import { MermaidDiagram } from "../mermaid-diagram";
|
||||
import { CodeBlockIframe } from "../code-block-iframe";
|
||||
|
||||
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
|
||||
// Coalesces fast keystrokes before re-rendering live previews.
|
||||
// `mermaid.initialize()` mutates a process-global config, so back-to-back
|
||||
// renders during typing can race a concurrent ReadonlyContent render
|
||||
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
|
||||
// "live preview" feel while making concurrent inits unlikely in practice.
|
||||
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
|
||||
// HTML preview reuses the same debounce: re-keying iframe.srcDoc on every
|
||||
// keystroke causes the iframe to re-load and flicker.
|
||||
const PREVIEW_DEBOUNCE_MS = 200;
|
||||
|
||||
const HTML_PREVIEW_HEIGHT = "h-[320px]";
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
@@ -26,12 +32,22 @@ function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
function CodeBlockView({ node }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
const [copied, setCopied] = useState(false);
|
||||
// HTML blocks default to "preview"; the user can flip to "source" to
|
||||
// edit the markup directly. Note: the source `<pre>` MUST stay mounted
|
||||
// (just hidden) so ProseMirror keeps its NodeView bindings — unmounting
|
||||
// it would break editing.
|
||||
const [view, setView] = useState<"preview" | "source">("preview");
|
||||
const language = node.attrs.language || "";
|
||||
const isMermaid = language === "mermaid";
|
||||
const isHtml = language === "html";
|
||||
const chart = node.textContent;
|
||||
const debouncedChart = useDebouncedValue(
|
||||
isMermaid ? chart : "",
|
||||
MERMAID_PREVIEW_DEBOUNCE_MS,
|
||||
PREVIEW_DEBOUNCE_MS,
|
||||
);
|
||||
const debouncedHtml = useDebouncedValue(
|
||||
isHtml ? chart : "",
|
||||
PREVIEW_DEBOUNCE_MS,
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
@@ -42,6 +58,10 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const showHtmlPreview = isHtml && view === "preview";
|
||||
const toggleView = () =>
|
||||
setView((v) => (v === "preview" ? "source" : "preview"));
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
|
||||
{isMermaid && debouncedChart.trim() && (
|
||||
@@ -52,6 +72,18 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
<MermaidDiagram chart={debouncedChart} />
|
||||
</div>
|
||||
)}
|
||||
{isHtml && showHtmlPreview && (
|
||||
// CSS-hidden when toggled off so the `<pre>` below stays mounted —
|
||||
// unmounting either side would either lose ProseMirror bindings
|
||||
// (source) or thrash iframe.srcDoc (preview).
|
||||
<div contentEditable={false} className="mb-1">
|
||||
<CodeBlockIframe
|
||||
html={debouncedHtml}
|
||||
title="HTML preview"
|
||||
heightClassName={HTML_PREVIEW_HEIGHT}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
|
||||
@@ -61,6 +93,29 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
{language}
|
||||
</span>
|
||||
)}
|
||||
{isHtml && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleView}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={
|
||||
view === "preview"
|
||||
? t(($) => $.code_block.show_source)
|
||||
: t(($) => $.code_block.show_preview)
|
||||
}
|
||||
aria-label={
|
||||
view === "preview"
|
||||
? t(($) => $.code_block.show_source)
|
||||
: t(($) => $.code_block.show_preview)
|
||||
}
|
||||
>
|
||||
{view === "preview" ? (
|
||||
<CodeIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
@@ -74,7 +129,14 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<pre spellCheck={false}>
|
||||
{/* `<pre>` + NodeViewContent must remain mounted so the user can keep
|
||||
editing the code block contents. When the HTML preview is showing
|
||||
we just visually hide it — ProseMirror still tracks it. */}
|
||||
<pre
|
||||
spellCheck={false}
|
||||
className={cn(showHtmlPreview && "sr-only")}
|
||||
aria-hidden={showHtmlPreview ? "true" : undefined}
|
||||
>
|
||||
{/* @ts-expect-error -- NodeViewContent supports as="code" at runtime */}
|
||||
<NodeViewContent as="code" />
|
||||
</pre>
|
||||
|
||||
@@ -17,51 +17,33 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Eye, FileText, Loader2, Download } from "lucide-react";
|
||||
import { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown";
|
||||
import { useT } from "../../i18n";
|
||||
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
import { useAttachmentPreview } from "../attachment-preview-modal";
|
||||
import { getPreviewKind } from "../utils/preview";
|
||||
import { AttachmentCard } from "../attachment-card";
|
||||
|
||||
const FILE_CARD_MARKDOWN_RE = new RegExp(
|
||||
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
|
||||
);
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// React NodeView
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileCardView({ node }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
const href = (node.attrs.href as string) || "";
|
||||
const filename = (node.attrs.filename as string) || "";
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
const { openByUrl, resolveAttachment } = useAttachmentDownloadResolver();
|
||||
const preview = useAttachmentPreview();
|
||||
|
||||
const openFile = () => {
|
||||
openByUrl(href);
|
||||
};
|
||||
|
||||
// Preview gate mirrors the Download gate (href is enough). We attempt
|
||||
// to resolve the full Attachment from the surrounding provider, but its
|
||||
// absence is no longer fatal — media kinds (pdf/video/audio) only need
|
||||
// the URL, so they remain previewable even when `resolveAttachment`
|
||||
// misses (e.g. the URL was copy-pasted across comments and isn't in the
|
||||
// current entity's attachments). Text kinds still require the id because
|
||||
// the preview proxy is ID-keyed.
|
||||
// Preview gate widens to "anything that can be downloaded AND whose
|
||||
// filename is a previewable type". Media kinds remain previewable when the
|
||||
// attachment record isn't reachable (e.g. URL was copy-pasted across
|
||||
// comments). Text kinds (markdown / html / text) need the id because the
|
||||
// preview proxy is ID-keyed.
|
||||
const attachment = href ? resolveAttachment(href) : undefined;
|
||||
const kind = filename
|
||||
? getPreviewKind(attachment?.content_type ?? "", filename)
|
||||
: null;
|
||||
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
|
||||
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
|
||||
|
||||
const openPreview = () => {
|
||||
if (attachment) {
|
||||
@@ -73,49 +55,16 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
|
||||
<div
|
||||
className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 className="size-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{uploading ? t(($) => $.file_card.uploading, { filename }) : filename}</p>
|
||||
</div>
|
||||
{!uploading && canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openPreview();
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!uploading && href && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
openFile();
|
||||
}}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div contentEditable={false}>
|
||||
<AttachmentCard
|
||||
filename={filename}
|
||||
contentType={attachment?.content_type ?? ""}
|
||||
attachmentId={attachment?.id}
|
||||
href={href}
|
||||
uploading={uploading}
|
||||
onPreview={openPreview}
|
||||
onDownload={() => openByUrl(href)}
|
||||
/>
|
||||
</div>
|
||||
{preview.modal}
|
||||
</NodeViewWrapper>
|
||||
|
||||
29
packages/views/editor/hooks/use-attachment-html-text.ts
Normal file
29
packages/views/editor/hooks/use-attachment-html-text.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Shared React Query for fetching attachment text bodies via the
|
||||
* `/api/attachments/{id}/content` proxy.
|
||||
*
|
||||
* Same retry / staleTime / gcTime policy as AttachmentPreviewModal's local
|
||||
* TextBackedPreview, lifted out so the modal and the inline `AttachmentCard`
|
||||
* (file-card NodeView / readonly file-card / standalone AttachmentList) hit
|
||||
* the same cache key — opening the modal after the inline preview already
|
||||
* loaded a body does not refetch.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
|
||||
export function useAttachmentHtmlText(attachmentId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["attachment-content", attachmentId ?? ""] as const,
|
||||
queryFn: () => api.getAttachmentTextContent(attachmentId as string),
|
||||
enabled: !!attachmentId,
|
||||
// 413 / 415 won't become 200 on retry; a transport error is easier to
|
||||
// recover from by re-opening than waiting on background retries with
|
||||
// no UI affordance.
|
||||
retry: false,
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
108
packages/views/editor/html-block-preview.tsx
Normal file
108
packages/views/editor/html-block-preview.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* HtmlBlockPreview — readonly rendering of fenced ```html code blocks.
|
||||
*
|
||||
* Default view is "preview" (iframe) per the V2 plan; user can flip to
|
||||
* "source" to see the highlighted markup and Copy it.
|
||||
*
|
||||
* Mounted by ReadonlyContent's `code` renderer for `lang === "html"`. The
|
||||
* `pre` renderer in ReadonlyContent recognizes this component by reference
|
||||
* and unwraps it from the default `<pre>` envelope, matching the same
|
||||
* two-layer trick already used for MermaidDiagram.
|
||||
*
|
||||
* NOT used in the editable Tiptap NodeView — that path must keep
|
||||
* `<NodeViewContent as="code" />` so the user can continue typing.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Check, Code as CodeIcon, Copy, Eye } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../i18n";
|
||||
import { CodeBlockIframe } from "./code-block-iframe";
|
||||
import { CodeBlockStatic } from "./code-block-static";
|
||||
|
||||
const CODE_BLOCK_IFRAME_HEIGHT = "h-[320px]";
|
||||
|
||||
// Label shown in the code-block header. Not a translatable string — it's a
|
||||
// language identifier (matches the `lang === "html"` token below).
|
||||
const HTML_LANGUAGE_LABEL = "html";
|
||||
|
||||
interface HtmlBlockPreviewProps {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
|
||||
const { t } = useT("editor");
|
||||
const [view, setView] = useState<"preview" | "source">("preview");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!html) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(html);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Clipboard failures are user-recoverable (click again, or copy
|
||||
// manually from the source view) — no need for a toast here.
|
||||
}
|
||||
};
|
||||
|
||||
const toggleView = () =>
|
||||
setView((v) => (v === "preview" ? "source" : "preview"));
|
||||
|
||||
return (
|
||||
<div className={cn("code-block-wrapper group/code relative my-2", className)}>
|
||||
<div
|
||||
className="absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
|
||||
>
|
||||
<span className="text-xs text-muted-foreground select-none">{HTML_LANGUAGE_LABEL}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleView}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={
|
||||
view === "preview"
|
||||
? t(($) => $.code_block.show_source)
|
||||
: t(($) => $.code_block.show_preview)
|
||||
}
|
||||
aria-label={
|
||||
view === "preview"
|
||||
? t(($) => $.code_block.show_source)
|
||||
: t(($) => $.code_block.show_preview)
|
||||
}
|
||||
>
|
||||
{view === "preview" ? (
|
||||
<CodeIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||||
title={t(($) => $.code_block.copy_code)}
|
||||
aria-label={t(($) => $.code_block.copy_code)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{view === "preview" ? (
|
||||
<CodeBlockIframe
|
||||
html={html}
|
||||
title="HTML preview"
|
||||
heightClassName={CODE_BLOCK_IFRAME_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<CodeBlockStatic language="xml" body={html} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,3 +20,5 @@ export {
|
||||
isPreviewable,
|
||||
} from "./attachment-preview-modal";
|
||||
export type { AttachmentPreviewHandle } from "./attachment-preview-modal";
|
||||
export { AttachmentCard } from "./attachment-card";
|
||||
export type { AttachmentCardProps } from "./attachment-card";
|
||||
|
||||
@@ -153,6 +153,23 @@ describe("ReadonlyContent Mermaid rendering", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not regress Mermaid unwrap after the HtmlBlockPreview branch was added", async () => {
|
||||
// Both Mermaid and HtmlBlockPreview rely on react-markdown's `code`
|
||||
// renderer returning a non-<code> React element, and on the `pre`
|
||||
// renderer recognizing the element by reference and unwrapping it. If
|
||||
// someone tightens the `pre` check to a single component, the other
|
||||
// one quietly regresses into a `<pre>`-wrapped DOM. This test pins the
|
||||
// contract.
|
||||
const { container } = render(
|
||||
<ReadonlyContent
|
||||
content={["```mermaid", "graph LR", " A --> B", "```"].join("\n")}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector(".mermaid-diagram")).not.toBeNull();
|
||||
// No outer <pre> envelope.
|
||||
expect(container.querySelector("pre")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens a fullscreen lightbox when the toolbar button is clicked", async () => {
|
||||
const { container } = render(
|
||||
<ReadonlyContent
|
||||
@@ -186,3 +203,53 @@ describe("ReadonlyContent Mermaid rendering", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReadonlyContent HTML block rendering", () => {
|
||||
// `language=html` fenced blocks should default to a preview iframe with
|
||||
// sandbox="allow-scripts" (chart JS executes in an opaque origin) and
|
||||
// must NOT be wrapped by react-markdown's default <pre>, which would
|
||||
// clamp the iframe with monospace / overflow styles. The two-layer
|
||||
// code+pre unwrap mirror's Mermaid's pattern.
|
||||
it("renders an iframe with sandbox='allow-scripts' for ```html and skips the outer <pre>", () => {
|
||||
const { container } = render(
|
||||
<ReadonlyContent
|
||||
content={["```html", '<h1 id="x">hi</h1>', "```"].join("\n")}
|
||||
/>,
|
||||
);
|
||||
const frame = container.querySelector<HTMLIFrameElement>("iframe");
|
||||
expect(frame).not.toBeNull();
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("srcdoc")).toContain('<h1 id="x">hi</h1>');
|
||||
expect(container.querySelector("pre")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the <pre><code> wrapper for adjacent languages like htmlbars / mermaidx", () => {
|
||||
// Regression: the previous `className.includes("language-html")` check
|
||||
// matched `language-htmlbars` too, so an htmlbars fence lost its outer
|
||||
// <pre> envelope and rendered as bare lowlight-highlighted spans. The
|
||||
// unwrap rule must match the exact class token, not a prefix.
|
||||
const { container } = render(
|
||||
<ReadonlyContent
|
||||
content={[
|
||||
"```htmlbars",
|
||||
"<div>{{name}}</div>",
|
||||
"```",
|
||||
"",
|
||||
"```mermaidx",
|
||||
"not a real lang",
|
||||
"```",
|
||||
].join("\n")}
|
||||
/>,
|
||||
);
|
||||
const pres = container.querySelectorAll("pre");
|
||||
// Both fences keep their <pre> wrapper.
|
||||
expect(pres.length).toBe(2);
|
||||
// And the inner <code> still carries the original language class.
|
||||
expect(
|
||||
container.querySelector("pre code.language-htmlbars"),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
container.querySelector("pre code.language-mermaidx"),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import { Maximize2, Download, Eye, Link as LinkIcon, FileText } from "lucide-react";
|
||||
import { Maximize2, Download, Link as LinkIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
@@ -45,9 +45,10 @@ import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { isAllowedFileCardHref } from "@multica/ui/markdown";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import { HtmlBlockPreview } from "./html-block-preview";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { useAttachmentPreview, type PreviewSource } from "./attachment-preview-modal";
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
import { AttachmentCard } from "./attachment-card";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -57,6 +58,13 @@ import "./content-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// Code fences that the `code` renderer returns as a non-<code> React element
|
||||
// (Mermaid diagram, HTML preview iframe). The `pre` renderer below unwraps
|
||||
// these so the default <pre><code> envelope doesn't clamp their styles.
|
||||
// Anchored to whole class tokens so `language-htmlbars` / `language-mermaidx`
|
||||
// don't accidentally match and lose their <pre> wrapper.
|
||||
const PRE_UNWRAP_RE = /(^|\s)language-(html|mermaid)(\s|$)/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -235,10 +243,10 @@ function ReadonlyImage({
|
||||
);
|
||||
}
|
||||
|
||||
// Inline file card — same download semantics as the standalone attachment
|
||||
// list: fresh-sign through `useDownloadAttachment` when the href matches a
|
||||
// known attachment, otherwise hand the raw URL to the platform's external
|
||||
// opener.
|
||||
// Inline file card — wraps the shared AttachmentCard with the same
|
||||
// download semantics as the standalone attachment list: fresh-sign through
|
||||
// `useDownloadAttachment` when the href matches a known attachment,
|
||||
// otherwise hand the raw URL to the platform's external opener.
|
||||
function ReadonlyFileCard({
|
||||
href,
|
||||
filename,
|
||||
@@ -252,16 +260,7 @@ function ReadonlyFileCard({
|
||||
onDownload: (attachmentId: string) => void;
|
||||
onPreview: (source: PreviewSource) => boolean;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const attachment = href ? resolveAttachment(href) : undefined;
|
||||
// Mirror file-card.tsx (NodeView) — preview gate widens to "anything that
|
||||
// can be downloaded AND whose filename is a previewable type". Media kinds
|
||||
// fall through to URL-only when the attachment record isn't reachable.
|
||||
const kind = filename
|
||||
? getPreviewKind(attachment?.content_type ?? "", filename)
|
||||
: null;
|
||||
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
|
||||
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
|
||||
const handleDownloadClick = () => {
|
||||
if (attachment) {
|
||||
onDownload(attachment.id);
|
||||
@@ -277,34 +276,14 @@ function ReadonlyFileCard({
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{filename}</p>
|
||||
</div>
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onClick={handlePreviewClick}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{href && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onClick={handleDownloadClick}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<AttachmentCard
|
||||
filename={filename}
|
||||
contentType={attachment?.content_type ?? ""}
|
||||
attachmentId={attachment?.id}
|
||||
href={href}
|
||||
onPreview={handlePreviewClick}
|
||||
onDownload={handleDownloadClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,6 +344,13 @@ function buildComponents(
|
||||
if (isBlock && lang === "mermaid") {
|
||||
return <MermaidDiagram chart={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
if (isBlock && lang === "html") {
|
||||
// Like Mermaid, return the React element directly here and rely on
|
||||
// the `pre` renderer below to unwrap it — react-markdown otherwise
|
||||
// wraps `code` children in a `<pre>` whose monospace + overflow
|
||||
// styles would clamp the preview iframe.
|
||||
return <HtmlBlockPreview html={String(children).replace(/\n$/, "")} />;
|
||||
}
|
||||
|
||||
if (!isBlock && !lang) {
|
||||
// Inline code — CSS handles styling via .rich-text-editor code
|
||||
@@ -393,10 +379,26 @@ function buildComponents(
|
||||
}
|
||||
},
|
||||
|
||||
// Pre — pass through (CSS handles styling via .rich-text-editor pre)
|
||||
// Pre — pass through (CSS handles styling via .rich-text-editor pre).
|
||||
// Special-case Mermaid / HtmlBlockPreview returned from the `code`
|
||||
// renderer above so the outer `<pre>` does not wrap them — this is the
|
||||
// standard two-layer pattern used to escape react-markdown's default
|
||||
// `<pre><code>` envelope.
|
||||
pre: ({ children }) => {
|
||||
if (isValidElement(children) && children.type === MermaidDiagram) {
|
||||
return <>{children}</>;
|
||||
// react-markdown calls `pre` BEFORE invoking the `code` renderer —
|
||||
// `children` is the unrendered `<code>` element from the AST. So we
|
||||
// identify "this block was meant to be unwrapped" by inspecting the
|
||||
// child's className (`language-mermaid`, `language-html`), not by
|
||||
// checking `children.type === MermaidDiagram`, which never matches.
|
||||
//
|
||||
// Match by exact class token: a substring `includes("language-html")`
|
||||
// would also fire on neighboring languages like `language-htmlbars`
|
||||
// and silently strip their <pre> wrapper.
|
||||
if (isValidElement(children)) {
|
||||
const childProps = children.props as { className?: string };
|
||||
if (PRE_UNWRAP_RE.test(childProps.className ?? "")) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useTypeLabels(): Record<InboxItemType, string> {
|
||||
assignee_changed: t(($) => $.types.assignee_changed),
|
||||
status_changed: t(($) => $.types.status_changed),
|
||||
priority_changed: t(($) => $.types.priority_changed),
|
||||
start_date_changed: t(($) => $.types.start_date_changed),
|
||||
due_date_changed: t(($) => $.types.due_date_changed),
|
||||
new_comment: t(($) => $.types.new_comment),
|
||||
mentioned: t(($) => $.types.mentioned),
|
||||
@@ -83,6 +84,10 @@ export function InboxDetailLabel({ item }: { item: InboxItem }) {
|
||||
}
|
||||
return <span>{typeLabels[item.type]}</span>;
|
||||
}
|
||||
case "start_date_changed": {
|
||||
if (details.to) return <span>{t(($) => $.labels.set_start_date_to, { date: shortDate(details.to) })}</span>;
|
||||
return <span>{t(($) => $.labels.removed_start_date)}</span>;
|
||||
}
|
||||
case "due_date_changed": {
|
||||
if (details.to) return <span>{t(($) => $.labels.set_due_date_to, { date: shortDate(details.to) })}</span>;
|
||||
return <span>{t(($) => $.labels.removed_due_date)}</span>;
|
||||
|
||||
@@ -136,7 +136,12 @@ export function InboxPage() {
|
||||
useEffect(() => {
|
||||
if (!selectedId || selectedRead) return;
|
||||
markReadMutate(selectedId, {
|
||||
onError: () => toast.error(t(($) => $.errors.mark_read_failed)),
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.errors.mark_read_failed),
|
||||
),
|
||||
});
|
||||
}, [selectedId, selectedRead, markReadMutate, t]);
|
||||
|
||||
@@ -157,21 +162,36 @@ export function InboxPage() {
|
||||
setSelectedKey(next ? (next.issue_id ?? next.id) : "");
|
||||
}
|
||||
archiveMutation.mutate(id, {
|
||||
onError: () => toast.error(t(($) => $.errors.archive_failed)),
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.errors.archive_failed),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
// Batch operations
|
||||
const handleMarkAllRead = () => {
|
||||
markAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error(t(($) => $.errors.mark_all_read_failed)),
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.errors.mark_all_read_failed),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveAll = () => {
|
||||
setSelectedKey("");
|
||||
archiveAllMutation.mutate(undefined, {
|
||||
onError: () => toast.error(t(($) => $.errors.archive_all_failed)),
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.errors.archive_all_failed),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -179,14 +199,24 @@ export function InboxPage() {
|
||||
const readKeys = items.filter((i) => i.read).map((i) => i.issue_id ?? i.id);
|
||||
if (readKeys.includes(selectedKey)) setSelectedKey("");
|
||||
archiveAllReadMutation.mutate(undefined, {
|
||||
onError: () => toast.error(t(($) => $.errors.archive_all_read_failed)),
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.errors.archive_all_read_failed),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const handleArchiveCompleted = () => {
|
||||
setSelectedKey("");
|
||||
archiveCompletedMutation.mutate(undefined, {
|
||||
onError: () => toast.error(t(($) => $.errors.archive_completed_failed)),
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.errors.archive_completed_failed),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -122,6 +122,7 @@ const mockIssue: Issue = {
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
project_id: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -90,6 +90,7 @@ const mockIssue: Issue = {
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
parent_issue_id: null,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
project_id: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
FolderOpen,
|
||||
Link2,
|
||||
MoreHorizontal,
|
||||
@@ -197,6 +198,33 @@ export function IssueActionsMenuItems({
|
||||
{t(($) => $.actions.assignee)}
|
||||
</P.Item>
|
||||
|
||||
{/* Start date */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
<CalendarClock className="h-3.5 w-3.5" />
|
||||
{t(($) => $.actions.start_date)}
|
||||
</P.SubTrigger>
|
||||
<P.SubContent>
|
||||
<P.Item onClick={() => updateField({ start_date: now().toISOString() })}>
|
||||
{t(($) => $.actions.start_today)}
|
||||
</P.Item>
|
||||
<P.Item onClick={() => updateField({ start_date: inDays(1) })}>
|
||||
{t(($) => $.actions.start_tomorrow)}
|
||||
</P.Item>
|
||||
<P.Item onClick={() => updateField({ start_date: inDays(7) })}>
|
||||
{t(($) => $.actions.start_next_week)}
|
||||
</P.Item>
|
||||
{issue.start_date && (
|
||||
<>
|
||||
<P.Separator />
|
||||
<P.Item onClick={() => updateField({ start_date: null })}>
|
||||
{t(($) => $.actions.start_clear)}
|
||||
</P.Item>
|
||||
</>
|
||||
)}
|
||||
</P.SubContent>
|
||||
</P.Sub>
|
||||
|
||||
{/* Due date */}
|
||||
<P.Sub>
|
||||
<P.SubTrigger>
|
||||
|
||||
@@ -65,7 +65,14 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
|
||||
if (!issueId) return;
|
||||
updateIssue.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error(t(($) => $.detail.update_failed)) },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.detail.update_failed),
|
||||
),
|
||||
},
|
||||
);
|
||||
// Hint: assigning an agent to a backlog issue won't trigger execution
|
||||
// until the issue is moved to an active status.
|
||||
|
||||
@@ -53,8 +53,12 @@ export function BatchActionToolbar({
|
||||
try {
|
||||
await batchUpdate.mutateAsync({ ids, updates });
|
||||
toast.success(t(($) => $.batch.update_success, { count }));
|
||||
} catch {
|
||||
toast.error(t(($) => $.batch.update_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.batch.update_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -63,8 +67,12 @@ export function BatchActionToolbar({
|
||||
await batchDelete.mutateAsync(ids);
|
||||
clear();
|
||||
toast.success(t(($) => $.batch.delete_success, { count }));
|
||||
} catch {
|
||||
toast.error(t(($) => $.batch.delete_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.batch.delete_failed),
|
||||
);
|
||||
} finally {
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { toast } from "sonner";
|
||||
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { CalendarClock, CalendarDays } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
@@ -16,7 +16,7 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { PriorityIcon } from "./priority-icon";
|
||||
import { PriorityPicker, AssigneePicker, DueDatePicker } from "./pickers";
|
||||
import { PriorityPicker, AssigneePicker, StartDatePicker, DueDatePicker } from "./pickers";
|
||||
import { PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useViewStore } from "@multica/core/issues/stores/view-store-context";
|
||||
import { ProgressRing } from "./progress-ring";
|
||||
@@ -81,7 +81,14 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issue.id, ...updates },
|
||||
{ onError: () => toast.error(t(($) => $.card.update_failed)) },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.card.update_failed),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[issue.id, updateIssueMutation, t],
|
||||
@@ -90,6 +97,7 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||
const showPriority = storeProperties.priority;
|
||||
const showDescription = storeProperties.description && issue.description;
|
||||
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
|
||||
const showStartDate = storeProperties.startDate && issue.start_date;
|
||||
const showDueDate = storeProperties.dueDate && issue.due_date;
|
||||
const showProject = storeProperties.project && project;
|
||||
const showChildProgress = storeProperties.childProgress && childProgress;
|
||||
@@ -138,8 +146,8 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Row 3: Assignee, priority badge, due date */}
|
||||
{(showAssignee || showPriority || showDueDate) && (
|
||||
{/* Row 3: Assignee, priority badge, start date, due date */}
|
||||
{(showAssignee || showPriority || showStartDate || showDueDate) && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
{showAssignee &&
|
||||
(editable ? (
|
||||
@@ -186,6 +194,29 @@ export const BoardCardContent = memo(function BoardCardContent({
|
||||
{priorityCfg.label}
|
||||
</span>
|
||||
))}
|
||||
{showStartDate && (
|
||||
<div className={showDueDate ? undefined : "ml-auto"}>
|
||||
{editable ? (
|
||||
<PickerWrapper>
|
||||
<StartDatePicker
|
||||
startDate={issue.start_date}
|
||||
onUpdate={handleUpdate}
|
||||
trigger={
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3" />
|
||||
{formatDate(issue.start_date!)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</PickerWrapper>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<CalendarClock className="size-3" />
|
||||
{formatDate(issue.start_date!)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{showDueDate && (
|
||||
<div className="ml-auto">
|
||||
{editable ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { CheckCircle2, ChevronRight, Copy, Download, Eye, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { CheckCircle2, ChevronRight, Copy, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Card } from "@multica/ui/components/ui/card";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { timeAgo } from "@multica/core/utils";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, isPreviewable } from "../../editor";
|
||||
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, AttachmentCard } from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
@@ -122,7 +122,6 @@ function DeleteCommentDialog({
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
|
||||
const { t } = useT("editor");
|
||||
const download = useDownloadAttachment();
|
||||
const preview = useAttachmentPreview();
|
||||
if (!attachments?.length) return null;
|
||||
@@ -150,35 +149,15 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
{standalone.map((a) => (
|
||||
<div
|
||||
<AttachmentCard
|
||||
key={a.id}
|
||||
className="flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted"
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{a.filename}</p>
|
||||
</div>
|
||||
{isPreviewable(a.content_type, a.filename) && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onClick={() => preview.tryOpen({ kind: "full", attachment: a })}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onClick={() => download(a.id)}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
filename={a.filename}
|
||||
contentType={a.content_type}
|
||||
attachmentId={a.id}
|
||||
href={a.url}
|
||||
onPreview={() => preview.tryOpen({ kind: "full", attachment: a })}
|
||||
onDownload={() => download(a.id)}
|
||||
/>
|
||||
))}
|
||||
{preview.modal}
|
||||
</div>
|
||||
@@ -285,8 +264,12 @@ function CommentRow({
|
||||
setEditing(false);
|
||||
setPendingAttachments([]);
|
||||
clearEditDraft(editDraftKey);
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.update_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.update_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -515,8 +498,12 @@ function CommentCardImpl({
|
||||
setEditing(false);
|
||||
setParentPendingAttachments([]);
|
||||
clearParentEditDraft(parentEditDraftKey);
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.update_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.update_failed),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { StatusIcon } from "./status-icon";
|
||||
export { StatusHeading } from "./status-heading";
|
||||
export { PriorityIcon } from "./priority-icon";
|
||||
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, DueDatePicker, LabelPicker } from "./pickers";
|
||||
export { StatusPicker, PriorityPicker, AssigneePicker, canAssignAgent, StartDatePicker, DueDatePicker, LabelPicker } from "./pickers";
|
||||
export { IssueDetail } from "./issue-detail";
|
||||
export { IssuesPage } from "./issues-page";
|
||||
export { CommentCard } from "./comment-card";
|
||||
|
||||
@@ -401,6 +401,7 @@ const mockIssue: Issue = {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: "2026-06-01T00:00:00Z",
|
||||
created_at: "2026-01-15T00:00:00Z",
|
||||
updated_at: "2026-01-20T00:00:00Z",
|
||||
@@ -573,6 +574,7 @@ describe("IssueDetail (shared)", () => {
|
||||
mockApiObj.getIssue.mockResolvedValue({
|
||||
...mockIssue,
|
||||
priority: "none",
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useNavigation } from "../../navigation";
|
||||
import {
|
||||
Archive,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
@@ -44,7 +45,7 @@ import type { Attachment, Issue, IssueStatus, IssuePriority, TimelineEntry, Upda
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { toast } from "sonner";
|
||||
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
|
||||
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, StartDatePicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
|
||||
import { IssueActionsDropdown, useIssueActions } from "../actions";
|
||||
import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
import { CommentCard } from "./comment-card";
|
||||
@@ -53,7 +54,6 @@ import { ResolvedThreadBar } from "./resolved-thread-bar";
|
||||
import { collectThreadReplies } from "./thread-utils";
|
||||
import { AgentLiveCard } from "./agent-live-card";
|
||||
import { ExecutionLogSection } from "./execution-log-section";
|
||||
import { TerminalPanel } from "./terminal-panel";
|
||||
import { PullRequestList } from "./pull-request-list";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -214,6 +214,11 @@ function formatActivity(
|
||||
if (details.from_id && !details.to_id) return t(($) => $.activity.removed_assignee);
|
||||
return t(($) => $.activity.changed_assignee);
|
||||
}
|
||||
case "start_date_changed": {
|
||||
if (!details.to) return t(($) => $.activity.start_date_removed);
|
||||
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
return t(($) => $.activity.start_date_set, { date: formatted });
|
||||
}
|
||||
case "due_date_changed": {
|
||||
if (!details.to) return t(($) => $.activity.due_date_removed);
|
||||
const formatted = new Date(details.to).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
@@ -283,10 +288,10 @@ const EMPTY_REPLIES: TimelineEntry[] = [];
|
||||
// the Properties block, rendered only when the issue actually has a parent.
|
||||
//
|
||||
// `OPTIONAL_PROP_KEYS` is the open set — adding a new optional field
|
||||
// (e.g. `start_date`) means appending here, wiring its row in the JSX
|
||||
// switch below, and adding a locale key. The picker, visibility rules,
|
||||
// and add-property menu all flow from this one list.
|
||||
const OPTIONAL_PROP_KEYS = ["priority", "due_date", "labels"] as const;
|
||||
// means appending here, wiring its row in the JSX switch below, and
|
||||
// adding a locale key. The picker, visibility rules, and add-property
|
||||
// menu all flow from this one list.
|
||||
const OPTIONAL_PROP_KEYS = ["priority", "start_date", "due_date", "labels"] as const;
|
||||
type OptionalPropKey = (typeof OPTIONAL_PROP_KEYS)[number];
|
||||
|
||||
function isOptionalPropSet(
|
||||
@@ -297,6 +302,8 @@ function isOptionalPropSet(
|
||||
switch (key) {
|
||||
case "priority":
|
||||
return issue.priority !== "none";
|
||||
case "start_date":
|
||||
return !!issue.start_date;
|
||||
case "due_date":
|
||||
return !!issue.due_date;
|
||||
case "labels":
|
||||
@@ -422,6 +429,7 @@ function ActivityBlock({
|
||||
const details = (entry.details ?? {}) as Record<string, string>;
|
||||
const isStatusChange = entry.action === "status_changed";
|
||||
const isPriorityChange = entry.action === "priority_changed";
|
||||
const isStartDateChange = entry.action === "start_date_changed";
|
||||
const isDueDateChange = entry.action === "due_date_changed";
|
||||
|
||||
let leadIcon: React.ReactNode;
|
||||
@@ -429,6 +437,8 @@ function ActivityBlock({
|
||||
leadIcon = <StatusIcon status={details.to as IssueStatus} className="h-4 w-4 shrink-0" />;
|
||||
} else if (isPriorityChange && details.to) {
|
||||
leadIcon = <PriorityIcon priority={details.to as IssuePriority} className="h-4 w-4 shrink-0" />;
|
||||
} else if (isStartDateChange) {
|
||||
leadIcon = <CalendarClock className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else if (isDueDateChange) {
|
||||
leadIcon = <Calendar className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
} else {
|
||||
@@ -486,7 +496,14 @@ function SubIssueRow({ child }: { child: Issue }) {
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
updateIssue.mutate(
|
||||
{ id: child.id, ...updates },
|
||||
{ onError: () => toast.error(t(($) => $.detail.update_failed)) },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.detail.update_failed),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[child.id, updateIssue, t],
|
||||
@@ -1234,6 +1251,15 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
/>
|
||||
</PropRow>
|
||||
)}
|
||||
{visibleOptionalProps.has("start_date") && (
|
||||
<PropRow label={t(($) => $.detail.prop_start_date)}>
|
||||
<StartDatePicker
|
||||
startDate={issue.start_date}
|
||||
onUpdate={handleUpdateField}
|
||||
defaultOpen={autoOpenProp === "start_date"}
|
||||
/>
|
||||
</PropRow>
|
||||
)}
|
||||
{visibleOptionalProps.has("due_date") && (
|
||||
<PropRow label={t(($) => $.detail.prop_due_date)}>
|
||||
<DueDatePicker
|
||||
@@ -1281,6 +1307,9 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
{k === "priority" && (
|
||||
<PriorityIcon priority="medium" inheritColor className="text-muted-foreground" />
|
||||
)}
|
||||
{k === "start_date" && (
|
||||
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{k === "due_date" && (
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
@@ -1289,6 +1318,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
)}
|
||||
<span className="truncate">
|
||||
{k === "priority" && t(($) => $.detail.prop_priority)}
|
||||
{k === "start_date" && t(($) => $.detail.prop_start_date)}
|
||||
{k === "due_date" && t(($) => $.detail.prop_due_date)}
|
||||
{k === "labels" && t(($) => $.detail.prop_labels)}
|
||||
</span>
|
||||
@@ -1367,12 +1397,6 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
when there are no runs to show. */}
|
||||
<ExecutionLogSection issueId={id} />
|
||||
|
||||
{/* Terminal panel — attaches to the PTY running in the daemon's
|
||||
workdir for the latest agent task. Desktop-only (the panel
|
||||
itself renders an explanatory placeholder on web).
|
||||
See MUL-2295. */}
|
||||
<TerminalPanelSection issueId={id} workspaceId={wsId} />
|
||||
|
||||
{/* Token usage */}
|
||||
{usage && usage.task_count > 0 && (
|
||||
<div>
|
||||
@@ -1911,23 +1935,3 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
</ResizablePanelGroup>
|
||||
);
|
||||
}
|
||||
|
||||
// TerminalPanelSection wraps TerminalPanel in a collapsible header that
|
||||
// matches the existing sidebar sections (Token usage, etc.). Collapsed
|
||||
// by default — opening a PTY is an explicit action, and ResizeObserver +
|
||||
// xterm bootstrap should not run for every issue view.
|
||||
function TerminalPanelSection({ issueId, workspaceId }: { issueId: string; workspaceId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<button
|
||||
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${open ? "" : "text-muted-foreground hover:text-foreground"}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
Terminal
|
||||
<ChevronRight className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`} />
|
||||
</button>
|
||||
{open && <TerminalPanel issueId={issueId} workspaceId={workspaceId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -568,9 +568,10 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
|
||||
labelFilters,
|
||||
}) > 0;
|
||||
|
||||
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_due_date" | "sort_created" | "sort_title"> = {
|
||||
const SORT_LABEL_KEY: Record<typeof SORT_OPTIONS[number]["value"], "sort_manual" | "sort_priority" | "sort_start_date" | "sort_due_date" | "sort_created" | "sort_title"> = {
|
||||
position: "sort_manual",
|
||||
priority: "sort_priority",
|
||||
start_date: "sort_start_date",
|
||||
due_date: "sort_due_date",
|
||||
created_at: "sort_created",
|
||||
title: "sort_title",
|
||||
@@ -579,10 +580,11 @@ export function IssueDisplayControls({ scopedIssues }: { scopedIssues: Issue[] }
|
||||
status: "group_status",
|
||||
assignee: "group_assignee",
|
||||
};
|
||||
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
|
||||
const CARD_PROPERTY_LABEL_KEY: Record<typeof CARD_PROPERTY_OPTIONS[number]["key"], "card_priority" | "card_description" | "card_assignee" | "card_start_date" | "card_due_date" | "card_project" | "card_labels" | "card_child_progress"> = {
|
||||
priority: "card_priority",
|
||||
description: "card_description",
|
||||
assignee: "card_assignee",
|
||||
startDate: "card_start_date",
|
||||
dueDate: "card_due_date",
|
||||
project: "card_project",
|
||||
labels: "card_labels",
|
||||
|
||||
@@ -354,6 +354,7 @@ const mockIssues: Issue[] = [
|
||||
assignee_id: "user-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
@@ -372,6 +373,7 @@ const mockIssues: Issue[] = [
|
||||
assignee_id: "agent-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
start_date: null,
|
||||
due_date: "2026-02-01T00:00:00Z",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
@@ -390,6 +392,7 @@ const mockIssues: Issue[] = [
|
||||
assignee_id: null,
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
@@ -408,6 +411,7 @@ const mockIssues: Issue[] = [
|
||||
assignee_id: "squad-1",
|
||||
creator_type: "member",
|
||||
creator_id: "user-1",
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -121,7 +121,14 @@ export function IssuesPage() {
|
||||
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{ onError: () => toast.error(t(($) => $.page.move_failed)) },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.page.move_failed),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[updateIssueMutation, t],
|
||||
|
||||
@@ -50,6 +50,7 @@ export const ListRow = memo(function ListRow({
|
||||
const showProject = storeProperties.project && project;
|
||||
const showChildProgress = storeProperties.childProgress && childProgress;
|
||||
const showAssignee = storeProperties.assignee && issue.assignee_type && issue.assignee_id;
|
||||
const showStartDate = storeProperties.startDate && issue.start_date;
|
||||
const showDueDate = storeProperties.dueDate && issue.due_date;
|
||||
const showLabels = storeProperties.labels && labels.length > 0;
|
||||
|
||||
@@ -110,6 +111,11 @@ export const ListRow = memo(function ListRow({
|
||||
<span className="truncate">{project!.title}</span>
|
||||
</span>
|
||||
)}
|
||||
{showStartDate && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.start_date!)}
|
||||
</span>
|
||||
)}
|
||||
{showDueDate && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDate(issue.due_date!)}
|
||||
|
||||
@@ -2,5 +2,6 @@ export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./proper
|
||||
export { StatusPicker } from "./status-picker";
|
||||
export { PriorityPicker } from "./priority-picker";
|
||||
export { AssigneePicker, canAssignAgent } from "./assignee-picker";
|
||||
export { StartDatePicker } from "./start-date-picker";
|
||||
export { DueDatePicker } from "./due-date-picker";
|
||||
export { LabelPicker } from "./label-picker";
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CalendarClock } from "lucide-react";
|
||||
import type { UpdateIssueRequest } from "@multica/core/types";
|
||||
import { Calendar } from "@multica/ui/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "../../../i18n";
|
||||
|
||||
export function StartDatePicker({
|
||||
startDate,
|
||||
onUpdate,
|
||||
trigger: customTrigger,
|
||||
triggerRender,
|
||||
align = "start",
|
||||
defaultOpen = false,
|
||||
}: {
|
||||
startDate: string | null;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => void;
|
||||
trigger?: React.ReactNode;
|
||||
triggerRender?: React.ReactElement;
|
||||
align?: "start" | "center" | "end";
|
||||
/** Open the popover on first mount. Used by progressive-disclosure
|
||||
* sidebars so a newly-added field immediately enters edit state. */
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const date = startDate ? new Date(startDate) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"}
|
||||
render={triggerRender}
|
||||
>
|
||||
{customTrigger ?? (
|
||||
<>
|
||||
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
{date ? (
|
||||
<span>
|
||||
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t(($) => $.pickers.start_date.trigger_label)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d: Date | undefined) => {
|
||||
onUpdate({ start_date: d ? d.toISOString() : null });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
{date && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
onUpdate({ start_date: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{t(($) => $.pickers.start_date.clear_action)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
211
packages/views/issues/components/pull-request-list.test.tsx
Normal file
211
packages/views/issues/components/pull-request-list.test.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import type { GitHubPullRequest } from "@multica/core/types";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enIssues from "../../locales/en/issues.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, issues: enIssues } };
|
||||
|
||||
vi.mock("@multica/core/github/queries", async () => {
|
||||
const actual = await vi.importActual<typeof import("@multica/core/github/queries")>(
|
||||
"@multica/core/github/queries",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
issuePullRequestsOptions: (issueId: string) => ({
|
||||
queryKey: ["github", "pull-requests", issueId],
|
||||
queryFn: async () => ({ pull_requests: mockPRs }),
|
||||
enabled: !!issueId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { PullRequestList } from "./pull-request-list";
|
||||
|
||||
let mockPRs: GitHubPullRequest[] = [];
|
||||
|
||||
function makePR(overrides: Partial<GitHubPullRequest> = {}): GitHubPullRequest {
|
||||
return {
|
||||
id: "pr-1",
|
||||
workspace_id: "ws-1",
|
||||
repo_owner: "acme",
|
||||
repo_name: "widget",
|
||||
number: 1,
|
||||
title: "Test PR",
|
||||
state: "open",
|
||||
html_url: "https://example.test/pr/1",
|
||||
branch: "feat/x",
|
||||
author_login: "octocat",
|
||||
author_avatar_url: null,
|
||||
merged_at: null,
|
||||
closed_at: null,
|
||||
pr_created_at: "2026-01-01T00:00:00Z",
|
||||
pr_updated_at: "2026-01-01T00:00:00Z",
|
||||
mergeable_state: null,
|
||||
checks_conclusion: null,
|
||||
checks_passed: 0,
|
||||
checks_failed: 0,
|
||||
checks_pending: 0,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
changed_files: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<I18nProvider resources={TEST_RESOURCES} locale="en">
|
||||
<PullRequestList issueId="issue-1" />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForRender() {
|
||||
return screen.findAllByRole("link");
|
||||
}
|
||||
|
||||
describe("PullRequestList sidebar rows", () => {
|
||||
it("uses the sidebar list-row surface instead of a card surface", async () => {
|
||||
mockPRs = [makePR({ title: "Visual row" })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
const row = screen.getByTestId("pull-request-row");
|
||||
expect(row).toHaveClass("rounded-md", "-mx-2", "hover:bg-accent/50");
|
||||
expect(row).not.toHaveClass("rounded-lg", "border", "bg-card");
|
||||
});
|
||||
|
||||
it("renders All-checks-passed status when only passed counts are non-zero", async () => {
|
||||
mockPRs = [makePR({ checks_passed: 3 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("All checks passed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Some-checks-failed when any failed count is non-zero", async () => {
|
||||
mockPRs = [makePR({ checks_failed: 1, checks_passed: 5 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Some checks failed")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders pending status when only pending suites remain", async () => {
|
||||
mockPRs = [makePR({ checks_pending: 2, checks_passed: 1 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Some checks haven't completed yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders conflicts status when mergeable_state=dirty", async () => {
|
||||
mockPRs = [makePR({ mergeable_state: "dirty" })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Has merge conflicts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Ready-to-merge when mergeable=clean and no suites observed", async () => {
|
||||
mockPRs = [makePR({ mergeable_state: "clean" })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Ready to merge")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Merged status for merged PRs, suppressing conflict/check text", async () => {
|
||||
mockPRs = [
|
||||
makePR({
|
||||
state: "merged",
|
||||
mergeable_state: "dirty",
|
||||
checks_conclusion: "failed",
|
||||
checks_failed: 5,
|
||||
}),
|
||||
];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Merged")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Has merge conflicts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Some checks failed")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Conflicts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Checks failed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders Closed-without-merging status for closed PRs, suppressing conflict/check badges", async () => {
|
||||
mockPRs = [
|
||||
makePR({
|
||||
state: "closed",
|
||||
mergeable_state: "clean",
|
||||
checks_conclusion: "passed",
|
||||
checks_passed: 3,
|
||||
}),
|
||||
];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("Closed without merging")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Ready to merge")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("All checks passed")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("No conflicts")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Checks passed")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides stats row when all stats are 0 (legacy backend)", async () => {
|
||||
mockPRs = [makePR()];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.queryByText(/files?$/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/^\+0/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows stats row with additions / deletions / file count when present", async () => {
|
||||
mockPRs = [makePR({ additions: 437, deletions: 6, changed_files: 6 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("+437")).toBeInTheDocument();
|
||||
expect(screen.getByText("−6")).toBeInTheDocument();
|
||||
expect(screen.getByText("6 files")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses singular file copy when changed_files=1", async () => {
|
||||
mockPRs = [makePR({ additions: 1, changed_files: 1 })];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("1 file")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses extra PR rows past the visible limit behind Show more toggle", async () => {
|
||||
mockPRs = [
|
||||
makePR({ id: "a", number: 1, title: "PR-A" }),
|
||||
makePR({ id: "b", number: 2, title: "PR-B" }),
|
||||
makePR({ id: "c", number: 3, title: "PR-C" }),
|
||||
makePR({ id: "d", number: 4, title: "PR-D" }),
|
||||
makePR({ id: "e", number: 5, title: "PR-E" }),
|
||||
];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("PR-A")).toBeInTheDocument();
|
||||
expect(screen.getByText("PR-B")).toBeInTheDocument();
|
||||
expect(screen.getByText("PR-C")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PR-D")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("PR-E")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Show 2 more")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses to 3 rows + hidden tail when count == threshold", async () => {
|
||||
mockPRs = [
|
||||
makePR({ id: "a", number: 1, title: "PR-A" }),
|
||||
makePR({ id: "b", number: 2, title: "PR-B" }),
|
||||
makePR({ id: "c", number: 3, title: "PR-C" }),
|
||||
makePR({ id: "d", number: 4, title: "PR-D" }),
|
||||
];
|
||||
renderList();
|
||||
await waitForRender();
|
||||
expect(screen.getByText("PR-A")).toBeInTheDocument();
|
||||
expect(screen.getByText("PR-B")).toBeInTheDocument();
|
||||
expect(screen.getByText("PR-C")).toBeInTheDocument();
|
||||
expect(screen.queryByText("PR-D")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Show 1 more")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDashed,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
GitPullRequestArrow,
|
||||
GitPullRequestClosed,
|
||||
GitMerge,
|
||||
GitPullRequestDraft,
|
||||
TriangleAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { issuePullRequestsOptions } from "@multica/core/github/queries";
|
||||
import type { GitHubPullRequest, GitHubPullRequestState } from "@multica/core/types";
|
||||
import {
|
||||
issuePullRequestsOptions,
|
||||
derivePullRequestStatusKind,
|
||||
derivePullRequestProgressSegments,
|
||||
shouldShowPullRequestStats,
|
||||
type PullRequestStatusKind,
|
||||
type PullRequestProgressSegment,
|
||||
} from "@multica/core/github";
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestChecksConclusion,
|
||||
GitHubPullRequestState,
|
||||
} from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
type IssuesT = ReturnType<typeof useT<"issues">>["t"];
|
||||
|
||||
// Keep the existing sidebar density: show the first 3 PR rows inline, then
|
||||
// collapse the rest once the section reaches 4 rows.
|
||||
const PR_LIMIT_BEFORE_COLLAPSE = 4;
|
||||
|
||||
const STATE_ICON: Record<
|
||||
GitHubPullRequestState,
|
||||
{ icon: React.ComponentType<{ className?: string }>; className: string }
|
||||
@@ -23,8 +45,18 @@ const STATE_ICON: Record<
|
||||
closed: { icon: GitPullRequestClosed, className: "text-rose-600 dark:text-rose-400" },
|
||||
};
|
||||
|
||||
const CHECKS_ICON: Record<
|
||||
GitHubPullRequestChecksConclusion,
|
||||
{ icon: React.ComponentType<{ className?: string }>; className: string }
|
||||
> = {
|
||||
passed: { icon: CheckCircle2, className: "text-emerald-600 dark:text-emerald-400" },
|
||||
failed: { icon: XCircle, className: "text-rose-600 dark:text-rose-400" },
|
||||
pending: { icon: CircleDashed, className: "text-amber-600 dark:text-amber-400" },
|
||||
};
|
||||
|
||||
export function PullRequestList({ issueId }: { issueId: string }) {
|
||||
const { t } = useT("issues");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { data, isLoading } = useQuery(issuePullRequestsOptions(issueId));
|
||||
const prs = data?.pull_requests ?? [];
|
||||
|
||||
@@ -39,11 +71,35 @@ export function PullRequestList({ issueId }: { issueId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Render rule:
|
||||
// - < PR_LIMIT_BEFORE_COLLAPSE: every PR row is visible.
|
||||
// - >= PR_LIMIT_BEFORE_COLLAPSE: first (LIMIT - 1) rows are visible and
|
||||
// the remainder sits behind a toggle.
|
||||
const useCollapse = prs.length >= PR_LIMIT_BEFORE_COLLAPSE;
|
||||
const expandedHead = useCollapse ? prs.slice(0, PR_LIMIT_BEFORE_COLLAPSE - 1) : prs;
|
||||
const collapsedTail = useCollapse ? prs.slice(PR_LIMIT_BEFORE_COLLAPSE - 1) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{prs.map((pr) => (
|
||||
{expandedHead.map((pr) => (
|
||||
<PullRequestRow key={pr.id} pr={pr} />
|
||||
))}
|
||||
{useCollapse ? (
|
||||
<div className="space-y-1">
|
||||
{expanded
|
||||
? collapsedTail.map((pr) => <PullRequestRow key={pr.id} pr={pr} />)
|
||||
: null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="block w-[calc(100%+1rem)] -mx-2 rounded-md px-2 py-1.5 text-left text-[11px] text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded
|
||||
? t(($) => $.detail.pull_request_card_show_less)
|
||||
: t(($) => $.detail.pull_request_card_show_more, { count: collapsedTail.length })}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,32 +107,230 @@ export function PullRequestList({ issueId }: { issueId: string }) {
|
||||
function PullRequestRow({ pr }: { pr: GitHubPullRequest }) {
|
||||
const { t } = useT("issues");
|
||||
const cfg = STATE_ICON[pr.state] ?? { icon: GitPullRequest, className: "" };
|
||||
const Icon = cfg.icon;
|
||||
const label =
|
||||
pr.state === "open"
|
||||
? t(($) => $.detail.pull_request_state_open)
|
||||
: pr.state === "draft"
|
||||
? t(($) => $.detail.pull_request_state_draft)
|
||||
: pr.state === "merged"
|
||||
? t(($) => $.detail.pull_request_state_merged)
|
||||
: pr.state === "closed"
|
||||
? t(($) => $.detail.pull_request_state_closed)
|
||||
: pr.state;
|
||||
const StateIcon = cfg.icon;
|
||||
const kind = derivePullRequestStatusKind({
|
||||
state: pr.state,
|
||||
mergeable_state: pr.mergeable_state,
|
||||
checks_failed: pr.checks_failed,
|
||||
checks_pending: pr.checks_pending,
|
||||
checks_passed: pr.checks_passed,
|
||||
});
|
||||
const segments = derivePullRequestProgressSegments({
|
||||
state: pr.state,
|
||||
checks_failed: pr.checks_failed,
|
||||
checks_pending: pr.checks_pending,
|
||||
checks_passed: pr.checks_passed,
|
||||
});
|
||||
const showStats = shouldShowPullRequestStats({
|
||||
additions: pr.additions,
|
||||
deletions: pr.deletions,
|
||||
changed_files: pr.changed_files,
|
||||
});
|
||||
const statusText = useStatusText(kind);
|
||||
const draftPrefix = pr.state === "draft";
|
||||
const stateLabel = getStateLabel(pr.state, t);
|
||||
|
||||
return (
|
||||
<a
|
||||
data-testid="pull-request-row"
|
||||
href={pr.html_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group"
|
||||
className={cn(
|
||||
"flex items-start gap-2 rounded-md px-2 py-1.5 -mx-2 hover:bg-accent/50 transition-colors group",
|
||||
draftPrefix ? "opacity-80" : null,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
|
||||
<StateIcon className={cn("h-3.5 w-3.5 mt-0.5 shrink-0", cfg.className)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium truncate group-hover:text-foreground">{pr.title}</p>
|
||||
<p className="text-xs font-medium leading-snug truncate group-hover:text-foreground">
|
||||
{pr.title}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground truncate">
|
||||
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {label}
|
||||
{pr.repo_owner}/{pr.repo_name}#{pr.number} · {stateLabel}
|
||||
{pr.author_login ? ` · @${pr.author_login}` : null}
|
||||
</p>
|
||||
<PullRequestRowDetails
|
||||
pr={pr}
|
||||
segments={segments}
|
||||
showStats={showStats}
|
||||
statusText={
|
||||
draftPrefix
|
||||
? t(($) => $.detail.pull_request_card_draft_prefix, { status: statusText })
|
||||
: statusText
|
||||
}
|
||||
statusKind={kind}
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function PullRequestRowDetails({
|
||||
pr,
|
||||
segments,
|
||||
showStats,
|
||||
statusText,
|
||||
statusKind,
|
||||
}: {
|
||||
pr: GitHubPullRequest;
|
||||
segments: PullRequestProgressSegment[] | null;
|
||||
showStats: boolean;
|
||||
statusText: string;
|
||||
statusKind: PullRequestStatusKind;
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const checksBadge = getChecksBadge(pr, t);
|
||||
const conflictsBadge = getConflictsBadge(pr, t);
|
||||
const isTerminal = statusKind === "closed" || statusKind === "merged";
|
||||
const showChecksBadge =
|
||||
!isTerminal &&
|
||||
!!checksBadge &&
|
||||
statusKind !== "checks_failed" &&
|
||||
statusKind !== "checks_pending" &&
|
||||
statusKind !== "checks_passed";
|
||||
const showConflictsBadge =
|
||||
!isTerminal && !!conflictsBadge && statusKind !== "conflicts" && statusKind !== "ready";
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-muted-foreground">
|
||||
{showStats ? <PullRequestStats pr={pr} /> : null}
|
||||
<PullRequestProgressStrip segments={segments} />
|
||||
<span className="truncate">{statusText}</span>
|
||||
{showChecksBadge ? <PullRequestBadge badge={checksBadge} /> : null}
|
||||
{showConflictsBadge ? <PullRequestBadge badge={conflictsBadge} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PullRequestStats({ pr }: { pr: GitHubPullRequest }) {
|
||||
const { t } = useT("issues");
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 tabular-nums">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">+{pr.additions ?? 0}</span>
|
||||
<span className="text-rose-600 dark:text-rose-400">−{pr.deletions ?? 0}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t(($) => $.detail.pull_request_card_files_count, {
|
||||
count: pr.changed_files ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PullRequestProgressStrip({
|
||||
segments,
|
||||
}: {
|
||||
segments: PullRequestProgressSegment[] | null;
|
||||
}) {
|
||||
if (!segments) return null;
|
||||
return (
|
||||
<span className="flex h-1 w-12 shrink-0 overflow-hidden rounded-full bg-muted" aria-hidden="true">
|
||||
{segments.map((seg) => (
|
||||
<span
|
||||
key={seg.kind}
|
||||
className={cn(
|
||||
"h-full block",
|
||||
seg.kind === "failed" && "bg-rose-500 dark:bg-rose-400",
|
||||
seg.kind === "pending" && "bg-amber-500 dark:bg-amber-400",
|
||||
seg.kind === "passed" && "bg-emerald-500 dark:bg-emerald-400",
|
||||
)}
|
||||
style={{ width: `${seg.ratio * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface PullRequestBadgeConfig {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
function PullRequestBadge({ badge }: { badge: PullRequestBadgeConfig }) {
|
||||
const Icon = badge.icon;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Icon className={cn("h-3 w-3", badge.className)} />
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getConflictsBadge(
|
||||
pr: GitHubPullRequest,
|
||||
t: IssuesT,
|
||||
): PullRequestBadgeConfig | null {
|
||||
const mergeable = pr.mergeable_state ?? null;
|
||||
return mergeable === "dirty"
|
||||
? {
|
||||
icon: TriangleAlert,
|
||||
label: t(($) => $.detail.pull_request_conflicts_dirty),
|
||||
className: "text-rose-600 dark:text-rose-400",
|
||||
}
|
||||
: mergeable === "clean"
|
||||
? {
|
||||
icon: CheckCircle2,
|
||||
label: t(($) => $.detail.pull_request_conflicts_clean),
|
||||
className: "text-emerald-600 dark:text-emerald-400",
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function getChecksBadge(
|
||||
pr: GitHubPullRequest,
|
||||
t: IssuesT,
|
||||
): PullRequestBadgeConfig | null {
|
||||
const checks = pr.checks_conclusion ?? null;
|
||||
return checks && CHECKS_ICON[checks]
|
||||
? {
|
||||
icon: CHECKS_ICON[checks].icon,
|
||||
className: CHECKS_ICON[checks].className,
|
||||
label:
|
||||
checks === "passed"
|
||||
? t(($) => $.detail.pull_request_checks_passed)
|
||||
: checks === "failed"
|
||||
? t(($) => $.detail.pull_request_checks_failed)
|
||||
: t(($) => $.detail.pull_request_checks_pending),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function getStateLabel(
|
||||
state: GitHubPullRequestState,
|
||||
t: IssuesT,
|
||||
): string {
|
||||
return state === "open"
|
||||
? t(($) => $.detail.pull_request_state_open)
|
||||
: state === "draft"
|
||||
? t(($) => $.detail.pull_request_state_draft)
|
||||
: state === "merged"
|
||||
? t(($) => $.detail.pull_request_state_merged)
|
||||
: state === "closed"
|
||||
? t(($) => $.detail.pull_request_state_closed)
|
||||
: state;
|
||||
}
|
||||
|
||||
function useStatusText(kind: PullRequestStatusKind): string {
|
||||
const { t } = useT("issues");
|
||||
switch (kind) {
|
||||
case "closed":
|
||||
return t(($) => $.detail.pull_request_card_status_closed);
|
||||
case "merged":
|
||||
return t(($) => $.detail.pull_request_card_status_merged);
|
||||
case "conflicts":
|
||||
return t(($) => $.detail.pull_request_card_status_conflicts);
|
||||
case "checks_failed":
|
||||
return t(($) => $.detail.pull_request_card_status_checks_failed);
|
||||
case "checks_pending":
|
||||
return t(($) => $.detail.pull_request_card_status_checks_pending);
|
||||
case "checks_passed":
|
||||
return t(($) => $.detail.pull_request_card_status_checks_passed);
|
||||
case "ready":
|
||||
return t(($) => $.detail.pull_request_card_status_ready);
|
||||
case "unknown":
|
||||
return t(($) => $.detail.pull_request_card_status_unknown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Terminal as XTerminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { getApi } from "@multica/core/api";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
|
||||
// Protocol message types — kept in lockstep with
|
||||
// server/pkg/protocol/messages.go. Strings are stable across daemon /
|
||||
// server / browser, so duplicating them client-side is OK; if we ever
|
||||
// regenerate types from Go we can swap these out.
|
||||
const MSG_TERMINAL_DATA = "terminal.data";
|
||||
const MSG_TERMINAL_RESIZE = "terminal.resize";
|
||||
const MSG_TERMINAL_CLOSE = "terminal.close";
|
||||
const MSG_TERMINAL_OPENED = "terminal.opened";
|
||||
const MSG_TERMINAL_EXIT = "terminal.exit";
|
||||
const MSG_TERMINAL_ERROR = "terminal.error";
|
||||
|
||||
interface Envelope {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
interface OpenedPayload {
|
||||
request_id: string;
|
||||
session_id: string;
|
||||
work_dir: string;
|
||||
shell: string;
|
||||
}
|
||||
|
||||
interface DataPayload {
|
||||
session_id: string;
|
||||
data_b64: string;
|
||||
}
|
||||
|
||||
interface ExitPayload {
|
||||
session_id: string;
|
||||
exit_code: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface ErrorPayload {
|
||||
request_id?: string;
|
||||
session_id?: string;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Detect Electron — server-side render guard plus the desktop preload
|
||||
// surface check. Mirrors the pattern used elsewhere in the desktop app;
|
||||
// the Terminal panel is intentionally desktop-only because the daemon
|
||||
// only runs on a developer machine.
|
||||
function isDesktopRuntime(): boolean {
|
||||
return typeof window !== "undefined" && "desktopAPI" in window;
|
||||
}
|
||||
|
||||
interface TerminalPanelProps {
|
||||
issueId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function TerminalPanel({ issueId, workspaceId }: TerminalPanelProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const termRef = useRef<XTerminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const sessionIdRef = useRef<string>("");
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "connecting" | "connected" | "closed" | "error"
|
||||
>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState<string>("");
|
||||
const [reconnectKey, setReconnectKey] = useState(0);
|
||||
|
||||
const wsUrl = useMemo(() => deriveTerminalWsUrl(issueId, workspaceId), [
|
||||
issueId,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktopRuntime()) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const term = new XTerminal({
|
||||
convertEol: true,
|
||||
cursorBlink: true,
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, Menlo, Monaco, 'Cascadia Mono', 'Roboto Mono', 'Courier New', monospace",
|
||||
fontSize: 13,
|
||||
theme: { background: "#0b0b0b", foreground: "#e6e6e6" },
|
||||
// Scrollback large enough to read a verbose `cargo build` or `git
|
||||
// log` without auto-clipping the top.
|
||||
scrollback: 5000,
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(containerRef.current);
|
||||
fit.fit();
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
term.writeln("\x1b[90mconnecting to daemon…\x1b[0m");
|
||||
|
||||
setStatus("connecting");
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
// Cookie auth carries the session by default. If we ever flip to
|
||||
// token-mode (no cookie), this is where we'd send an `auth` frame
|
||||
// mirroring realtime/ws-client.ts. Server falls back gracefully.
|
||||
setStatus("connected");
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// The browser only surfaces a generic Event; the server sends a
|
||||
// structured terminal.error frame which we already render below.
|
||||
// Keep this minimal so we don't double-up the error UI.
|
||||
setStatus("error");
|
||||
};
|
||||
|
||||
ws.onclose = (ev) => {
|
||||
setStatus("closed");
|
||||
term.writeln(
|
||||
`\r\n\x1b[90mconnection closed (code=${ev.code})${
|
||||
ev.reason ? ` reason=${ev.reason}` : ""
|
||||
}\x1b[0m`,
|
||||
);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let env: Envelope;
|
||||
try {
|
||||
env = JSON.parse(typeof ev.data === "string" ? ev.data : "");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (env.type) {
|
||||
case MSG_TERMINAL_OPENED: {
|
||||
const p = env.payload as OpenedPayload;
|
||||
sessionIdRef.current = p.session_id;
|
||||
term.writeln(
|
||||
`\x1b[90mattached to ${p.shell} (cwd: ${p.work_dir})\x1b[0m`,
|
||||
);
|
||||
// Send an initial resize matching the terminal's actual size,
|
||||
// because the server-side open uses default 80x24 until we tell
|
||||
// it otherwise.
|
||||
const cols = term.cols;
|
||||
const rows = term.rows;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: MSG_TERMINAL_RESIZE,
|
||||
payload: {
|
||||
session_id: p.session_id,
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case MSG_TERMINAL_DATA: {
|
||||
const p = env.payload as DataPayload;
|
||||
if (typeof p.data_b64 !== "string") break;
|
||||
const decoded = atobToUint8(p.data_b64);
|
||||
// xterm.js accepts Uint8Array; we avoid the latin1 round-trip
|
||||
// that would otherwise mangle UTF-8 PTY output.
|
||||
term.write(decoded);
|
||||
break;
|
||||
}
|
||||
case MSG_TERMINAL_EXIT: {
|
||||
const p = env.payload as ExitPayload;
|
||||
term.writeln(
|
||||
`\r\n\x1b[90mprocess exited (code=${p.exit_code}${
|
||||
p.reason ? `, reason=${p.reason}` : ""
|
||||
})\x1b[0m`,
|
||||
);
|
||||
ws.close();
|
||||
break;
|
||||
}
|
||||
case MSG_TERMINAL_ERROR: {
|
||||
const p = env.payload as ErrorPayload;
|
||||
setErrorMessage(`${p.code}: ${p.message}`);
|
||||
term.writeln(`\r\n\x1b[31m${p.code}: ${p.message}\x1b[0m`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Forward keystrokes as terminal.data with base64 of the UTF-8 bytes.
|
||||
const dataSub = term.onData((data) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!sessionIdRef.current) return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: MSG_TERMINAL_DATA,
|
||||
payload: {
|
||||
session_id: sessionIdRef.current,
|
||||
data_b64: utf8ToBase64(data),
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const resizeSub = term.onResize(({ cols, rows }) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!sessionIdRef.current) return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: MSG_TERMINAL_RESIZE,
|
||||
payload: {
|
||||
session_id: sessionIdRef.current,
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Observe container size and re-fit so the PTY size tracks the panel
|
||||
// (the right sidebar can be resized at runtime).
|
||||
const ro = new ResizeObserver(() => {
|
||||
try {
|
||||
fit.fit();
|
||||
} catch {
|
||||
// fit() throws when the container has zero height during teardown;
|
||||
// ignore — the next mount will rebind.
|
||||
}
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
|
||||
return () => {
|
||||
dataSub.dispose();
|
||||
resizeSub.dispose();
|
||||
ro.disconnect();
|
||||
try {
|
||||
if (sessionIdRef.current && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: MSG_TERMINAL_CLOSE,
|
||||
payload: { session_id: sessionIdRef.current, reason: "panel_unmount" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// ws may be already closing; nothing to do.
|
||||
}
|
||||
ws.close();
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
fitRef.current = null;
|
||||
wsRef.current = null;
|
||||
sessionIdRef.current = "";
|
||||
};
|
||||
}, [wsUrl, reconnectKey]);
|
||||
|
||||
if (!isDesktopRuntime()) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
The terminal is only available in the Multica Desktop app. It attaches
|
||||
to the PTY hosted by the local daemon that ran the agent task.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Status: <span className="font-medium">{status}</span>
|
||||
{errorMessage ? (
|
||||
<span className="ml-2 text-destructive">— {errorMessage}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setErrorMessage("");
|
||||
setReconnectKey((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
Reconnect
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-[360px] w-full overflow-hidden rounded-md border bg-black"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveTerminalWsUrl(issueId: string, workspaceId: string): string {
|
||||
// The API client knows the http(s) base URL; flip the scheme to ws(s)
|
||||
// and target the proxy endpoint registered in router.go. Falls back to
|
||||
// the page origin if for some reason the API base is empty (dev
|
||||
// environments where the API lives on the same host).
|
||||
let base = "";
|
||||
try {
|
||||
base = getApi().getBaseUrl();
|
||||
} catch {
|
||||
base = "";
|
||||
}
|
||||
if (!base && typeof window !== "undefined") {
|
||||
base = window.location.origin;
|
||||
}
|
||||
const url = new URL(base);
|
||||
if (url.protocol === "https:") {
|
||||
url.protocol = "wss:";
|
||||
} else if (url.protocol === "http:") {
|
||||
url.protocol = "ws:";
|
||||
}
|
||||
url.pathname = url.pathname.replace(/\/$/, "") +
|
||||
`/ws/issues/${encodeURIComponent(issueId)}/terminal`;
|
||||
url.search = `?workspace_id=${encodeURIComponent(workspaceId)}&cols=120&rows=30`;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function utf8ToBase64(s: string): string {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let bin = "";
|
||||
bytes.forEach((b) => {
|
||||
bin += String.fromCharCode(b);
|
||||
});
|
||||
return btoa(bin);
|
||||
}
|
||||
// Fallback for old runtimes: assume latin1.
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
function atobToUint8(s: string): Uint8Array {
|
||||
const bin = atob(s);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
out[i] = bin.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const baseIssue: Issue = {
|
||||
parent_issue_id: PARENT_ISSUE_ID,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
labels: [],
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
|
||||
@@ -265,8 +265,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await createComment({ content, attachmentIds });
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.send_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.send_failed),
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -284,8 +288,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
parentId,
|
||||
attachmentIds,
|
||||
});
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.send_reply_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.send_reply_failed),
|
||||
);
|
||||
}
|
||||
},
|
||||
[userId, createComment, t],
|
||||
@@ -295,8 +303,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
async (commentId: string, content: string, attachmentIds?: string[]) => {
|
||||
try {
|
||||
await updateComment({ commentId, content, attachmentIds });
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.update_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.update_failed),
|
||||
);
|
||||
}
|
||||
},
|
||||
[updateComment, t],
|
||||
@@ -306,8 +318,12 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
async (commentId: string) => {
|
||||
try {
|
||||
await deleteCommentAsync(commentId);
|
||||
} catch {
|
||||
toast.error(t(($) => $.comment.delete_failed));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.comment.delete_failed),
|
||||
);
|
||||
}
|
||||
},
|
||||
[deleteCommentAsync, t],
|
||||
@@ -317,11 +333,13 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
async (commentId: string, resolved: boolean) => {
|
||||
try {
|
||||
await resolveCommentAsync({ commentId, resolved });
|
||||
} catch {
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
resolved
|
||||
? t(($) => $.comment.resolve.resolve_failed)
|
||||
: t(($) => $.comment.resolve.unresolve_failed),
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: resolved
|
||||
? t(($) => $.comment.resolve.resolve_failed)
|
||||
: t(($) => $.comment.resolve.unresolve_failed),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -30,6 +30,7 @@ function makeIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
parent_issue_id: null,
|
||||
project_id: null,
|
||||
position: 0,
|
||||
start_date: null,
|
||||
due_date: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user