mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 15:09:22 +02:00
Compare commits
56 Commits
feat/usage
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
117c7ba6ae | ||
|
|
7ac797fcd8 | ||
|
|
fd913a2596 | ||
|
|
39f43a9a98 | ||
|
|
59617f376e | ||
|
|
9a577f3e11 | ||
|
|
7be3838ada | ||
|
|
98ef021d1d | ||
|
|
6f21cb8f3e | ||
|
|
d7e58760f3 | ||
|
|
6e0f7b0f36 | ||
|
|
b5102eb3d2 | ||
|
|
e19f7967b9 | ||
|
|
ccd9e6cdfb | ||
|
|
8d30d76300 | ||
|
|
0339de54e7 | ||
|
|
c577a29c10 | ||
|
|
434003d129 | ||
|
|
93153d08b7 | ||
|
|
35fc318d68 | ||
|
|
5476e7678d | ||
|
|
e65c0889b9 | ||
|
|
8db354f721 | ||
|
|
3c510c31ed | ||
|
|
54f884ebc8 | ||
|
|
e0a6a39a47 | ||
|
|
6f5fbb7813 | ||
|
|
baedc48f59 | ||
|
|
933f417dac | ||
|
|
e6cf5a6eca | ||
|
|
d9ae891064 | ||
|
|
ffba2607aa | ||
|
|
b97cc3cb6e | ||
|
|
b58ab2cc48 | ||
|
|
eabfb8f3d1 | ||
|
|
e8d4b9a0a2 | ||
|
|
fe1ccb19c9 | ||
|
|
5f1ced867c | ||
|
|
4d8b6ddb84 | ||
|
|
692570f41a | ||
|
|
84d75cdd1e | ||
|
|
fab0671332 | ||
|
|
46c1e2c889 | ||
|
|
c78bfbcf17 | ||
|
|
1796ef6dff | ||
|
|
ceb967aefa | ||
|
|
d04b00b32e | ||
|
|
a4a18605eb | ||
|
|
dfe2a57361 | ||
|
|
6621231237 | ||
|
|
433cd1aaf5 | ||
|
|
8cc48b1176 | ||
|
|
2d501322e9 | ||
|
|
60bae62622 | ||
|
|
c328c402d8 | ||
|
|
2323b72710 |
39
.agents/skills/web-design-guidelines/SKILL.md
Normal file
39
.agents/skills/web-design-guidelines/SKILL.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: web-design-guidelines
|
||||
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
|
||||
metadata:
|
||||
author: vercel
|
||||
version: "1.0.0"
|
||||
argument-hint: <file-or-pattern>
|
||||
---
|
||||
|
||||
# Web Interface Guidelines
|
||||
|
||||
Review files for compliance with Web Interface Guidelines.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Fetch the latest guidelines from the source URL below
|
||||
2. Read the specified files (or prompt user for files/pattern)
|
||||
3. Check against all rules in the fetched guidelines
|
||||
4. Output findings in the terse `file:line` format
|
||||
|
||||
## Guidelines Source
|
||||
|
||||
Fetch fresh guidelines before each review:
|
||||
|
||||
```
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
|
||||
|
||||
## Usage
|
||||
|
||||
When a user provides a file or pattern argument:
|
||||
1. Fetch guidelines from the source URL above
|
||||
2. Read the specified files
|
||||
3. Apply all rules from the fetched guidelines
|
||||
4. Output findings using the format specified in the guidelines
|
||||
|
||||
If no files specified, ask the user which files to review.
|
||||
@@ -112,6 +112,13 @@ CLOUDFRONT_DOMAIN=
|
||||
# attribute and browsers silently drop such cookies.
|
||||
COOKIE_DOMAIN=
|
||||
|
||||
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
|
||||
# "8760h", "720h30m") or plain integer seconds.
|
||||
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
|
||||
# set a longer value to reduce re-authentication frequency.
|
||||
# Note: longer TTL = longer exposure window if a cookie is leaked.
|
||||
# AUTH_TOKEN_TTL=2592000
|
||||
|
||||
# Local file storage (fallback when S3_BUCKET is not set)
|
||||
LOCAL_UPLOAD_DIR=./data/uploads
|
||||
LOCAL_UPLOAD_BASE_URL=http://localhost:8080
|
||||
|
||||
@@ -269,21 +269,45 @@ Each profile gets its own config directory (`~/.multica/profiles/<name>/`), daem
|
||||
|
||||
## Workspaces
|
||||
|
||||
### Working with multiple workspaces
|
||||
|
||||
Every command runs against a single workspace. The CLI resolves which one in this order (highest priority first):
|
||||
|
||||
1. `--workspace-id <id>` flag on the command
|
||||
2. `MULTICA_WORKSPACE_ID` environment variable
|
||||
3. The default workspace stored in your current profile (set by `multica workspace switch` or `multica login`)
|
||||
|
||||
`multica workspace switch <id|slug>` is the day-to-day way to change the default workspace. For scripting and headless setups where you don't want any stored state, prefer the `--workspace-id` flag or the env variable. `multica config set workspace_id <id>` is the low-level equivalent of `switch` (it writes the same setting but skips the access check).
|
||||
|
||||
If you need full isolation between organizations or accounts — separate tokens, separate daemons, separate config dirs — use `--profile <name>` instead. Each profile keeps its own default workspace.
|
||||
|
||||
### List Workspaces
|
||||
|
||||
```bash
|
||||
multica workspace list
|
||||
multica workspace list --output json
|
||||
```
|
||||
|
||||
Watched workspaces are marked with `*`. The daemon only processes tasks for watched workspaces.
|
||||
The current default workspace is marked with `*`.
|
||||
|
||||
### Watch / Unwatch
|
||||
### Show Current Workspace
|
||||
|
||||
```bash
|
||||
multica workspace watch <workspace-id>
|
||||
multica workspace unwatch <workspace-id>
|
||||
multica workspace current
|
||||
multica workspace current --output json
|
||||
```
|
||||
|
||||
Prints the workspace that commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` would target.
|
||||
|
||||
### Switch Default Workspace
|
||||
|
||||
```bash
|
||||
multica workspace switch <workspace-id>
|
||||
multica workspace switch <slug>
|
||||
```
|
||||
|
||||
Verifies you have access to the workspace, then sets it as the default for the current profile. Subsequent commands without `--workspace-id` and `MULTICA_WORKSPACE_ID` target this workspace. Pair `--profile` if you want to change a non-default profile's workspace.
|
||||
|
||||
### Get Details
|
||||
|
||||
```bash
|
||||
@@ -508,6 +532,8 @@ multica config set app_url https://app.example.com
|
||||
multica config set workspace_id <workspace-id>
|
||||
```
|
||||
|
||||
`config set workspace_id <id>` is the low-level interface — it writes the value verbatim without checking that the workspace exists or that you have access. Prefer `multica workspace switch <id|slug>` for day-to-day workspace changes; it does both checks before saving.
|
||||
|
||||
## Autopilot Commands
|
||||
|
||||
Autopilots are scheduled/triggered automations that dispatch agent tasks (either by creating an issue or by running an agent directly).
|
||||
|
||||
@@ -142,6 +142,8 @@ The `multica` CLI connects your local machine to Multica — authenticate, manag
|
||||
| `multica daemon status` | Check daemon status |
|
||||
| `multica setup` | One-command setup for Multica Cloud (configure + login + start daemon) |
|
||||
| `multica setup self-host` | Same, but for self-hosted deployments |
|
||||
| `multica workspace list` | List your workspaces (current is marked with `*`) |
|
||||
| `multica workspace switch <id\|slug>` | Switch the default workspace for this profile |
|
||||
| `multica issue list` | List issues in your workspace |
|
||||
| `multica issue create` | Create a new issue |
|
||||
| `multica update` | Update to the latest version |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -62,18 +62,25 @@ function WindowOverlayInner() {
|
||||
{overlay.type === "invitations" && <InvitationsPage />}
|
||||
{overlay.type === "onboarding" && (
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
onComplete={(ws, issueId) => {
|
||||
close();
|
||||
// Post-onboarding landing is always the workspace issues
|
||||
// list. The welcome-issue flow moved into a dialog that
|
||||
// renders on that page (StarterContentPrompt), so the
|
||||
// flow doesn't need to thread a target issue id back here.
|
||||
if (ws) {
|
||||
// Runtime-connected onboarding lands on its single guide
|
||||
// issue. Runtime-less exits still land on the issues list.
|
||||
if (ws && issueId) {
|
||||
push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
push(paths.root());
|
||||
}
|
||||
}}
|
||||
// Restart the bundled daemon when the user hits Refresh on
|
||||
// Step 3. The daemon's PATH probe runs once at boot, so a
|
||||
// newly-installed CLI (Claude / Codex / Cursor) doesn't show
|
||||
// up until the daemon is bounced.
|
||||
onRuntimeRefresh={async () => {
|
||||
await window.daemonAPI?.restart?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
export function AttachmentPreviewRoute() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const filename = searchParams.get("name") ?? undefined;
|
||||
|
||||
if (!id) return null;
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { SkillDetailPage } from "./pages/skill-detail-page";
|
||||
import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { MemberDetailPage } from "./pages/member-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { DashboardPage } from "@multica/views/dashboard";
|
||||
@@ -160,6 +161,11 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "attachments/:id/preview",
|
||||
element: <AttachmentPreviewRoute />,
|
||||
handle: { title: "Attachment" },
|
||||
},
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
|
||||
@@ -22,7 +22,7 @@ Create a new autopilot on the workspace's **Autopilot** page. You set:
|
||||
|
||||
An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title currently supports a single placeholder, `{{date}}`, which interpolates to the UTC date in `YYYY-MM-DD` format; any other `{{...}}` token is rejected at create-time so a typo cannot silently land as the literal string in your issue titles), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
@@ -22,7 +22,7 @@ Autopilots 让 [智能体](/agents) **按调度自动开工**——配好 cron
|
||||
|
||||
Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题目前只支持一个占位符 `{{date}}`,会插值成 UTC 日期 `YYYY-MM-DD`;其他 `{{...}}` 形式的占位符会在创建时被拒绝,避免拼错以后悄无声息地把原文当成 issue 标题),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
@@ -70,7 +70,7 @@ If logic appears in both apps, it MUST be extracted to a shared package. There a
|
||||
|
||||
### Issue keys
|
||||
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (3 letters, uppercase) + sequence number. The prefix is set at workspace creation and is never changed afterward.
|
||||
Every issue has a human-readable key like `MUL-123`: workspace `issue_prefix` (uppercase letters and digits, typically 3 chars, max 10) + sequence number. Workspace admins can change the prefix in Settings → General; changing it renumbers every existing issue, so external references that embed the old prefix (PR titles, branch names, links in docs and chat) stop resolving.
|
||||
|
||||
### Comments in code
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ monorepo 的包边界是硬约束:
|
||||
|
||||
### Issue 编号
|
||||
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(3 个大写字母)+ 流水号。前缀在工作区创建时定,之后不可改。
|
||||
每个 issue 有人类可读的编号,比如 `MUL-123`:工作区 `issue_prefix`(大写字母和数字,通常 3 个字符,最长 10 个)+ 流水号。工作区管理员可以在 Settings → General 中修改前缀;修改会让所有现有 issue 重新编号,外部引用——PR 标题、分支名、文档与聊天里的链接——里的旧前缀会失效。
|
||||
|
||||
### 代码注释
|
||||
|
||||
|
||||
169
apps/docs/content/docs/install-agent-runtime.mdx
Normal file
169
apps/docs/content/docs/install-agent-runtime.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: Install an agent runtime
|
||||
description: Multica drives whichever AI coding tools you have on your machine. This page shows you how to install each of the 11 supported tools so the daemon can detect them.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
A **runtime** in Multica is the daemon on your machine paired with one AI coding tool the daemon found on your `PATH`. If the onboarding "Connect a runtime" step shows **No supported tools detected**, it means the daemon scanned `PATH` and didn't find any of the 11 tools it knows how to drive. Install one (or several) of the tools below, then come back to the step and re-scan — the runtime will show up within a few seconds.
|
||||
|
||||
This page is the install-side companion to:
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection works
|
||||
- [AI coding tools matrix](/providers) — what each tool can and can't do (session resumption, MCP, model selection)
|
||||
|
||||
<Callout type="info">
|
||||
The Multica server never sees your API keys or the tools themselves. Everything below — installation, authentication, model access — lives on your local machine. If something fails, it's almost always a local problem.
|
||||
</Callout>
|
||||
|
||||
## Before you start
|
||||
|
||||
Two prerequisites apply to **every** tool below:
|
||||
|
||||
1. **The Multica daemon must be running.** Either run `multica daemon start` after installing the [Multica CLI](/cli), or use the [Multica desktop app](/desktop-app), which launches the daemon automatically. Without a running daemon there is nothing to detect tools.
|
||||
2. **The tool's binary must be reachable on `PATH`.** The daemon shells out to each tool by name (see the **Daemon looks for** column in each section). If `which <name>` doesn't find it in your terminal, the daemon won't find it either. After installing, open a fresh terminal (or restart the daemon) so the new `PATH` entry is picked up.
|
||||
|
||||
After installing a tool, restart the daemon:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
Or, in the desktop app, just relaunch the app. The daemon re-scans `PATH` on every start.
|
||||
|
||||
## The 11 supported tools
|
||||
|
||||
Listed roughly from most to least common. Pick whichever ones you already have credentials for — you don't need all 11.
|
||||
|
||||
### Claude Code (Anthropic)
|
||||
|
||||
The most complete integration. Session resumption works, MCP works, and it's the **only one of the 11 that actually consumes the `mcp_config` field** on agents (see the [matrix](/providers#mcp-configuration-only-claude-code-actually-reads-it)).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `claude` |
|
||||
| Install | Follow the official guide at [claude.com/claude-code](https://www.claude.com/claude-code). The standard route is the npm package `@anthropic-ai/claude-code` (Node.js 18+ required). |
|
||||
| Authentication | Run `claude` once and follow the in-CLI login flow, or set `ANTHROPIC_API_KEY`. |
|
||||
| Notes | First-choice recommendation for new users. |
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `codex` |
|
||||
| Install | Follow the official guide at [github.com/openai/codex](https://github.com/openai/codex). The standard route is the npm package `@openai/codex`. |
|
||||
| Authentication | `codex login` (browser-based) or `OPENAI_API_KEY`. |
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
The CLI counterpart to the Cursor editor. **Session resumption is broken** — Cursor's CLI doesn't return a session id, so the value you pass on resume is always invalid.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `cursor-agent` |
|
||||
| Install | Install the [Cursor editor](https://cursor.com/) and then the CLI per their docs at [docs.cursor.com](https://docs.cursor.com/). The binary name is `cursor-agent`, not `cursor`. |
|
||||
| Authentication | Sign in through the Cursor editor; the CLI reuses that session. |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
Model routing goes through your GitHub account entitlement — the tool doesn't pick a model itself; GitHub decides which model you get.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `copilot` |
|
||||
| Install | See GitHub's CLI docs at [github.com/github/copilot-cli](https://github.com/github/copilot-cli). |
|
||||
| Authentication | Browser-based GitHub login through the CLI. |
|
||||
| Notes | Requires an active GitHub Copilot subscription on the signed-in account. |
|
||||
|
||||
### Gemini (Google)
|
||||
|
||||
Supports the Gemini 2.5 and 3 series. No session resumption, no MCP — suitable for one-shot tasks.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `gemini` |
|
||||
| Install | Follow the official guide at [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli). The standard route is the npm package `@google/gemini-cli`. |
|
||||
| Authentication | `gemini` will prompt for a Google account login, or set `GEMINI_API_KEY`. |
|
||||
|
||||
### OpenCode (SST)
|
||||
|
||||
Open-source CLI agent. Dynamically discovers available models from its own configuration file — good fit for users who want to bring their own model catalog.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `opencode` |
|
||||
| Install | Follow the official guide at [opencode.ai](https://opencode.ai/) or the GitHub repo at [github.com/sst/opencode](https://github.com/sst/opencode). The typical route is the install script or the npm package. |
|
||||
| Authentication | Configure your model provider(s) per OpenCode's docs (Anthropic, OpenAI, etc.). |
|
||||
|
||||
### Kiro CLI (Amazon)
|
||||
|
||||
ACP-over-stdio transport. Session resumption works through ACP `session/load`; skills are copied into `.kiro/skills/`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kiro-cli` |
|
||||
| Install | See the Kiro docs at [kiro.dev](https://kiro.dev/). The binary name is `kiro-cli`, not `kiro`. |
|
||||
| Authentication | AWS-account-based; follow Kiro's own onboarding. |
|
||||
|
||||
### Kimi (Moonshot)
|
||||
|
||||
ACP-protocol agent, primarily aimed at the Chinese market. Skills live under `.kimi/skills/` (native discovery).
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `kimi` |
|
||||
| Install | Follow the official guide at [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli). |
|
||||
| Authentication | Moonshot API key, configured per the vendor's docs. |
|
||||
|
||||
### Hermes (Nous Research)
|
||||
|
||||
ACP-protocol agent (shares the transport with Kimi). Session resumption works. The skill injection path falls back to the generic `.agent_context/skills/` — verify your skills are loading before relying on them.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `hermes` |
|
||||
| Install | See Nous Research's repository at [github.com/NousResearch](https://github.com/NousResearch) for the latest CLI distribution. |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
Open-source CLI agent orchestrator. **Model is bound at the agent layer** (`openclaw agents add --model`) — it can't be overridden per task, and you can't pass `--model` or `--system-prompt` from Multica.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `openclaw` |
|
||||
| Install | See the project at [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw) (community-maintained). |
|
||||
| Authentication | Configure the underlying model provider per OpenClaw's docs. |
|
||||
|
||||
### Pi (Inflection AI)
|
||||
|
||||
Minimalist. **Session resumption is unusual** — the resume id is the path to a session file on disk, not a string id.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Daemon looks for | `pi` |
|
||||
| Install | See Inflection's CLI docs at [pi.ai](https://pi.ai/). |
|
||||
| Authentication | Per the vendor's docs. |
|
||||
|
||||
## After installing
|
||||
|
||||
1. **Confirm the binary is on `PATH`.** Open a fresh terminal and run `which <name>` (for example `which claude`, `which cursor-agent`, `which kiro-cli`). If it prints a path, the daemon will find it. If it prints nothing, fix your shell `PATH` first (the typical cause is a per-shell rc file that wasn't reloaded).
|
||||
2. **Restart the daemon.** `multica daemon restart`, or relaunch the desktop app. The daemon only scans `PATH` at startup.
|
||||
3. **Check the Runtimes page.** In the Multica UI, the **Runtimes** page should now list one row per `(workspace × tool)` combination. If the row says "offline", see [Daemon and runtimes → When a runtime is marked offline](/daemon-runtimes#when-a-runtime-is-marked-offline).
|
||||
4. **Go back to onboarding.** The "Connect a runtime" step polls and will pick up the new runtime within a few seconds — no need to refresh.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`which` finds the binary but the daemon doesn't.** The daemon was started with an older `PATH`. Restart it.
|
||||
- **The binary exists but launching fails.** Run the tool's own `--version` or `--help` once from the terminal — most failures here are missing auth, expired tokens, or a Node.js / runtime mismatch.
|
||||
- **The Runtimes page shows the row, but tasks fail immediately.** Check `multica daemon logs -f` while triggering a task. The daemon surfaces the tool's own error output.
|
||||
|
||||
For broader symptoms, see the [Troubleshooting guide](/troubleshooting).
|
||||
|
||||
## Next
|
||||
|
||||
- [Daemon and runtimes](/daemon-runtimes) — how detection, heartbeats, and offline handling work
|
||||
- [AI coding tools matrix](/providers) — capability differences once a tool is connected
|
||||
- [Creating and configuring agents](/agents-create) — pick a tool for your agent and start running tasks
|
||||
169
apps/docs/content/docs/install-agent-runtime.zh.mdx
Normal file
169
apps/docs/content/docs/install-agent-runtime.zh.mdx
Normal file
@@ -0,0 +1,169 @@
|
||||
---
|
||||
title: 安装一个 Agent 运行时
|
||||
description: Multica 驱动本机上已安装的 AI 编程工具。这一页讲清楚怎么安装目前支持的 11 款工具,让守护进程能扫到。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 Multica 里,一个**运行时**(runtime)就是你机器上的守护进程,配上守护进程在 `PATH` 里扫到的某一款 AI 编程工具。如果 onboarding 的 "连接运行时" 这一步显示 **未检测到支持的工具**,说明守护进程扫了 `PATH`,但 11 款它认得的工具一个都没找到。装下面任意一款(或几款),回到这一步重新扫描,几秒内运行时就会出现。
|
||||
|
||||
这一页是装机的入口,和它配套的是:
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测是怎么工作的
|
||||
- [AI 编程工具矩阵](/zh/providers) — 每款工具的能力差异(会话续接、MCP、模型选择)
|
||||
|
||||
<Callout type="info">
|
||||
Multica 服务器从不接触你的 API key,也不接触工具本身。下面这些操作 —— 安装、登录、模型访问 —— 全部发生在你本机。出问题几乎都是本地问题。
|
||||
</Callout>
|
||||
|
||||
## 开始前
|
||||
|
||||
下面每一款工具都有两个共同前提:
|
||||
|
||||
1. **Multica 守护进程在运行。** 装完 [Multica CLI](/zh/cli) 后跑 `multica daemon start`;或者用 [Multica 桌面端](/zh/desktop-app),它启动时自动拉起守护进程。守护进程没起来,就没人去扫工具。
|
||||
2. **工具的可执行文件在 `PATH` 上。** 守护进程通过名字 shell out 调起工具(见每一节里 **守护进程扫描** 那行的命令名)。终端里 `which <名字>` 找不到,守护进程也找不到。装完后打开新终端(或者重启守护进程),让新的 `PATH` 生效。
|
||||
|
||||
装完一款工具后,重启守护进程:
|
||||
|
||||
```bash
|
||||
multica daemon restart
|
||||
```
|
||||
|
||||
桌面端的话,重启 app 即可。守护进程只在启动时扫一次 `PATH`。
|
||||
|
||||
## 11 款支持的工具
|
||||
|
||||
大致按常见程度排序。挑你已经有账号 / API key 的那几款就行 —— 不需要 11 个全装。
|
||||
|
||||
### Claude Code(Anthropic)
|
||||
|
||||
集成最完整的一款。会话续接好用,MCP 好用,而且 **11 款里只有它真正会读 agent 配置里的 `mcp_config` 字段**(见[矩阵](/zh/providers))。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `claude` |
|
||||
| 安装 | 看官方指引 [claude.com/claude-code](https://www.claude.com/claude-code)。常见装法是 npm 包 `@anthropic-ai/claude-code`(需要 Node.js 18+)。 |
|
||||
| 认证 | 跑一次 `claude`,跟着 CLI 里的登录流程走;或者设置 `ANTHROPIC_API_KEY`。 |
|
||||
| 备注 | 新用户首选。 |
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `codex` |
|
||||
| 安装 | 看官方指引 [github.com/openai/codex](https://github.com/openai/codex)。常见装法是 npm 包 `@openai/codex`。 |
|
||||
| 认证 | `codex login`(浏览器登录),或 `OPENAI_API_KEY`。 |
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `cursor-agent` |
|
||||
| 安装 | 先装 [Cursor 编辑器](https://cursor.com/),再按 [docs.cursor.com](https://docs.cursor.com/) 的说明装 CLI。可执行文件叫 `cursor-agent`,不是 `cursor`。 |
|
||||
| 认证 | 在 Cursor 编辑器里登录,CLI 复用同一份会话。 |
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
模型走的是你 GitHub 账号的 entitlement —— 工具自己不挑模型,GitHub 决定你拿到哪个模型。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `copilot` |
|
||||
| 安装 | 看 GitHub 的 CLI 文档 [github.com/github/copilot-cli](https://github.com/github/copilot-cli)。 |
|
||||
| 认证 | CLI 里走 GitHub 浏览器登录。 |
|
||||
| 备注 | 登录账号必须有有效的 GitHub Copilot 订阅。 |
|
||||
|
||||
### Gemini(Google)
|
||||
|
||||
支持 Gemini 2.5 和 3 系列。没有会话续接,没有 MCP —— 适合一次性、无需上下文记忆的任务。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `gemini` |
|
||||
| 安装 | 看官方指引 [github.com/google-gemini/gemini-cli](https://github.com/google-gemini/gemini-cli)。常见装法是 npm 包 `@google/gemini-cli`。 |
|
||||
| 认证 | 跑 `gemini` 会提示 Google 账号登录,或设置 `GEMINI_API_KEY`。 |
|
||||
|
||||
### OpenCode(SST)
|
||||
|
||||
开源 CLI agent。会从自己的配置文件里动态发现可用模型 —— 适合想自己掌控模型清单的用户。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `opencode` |
|
||||
| 安装 | 看官方指引 [opencode.ai](https://opencode.ai/) 或仓库 [github.com/sst/opencode](https://github.com/sst/opencode)。一般是装脚本或 npm 包。 |
|
||||
| 认证 | 按 OpenCode 的文档配你自己的模型供应商(Anthropic、OpenAI 等)。 |
|
||||
|
||||
### Kiro CLI(Amazon)
|
||||
|
||||
ACP-over-stdio 传输。会话续接通过 ACP `session/load` 工作;skills 拷到 `.kiro/skills/`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kiro-cli` |
|
||||
| 安装 | 看 Kiro 的文档 [kiro.dev](https://kiro.dev/)。可执行文件叫 `kiro-cli`,不是 `kiro`。 |
|
||||
| 认证 | 基于 AWS 账号,按 Kiro 自己的引导走。 |
|
||||
|
||||
### Kimi(Moonshot)
|
||||
|
||||
ACP 协议 agent,主要面向中国市场。Skills 放在 `.kimi/skills/`(原生发现路径)。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `kimi` |
|
||||
| 安装 | 看官方指引 [github.com/MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli)。 |
|
||||
| 认证 | Moonshot API key,按厂商文档配置。 |
|
||||
|
||||
### Hermes(Nous Research)
|
||||
|
||||
ACP 协议 agent(和 Kimi 共享传输层)。会话续接可用。Skill 注入用的是通用回退路径 `.agent_context/skills/` —— 用之前先验证 skills 真的被加载了。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `hermes` |
|
||||
| 安装 | 看 Nous Research 的仓库 [github.com/NousResearch](https://github.com/NousResearch) 获取最新 CLI。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
### OpenClaw
|
||||
|
||||
开源 CLI agent 编排器。**模型绑在 agent 层**(`openclaw agents add --model`)—— 不能按任务覆盖,从 Multica 也传不了 `--model` / `--system-prompt`。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `openclaw` |
|
||||
| 安装 | 看项目 [github.com/openclaw-org/openclaw](https://github.com/openclaw-org/openclaw)(社区维护)。 |
|
||||
| 认证 | 按 OpenClaw 的文档配底层模型供应商。 |
|
||||
|
||||
### Pi(Inflection AI)
|
||||
|
||||
极简风格。**会话续接的方式不太一样** —— resume id 是磁盘上的会话文件路径,不是字符串 id。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 守护进程扫描 | `pi` |
|
||||
| 安装 | 看 Inflection 的 CLI 文档 [pi.ai](https://pi.ai/)。 |
|
||||
| 认证 | 按厂商文档。 |
|
||||
|
||||
## 装完之后
|
||||
|
||||
1. **确认可执行文件在 `PATH` 上。** 开一个新终端,跑 `which <名字>`(比如 `which claude`、`which cursor-agent`、`which kiro-cli`)。打印出路径,守护进程就找得到;什么都不打印,先修 shell 的 `PATH`(最常见原因是 rc 文件没重新加载)。
|
||||
2. **重启守护进程。** `multica daemon restart`,或者重启桌面端。守护进程只在启动时扫一次 `PATH`。
|
||||
3. **看 Runtimes 页面。** Multica UI 的 **Runtimes** 页应该会出现一行 `(工作区 × 工具)`。如果显示 "offline",看[守护进程与运行时 → 运行时何时被标记为离线](/zh/daemon-runtimes#运行时何时被标记为离线)。
|
||||
4. **回到 onboarding。** "连接运行时" 这一步会一直轮询,几秒内就能扫到新运行时,不需要手动刷新。
|
||||
|
||||
## 排错
|
||||
|
||||
- **`which` 找得到,但守护进程找不到。** 守护进程是用旧 `PATH` 启的,重启它。
|
||||
- **可执行文件在,但启动就失败。** 在终端单独跑一次工具的 `--version` 或 `--help`,绝大多数失败都是登录没做、token 过期、Node.js / 运行时版本不对。
|
||||
- **Runtimes 页面看到行,但任务一跑就失败。** 一边触发任务一边跑 `multica daemon logs -f`。守护进程会把工具自己的报错原样吐出来。
|
||||
|
||||
更宽的症状看[排错指南](/zh/troubleshooting)。
|
||||
|
||||
## 接下来
|
||||
|
||||
- [守护进程与运行时](/zh/daemon-runtimes) — 检测、心跳、离线处理
|
||||
- [AI 编程工具矩阵](/zh/providers) — 工具连上之后的能力差异
|
||||
- [创建并配置智能体](/zh/agents-create) — 给你的 agent 挑一款工具,开始跑任务
|
||||
@@ -19,6 +19,7 @@
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"install-agent-runtime",
|
||||
"tasks",
|
||||
"providers",
|
||||
"---Collaborating with agents---",
|
||||
|
||||
@@ -45,6 +45,10 @@ Once it's up:
|
||||
- **Frontend**: [http://localhost:3000](http://localhost:3000)
|
||||
- **Backend**: [http://localhost:8080](http://localhost:8080)
|
||||
|
||||
<Callout type="info">
|
||||
**Ports listen on `127.0.0.1` only.** `docker-compose.selfhost.yml` binds every published port to loopback — `ss -tlnp` will not show `0.0.0.0:8080`, and the services are unreachable from other machines by design. The default `JWT_SECRET` and Postgres credentials must never sit on the open internet. For cross-machine access, front the stack with a reverse proxy that terminates TLS — see [Step 5b — Cross-machine: front with a reverse proxy](#5b-cross-machine-front-with-a-reverse-proxy).
|
||||
</Callout>
|
||||
|
||||
## 2. Important: keep production safety on
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -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. 创建智能体 + 分配第一个任务
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Three things get decided when you create a workspace:
|
||||
|
||||
- **Workspace name** — the display name members see. Spaces and non-ASCII characters are allowed. You can change it later.
|
||||
- **Slug** — the string used in the workspace URL. Lowercase letters and digits only (joined with `-`). **It cannot be changed after creation**, so pick carefully. If the slug is taken or hits a system-reserved word, the create screen will ask you to choose another.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Use uppercase letters.
|
||||
- **Issue prefix** — the prefix for every issue number in the workspace (the `MUL` in `MUL-123`). Uppercase letters and digits, up to 10 characters.
|
||||
|
||||
<Callout type="warning">
|
||||
**Avoid changing the issue prefix.** Issue numbers are rendered with the current prefix — change it and `MUL-5` instantly becomes `NEW-5`. Every external link, Slack mention, and historical reference in comments breaks against the old number. Treat the issue prefix as "set at creation, never touched."
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- **工作区名字** — 给成员看的显示名称,可以包含空格和中文。后续随时能改。
|
||||
- **Slug(短链标识符)** — 工作区 URL 中使用的字符串,只能是小写字母和数字(用 `-` 连接)。**创建后不能改**,提前想好。如果 slug 已被占用或命中系统保留词,创建界面会让你换一个。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。使用大写字母。
|
||||
- **Issue 前缀** — 工作区里所有 issue 编号的前缀(比如 `MUL-123` 里的 `MUL`)。只能是大写字母和数字,最长 10 个字符。
|
||||
|
||||
<Callout type="warning">
|
||||
**尽量不要修改 issue 前缀。** 系统在展示 issue 编号时会用当前的前缀——改了之后,`MUL-5` 会立刻变成 `NEW-5`。所有外部链接、Slack 提及、评论里的历史引用都会对不上旧编号。把 issue 前缀当成"创建后不改"的设计来对待。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
@@ -17,9 +17,9 @@ import { CliInstallInstructions, OnboardingFlow } from "@multica/views/onboardin
|
||||
* web (matching `WindowOverlay` on desktop); content is the shared
|
||||
* `<OnboardingFlow />`. Kept minimal — guard on auth, render, exit.
|
||||
*
|
||||
* On complete: if a workspace was just created, navigate into it;
|
||||
* otherwise fall back to root (proxy / landing picks the user's first ws
|
||||
* or bounces to onboarding if still zero).
|
||||
* On complete: runtime-connected onboarding may provide a guide issue id;
|
||||
* navigate there. Otherwise land on the workspace issues list, or root if
|
||||
* the flow never produced a workspace.
|
||||
*
|
||||
* `CliInstallInstructions` is passed in as the `runtimeInstructions`
|
||||
* slot so the flow can render it inside the CLI dialog. The commands it
|
||||
@@ -34,6 +34,14 @@ export default function OnboardingPage() {
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
// The bootstrap path calls refreshMe() before returning, which flips
|
||||
// hasOnboarded to true while the page is still mounted. Without this
|
||||
// flag the guard below races onComplete: the guard's router.replace
|
||||
// (issues list) can overtake onComplete's router.push (guide issue),
|
||||
// dropping the user on the wrong destination. Marking the page as
|
||||
// "completing" right before onComplete navigates keeps the guard
|
||||
// silent for the in-flight transition.
|
||||
const completingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || !user) {
|
||||
@@ -41,6 +49,7 @@ export default function OnboardingPage() {
|
||||
return;
|
||||
}
|
||||
if (!workspacesFetched) return;
|
||||
if (completingRef.current) return;
|
||||
// Bounce out only when onboarding genuinely doesn't apply: the user is
|
||||
// already onboarded. We deliberately don't bounce on `workspaces.length`
|
||||
// here — Step 3 of the flow creates a workspace mid-onboarding, and a
|
||||
@@ -62,12 +71,14 @@ export default function OnboardingPage() {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-background">
|
||||
<OnboardingFlow
|
||||
onComplete={(ws) => {
|
||||
// No more firstIssueId handoff — the welcome issue is created
|
||||
// inside the workspace via StarterContentPrompt, not during
|
||||
// onboarding. Always land on the workspace issues list (or
|
||||
// root if the flow never produced a workspace).
|
||||
if (ws) {
|
||||
onComplete={(ws, issueId) => {
|
||||
// Runtime-connected onboarding now creates one focused
|
||||
// onboarding issue. Skip/runtime-less exits still land on the
|
||||
// workspace issues list.
|
||||
completingRef.current = true;
|
||||
if (ws && issueId) {
|
||||
router.push(paths.workspace(ws.slug).issueDetail(issueId));
|
||||
} else if (ws) {
|
||||
router.push(paths.workspace(ws.slug).issues());
|
||||
} else {
|
||||
router.push(paths.root());
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AttachmentPreviewPage } from "@multica/views/attachments";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
|
||||
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
|
||||
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
|
||||
// wants the full viewport for the HTML iframe. Workspace resolution still
|
||||
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
|
||||
export default function AttachmentPreviewWebPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const search = useSearchParams();
|
||||
const filename = search.get("name") ?? undefined;
|
||||
|
||||
return (
|
||||
<ErrorBoundary resetKeys={[id]}>
|
||||
<AttachmentPreviewPage attachmentId={id} filename={filename} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -284,6 +284,33 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title:
|
||||
"Webhook Autopilots, Clearer Workboards & Better Runtime Control",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilots can now start from webhook events, show delivery history, and replay a delivery when a connected system needs another attempt",
|
||||
"Issue boards can group work by assignee, show linked pull request status, and include start dates for clearer planning",
|
||||
"Runtime pages now have a redesigned machine view plus time and task trends in usage charts",
|
||||
"Skills can be copied from local runtimes in bulk, making workspace setup faster",
|
||||
"HTML attachments and HTML code blocks can be previewed directly inside issue discussions",
|
||||
],
|
||||
improvements: [
|
||||
"Failed issue actions now show clearer error messages so teams can understand what happened without digging through logs",
|
||||
"GitHub-linked pull requests now surface CI and merge-conflict status inside Multica",
|
||||
"Self-hosted deployments get safer defaults and clearer guidance for reverse proxies, auth limits, and local-only services",
|
||||
"Search results are ranked more usefully and include better snippets",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot-created issues can repeat reliably and are attributed to the right assignee agent",
|
||||
"Runtime setup now prefers the local machine by default and uses cleaner labels in machine lists",
|
||||
"Squad pages scroll correctly and show which members are already working",
|
||||
"Desktop zoom shortcuts work again across the common keyboard combinations",
|
||||
"Auth, dependency, and local-service updates improve the safety of hosted and self-hosted deployments",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
|
||||
@@ -284,6 +284,32 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.2",
|
||||
date: "2026-05-18",
|
||||
title: "Webhook 自动任务、更清晰的工作看板与更稳的运行环境",
|
||||
changes: [],
|
||||
features: [
|
||||
"Autopilot 现在可以由 webhook 事件触发,并能查看投递记录,在外部系统需要时重新投递一次",
|
||||
"Issue 看板支持按负责人分组,展示关联 Pull Request 状态,并加入开始日期,排期更清楚",
|
||||
"Runtime 页面升级了机器视图,并在用量图表中加入时间和任务趋势",
|
||||
"Skills 支持从本地 runtime 批量复制到 workspace,团队初始化更快",
|
||||
"HTML 附件和 HTML 代码块可以直接在 Issue 讨论中预览",
|
||||
],
|
||||
improvements: [
|
||||
"Issue 操作失败时会显示更明确的错误原因,团队不用翻日志也能理解发生了什么",
|
||||
"关联 GitHub 的 Pull Request 会在 Multica 内展示 CI 和合并冲突状态",
|
||||
"自托管部署获得更安全的默认配置,并补充反向代理、登录限制和本地服务的说明",
|
||||
"搜索结果排序更准确,也会展示更有帮助的摘要片段",
|
||||
],
|
||||
fixes: [
|
||||
"Autopilot 创建 Issue 时可以稳定重复触发,并正确归属到负责的 assignee agent",
|
||||
"Runtime 设置默认优先选择本地机器,机器列表中的名称也更清晰",
|
||||
"Squad 页面可以正常滚动,并能看到成员当前是否已经在处理工作",
|
||||
"桌面端缩放快捷键在常见组合下恢复正常",
|
||||
"登录、安全补丁和本地服务配置更新,让托管版和自托管部署都更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.1",
|
||||
date: "2026-05-15",
|
||||
|
||||
@@ -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:
|
||||
@@ -88,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
|
||||
|
||||
|
||||
111
e2e/onboarding-v2-smoke.spec.ts
Normal file
111
e2e/onboarding-v2-smoke.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
|
||||
// Smoke test for Onboarding V2: verifies the new per-question flow
|
||||
// renders and captures screenshots for review. Uses a unique email
|
||||
// per run so the user is always a fresh, un-onboarded user landing
|
||||
// on /onboarding.
|
||||
|
||||
const EMAIL = `onboarding-v2-${Date.now()}@localhost`;
|
||||
const SHOTS_DIR = "/tmp/onboarding-v2-shots";
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } });
|
||||
|
||||
test("onboarding v2 — welcome → source → role → use_case (skip path)", async ({ page }) => {
|
||||
const api = new TestApiClient();
|
||||
await api.login(EMAIL, "OBv2 Tester");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// 1. Welcome screen
|
||||
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/01-welcome.png`, fullPage: false });
|
||||
|
||||
// Click Start exploring to advance to Source
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
|
||||
// 2. Source step
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 1 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
// Pick Friends/colleagues then click Continue to advance.
|
||||
await page.getByRole("radio", { name: /Friends or colleagues/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
// Skip role
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// 4. Use case step
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 3 of 6`)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/04-use-case.png` });
|
||||
|
||||
// Pick ship_code then Continue → workspace step.
|
||||
await page.getByRole("radio", { name: /Ship code with AI agents/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 5. Workspace step (legacy)
|
||||
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/05-workspace.png` });
|
||||
});
|
||||
|
||||
test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
|
||||
const api = new TestApiClient();
|
||||
await api.login(`rage-skip-${Date.now()}@localhost`, "Rage Skipper");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Skip × 3
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
await expect(page.getByText("What do you want to use Multica for?")).toBeVisible({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Skip" }).click();
|
||||
|
||||
// Lands on workspace step
|
||||
await expect(page.getByRole("heading", { name: /Name your workspace/i })).toBeVisible({ timeout: 10000 });
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/06-after-rage-skip.png` });
|
||||
});
|
||||
|
||||
test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context }) => {
|
||||
await context.addCookies([
|
||||
{ name: "multica-locale", value: "zh-Hans", url: "http://localhost:13442" },
|
||||
]);
|
||||
const api = new TestApiClient();
|
||||
await api.login(`zh-${Date.now()}@localhost`, "中文用户");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("button").first().click().catch(() => {});
|
||||
|
||||
// Source screen — Chinese question
|
||||
await expect(page.getByText("你是从哪里了解到 Multica 的?")).toBeVisible({ timeout: 10000 });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/07-source-zh.png` });
|
||||
});
|
||||
@@ -3,3 +3,7 @@ export {
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
type TranscriptSortDirection,
|
||||
} from "./transcript-view-store";
|
||||
|
||||
22
packages/core/agents/stores/transcript-view-store.test.ts
Normal file
22
packages/core/agents/stores/transcript-view-store.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useTranscriptViewStore } from "./transcript-view-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useTranscriptViewStore.setState({ sortDirection: "chronological" });
|
||||
});
|
||||
|
||||
describe("useTranscriptViewStore", () => {
|
||||
it("defaults to chronological so existing readers see no behavior change", () => {
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
|
||||
});
|
||||
|
||||
it("setSortDirection switches between the two known directions", () => {
|
||||
const { setSortDirection } = useTranscriptViewStore.getState();
|
||||
|
||||
setSortDirection("newest_first");
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("newest_first");
|
||||
|
||||
setSortDirection("chronological");
|
||||
expect(useTranscriptViewStore.getState().sortDirection).toBe("chronological");
|
||||
});
|
||||
});
|
||||
26
packages/core/agents/stores/transcript-view-store.ts
Normal file
26
packages/core/agents/stores/transcript-view-store.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type TranscriptSortDirection = "chronological" | "newest_first";
|
||||
|
||||
interface TranscriptViewState {
|
||||
sortDirection: TranscriptSortDirection;
|
||||
setSortDirection: (dir: TranscriptSortDirection) => void;
|
||||
}
|
||||
|
||||
export const useTranscriptViewStore = create<TranscriptViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
sortDirection: "chronological",
|
||||
setSortDirection: (sortDirection) => set({ sortDirection }),
|
||||
}),
|
||||
{
|
||||
name: "multica_transcript_view",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
partialize: (state) => ({ sortDirection: state.sortDirection }),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -23,6 +23,9 @@ import type {
|
||||
AgentRunCount,
|
||||
AgentRuntime,
|
||||
InboxItem,
|
||||
InboxFilterScope,
|
||||
InboxScopeCounts,
|
||||
InboxResourceAvailability,
|
||||
IssueSubscriber,
|
||||
Comment,
|
||||
Reaction,
|
||||
@@ -89,6 +92,8 @@ import type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
WebhookDelivery,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
@@ -96,6 +101,7 @@ import type {
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -119,11 +125,19 @@ 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,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
SquadMemberStatusListResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
WebhookDeliveryResponseSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -151,6 +165,38 @@ export interface LoginResponse {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface OnboardingRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
agent_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
agent_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
export interface OnboardingNoRuntimeBootstrapResponse {
|
||||
workspace_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
||||
const EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE:
|
||||
OnboardingNoRuntimeBootstrapResponse = {
|
||||
workspace_id: "",
|
||||
issue_id: "",
|
||||
};
|
||||
|
||||
// Serialize the inbox `scope` filter into a `?scope=me,my_agent` query
|
||||
// fragment. The server rejects empty arrays, so callers must skip the bulk
|
||||
// request entirely when no chip is selected (RFC v3 §E.1, mode=empty).
|
||||
function inboxScopeQuery(scope?: InboxFilterScope[] | null): string {
|
||||
if (!scope || scope.length === 0) return "";
|
||||
return `?scope=${encodeURIComponent(scope.join(","))}`;
|
||||
}
|
||||
|
||||
// --- Starter content (post-onboarding import) -----------------------------
|
||||
// Shape mirrors the Go request/response in handler/onboarding.go.
|
||||
//
|
||||
@@ -405,6 +451,43 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async bootstrapOnboardingRuntime(payload: {
|
||||
workspace_id: string;
|
||||
runtime_id: string;
|
||||
}): Promise<OnboardingRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrapOnboardingNoRuntime(payload: {
|
||||
workspace_id: string;
|
||||
}): Promise<OnboardingNoRuntimeBootstrapResponse> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
"/api/me/onboarding/no-runtime-bootstrap",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
OnboardingNoRuntimeBootstrapResponseSchema,
|
||||
EMPTY_ONBOARDING_NO_RUNTIME_BOOTSTRAP_RESPONSE,
|
||||
{ endpoint: "POST /api/me/onboarding/no-runtime-bootstrap" },
|
||||
);
|
||||
}
|
||||
|
||||
async joinCloudWaitlist(payload: {
|
||||
email: string;
|
||||
reason?: string;
|
||||
@@ -471,6 +554,7 @@ export class ApiClient {
|
||||
if (params?.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params?.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params?.project_id) search.set("project_id", params.project_id);
|
||||
if (params?.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
const path = `/api/issues?${search}`;
|
||||
const raw = await this.fetch<unknown>(path);
|
||||
@@ -491,6 +575,7 @@ export class ApiClient {
|
||||
if (params.assignee_ids?.length) search.set("assignee_ids", params.assignee_ids.join(","));
|
||||
if (params.creator_id) search.set("creator_id", params.creator_id);
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.involves_user_id) search.set("involves_user_id", params.involves_user_id);
|
||||
if (params.assignee_filters?.length) {
|
||||
search.set("assignee_filters", params.assignee_filters.map((f) => `${f.type}:${f.id}`).join(","));
|
||||
}
|
||||
@@ -847,12 +932,11 @@ export class ApiClient {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getDashboardUsageDaily(
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||||
return parseWithFallback<DashboardUsageDaily[]>(
|
||||
raw,
|
||||
@@ -863,12 +947,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardUsageByAgent(
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||||
raw,
|
||||
@@ -879,12 +962,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardAgentRunTime(
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardAgentRunTime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||||
raw,
|
||||
@@ -895,12 +977,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
async getDashboardRunTimeDaily(
|
||||
params: { days?: number; project_id?: string | null; squad_id?: string | null },
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardRunTimeDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
if (params.squad_id) search.set("squad_id", params.squad_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/runtime/daily?${search}`);
|
||||
return parseWithFallback<DashboardRunTimeDaily[]>(
|
||||
raw,
|
||||
@@ -1025,8 +1106,8 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// Inbox
|
||||
async listInbox(): Promise<InboxItem[]> {
|
||||
return this.fetch("/api/inbox");
|
||||
async listInbox(scope?: InboxFilterScope[]): Promise<InboxItem[]> {
|
||||
return this.fetch(`/api/inbox${inboxScopeQuery(scope)}`);
|
||||
}
|
||||
|
||||
async markInboxRead(id: string): Promise<InboxItem> {
|
||||
@@ -1041,20 +1122,28 @@ export class ApiClient {
|
||||
return this.fetch("/api/inbox/unread-count");
|
||||
}
|
||||
|
||||
async markAllInboxRead(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/mark-all-read", { method: "POST" });
|
||||
async getInboxScopeCounts(): Promise<InboxScopeCounts> {
|
||||
return this.fetch("/api/inbox/scope-counts");
|
||||
}
|
||||
|
||||
async archiveAllInbox(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/archive-all", { method: "POST" });
|
||||
async getInboxResourceAvailability(): Promise<InboxResourceAvailability> {
|
||||
return this.fetch("/api/inbox/resource-availability");
|
||||
}
|
||||
|
||||
async archiveAllReadInbox(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/archive-all-read", { method: "POST" });
|
||||
async markAllInboxRead(scope?: InboxFilterScope[]): Promise<{ count: number }> {
|
||||
return this.fetch(`/api/inbox/mark-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveCompletedInbox(): Promise<{ count: number }> {
|
||||
return this.fetch("/api/inbox/archive-completed", { method: "POST" });
|
||||
async archiveAllInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
|
||||
return this.fetch(`/api/inbox/archive-all${inboxScopeQuery(scope)}`, { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveAllReadInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
|
||||
return this.fetch(`/api/inbox/archive-all-read${inboxScopeQuery(scope)}`, { method: "POST" });
|
||||
}
|
||||
|
||||
async archiveCompletedInbox(scope?: InboxFilterScope[]): Promise<{ count: number }> {
|
||||
return this.fetch(`/api/inbox/archive-completed${inboxScopeQuery(scope)}`, { method: "POST" });
|
||||
}
|
||||
|
||||
// Notification preferences
|
||||
@@ -1097,7 +1186,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[] }): Promise<Workspace> {
|
||||
async updateWorkspace(id: string, data: { name?: string; description?: string; context?: string; settings?: Record<string, unknown>; repos?: WorkspaceRepo[]; issue_prefix?: string }): Promise<Workspace> {
|
||||
return this.fetch(`/api/workspaces/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
@@ -1542,6 +1631,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();
|
||||
@@ -1617,6 +1717,64 @@ export class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
// 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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,9 @@ import type {
|
||||
CreateAgentFromTemplateResponse,
|
||||
GroupedIssuesResponse,
|
||||
ListIssuesResponse,
|
||||
ListWebhookDeliveriesResponse,
|
||||
TimelineEntry,
|
||||
WebhookDelivery,
|
||||
} from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,6 +198,17 @@ export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
agent_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const OnboardingNoRuntimeBootstrapResponseSchema = z.object({
|
||||
workspace_id: z.string(),
|
||||
issue_id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
@@ -334,6 +347,31 @@ export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateR
|
||||
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.
|
||||
//
|
||||
@@ -375,3 +413,73 @@ export interface DuplicateIssueErrorBody {
|
||||
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,
|
||||
@@ -8,5 +15,6 @@ export {
|
||||
useUpdateAutopilotTrigger,
|
||||
useDeleteAutopilotTrigger,
|
||||
useRotateAutopilotTriggerWebhookToken,
|
||||
useReplayAutopilotDelivery,
|
||||
} from "./mutations";
|
||||
export { buildAutopilotWebhookUrl } from "./webhook";
|
||||
|
||||
@@ -140,3 +140,20 @@ export function useRotateAutopilotTriggerWebhookToken() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ export const autopilotKeys = {
|
||||
[...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) {
|
||||
@@ -51,3 +55,35 @@ export function autopilotRunOptions(
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,74 +1,45 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
// Workspace dashboard query options. All four endpoints share the same
|
||||
// (wsId, days, projectId, squadId) key shape so workspace switching,
|
||||
// time-range changes, the project filter, and the squad filter each
|
||||
// invalidate the cache cleanly.
|
||||
// Workspace dashboard query options. All three endpoints share the same
|
||||
// (wsId, days, projectId) key shape so workspace switching, time-range
|
||||
// changes, and the project filter each invalidate the cache cleanly.
|
||||
//
|
||||
// `projectId` and `squadId` are normalised to `null` (not undefined /
|
||||
// "all") so the queryKey shape is stable across renders when either
|
||||
// dropdown sits on its "all" sentinel.
|
||||
// The cache key includes `wsId` explicitly: TanStack Query already isolates
|
||||
// per workspace via the key, but threading wsId into the queryFn lets
|
||||
// callers fail fast (return [] on empty wsId) instead of issuing a request
|
||||
// the server would reject.
|
||||
//
|
||||
// `projectId` is normalised to `null` (not undefined / "all") so the
|
||||
// queryKey shape is stable across renders even when the dropdown sits on
|
||||
// "all projects".
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: (wsId: string) => ["dashboard", wsId] as const,
|
||||
daily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) => [...dashboardKeys.all(wsId), "daily", days, projectId, squadId] as const,
|
||||
byAgent: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId, squadId] as const,
|
||||
agentRuntime: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) =>
|
||||
[
|
||||
...dashboardKeys.all(wsId),
|
||||
"agent-runtime",
|
||||
days,
|
||||
projectId,
|
||||
squadId,
|
||||
] as const,
|
||||
runTimeDaily: (
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) =>
|
||||
[
|
||||
...dashboardKeys.all(wsId),
|
||||
"runtime-daily",
|
||||
days,
|
||||
projectId,
|
||||
squadId,
|
||||
] as const,
|
||||
daily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
|
||||
byAgent: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
runTimeDaily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "runtime-daily", days, projectId] as const,
|
||||
};
|
||||
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
// driven on the server (5-min rollup cadence) and the dashboard isn't a
|
||||
// real-time view, so background refetches every minute are plenty.
|
||||
const STALE_TIME = 60 * 1000;
|
||||
|
||||
export function dashboardUsageDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId, squadId),
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -78,16 +49,11 @@ export function dashboardUsageByAgentOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId, squadId),
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageByAgent({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -97,16 +63,11 @@ export function dashboardAgentRunTimeOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId, squadId),
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardAgentRunTime({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
@@ -116,16 +77,11 @@ export function dashboardRunTimeDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
squadId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId, squadId),
|
||||
queryKey: dashboardKeys.runTimeDaily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardRunTimeDaily({
|
||||
days,
|
||||
project_id: projectId ?? undefined,
|
||||
squad_id: squadId ?? undefined,
|
||||
}),
|
||||
api.getDashboardRunTimeDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./queries";
|
||||
export * from "./mutations";
|
||||
export * from "./ws-updaters";
|
||||
export * from "./stores";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import { inboxKeys } from "./queries";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import type { InboxItem } from "../types";
|
||||
import type { InboxItem, InboxFilterScope } from "../types";
|
||||
|
||||
export function useMarkInboxRead() {
|
||||
const qc = useQueryClient();
|
||||
@@ -22,6 +22,7 @@ export function useMarkInboxRead() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -51,21 +52,27 @@ export function useArchiveInbox() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// All bulk mutations accept an optional `scope` parameter. When the caller
|
||||
// is in mode=all (RFC v3 §E.1) it should pass undefined; when in mode=subset
|
||||
// it should pass the resolved chip subset; in mode=empty the button is
|
||||
// disabled and these mutations should not fire.
|
||||
export function useMarkAllInboxRead() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.markAllInboxRead(),
|
||||
onMutate: async () => {
|
||||
mutationFn: (scope?: InboxFilterScope[]) => api.markAllInboxRead(scope),
|
||||
onMutate: async (scope) => {
|
||||
await qc.cancelQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
const prev = qc.getQueryData<InboxItem[]>(inboxKeys.list(wsId));
|
||||
const inScope = scopeMatcher(scope);
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.map((item) =>
|
||||
!item.archived ? { ...item, read: true } : item,
|
||||
!item.archived && inScope(item) ? { ...item, read: true } : item,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
@@ -75,6 +82,7 @@ export function useMarkAllInboxRead() {
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -83,9 +91,10 @@ export function useArchiveAllInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.archiveAllInbox(),
|
||||
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllInbox(scope),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -94,9 +103,10 @@ export function useArchiveAllReadInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.archiveAllReadInbox(),
|
||||
mutationFn: (scope?: InboxFilterScope[]) => api.archiveAllReadInbox(scope),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -105,9 +115,21 @@ export function useArchiveCompletedInbox() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
return useMutation({
|
||||
mutationFn: () => api.archiveCompletedInbox(),
|
||||
mutationFn: (scope?: InboxFilterScope[]) => api.archiveCompletedInbox(scope),
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// True when the inbox item belongs to the user-selected scope subset, or
|
||||
// when no scope was passed (= mark/archive everything).
|
||||
function scopeMatcher(scope?: InboxFilterScope[]) {
|
||||
if (!scope || scope.length === 0) return (_item: InboxItem) => true;
|
||||
const set = new Set(scope);
|
||||
return (item: InboxItem) => {
|
||||
const s = item.assignee_scope;
|
||||
return s != null && (set as Set<string>).has(s);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,49 @@
|
||||
import { queryOptions, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { InboxItem } from "../types";
|
||||
import type {
|
||||
InboxItem,
|
||||
InboxFilterScope,
|
||||
InboxScopeCounts,
|
||||
InboxResourceAvailability,
|
||||
} from "../types";
|
||||
|
||||
export const inboxKeys = {
|
||||
all: (wsId: string) => ["inbox", wsId] as const,
|
||||
// The list key is intentionally a single key per workspace — the scope
|
||||
// filter is applied client-side on top of the full cached list (RFC v3
|
||||
// §E selector), so we don't fragment the cache by scope. When the user
|
||||
// changes chips we just re-derive from the same query.
|
||||
list: (wsId: string) => [...inboxKeys.all(wsId), "list"] as const,
|
||||
scopeCounts: (wsId: string) =>
|
||||
[...inboxKeys.all(wsId), "scope-counts"] as const,
|
||||
resourceAvailability: (wsId: string) =>
|
||||
[...inboxKeys.all(wsId), "resource-availability"] as const,
|
||||
};
|
||||
|
||||
export function inboxListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.list(wsId),
|
||||
// Always fetch the full list (no scope param). The chip filter runs in
|
||||
// the selector — that way the badge counts and the dedupe logic always
|
||||
// operate on the complete picture, and toggling a chip is instant.
|
||||
queryFn: () => api.listInbox(),
|
||||
});
|
||||
}
|
||||
|
||||
export function inboxScopeCountsOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.scopeCounts(wsId),
|
||||
queryFn: () => api.getInboxScopeCounts(),
|
||||
});
|
||||
}
|
||||
|
||||
export function inboxResourceAvailabilityOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: inboxKeys.resourceAvailability(wsId),
|
||||
queryFn: () => api.getInboxResourceAvailability(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread inbox count for the given workspace, aligned with what the inbox
|
||||
* list UI renders: archived items excluded, then deduplicated by issue so a
|
||||
@@ -57,3 +87,29 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a deduplicated inbox list to the user-selected chips. Applies the
|
||||
* RFC v3 §E selector rules: a strict subset of {me, my_agent, my_squad}
|
||||
* keeps only items tagged with one of those scopes (other/none are dropped);
|
||||
* a null filter (= "all" mode) passes everything through unchanged.
|
||||
*
|
||||
* `null` is the no-op signal. Pass `null` whenever you don't want to filter,
|
||||
* including the empty-mode case where the caller is also expected to render
|
||||
* an empty state instead of calling this.
|
||||
*/
|
||||
export function filterInboxByScope(
|
||||
items: InboxItem[],
|
||||
scopes: InboxFilterScope[] | null,
|
||||
): InboxItem[] {
|
||||
if (!scopes) return items;
|
||||
const set = new Set(scopes);
|
||||
return items.filter((i) => {
|
||||
const s = i.assignee_scope;
|
||||
return s != null && (set as Set<string>).has(s);
|
||||
});
|
||||
}
|
||||
|
||||
// Re-exports — kept for backwards compatibility with code importing the
|
||||
// inbox scope-count / availability response shapes from this module.
|
||||
export type { InboxScopeCounts, InboxResourceAvailability };
|
||||
|
||||
83
packages/core/inbox/stores/inbox-scope-store.ts
Normal file
83
packages/core/inbox/stores/inbox-scope-store.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
import type { InboxFilterScope } from "../../types";
|
||||
|
||||
// All three assignment chips, in stable display order. Used both for the
|
||||
// "default = all selected" initial state and for callers that need to render
|
||||
// chips deterministically.
|
||||
export const INBOX_FILTER_SCOPES: readonly InboxFilterScope[] = [
|
||||
"me",
|
||||
"my_agent",
|
||||
"my_squad",
|
||||
] as const;
|
||||
|
||||
interface InboxScopeState {
|
||||
// Persisted selection. The default is the full set so a freshly installed
|
||||
// app shows every notification — see RFC v3 §E.1 mode=all.
|
||||
selected: InboxFilterScope[];
|
||||
toggle: (scope: InboxFilterScope) => void;
|
||||
set: (scopes: InboxFilterScope[]) => void;
|
||||
selectAll: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useInboxScopeStore = create<InboxScopeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
selected: [...INBOX_FILTER_SCOPES],
|
||||
toggle: (scope) =>
|
||||
set((state) => ({
|
||||
selected: state.selected.includes(scope)
|
||||
? state.selected.filter((s) => s !== scope)
|
||||
: [...state.selected, scope],
|
||||
})),
|
||||
set: (scopes) => set({ selected: scopes }),
|
||||
selectAll: () => set({ selected: [...INBOX_FILTER_SCOPES] }),
|
||||
clear: () => set({ selected: [] }),
|
||||
}),
|
||||
{
|
||||
name: "multica_inbox_scope",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useInboxScopeStore.persist.rehydrate());
|
||||
|
||||
// Resolved filter mode. Matches the three-state algorithm in RFC v3 §E.1:
|
||||
// - all: 3 selected → no `scope` is sent; selector keeps me/my_agent/my_squad/other/none
|
||||
// - subset: 1-2 selected → `scope=...` is sent; selector filters to the subset
|
||||
// - empty: 0 selected → don't request; show empty state, bulk disabled
|
||||
export type InboxFilterMode = "all" | "subset" | "empty";
|
||||
|
||||
export interface InboxFilterResolution {
|
||||
mode: InboxFilterMode;
|
||||
// Scopes to send on the wire. `null` for mode="all" (omit param entirely),
|
||||
// a string[] for mode="subset", `[]` for mode="empty".
|
||||
scopes: InboxFilterScope[] | null;
|
||||
}
|
||||
|
||||
export function resolveInboxFilter(
|
||||
selected: InboxFilterScope[],
|
||||
): InboxFilterResolution {
|
||||
// Dedupe + restrict to the three valid chip values. "other" / "none" are
|
||||
// server-internal buckets and must never appear on the wire.
|
||||
const unique = new Set<InboxFilterScope>();
|
||||
for (const s of selected) {
|
||||
if (s === "me" || s === "my_agent" || s === "my_squad") unique.add(s);
|
||||
}
|
||||
if (unique.size === INBOX_FILTER_SCOPES.length) {
|
||||
return { mode: "all", scopes: null };
|
||||
}
|
||||
if (unique.size === 0) {
|
||||
return { mode: "empty", scopes: [] };
|
||||
}
|
||||
return {
|
||||
mode: "subset",
|
||||
scopes: INBOX_FILTER_SCOPES.filter((s) => unique.has(s)),
|
||||
};
|
||||
}
|
||||
7
packages/core/inbox/stores/index.ts
Normal file
7
packages/core/inbox/stores/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
useInboxScopeStore,
|
||||
resolveInboxFilter,
|
||||
INBOX_FILTER_SCOPES,
|
||||
type InboxFilterMode,
|
||||
type InboxFilterResolution,
|
||||
} from "./inbox-scope-store";
|
||||
@@ -10,6 +10,19 @@ export function onInboxNew(
|
||||
// Use invalidateQueries instead of setQueryData — triggers a refetch that
|
||||
// reliably notifies all observers. The inbox list is small so this is cheap.
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
}
|
||||
|
||||
// `inbox:batch-read` and `inbox:batch-archived` are emitted when the user
|
||||
// runs a bulk endpoint (mark-all-read / archive-*). They can carry a `scope`
|
||||
// filter (RFC v3 §C.5) and `inbox:batch-archived` additionally carries an
|
||||
// `operation` (RFC v4 §1). We currently fall back to a generic invalidate
|
||||
// for both — precise cache updates per operation+scope are a documented
|
||||
// follow-up: the payload contract is already in place, so the optimization
|
||||
// is a frontend-only change later.
|
||||
export function onInboxBatch(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
}
|
||||
|
||||
export function onInboxIssueStatusChanged(
|
||||
@@ -27,7 +40,9 @@ export function onInboxIssueStatusChanged(
|
||||
|
||||
// Mirrors the DB-level ON DELETE CASCADE on inbox_item.issue_id: when an issue
|
||||
// is deleted, all inbox items that referenced it are gone server-side, so drop
|
||||
// them from the cache too.
|
||||
// them from the cache too. Scope counts shift in lockstep with the pruned
|
||||
// rows, so invalidate them here as well — otherwise the chip badge keeps
|
||||
// counting an issue that no longer exists.
|
||||
export function onInboxIssueDeleted(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
@@ -36,8 +51,14 @@ export function onInboxIssueDeleted(
|
||||
qc.setQueryData<InboxItem[]>(inboxKeys.list(wsId), (old) =>
|
||||
old?.filter((i) => i.issue_id !== issueId),
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
}
|
||||
|
||||
// Generic single-item inbox invalidation (e.g. `inbox:archived`,
|
||||
// `inbox:read`). The chip badge is derived from the same rows that just
|
||||
// changed, so it has to be re-fetched alongside the list — otherwise the
|
||||
// badge stays at the pre-change value until a hard refresh.
|
||||
export function onInboxInvalidate(qc: QueryClient, wsId: string) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "../api";
|
||||
import {
|
||||
issueKeys,
|
||||
ISSUE_PAGE_SIZE,
|
||||
PAGINATED_STATUSES,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "./queries";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
pruneDeletedIssueFromParentChildrenCaches,
|
||||
} from "./delete-cache";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { GroupedIssuesResponse, Issue, IssueAssigneeGroup, IssueReaction, IssueStatus } from "../types";
|
||||
import type {
|
||||
@@ -103,6 +105,75 @@ export function useLoadMoreByStatus(
|
||||
return { loadMore, hasMore, isLoading, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain every remaining paginated page across all statuses into the cache.
|
||||
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
|
||||
* view) and need the full project issue set up-front. Each iteration appends
|
||||
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
|
||||
* until the cache totals match the server.
|
||||
*/
|
||||
export function useLoadAllRemaining(
|
||||
myIssues?: { scope: string; filter: MyIssuesFilter },
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const queryKey = myIssues
|
||||
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
|
||||
: issueKeys.list(wsId);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
if (isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Round-trip the cache rather than caching `loaded` locally so a
|
||||
// concurrent WS-driven update or another loadMore can't make us
|
||||
// re-fetch an already-loaded page.
|
||||
for (;;) {
|
||||
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
|
||||
if (!cache) return;
|
||||
const pending = PAGINATED_STATUSES.filter((status) => {
|
||||
const bucket = cache.byStatus[status];
|
||||
if (!bucket) return false;
|
||||
return bucket.issues.length < bucket.total;
|
||||
});
|
||||
if (pending.length === 0) return;
|
||||
const results = await Promise.all(
|
||||
pending.map((status) =>
|
||||
api
|
||||
.listIssues({
|
||||
status,
|
||||
limit: ISSUE_PAGE_SIZE,
|
||||
offset: cache.byStatus[status]!.issues.length,
|
||||
...myIssues?.filter,
|
||||
})
|
||||
.then((res) => ({ status, res })),
|
||||
),
|
||||
);
|
||||
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const { status, res } of results) {
|
||||
const prev = getBucket(next, status);
|
||||
const existingIds = new Set(prev.issues.map((i) => i.id));
|
||||
const appended = res.issues.filter((i) => !existingIds.has(i.id));
|
||||
next = setBucket(next, status, {
|
||||
issues: [...prev.issues, ...appended],
|
||||
total: res.total,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, qc, queryKey, myIssues?.filter]);
|
||||
|
||||
return { loadAll, isLoading };
|
||||
}
|
||||
|
||||
export function useLoadMoreByAssigneeGroup(
|
||||
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
|
||||
queryKey: QueryKey,
|
||||
@@ -257,6 +328,20 @@ export function useUpdateIssue() {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
// Inbox rows carry a server-computed `assignee_scope` derived from
|
||||
// the issue's assignee. Re-assigning the issue (member ↔ agent ↔
|
||||
// squad ↔ none) shifts the row's chip bucket and the scope-count
|
||||
// badge, so flush both whenever this mutation touched assignment.
|
||||
// The WS handler also invalidates on the broadcast issue:updated;
|
||||
// doing it here too lets the originating tab refresh without
|
||||
// round-tripping through the server.
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(vars, "assignee_id") ||
|
||||
Object.prototype.hasOwnProperty.call(vars, "assignee_type")
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
}
|
||||
// Refresh the issue's attachments cache when the description editor
|
||||
// bound new uploads — the description editor reads `issueAttachments`
|
||||
// to resolve text-preview Eye gates, and unlike other mutations this
|
||||
@@ -395,10 +480,19 @@ export function useBatchUpdateIssues() {
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
onSettled: (_data, _err, vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
||||
// Bulk reassignments shift `assignee_scope` across N rows — same
|
||||
// reasoning as useUpdateIssue.
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_id") ||
|
||||
Object.prototype.hasOwnProperty.call(vars.updates, "assignee_type")
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.scopeCounts(wsId) });
|
||||
}
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
|
||||
@@ -55,7 +55,7 @@ export const issueKeys = {
|
||||
|
||||
export type MyIssuesFilter = Pick<
|
||||
ListIssuesParams,
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id"
|
||||
"assignee_id" | "assignee_ids" | "creator_id" | "project_id" | "involves_user_id"
|
||||
>;
|
||||
|
||||
export type AssigneeGroupedIssuesFilter = Omit<
|
||||
@@ -79,6 +79,34 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface IssueListPagination {
|
||||
loaded: number;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
|
||||
* Gantt view, which doesn't have a per-status load-more affordance) can tell
|
||||
* whether the cache is missing pages and warn the user instead of silently
|
||||
* rendering an incomplete schedule.
|
||||
*/
|
||||
export function summarizeIssueListPagination(
|
||||
data: ListIssuesCache | undefined,
|
||||
): IssueListPagination {
|
||||
if (!data) return { loaded: 0, total: 0, hasMore: false };
|
||||
let loaded = 0;
|
||||
let total = 0;
|
||||
for (const status of PAGINATED_STATUSES) {
|
||||
const bucket = data.byStatus[status];
|
||||
if (bucket) {
|
||||
loaded += bucket.issues.length;
|
||||
total += bucket.total;
|
||||
}
|
||||
}
|
||||
return { loaded, total, hasMore: loaded < total };
|
||||
}
|
||||
|
||||
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
|
||||
const responses = await Promise.all(
|
||||
PAGINATED_STATUSES.map((status) =>
|
||||
@@ -142,6 +170,24 @@ export function myIssueListOptions(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
|
||||
* TanStack Query dedupes), but `select` derives a pagination summary instead
|
||||
* of the flat issue list. Use this alongside the list query when a consumer
|
||||
* needs to know how many issues live behind unfetched pages.
|
||||
*/
|
||||
export function myIssueListPaginationOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
filter: MyIssuesFilter,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.myList(wsId, scope, filter),
|
||||
queryFn: () => fetchFirstPages(filter),
|
||||
select: summarizeIssueListPagination,
|
||||
});
|
||||
}
|
||||
|
||||
export function myIssueAssigneeGroupsOptions(
|
||||
wsId: string,
|
||||
scope: string,
|
||||
|
||||
@@ -23,6 +23,8 @@ const _actorIssuesViewStore = createStore<ActorIssuesViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...viewStoreSlice(set as unknown as StoreApi<IssueViewState>["setState"]),
|
||||
// Actor tasks panel is list-only; override the slice's "board" default.
|
||||
viewMode: "list",
|
||||
scope: "assigned" as ActorIssuesScope,
|
||||
setScope: (scope: ActorIssuesScope) => set({ scope }),
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ALL_STATUSES } from "../config";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type ViewMode = "board" | "list";
|
||||
export type ViewMode = "board" | "list" | "gantt";
|
||||
export type GanttZoom = "day" | "week" | "month";
|
||||
export type IssueGrouping = "status" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
@@ -70,7 +71,11 @@ export interface IssueViewState {
|
||||
sortDirection: SortDirection;
|
||||
cardProperties: CardProperties;
|
||||
listCollapsedStatuses: IssueStatus[];
|
||||
ganttZoom: GanttZoom;
|
||||
ganttShowCompleted: boolean;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
setGanttZoom: (zoom: GanttZoom) => void;
|
||||
toggleGanttShowCompleted: () => void;
|
||||
setGrouping: (grouping: IssueGrouping) => void;
|
||||
toggleStatusFilter: (status: IssueStatus) => void;
|
||||
togglePriorityFilter: (priority: IssuePriority) => void;
|
||||
@@ -113,8 +118,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
labels: true,
|
||||
},
|
||||
listCollapsedStatuses: [],
|
||||
ganttZoom: "week",
|
||||
ganttShowCompleted: false,
|
||||
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
|
||||
toggleGanttShowCompleted: () =>
|
||||
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
|
||||
setGrouping: (grouping) => set({ grouping }),
|
||||
toggleStatusFilter: (status) =>
|
||||
set((state) => ({
|
||||
@@ -232,6 +242,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
sortDirection: state.sortDirection,
|
||||
cardProperties: state.cardProperties,
|
||||
listCollapsedStatuses: state.listCollapsedStatuses,
|
||||
ganttZoom: state.ganttZoom,
|
||||
ganttShowCompleted: state.ganttShowCompleted,
|
||||
}),
|
||||
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
|
||||
// saved before a new toggle was introduced wins entirely and the new key is
|
||||
|
||||
@@ -2,13 +2,15 @@ export type {
|
||||
OnboardingStep,
|
||||
OnboardingCompletionPath,
|
||||
QuestionnaireAnswers,
|
||||
TeamSize,
|
||||
Source,
|
||||
Role,
|
||||
UseCase,
|
||||
} from "./types";
|
||||
export {
|
||||
saveQuestionnaire,
|
||||
completeOnboarding,
|
||||
bootstrapRuntimeOnboarding,
|
||||
bootstrapNoRuntimeOnboarding,
|
||||
joinCloudWaitlist,
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
|
||||
@@ -3,113 +3,145 @@ import { recommendTemplate } from "./recommend-template";
|
||||
import type { Role, UseCase } from "./types";
|
||||
|
||||
const ALL_USE_CASES: UseCase[] = [
|
||||
"coding",
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"ship_code",
|
||||
"manage_team",
|
||||
"personal_tasks",
|
||||
"plan_research",
|
||||
"write_publish",
|
||||
"automate_ops",
|
||||
"evaluate",
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate", () => {
|
||||
describe("identity fallbacks — role alone decides", () => {
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=other (use_case=%s) → assistant",
|
||||
const ALL_ROLES: Role[] = [
|
||||
"engineer",
|
||||
"product",
|
||||
"designer",
|
||||
"founder",
|
||||
"marketing",
|
||||
"writer",
|
||||
"research",
|
||||
"ops",
|
||||
"student",
|
||||
"other",
|
||||
];
|
||||
|
||||
describe("recommendTemplate (v2)", () => {
|
||||
describe("engineer × use_case tiebreaker", () => {
|
||||
it.each<UseCase>(["manage_team", "plan_research"])(
|
||||
"engineer × %s → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "other", use_case })).toBe(
|
||||
"assistant",
|
||||
expect(recommendTemplate({ role: "engineer", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
},
|
||||
);
|
||||
it("engineer × write_publish → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "engineer", use_case: "write_publish" }),
|
||||
).toBe("writing");
|
||||
});
|
||||
it.each<UseCase>([
|
||||
"ship_code",
|
||||
"personal_tasks",
|
||||
"automate_ops",
|
||||
"evaluate",
|
||||
"other",
|
||||
])("engineer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case })).toBe("coding");
|
||||
});
|
||||
it("engineer × null → coding", () => {
|
||||
expect(recommendTemplate({ role: "engineer", use_case: null })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=founder (use_case=%s) → assistant",
|
||||
describe("product × use_case", () => {
|
||||
it("product × ship_code → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product", use_case: "ship_code" }),
|
||||
).toBe("coding");
|
||||
});
|
||||
it.each<UseCase>(["manage_team", "plan_research", "evaluate", "other"])(
|
||||
"product × %s → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "founder", use_case })).toBe(
|
||||
"assistant",
|
||||
expect(recommendTemplate({ role: "product", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
},
|
||||
);
|
||||
it("product × null → planning", () => {
|
||||
expect(recommendTemplate({ role: "product", use_case: null })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(ALL_USE_CASES)(
|
||||
"role=writer (use_case=%s) → writing",
|
||||
describe("marketing × use_case", () => {
|
||||
it.each<UseCase>(["write_publish", "plan_research"])(
|
||||
"marketing × %s → writing",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe(
|
||||
expect(recommendTemplate({ role: "marketing", use_case })).toBe(
|
||||
"writing",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("developer × use_case tiebreaker", () => {
|
||||
it("developer × planning → planning", () => {
|
||||
it("marketing × manage_team → planning", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: "planning" }),
|
||||
recommendTemplate({ role: "marketing", use_case: "manage_team" }),
|
||||
).toBe("planning");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"coding",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("developer × %s → coding", (use_case) => {
|
||||
expect(recommendTemplate({ role: "developer", use_case })).toBe(
|
||||
"coding",
|
||||
);
|
||||
});
|
||||
|
||||
it("developer × null use_case → coding (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "developer", use_case: null }),
|
||||
).toBe("coding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("product_lead × use_case tiebreaker", () => {
|
||||
it("product_lead × coding → coding", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: "coding" }),
|
||||
).toBe("coding");
|
||||
describe("single-template roles", () => {
|
||||
it.each(ALL_USE_CASES)("writer × %s → writing", (use_case) => {
|
||||
expect(recommendTemplate({ role: "writer", use_case })).toBe("writing");
|
||||
});
|
||||
|
||||
it.each<UseCase>([
|
||||
"planning",
|
||||
"writing_research",
|
||||
"explore",
|
||||
"other",
|
||||
])("product_lead × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "product_lead", use_case })).toBe(
|
||||
it.each(ALL_USE_CASES)("designer × %s → assistant", (use_case) => {
|
||||
expect(recommendTemplate({ role: "designer", use_case })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
});
|
||||
it.each(ALL_USE_CASES)("research × %s → planning", (use_case) => {
|
||||
expect(recommendTemplate({ role: "research", use_case })).toBe(
|
||||
"planning",
|
||||
);
|
||||
});
|
||||
|
||||
it("product_lead × null use_case → planning (default)", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: "product_lead", use_case: null }),
|
||||
).toBe("planning");
|
||||
});
|
||||
it.each<Role>(["founder", "ops", "student", "other"])(
|
||||
"%s → assistant",
|
||||
(role) => {
|
||||
expect(recommendTemplate({ role, use_case: null })).toBe("assistant");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("unanswered questionnaire", () => {
|
||||
it("null role → assistant regardless of use_case", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
describe("role skipped — use_case fallback", () => {
|
||||
it("null × ship_code → coding", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: "ship_code" })).toBe(
|
||||
"coding",
|
||||
);
|
||||
expect(recommendTemplate({ role: null, use_case: "coding" })).toBe(
|
||||
});
|
||||
it("null × write_publish → writing", () => {
|
||||
expect(
|
||||
recommendTemplate({ role: null, use_case: "write_publish" }),
|
||||
).toBe("writing");
|
||||
});
|
||||
it.each<UseCase>(["manage_team", "plan_research"])(
|
||||
"null × %s → planning",
|
||||
(use_case) => {
|
||||
expect(recommendTemplate({ role: null, use_case })).toBe("planning");
|
||||
},
|
||||
);
|
||||
it("both null → assistant", () => {
|
||||
expect(recommendTemplate({ role: null, use_case: null })).toBe(
|
||||
"assistant",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("exhaustive role coverage", () => {
|
||||
const roles: Role[] = [
|
||||
"developer",
|
||||
"product_lead",
|
||||
"writer",
|
||||
"founder",
|
||||
"other",
|
||||
];
|
||||
it.each(roles)("role=%s returns a valid template id", (role) => {
|
||||
it.each(ALL_ROLES)("role=%s returns a valid template id", (role) => {
|
||||
const result = recommendTemplate({ role, use_case: null });
|
||||
expect(["coding", "planning", "writing", "assistant"]).toContain(result);
|
||||
});
|
||||
|
||||
@@ -1,41 +1,69 @@
|
||||
import type { QuestionnaireAnswers } from "./types";
|
||||
import type { QuestionnaireAnswers, Role, UseCase } from "./types";
|
||||
|
||||
/**
|
||||
* Identifier for the four agent templates offered during onboarding Step 4.
|
||||
* Keep in sync with the template registry inside StepAgent in
|
||||
* Identifier for the four legacy onboarding agent templates. Keep in
|
||||
* sync with the template registry inside StepAgent in
|
||||
* `packages/views/onboarding/steps/step-agent.tsx`.
|
||||
*/
|
||||
export type AgentTemplateId = "coding" | "planning" | "writing" | "assistant";
|
||||
|
||||
/**
|
||||
* Pick a recommended agent template for a user based on their
|
||||
* questionnaire answers. Role is treated as the primary signal (who the
|
||||
* user is); use_case is only a tiebreaker for roles that legitimately
|
||||
* split between templates (developer / product_lead).
|
||||
* Pick a recommended agent template based on the v2 questionnaire
|
||||
* (role × use_case). Role is the primary signal; use_case is a
|
||||
* tiebreaker for roles that legitimately split between templates
|
||||
* (engineer / product / marketing).
|
||||
*
|
||||
* `role = other` and `role = founder` both fall back to the generic
|
||||
* Assistant: "other" means the user declined to claim a role, and
|
||||
* "founder" means they wear every hat, so a single specialized agent is
|
||||
* a poor default.
|
||||
* Fallback chain when role is skipped or null:
|
||||
* 1. Derive from use_case alone.
|
||||
* 2. Both unknown → `assistant` (the generic default).
|
||||
*
|
||||
* Pure / deterministic — safe to call on every render.
|
||||
*/
|
||||
export function recommendTemplate(
|
||||
answers: Pick<QuestionnaireAnswers, "role" | "use_case">,
|
||||
): AgentTemplateId {
|
||||
const { role, use_case } = answers;
|
||||
const role: Role | null = answers.role;
|
||||
const useCase: UseCase | null = answers.use_case;
|
||||
|
||||
if (role === "other" || role === "founder") return "assistant";
|
||||
if (role === "writer") return "writing";
|
||||
if (role === null) return fallbackFromUseCase(useCase);
|
||||
|
||||
if (role === "developer") {
|
||||
return use_case === "planning" ? "planning" : "coding";
|
||||
switch (role) {
|
||||
case "engineer":
|
||||
if (useCase === "manage_team" || useCase === "plan_research")
|
||||
return "planning";
|
||||
if (useCase === "write_publish") return "writing";
|
||||
return "coding";
|
||||
case "product":
|
||||
if (useCase === "ship_code") return "coding";
|
||||
return "planning";
|
||||
case "designer":
|
||||
return "assistant";
|
||||
case "writer":
|
||||
return "writing";
|
||||
case "marketing":
|
||||
if (useCase === "write_publish" || useCase === "plan_research")
|
||||
return "writing";
|
||||
return "planning";
|
||||
case "research":
|
||||
return "planning";
|
||||
case "founder":
|
||||
case "ops":
|
||||
case "student":
|
||||
case "other":
|
||||
return "assistant";
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackFromUseCase(useCase: UseCase | null): AgentTemplateId {
|
||||
switch (useCase) {
|
||||
case "ship_code":
|
||||
return "coding";
|
||||
case "write_publish":
|
||||
return "writing";
|
||||
case "manage_team":
|
||||
case "plan_research":
|
||||
return "planning";
|
||||
default:
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
if (role === "product_lead") {
|
||||
return use_case === "coding" ? "coding" : "planning";
|
||||
}
|
||||
|
||||
// Unknown / null role — user hasn't answered Q2 yet.
|
||||
return "assistant";
|
||||
}
|
||||
|
||||
@@ -15,9 +15,10 @@ import type { OnboardingStep } from "./types";
|
||||
* as progress toward completing setup.
|
||||
*/
|
||||
export const ONBOARDING_STEP_ORDER: readonly OnboardingStep[] = [
|
||||
"questionnaire",
|
||||
"source",
|
||||
"role",
|
||||
"use_case",
|
||||
"workspace",
|
||||
"runtime",
|
||||
"agent",
|
||||
"first_issue",
|
||||
"teammate",
|
||||
] as const;
|
||||
|
||||
@@ -4,14 +4,16 @@ import { setPersonProperties } from "../analytics";
|
||||
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
||||
|
||||
/**
|
||||
* Persist Q1/Q2/Q3 answers and sync the refreshed user into the auth
|
||||
* store. Source of truth is `user.onboarding_questionnaire` (JSONB on
|
||||
* the server). No client-side cache here.
|
||||
* Persist questionnaire answers (one or more slots at a time — each
|
||||
* onboarding step PATCHes its own slot) and sync the refreshed user
|
||||
* into the auth store. Source of truth is
|
||||
* `user.onboarding_questionnaire` (JSONB on the server). No
|
||||
* client-side cache here.
|
||||
*
|
||||
* Resume-by-step is intentionally not persisted: every onboarding
|
||||
* entry starts at Welcome. The questionnaire is the only piece of
|
||||
* progress that survives a re-entry — it pre-fills Step 1 so the
|
||||
* user doesn't re-answer.
|
||||
* entry starts at Welcome. Answered slots are pre-filled on
|
||||
* re-entry; skipped slots are treated as fresh (the user can answer
|
||||
* this time).
|
||||
*/
|
||||
export async function saveQuestionnaire(
|
||||
answers: Partial<QuestionnaireAnswers>,
|
||||
@@ -19,12 +21,11 @@ export async function saveQuestionnaire(
|
||||
const user = await api.patchOnboarding({ questionnaire: answers });
|
||||
useAuthStore.getState().setUser(user);
|
||||
// Mirror the three cohort signals into person properties so every
|
||||
// PostHog event on this user can be broken down by role / use_case /
|
||||
// team_size without re-joining the DB. Matches the $set block the
|
||||
// server writes alongside `onboarding_questionnaire_submitted`.
|
||||
if (answers.team_size || answers.role || answers.use_case) {
|
||||
// PostHog event on this user can be broken down by source / role /
|
||||
// use_case without re-joining the DB.
|
||||
if (answers.source || answers.role || answers.use_case) {
|
||||
setPersonProperties({
|
||||
...(answers.team_size ? { team_size: answers.team_size } : {}),
|
||||
...(answers.source ? { source: answers.source } : {}),
|
||||
...(answers.role ? { role: answers.role } : {}),
|
||||
...(answers.use_case ? { use_case: answers.use_case } : {}),
|
||||
});
|
||||
@@ -52,6 +53,38 @@ export async function completeOnboarding(
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-connected onboarding path. The server creates or reuses the
|
||||
* default Multica Helper agent and the single onboarding issue, marks
|
||||
* onboarding complete, and suppresses the older starter-content prompt.
|
||||
*/
|
||||
export async function bootstrapRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<{ workspace_id: string; agent_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingRuntime({
|
||||
workspace_id: workspaceId,
|
||||
runtime_id: runtimeId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime-skipped onboarding path. The server creates or reuses one
|
||||
* self-serve onboarding issue, marks onboarding complete, and suppresses
|
||||
* the older starter-content prompt so the user is not flooded with tasks.
|
||||
*/
|
||||
export async function bootstrapNoRuntimeOnboarding(
|
||||
workspaceId: string,
|
||||
): Promise<{ workspace_id: string; issue_id: string }> {
|
||||
const result = await api.bootstrapOnboardingNoRuntime({
|
||||
workspace_id: workspaceId,
|
||||
});
|
||||
await useAuthStore.getState().refreshMe();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Records interest in cloud runtimes. Pure side effect — does NOT
|
||||
* complete onboarding; the user still has to pick a real Step 3
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
export type OnboardingStep =
|
||||
| "welcome"
|
||||
| "questionnaire"
|
||||
| "source"
|
||||
| "role"
|
||||
| "use_case"
|
||||
| "workspace"
|
||||
| "runtime"
|
||||
| "teammate"
|
||||
| "agent"
|
||||
| "first_issue";
|
||||
|
||||
@@ -13,33 +16,64 @@ export type OnboardingStep =
|
||||
* `OnboardingPath*` constants in `server/internal/analytics/events.go`.
|
||||
*/
|
||||
export type OnboardingCompletionPath =
|
||||
| "full" // Reached Step 5 (first_issue) with a runtime connected
|
||||
| "runtime_skipped" // Step 3 skipped (no runtime) but still completed
|
||||
| "cloud_waitlist" // Submitted the cloud waitlist form and skipped Step 3
|
||||
| "skip_existing" // "I've done this before" from Welcome
|
||||
| "invite_accept"; // Accepted at least one invite from /invitations
|
||||
| "full"
|
||||
| "runtime_skipped"
|
||||
| "cloud_waitlist"
|
||||
| "skip_existing"
|
||||
| "invite_accept";
|
||||
|
||||
export type TeamSize = "solo" | "team" | "other";
|
||||
export type Source =
|
||||
| "friends_colleagues"
|
||||
| "search"
|
||||
| "social_x"
|
||||
| "social_linkedin"
|
||||
| "social_youtube"
|
||||
| "social_other"
|
||||
| "blog_newsletter"
|
||||
| "ai_assistant"
|
||||
| "from_work"
|
||||
| "event_conference"
|
||||
| "dont_remember"
|
||||
| "other";
|
||||
|
||||
export type Role =
|
||||
| "developer"
|
||||
| "product_lead"
|
||||
| "writer"
|
||||
| "engineer"
|
||||
| "product"
|
||||
| "designer"
|
||||
| "founder"
|
||||
| "marketing"
|
||||
| "writer"
|
||||
| "research"
|
||||
| "ops"
|
||||
| "student"
|
||||
| "other";
|
||||
|
||||
export type UseCase =
|
||||
| "coding"
|
||||
| "planning"
|
||||
| "writing_research"
|
||||
| "explore"
|
||||
| "ship_code"
|
||||
| "manage_team"
|
||||
| "personal_tasks"
|
||||
| "plan_research"
|
||||
| "write_publish"
|
||||
| "automate_ops"
|
||||
| "evaluate"
|
||||
| "other";
|
||||
|
||||
/**
|
||||
* v2 questionnaire shape. `*_skipped: true` distinguishes an explicit
|
||||
* Skip click from a slot the user never reached. Both states are
|
||||
* "unknown" for recommendation purposes; the skip marker exists for
|
||||
* analytics and so future re-prompts can avoid nagging users who
|
||||
* already declined.
|
||||
*/
|
||||
export interface QuestionnaireAnswers {
|
||||
team_size: TeamSize | null;
|
||||
team_size_other: string | null;
|
||||
source: Source | null;
|
||||
source_other: string | null;
|
||||
source_skipped: boolean;
|
||||
role: Role | null;
|
||||
role_other: string | null;
|
||||
role_skipped: boolean;
|
||||
use_case: UseCase | null;
|
||||
use_case_other: string | null;
|
||||
use_case_skipped: boolean;
|
||||
version: 2;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
"./inbox/queries": "./inbox/queries.ts",
|
||||
"./inbox/mutations": "./inbox/mutations.ts",
|
||||
"./inbox/ws-updaters": "./inbox/ws-updaters.ts",
|
||||
"./inbox/stores": "./inbox/stores/index.ts",
|
||||
"./inbox/stores/*": "./inbox/stores/*.ts",
|
||||
"./notification-preferences": "./notification-preferences/index.ts",
|
||||
"./notification-preferences/queries": "./notification-preferences/queries.ts",
|
||||
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("paths.workspace(slug)", () => {
|
||||
expect(ws.squads()).toBe("/acme/squads");
|
||||
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
|
||||
expect(ws.settings()).toBe("/acme/settings");
|
||||
expect(ws.attachmentPreview("att_42")).toBe("/acme/attachments/att_42/preview");
|
||||
});
|
||||
|
||||
it("URL-encodes special characters in ids", () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ function workspaceScoped(slug: string) {
|
||||
skills: () => `${ws}/skills`,
|
||||
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
|
||||
settings: () => `${ws}/settings`,
|
||||
attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
|
||||
import { applyChatDoneToCache } from "./use-realtime-sync";
|
||||
import { issueKeys } from "../issues/queries";
|
||||
import { workspaceKeys } from "../workspace/queries";
|
||||
import type {
|
||||
ChatDonePayload,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
Workspace,
|
||||
} from "../types";
|
||||
import {
|
||||
applyChatDoneToCache,
|
||||
applyWorkspaceUpdatedToCache,
|
||||
} from "./use-realtime-sync";
|
||||
|
||||
const sessionId = "session-1";
|
||||
const taskId = "task-1";
|
||||
@@ -115,3 +125,78 @@ describe("applyChatDoneToCache", () => {
|
||||
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWorkspaceUpdatedToCache", () => {
|
||||
const wsId = "ws-1";
|
||||
|
||||
function workspace(overrides: Partial<Workspace> = {}): Workspace {
|
||||
return {
|
||||
id: wsId,
|
||||
name: "Test",
|
||||
slug: "test",
|
||||
description: null,
|
||||
context: null,
|
||||
settings: {},
|
||||
repos: [],
|
||||
issue_prefix: "TES",
|
||||
created_at: "2026-05-18T00:00:00Z",
|
||||
updated_at: "2026-05-18T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("invalidates issue cache when issue_prefix changes", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
|
||||
workspace({ issue_prefix: "TES" }),
|
||||
]);
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "NEW" }),
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: workspaceKeys.list(),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not invalidate issue cache when only non-prefix fields change", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<Workspace[]>(workspaceKeys.list(), [
|
||||
workspace({ issue_prefix: "TES", name: "Old name" }),
|
||||
]);
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "TES", name: "New name" }),
|
||||
});
|
||||
|
||||
expect(invalidate).not.toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: workspaceKeys.list(),
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates issue cache when the workspace isn't in the cached list yet", () => {
|
||||
// Conservative: a workspace appearing for the first time may correspond
|
||||
// to issue queries that were primed without ever seeing the (possibly
|
||||
// changing) prefix. Erring on the side of refresh keeps identifiers
|
||||
// accurate at minimal cost.
|
||||
const qc = createQueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
applyWorkspaceUpdatedToCache(qc, {
|
||||
workspace: workspace({ issue_prefix: "NEW" }),
|
||||
});
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: issueKeys.all(wsId),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,16 +27,18 @@ import {
|
||||
onIssueDeleted,
|
||||
onIssueLabelsChanged,
|
||||
} from "../issues/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted, onInboxBatch } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import type { Workspace } from "../types/workspace";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
WorkspaceUpdatedPayload,
|
||||
MemberRemovedPayload,
|
||||
IssueUpdatedPayload,
|
||||
IssueCreatedPayload,
|
||||
@@ -107,6 +109,36 @@ export function applyChatDoneToCache(
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a workspace:updated event to the cache. Always refreshes the
|
||||
* workspace list. If the incoming `issue_prefix` differs from what's
|
||||
* currently cached, also invalidates issueKeys.all for that workspace,
|
||||
* since every issue's rendered identifier (`MUL-123`) is recomputed from
|
||||
* the workspace prefix at read time. Without this, the UI keeps showing
|
||||
* the old `OLD-N` keys until the next hard refresh.
|
||||
*
|
||||
* If the workspace isn't in the cached list (first observation), we
|
||||
* conservatively invalidate — the prefix is effectively "new" relative to
|
||||
* what's cached, so any issues already loaded under the old prefix would
|
||||
* be stale anyway.
|
||||
*/
|
||||
export function applyWorkspaceUpdatedToCache(
|
||||
qc: QueryClient,
|
||||
payload: WorkspaceUpdatedPayload,
|
||||
): void {
|
||||
const next = payload.workspace;
|
||||
if (next?.id) {
|
||||
const cached =
|
||||
qc
|
||||
.getQueryData<Workspace[]>(workspaceKeys.list())
|
||||
?.find((w) => w.id === next.id) ?? null;
|
||||
if (!cached || cached.issue_prefix !== next.issue_prefix) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(next.id) });
|
||||
}
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidates all workspace-scoped queries. Used after reconnect and when a
|
||||
* new WSClient instance is detected (workspace switch) to recover events
|
||||
@@ -119,6 +151,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,12 +210,35 @@ 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) });
|
||||
// Creating/deleting the user's first owned agent flips
|
||||
// `has_my_agent`, which gates the "my agent" chip's
|
||||
// disabled-but-selected state. Refresh the resource-availability
|
||||
// probe so the chip un-greys (or greys) on the first relationship
|
||||
// change instead of waiting for reload.
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
|
||||
}
|
||||
},
|
||||
member: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.members(wsId) });
|
||||
// Member adds/removes can flip `has_my_squad` (user joining or
|
||||
// leaving a squad as a human member). Mirror the agent handler.
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
|
||||
}
|
||||
},
|
||||
// workspace:updated is handled by the specific handler below
|
||||
// (compares prefixes to decide whether to also invalidate issues).
|
||||
// This generic fallback still fires for workspace:deleted (paired
|
||||
// with the specific navigation handler) and any future workspace:*
|
||||
// events without dedicated handlers.
|
||||
workspace: () => {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.list() });
|
||||
},
|
||||
@@ -200,6 +256,10 @@ export function useRealtimeSync(
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
|
||||
// squad:deleted triggers assignee transfer — refresh issues too.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
// Creating/deleting a squad the user is involved in flips
|
||||
// `has_my_squad`. Refresh resource-availability so the
|
||||
// "my squad" chip's disabled state reacts in realtime.
|
||||
qc.invalidateQueries({ queryKey: inboxKeys.resourceAvailability(wsId) });
|
||||
}
|
||||
},
|
||||
label: () => {
|
||||
@@ -220,7 +280,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();
|
||||
@@ -266,6 +333,10 @@ export function useRealtimeSync(
|
||||
// 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) });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -284,7 +355,9 @@ export function useRealtimeSync(
|
||||
|
||||
// Event types handled by specific handlers below -- skip generic refresh
|
||||
const specificEvents = new Set([
|
||||
"workspace:updated",
|
||||
"issue:updated", "issue:created", "issue:deleted", "issue_labels:changed", "inbox:new",
|
||||
"inbox:batch-read", "inbox:batch-archived",
|
||||
"comment:created", "comment:updated", "comment:deleted",
|
||||
"comment:resolved", "comment:unresolved",
|
||||
"activity:created",
|
||||
@@ -328,6 +401,13 @@ export function useRealtimeSync(
|
||||
if (issue.status) {
|
||||
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);
|
||||
}
|
||||
// The inbox row's `assignee_scope` is derived from the issue's
|
||||
// assignee, so any issue:updated event may have shifted it (the
|
||||
// payload doesn't tell us which fields changed). Invalidate the
|
||||
// inbox list and scope counts so chip filtering, chip badges, and
|
||||
// scope-targeted bulk actions reflect the new scope without
|
||||
// requiring a full reload.
|
||||
onInboxInvalidate(qc, wsId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -413,6 +493,21 @@ export function useRealtimeSync(
|
||||
});
|
||||
});
|
||||
|
||||
// Bulk mark-all-read / archive-* on another device — refresh this device's
|
||||
// inbox so the change appears. The payload carries `scope` (and for
|
||||
// archived, `operation`) per RFC v3 §C.5 / v4 §1; precise cache updates
|
||||
// off those fields are a documented follow-up — invalidate is the safe
|
||||
// baseline today.
|
||||
const unsubInboxBatchRead = ws.on("inbox:batch-read", () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onInboxBatch(qc, wsId);
|
||||
});
|
||||
|
||||
const unsubInboxBatchArchived = ws.on("inbox:batch-archived", () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) onInboxBatch(qc, wsId);
|
||||
});
|
||||
|
||||
// --- Timeline event handlers (global fallback) ---
|
||||
// These events are also handled granularly by useIssueTimeline when
|
||||
// IssueDetail is mounted. This global handler exists to mark the
|
||||
@@ -521,6 +616,10 @@ export function useRealtimeSync(
|
||||
}
|
||||
};
|
||||
|
||||
const unsubWsUpdated = ws.on("workspace:updated", (p) => {
|
||||
applyWorkspaceUpdatedToCache(qc, p as WorkspaceUpdatedPayload);
|
||||
});
|
||||
|
||||
const unsubWsDeleted = ws.on("workspace:deleted", (p) => {
|
||||
const { workspace_id } = p as WorkspaceDeletedPayload;
|
||||
// Event payload has UUID; look up slug from cached workspace list
|
||||
@@ -818,6 +917,8 @@ export function useRealtimeSync(
|
||||
unsubIssueDeleted();
|
||||
unsubIssueLabelsChanged();
|
||||
unsubInboxNew();
|
||||
unsubInboxBatchRead();
|
||||
unsubInboxBatchArchived();
|
||||
unsubCommentCreated();
|
||||
unsubCommentUpdated();
|
||||
unsubCommentDeleted();
|
||||
@@ -830,6 +931,7 @@ export function useRealtimeSync(
|
||||
unsubIssueReactionRemoved();
|
||||
unsubSubscriberAdded();
|
||||
unsubSubscriberRemoved();
|
||||
unsubWsUpdated();
|
||||
unsubWsDeleted();
|
||||
unsubMemberRemoved();
|
||||
unsubMemberAdded();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -45,6 +45,15 @@ export interface ListIssuesParams {
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
project_id?: string;
|
||||
/**
|
||||
* Widen the assignee filter to issues where the user is the *indirect*
|
||||
* assignee — assignee is one of the user's owned agents, or a squad that
|
||||
* involves the user (human member / leader-via-owned-agent / agent member
|
||||
* owned by the user). Direct member assignment is intentionally excluded:
|
||||
* `involves_user_id` and `assignee_id=<user>` (tab "Assigned to me") produce
|
||||
* disjoint result sets by construction.
|
||||
*/
|
||||
involves_user_id?: string;
|
||||
open_only?: boolean;
|
||||
}
|
||||
|
||||
@@ -65,6 +74,8 @@ export interface ListGroupedIssuesParams {
|
||||
assignee_ids?: string[];
|
||||
creator_id?: string;
|
||||
project_id?: string;
|
||||
/** See `ListIssuesParams.involves_user_id` — same semantics. */
|
||||
involves_user_id?: string;
|
||||
assignee_filters?: IssueActorRef[];
|
||||
include_no_assignee?: boolean;
|
||||
creator_filters?: IssueActorRef[];
|
||||
@@ -112,6 +123,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 {
|
||||
|
||||
@@ -117,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;
|
||||
}
|
||||
|
||||
@@ -135,11 +135,20 @@ export interface InboxArchivedPayload {
|
||||
export interface InboxBatchReadPayload {
|
||||
recipient_id: string;
|
||||
count: number;
|
||||
// Optional assignment-scope filter the originating mark-all-read was
|
||||
// narrowed to (RFC v3 §C.5). When present, listeners may apply a precise
|
||||
// cache update; when absent, the safe default is a full inbox invalidate.
|
||||
scope?: import("./inbox").InboxFilterScope[] | null;
|
||||
}
|
||||
|
||||
export interface InboxBatchArchivedPayload {
|
||||
recipient_id: string;
|
||||
count: number;
|
||||
// Identifies the bulk archive variant so listeners can pick the right
|
||||
// predicate for a precise cache update (RFC v4 §1). Optional for backward
|
||||
// compatibility with older servers.
|
||||
operation?: import("./inbox").InboxBatchArchiveOperation | null;
|
||||
scope?: import("./inbox").InboxFilterScope[] | null;
|
||||
}
|
||||
|
||||
export interface CommentCreatedPayload {
|
||||
|
||||
@@ -21,6 +21,22 @@ export type InboxItemType =
|
||||
| "quick_create_done"
|
||||
| "quick_create_failed";
|
||||
|
||||
/**
|
||||
* Inbox assignment scope buckets (RFC v3 §B). The three "my_*" values map to
|
||||
* the user-selectable chips; "other" and "none" are server-internal fallback
|
||||
* buckets that fill the default-no-filter view but cannot be explicitly
|
||||
* filtered to.
|
||||
*/
|
||||
export type InboxAssigneeScope =
|
||||
| "me"
|
||||
| "my_agent"
|
||||
| "my_squad"
|
||||
| "other"
|
||||
| "none";
|
||||
|
||||
/** User-selectable subset of InboxAssigneeScope (chips). */
|
||||
export type InboxFilterScope = "me" | "my_agent" | "my_squad";
|
||||
|
||||
export interface InboxItem {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -38,4 +54,26 @@ export interface InboxItem {
|
||||
archived: boolean;
|
||||
created_at: string;
|
||||
details: Record<string, string> | null;
|
||||
// Server-tagged scope of the issue this inbox item references (RFC v3 §A).
|
||||
// Optional because older servers may not emit it.
|
||||
issue_assignee_type?: "member" | "agent" | "squad" | null;
|
||||
issue_assignee_id?: string | null;
|
||||
assignee_scope?: InboxAssigneeScope | null;
|
||||
}
|
||||
|
||||
export type InboxScopeCounts = Record<InboxAssigneeScope, number>;
|
||||
|
||||
export interface InboxResourceAvailability {
|
||||
has_my_agent: boolean;
|
||||
has_my_squad: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies which bulk-archive endpoint produced an `inbox:batch-archived`
|
||||
* WS event. Frontends use this to choose the right predicate when applying a
|
||||
* precise cache update (RFC v4 §1).
|
||||
*/
|
||||
export type InboxBatchArchiveOperation =
|
||||
| "archive_all"
|
||||
| "archive_read"
|
||||
| "archive_completed";
|
||||
|
||||
@@ -49,7 +49,16 @@ export type {
|
||||
IssueUsageSummary,
|
||||
} from "./agent";
|
||||
export type { Workspace, WorkspaceRepo, Member, MemberRole, User, MemberWithUser, Invitation } from "./workspace";
|
||||
export type { InboxItem, InboxSeverity, InboxItemType } from "./inbox";
|
||||
export type {
|
||||
InboxItem,
|
||||
InboxSeverity,
|
||||
InboxItemType,
|
||||
InboxAssigneeScope,
|
||||
InboxFilterScope,
|
||||
InboxScopeCounts,
|
||||
InboxResourceAvailability,
|
||||
InboxBatchArchiveOperation,
|
||||
} from "./inbox";
|
||||
export type { NotificationGroupKey, NotificationGroupValue, NotificationPreferences, NotificationPreferenceResponse } from "./notification-preference";
|
||||
export type { Comment, CommentType, CommentAuthorType, Reaction } from "./comment";
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
@@ -102,6 +111,10 @@ export type {
|
||||
ListAutopilotsResponse,
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
WebhookDelivery,
|
||||
WebhookDeliveryStatus,
|
||||
WebhookSignatureStatus,
|
||||
ListWebhookDeliveriesResponse,
|
||||
} from "./autopilot";
|
||||
export type {
|
||||
Squad,
|
||||
@@ -115,4 +128,8 @@ export type {
|
||||
RemoveSquadMemberRequest,
|
||||
UpdateSquadMemberRoleRequest,
|
||||
CreateSquadActivityLogRequest,
|
||||
SquadMemberStatusValue,
|
||||
SquadActiveIssueBrief,
|
||||
SquadMemberStatus,
|
||||
SquadMemberStatusListResponse,
|
||||
} from "./squad";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -40,7 +40,7 @@ function ActorAvatar({
|
||||
// Squads (a group, non-human) get a square tile so they don't read as
|
||||
// a single person; everyone else stays round.
|
||||
isSquad ? "rounded-md" : "rounded-full",
|
||||
"bg-muted text-muted-foreground",
|
||||
(!avatarUrl || imgError) && "bg-muted text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size, fontSize: size * 0.45 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,6 +60,20 @@ export interface MarkdownProps {
|
||||
* When provided, enables file card preprocessing and rendering.
|
||||
*/
|
||||
cdnDomain?: string
|
||||
/**
|
||||
* Optional override for the image renderer. When provided, replaces the
|
||||
* default `<img>` with constrained sizing. The views-package wrapper uses
|
||||
* this to inject the unified `<Attachment>` component so chat messages get
|
||||
* the same hover toolbar / lightbox / preview-modal treatment as comments.
|
||||
*/
|
||||
renderImage?: (props: { src: string; alt: string }) => React.ReactNode
|
||||
/**
|
||||
* Optional override for the file-card renderer. When provided, replaces
|
||||
* the simplified card chrome (filename + download button) with whatever
|
||||
* the caller supplies. Used the same way as `renderImage` to bridge into
|
||||
* the views-package `<Attachment>` component.
|
||||
*/
|
||||
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode
|
||||
}
|
||||
|
||||
// Sanitization schema — extends GitHub defaults to allow code highlighting classes
|
||||
@@ -113,6 +127,8 @@ function createComponents(
|
||||
onUrlClick?: (url: string) => void,
|
||||
onFileClick?: (path: string) => void,
|
||||
renderMention?: (props: { type: string; id: string }) => React.ReactNode,
|
||||
renderImage?: (props: { src: string; alt: string }) => React.ReactNode,
|
||||
renderFileCard?: (props: { href: string; filename: string }) => React.ReactNode,
|
||||
): Partial<Components> {
|
||||
const baseComponents: Partial<Components> = {
|
||||
// FileCard: intercept <div data-type="fileCard"> from preprocessFileCards
|
||||
@@ -122,6 +138,9 @@ function createComponents(
|
||||
const rawHref = (node?.properties?.dataHref as string) || ''
|
||||
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
|
||||
const filename = (node?.properties?.dataFilename as string) || ''
|
||||
if (renderFileCard) {
|
||||
return <>{renderFileCard({ href, filename })}</>
|
||||
}
|
||||
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" />
|
||||
@@ -143,14 +162,19 @@ function createComponents(
|
||||
return <div {...props}>{children}</div>
|
||||
},
|
||||
// Images: render uploaded images with constrained sizing
|
||||
img: ({ src, alt }) => (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
className="max-w-full h-auto rounded-md my-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
),
|
||||
img: ({ src, alt }) => {
|
||||
if (renderImage) {
|
||||
return <>{renderImage({ src: typeof src === 'string' ? src : '', alt: alt ?? '' })}</>
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt ?? ""}
|
||||
className="max-w-full h-auto rounded-md my-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
},
|
||||
// Links: Make clickable with callbacks, or render as mention
|
||||
a: ({ href, children }) => {
|
||||
// Mention links: mention://member/id, mention://agent/id, mention://issue/id, mention://all/all
|
||||
@@ -384,11 +408,13 @@ export function Markdown({
|
||||
onUrlClick,
|
||||
onFileClick,
|
||||
renderMention,
|
||||
renderImage,
|
||||
renderFileCard,
|
||||
cdnDomain
|
||||
}: MarkdownProps): React.JSX.Element {
|
||||
const components = React.useMemo(
|
||||
() => createComponents(mode, onUrlClick, onFileClick, renderMention),
|
||||
[mode, onUrlClick, onFileClick, renderMention]
|
||||
() => createComponents(mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard),
|
||||
[mode, onUrlClick, onFileClick, renderMention, renderImage, renderFileCard]
|
||||
)
|
||||
|
||||
// Preprocess: convert mention shortcodes, raw URLs, and file cards to renderable content
|
||||
|
||||
@@ -240,7 +240,7 @@ function AvatarEditor({
|
||||
|
||||
if (!canEdit) {
|
||||
return (
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted">
|
||||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-lg">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={agent.id}
|
||||
@@ -271,7 +271,7 @@ function AvatarEditor({
|
||||
type="button"
|
||||
// rounded-lg matches the standard agent avatar treatment used in
|
||||
// list rows. Avoid rounded-full — circles are reserved for humans.
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-muted focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="group relative h-14 w-14 shrink-0 overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
aria-label={t(($) => $.inspector.change_avatar_aria)}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps)
|
||||
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasValue
|
||||
? "border bg-muted"
|
||||
? "border"
|
||||
: "border border-dashed bg-muted/40 hover:bg-muted",
|
||||
)}
|
||||
aria-label={
|
||||
|
||||
72
packages/views/attachments/attachment-preview-page.tsx
Normal file
72
packages/views/attachments/attachment-preview-page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentPreviewPage — full-page HTML attachment viewer.
|
||||
*
|
||||
* Destination for `openInNewTab` from HtmlAttachmentPreview's toolbar. The
|
||||
* inline preview (HtmlAttachmentPreview) renders the same content in a 480px
|
||||
* card with a hover toolbar; this is the same content edge-to-edge so the
|
||||
* user can resize / interact with the document at full size.
|
||||
*
|
||||
* Same security posture as the inline preview: iframe sandbox is
|
||||
* "allow-scripts" only — no allow-same-origin, no allow-top-navigation. The
|
||||
* iframe runs in an opaque origin and cannot reach cookies, localStorage,
|
||||
* parent, or top-level navigation.
|
||||
*
|
||||
* The route is workspace-scoped (`/{slug}/attachments/{id}/preview`) for
|
||||
* tenancy isolation; the `/api/attachments/{id}/content` proxy itself is
|
||||
* already auth-checked, so the slug is purely a URL contract.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
|
||||
|
||||
interface AttachmentPreviewPageProps {
|
||||
attachmentId: string;
|
||||
/** Optional display name. Falls back to a generic label and is only used
|
||||
* for the document title — never echoed into the iframe sandbox. */
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export function AttachmentPreviewPage({
|
||||
attachmentId,
|
||||
filename,
|
||||
}: AttachmentPreviewPageProps) {
|
||||
const { t } = useT("editor");
|
||||
const query = useAttachmentHtmlText(attachmentId);
|
||||
|
||||
// Set document.title so desktop's MutationObserver-based tab title picks
|
||||
// up the filename. Web shows the same string in the browser tab.
|
||||
useEffect(() => {
|
||||
if (filename) document.title = filename;
|
||||
}, [filename]);
|
||||
|
||||
const text = query.data?.text;
|
||||
const isLoading = query.isLoading;
|
||||
const isError = !isLoading && (!!query.error || !text);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{isLoading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div
|
||||
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
|
||||
data-testid="attachment-preview-page-error"
|
||||
>
|
||||
{t(($) => $.attachment.preview_failed)}
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
sandbox="allow-scripts"
|
||||
title={filename ?? "HTML attachment"}
|
||||
className="flex-1 w-full border-0 bg-background"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
packages/views/attachments/index.ts
Normal file
1
packages/views/attachments/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AttachmentPreviewPage } from "./attachment-preview-page";
|
||||
@@ -56,6 +56,7 @@ 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 {
|
||||
@@ -313,6 +314,25 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
};
|
||||
|
||||
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-start gap-3 rounded-md border px-3 py-2">
|
||||
@@ -345,7 +365,7 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
{t(($) => $.trigger_row.next_label, { date: formatDate(trigger.next_run_at) })}
|
||||
</div>
|
||||
)}
|
||||
{isWebhook && webhookUrl && (
|
||||
{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}
|
||||
@@ -369,17 +389,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
|
||||
>
|
||||
<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>
|
||||
@@ -755,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,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>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useAutoScroll } from "@multica/ui/hooks/use-auto-scroll";
|
||||
import { taskMessagesOptions } from "@multica/core/chat/queries";
|
||||
import { Markdown } from "@multica/views/common/markdown";
|
||||
import { copyMarkdown } from "../../editor";
|
||||
import { AttachmentList } from "../../issues/components/comment-card";
|
||||
import type { AgentAvailability } from "@multica/core/agents";
|
||||
import type { ChatMessage, ChatPendingTask, TaskMessagePayload, TaskFailureReason } from "@multica/core/types";
|
||||
import type { ChatTimelineItem } from "@multica/core/chat";
|
||||
@@ -155,8 +156,13 @@ function MessageBubble({ message, isPending }: { message: ChatMessage; isPending
|
||||
* Neutralise prose's leading/trailing margin so single-line
|
||||
* bubbles stay as compact as the plain-text version used to. */}
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
<Markdown attachments={message.attachments}>{message.content}</Markdown>
|
||||
</div>
|
||||
<AttachmentList
|
||||
attachments={message.attachments}
|
||||
content={message.content}
|
||||
className="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -202,12 +208,16 @@ function AssistantMessage({
|
||||
return (
|
||||
<div className="w-full space-y-1.5">
|
||||
{timeline.length > 0 ? (
|
||||
<TimelineView items={timeline} />
|
||||
<TimelineView items={timeline} attachments={message.attachments} />
|
||||
) : (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{message.content}</Markdown>
|
||||
<Markdown attachments={message.attachments}>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
<AttachmentList
|
||||
attachments={message.attachments}
|
||||
content={message.content}
|
||||
/>
|
||||
<MessageFooter
|
||||
message={message}
|
||||
timeline={timeline}
|
||||
@@ -382,9 +392,11 @@ function FailureBubble({
|
||||
function TimelineView({
|
||||
items,
|
||||
isStreaming,
|
||||
attachments,
|
||||
}: {
|
||||
items: ChatTimelineItem[];
|
||||
isStreaming?: boolean;
|
||||
attachments?: import("@multica/core/types").Attachment[];
|
||||
}) {
|
||||
const { preface, middle, final } = splitTimeline(items);
|
||||
|
||||
@@ -392,15 +404,23 @@ function TimelineView({
|
||||
<>
|
||||
{preface.length > 0 && (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{preface.map((t) => t.content ?? "").join("")}</Markdown>
|
||||
<Markdown attachments={attachments}>
|
||||
{preface.map((t) => t.content ?? "").join("")}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
{middle.length > 0 && (
|
||||
<OuterProcessFold items={middle} defaultOpen={!!isStreaming} />
|
||||
<OuterProcessFold
|
||||
items={middle}
|
||||
defaultOpen={!!isStreaming}
|
||||
attachments={attachments}
|
||||
/>
|
||||
)}
|
||||
{final.length > 0 && (
|
||||
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Markdown>{final.map((t) => t.content ?? "").join("")}</Markdown>
|
||||
<Markdown attachments={attachments}>
|
||||
{final.map((t) => t.content ?? "").join("")}
|
||||
</Markdown>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -410,9 +430,11 @@ function TimelineView({
|
||||
function OuterProcessFold({
|
||||
items,
|
||||
defaultOpen,
|
||||
attachments,
|
||||
}: {
|
||||
items: ChatTimelineItem[];
|
||||
defaultOpen?: boolean;
|
||||
attachments?: import("@multica/core/types").Attachment[];
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
// useState seeds once at mount — subsequent renders never overwrite the
|
||||
@@ -433,7 +455,7 @@ function OuterProcessFold({
|
||||
<div className="mt-1 rounded-lg border bg-muted/20 p-2 space-y-0.5">
|
||||
{items.map((item) =>
|
||||
item.type === "text" ? (
|
||||
<MiddleTextRow key={item.seq} item={item} />
|
||||
<MiddleTextRow key={item.seq} item={item} attachments={attachments} />
|
||||
) : (
|
||||
<ItemRow key={item.seq} item={item} />
|
||||
),
|
||||
@@ -448,10 +470,16 @@ function OuterProcessFold({
|
||||
// down-shifted (xs / muted) so it reads as part of the agent's process,
|
||||
// not the final answer — the final answer renders below the fold at full
|
||||
// prose size.
|
||||
function MiddleTextRow({ item }: { item: ChatTimelineItem }) {
|
||||
function MiddleTextRow({
|
||||
item,
|
||||
attachments,
|
||||
}: {
|
||||
item: ChatTimelineItem;
|
||||
attachments?: import("@multica/core/types").Attachment[];
|
||||
}) {
|
||||
return (
|
||||
<div className="py-0.5 text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<Markdown>{item.content ?? ""}</Markdown>
|
||||
<Markdown attachments={attachments}>{item.content ?? ""}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { toast } from "sonner";
|
||||
import { ListTodo } from "lucide-react";
|
||||
import { ListTodo, Search } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { UpdateIssueRequest } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { BOARD_STATUSES } from "@multica/core/issues/config";
|
||||
import {
|
||||
childIssueProgressOptions,
|
||||
myIssueAssigneeGroupsOptions,
|
||||
myIssueListOptions,
|
||||
type AssigneeGroupedIssuesFilter,
|
||||
type MyIssuesFilter,
|
||||
} from "@multica/core/issues/queries";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import {
|
||||
actorIssuesViewStore,
|
||||
type ActorIssuesScope,
|
||||
@@ -24,13 +19,14 @@ import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-st
|
||||
import { useClearFiltersOnWorkspaceChange } from "@multica/core/issues/stores/view-store";
|
||||
import { ViewStoreProvider } from "@multica/core/issues/stores/view-store-context";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
|
||||
import { BoardView } from "../issues/components/board-view";
|
||||
import { ListView } from "../issues/components/list-view";
|
||||
import { BatchActionToolbar } from "../issues/components/batch-action-toolbar";
|
||||
import { IssueDisplayControls } from "../issues/components/issues-header";
|
||||
import { filterIssues } from "../issues/utils/filter";
|
||||
import { matchesPinyin } from "../editor/extensions/pinyin-match";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
export type TaskActorType = "member" | "agent";
|
||||
@@ -48,8 +44,7 @@ export function ActorIssuesPanel({
|
||||
const wsId = useWorkspaceId();
|
||||
const scope = useStore(actorIssuesViewStore, (s) => s.scope);
|
||||
const setScope = useStore(actorIssuesViewStore, (s) => s.setScope);
|
||||
const viewMode = useStore(actorIssuesViewStore, (s) => s.viewMode);
|
||||
const grouping = useStore(actorIssuesViewStore, (s) => s.grouping);
|
||||
const setViewMode = useStore(actorIssuesViewStore, (s) => s.setViewMode);
|
||||
const statusFilters = useStore(actorIssuesViewStore, (s) => s.statusFilters);
|
||||
const priorityFilters = useStore(actorIssuesViewStore, (s) => s.priorityFilters);
|
||||
const assigneeFilters = useStore(actorIssuesViewStore, (s) => s.assigneeFilters);
|
||||
@@ -59,11 +54,19 @@ export function ActorIssuesPanel({
|
||||
const includeNoProject = useStore(actorIssuesViewStore, (s) => s.includeNoProject);
|
||||
const labelFilters = useStore(actorIssuesViewStore, (s) => s.labelFilters);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useClearFiltersOnWorkspaceChange(actorIssuesViewStore, wsId);
|
||||
|
||||
// The actor tasks panel is list-only; clear any persisted "board" state
|
||||
// so list-only affordances (e.g. BatchActionToolbar) render correctly.
|
||||
useEffect(() => {
|
||||
setViewMode("list");
|
||||
}, [setViewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
useIssueSelectionStore.getState().clear();
|
||||
}, [viewMode, scope, actorType, actorId]);
|
||||
}, [scope, actorType, actorId]);
|
||||
|
||||
const queryFilter: MyIssuesFilter = useMemo(
|
||||
() =>
|
||||
@@ -73,73 +76,25 @@ export function ActorIssuesPanel({
|
||||
[scope, actorId],
|
||||
);
|
||||
const queryScope = `${actorType}:${actorId}:${scope}`;
|
||||
const usesAssigneeBoard = viewMode === "board" && grouping === "assignee";
|
||||
|
||||
const assigneeGroupFilter = useMemo<AssigneeGroupedIssuesFilter>(() => {
|
||||
const filter: AssigneeGroupedIssuesFilter = {
|
||||
...queryFilter,
|
||||
statuses: statusFilters.length > 0 ? statusFilters : [...BOARD_STATUSES],
|
||||
priorities: priorityFilters,
|
||||
assignee_filters: assigneeFilters,
|
||||
include_no_assignee: includeNoAssignee,
|
||||
creator_filters: creatorFilters,
|
||||
project_ids: projectFilters,
|
||||
include_no_project: includeNoProject,
|
||||
label_ids: labelFilters,
|
||||
};
|
||||
if (scope === "assigned") {
|
||||
filter.assignee_types = [actorType];
|
||||
}
|
||||
return filter;
|
||||
}, [
|
||||
actorType,
|
||||
assigneeFilters,
|
||||
creatorFilters,
|
||||
includeNoAssignee,
|
||||
includeNoProject,
|
||||
labelFilters,
|
||||
priorityFilters,
|
||||
projectFilters,
|
||||
queryFilter,
|
||||
scope,
|
||||
statusFilters,
|
||||
]);
|
||||
const assigneeGroupsOptions = myIssueAssigneeGroupsOptions(
|
||||
wsId,
|
||||
queryScope,
|
||||
assigneeGroupFilter,
|
||||
);
|
||||
const rawIssuesQuery = useQuery({
|
||||
...myIssueListOptions(wsId, queryScope, queryFilter),
|
||||
enabled: !usesAssigneeBoard,
|
||||
});
|
||||
const assigneeGroupsQuery = useQuery({
|
||||
...assigneeGroupsOptions,
|
||||
enabled: usesAssigneeBoard,
|
||||
});
|
||||
const rawIssuesQuery = useQuery(myIssueListOptions(wsId, queryScope, queryFilter));
|
||||
const rawIssues = useMemo(
|
||||
() => rawIssuesQuery.data ?? [],
|
||||
[rawIssuesQuery.data],
|
||||
);
|
||||
const groupedIssues = useMemo(
|
||||
() => assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [],
|
||||
[assigneeGroupsQuery.data],
|
||||
);
|
||||
const isLoading = usesAssigneeBoard
|
||||
? assigneeGroupsQuery.isLoading
|
||||
: rawIssuesQuery.isLoading;
|
||||
const isLoading = rawIssuesQuery.isLoading;
|
||||
|
||||
const actorIssues = useMemo(
|
||||
() =>
|
||||
(usesAssigneeBoard ? groupedIssues : rawIssues).filter((issue) =>
|
||||
rawIssues.filter((issue) =>
|
||||
scope === "assigned"
|
||||
? issue.assignee_type === actorType && issue.assignee_id === actorId
|
||||
: issue.creator_type === actorType && issue.creator_id === actorId,
|
||||
),
|
||||
[actorId, actorType, groupedIssues, rawIssues, scope, usesAssigneeBoard],
|
||||
[actorId, actorType, rawIssues, scope],
|
||||
);
|
||||
|
||||
const issues = useMemo(
|
||||
const filteredIssues = useMemo(
|
||||
() =>
|
||||
filterIssues(actorIssues, {
|
||||
statusFilters,
|
||||
@@ -164,6 +119,19 @@ export function ActorIssuesPanel({
|
||||
],
|
||||
);
|
||||
|
||||
const issues = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return filteredIssues;
|
||||
return filteredIssues.filter((issue) => {
|
||||
const title = issue.title ?? "";
|
||||
return (
|
||||
title.toLowerCase().includes(query) ||
|
||||
issue.identifier.toLowerCase().includes(query) ||
|
||||
matchesPinyin(title, query)
|
||||
);
|
||||
});
|
||||
}, [filteredIssues, search]);
|
||||
|
||||
const { data: childProgressMap = new Map() } = useQuery(
|
||||
childIssueProgressOptions(wsId),
|
||||
);
|
||||
@@ -175,29 +143,6 @@ export function ActorIssuesPanel({
|
||||
return BOARD_STATUSES;
|
||||
}, [statusFilters]);
|
||||
|
||||
const hiddenStatuses = useMemo(
|
||||
() => BOARD_STATUSES.filter((s) => !visibleStatuses.includes(s)),
|
||||
[visibleStatuses],
|
||||
);
|
||||
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const handleMoveIssue = useCallback(
|
||||
(issueId: string, updates: Pick<UpdateIssueRequest, "status" | "assignee_type" | "assignee_id" | "position">) => {
|
||||
updateIssueMutation.mutate(
|
||||
{ id: issueId, ...updates },
|
||||
{
|
||||
onError: (err) =>
|
||||
toast.error(
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: t(($) => $.page.move_failed),
|
||||
),
|
||||
},
|
||||
);
|
||||
},
|
||||
[updateIssueMutation, t],
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <ActorIssuesSkeleton />;
|
||||
}
|
||||
@@ -205,33 +150,44 @@ export function ActorIssuesPanel({
|
||||
return (
|
||||
<ViewStoreProvider store={actorIssuesViewStore}>
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPE_VALUES.map((value) => (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
scope === value
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
onClick={() => setScope(value)}
|
||||
>
|
||||
{t(($) => $.actor_issues.scope[value].label)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{t(($) => $.actor_issues.scope[value].description)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b px-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t(($) => $.actor_issues.search_placeholder)}
|
||||
className="h-8 w-64 pl-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPE_VALUES.map((value) => (
|
||||
<Tooltip key={value}>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={
|
||||
scope === value
|
||||
? "bg-accent text-accent-foreground hover:bg-accent/80"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
onClick={() => setScope(value)}
|
||||
>
|
||||
{t(($) => $.actor_issues.scope[value].label)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent side="bottom">
|
||||
{t(($) => $.actor_issues.scope[value].description)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<IssueDisplayControls scopedIssues={actorIssues} />
|
||||
<IssueDisplayControls scopedIssues={actorIssues} hideViewToggle />
|
||||
</div>
|
||||
|
||||
{actorIssues.length === 0 ? (
|
||||
@@ -244,33 +200,23 @@ export function ActorIssuesPanel({
|
||||
{t(($) => $.actor_issues.empty[scope].description)}
|
||||
</p>
|
||||
</div>
|
||||
) : search.trim() !== "" && issues.length === 0 ? (
|
||||
<div className="flex flex-1 min-h-0 flex-col items-center justify-center gap-2 text-muted-foreground">
|
||||
<Search className="h-10 w-10 text-muted-foreground/40" />
|
||||
<p className="text-sm">{t(($) => $.actor_issues.search_empty)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
{viewMode === "board" ? (
|
||||
<BoardView
|
||||
issues={usesAssigneeBoard ? actorIssues : issues}
|
||||
assigneeGroups={usesAssigneeBoard ? assigneeGroupsQuery.data?.groups : undefined}
|
||||
assigneeGroupQueryKey={usesAssigneeBoard ? assigneeGroupsOptions.queryKey : undefined}
|
||||
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
|
||||
visibleStatuses={visibleStatuses}
|
||||
hiddenStatuses={hiddenStatuses}
|
||||
onMoveIssue={handleMoveIssue}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={queryScope}
|
||||
myIssuesFilter={queryFilter}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={queryScope}
|
||||
myIssuesFilter={queryFilter}
|
||||
/>
|
||||
)}
|
||||
<ListView
|
||||
issues={issues}
|
||||
visibleStatuses={visibleStatuses}
|
||||
childProgressMap={childProgressMap}
|
||||
myIssuesScope={queryScope}
|
||||
myIssuesFilter={queryFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{viewMode === "list" && <BatchActionToolbar />}
|
||||
<BatchActionToolbar />
|
||||
</div>
|
||||
</ViewStoreProvider>
|
||||
);
|
||||
@@ -280,23 +226,19 @@ function ActorIssuesSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-64 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
<Skeleton className="h-8 w-20 rounded-md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
<Skeleton className="h-8 w-8 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 gap-4 overflow-hidden p-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex min-w-52 flex-1 flex-col gap-2">
|
||||
<Skeleton className="h-4 w-20" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
<Skeleton className="h-24 w-full rounded-lg" />
|
||||
</div>
|
||||
<div className="flex flex-1 min-h-0 flex-col gap-2 p-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,25 @@ import {
|
||||
type RenderMode,
|
||||
} from "@multica/ui/markdown";
|
||||
import { useConfigStore } from "@multica/core/config";
|
||||
import type { Attachment as AttachmentRecord } from "@multica/core/types";
|
||||
import { IssueMentionCard } from "../issues/components/issue-mention-card";
|
||||
import {
|
||||
Attachment as AttachmentRenderer,
|
||||
AttachmentDownloadProvider,
|
||||
} from "../editor";
|
||||
|
||||
export type { RenderMode };
|
||||
|
||||
export type MarkdownProps = MarkdownBaseProps;
|
||||
export interface MarkdownProps extends MarkdownBaseProps {
|
||||
/**
|
||||
* Attachments associated with the surrounding entity (chat message, skill
|
||||
* file). When passed, the renderer resolves inline image / file-card URLs
|
||||
* to full attachment records via AttachmentDownloadProvider, unlocking the
|
||||
* unified hover toolbar / lightbox / preview-modal behavior used in
|
||||
* editor surfaces.
|
||||
*/
|
||||
attachments?: AttachmentRecord[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default renderMention that delegates to IssueMentionCard for issue mentions
|
||||
@@ -30,13 +44,58 @@ function defaultRenderMention({
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderImage({ src, alt }: { src: string; alt: string }): React.ReactNode {
|
||||
return (
|
||||
<AttachmentRenderer
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: src,
|
||||
filename: alt,
|
||||
// chat / skill markdown `![]()` is structurally an image. Without
|
||||
// forceKind, empty/descriptive alt strings would route to the
|
||||
// file-card chrome via getPreviewKind autodetect.
|
||||
forceKind: "image",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFileCard({
|
||||
href,
|
||||
filename,
|
||||
}: {
|
||||
href: string;
|
||||
filename: string;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<AttachmentRenderer
|
||||
attachment={{ kind: "url", url: href, filename }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* App-level Markdown wrapper that injects IssueMentionCard via renderMention
|
||||
* and cdnDomain from the config store for file card rendering.
|
||||
* App-level Markdown wrapper. Injects:
|
||||
* - IssueMentionCard for issue mentions
|
||||
* - cdnDomain from the config store (drives fileCard preprocessing)
|
||||
* - unified <Attachment> as the image / file-card renderer
|
||||
* - AttachmentDownloadProvider so url → record resolution works inside
|
||||
* the injected <Attachment> components
|
||||
*/
|
||||
export function Markdown(props: MarkdownProps): React.JSX.Element {
|
||||
const cdnDomain = useConfigStore((s) => s.cdnDomain);
|
||||
return <MarkdownBase renderMention={defaultRenderMention} cdnDomain={cdnDomain} {...props} />;
|
||||
const { attachments, ...rest } = props;
|
||||
return (
|
||||
<AttachmentDownloadProvider attachments={attachments}>
|
||||
<MarkdownBase
|
||||
renderMention={defaultRenderMention}
|
||||
renderImage={renderImage}
|
||||
renderFileCard={renderFileCard}
|
||||
cdnDomain={cdnDomain}
|
||||
{...rest}
|
||||
/>
|
||||
</AttachmentDownloadProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export const MemoizedMarkdown = React.memo(Markdown);
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
Cloud,
|
||||
Cpu,
|
||||
Filter,
|
||||
ArrowDownNarrowWide,
|
||||
ArrowUpNarrowWide,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
@@ -31,6 +33,7 @@ import {
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { ActorAvatar } from "../actor-avatar";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useTranscriptViewStore, type TranscriptSortDirection } from "@multica/core/agents/stores";
|
||||
import type { AgentTask, Agent, AgentRuntime } from "@multica/core/types/agent";
|
||||
import { redactSecrets } from "./redact";
|
||||
import type { TimelineItem } from "./build-timeline";
|
||||
@@ -178,6 +181,8 @@ export function AgentTranscriptDialog({
|
||||
const [agentInfo, setAgentInfo] = useState<Agent | null>(null);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<AgentRuntime | null>(null);
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const sortDirection = useTranscriptViewStore((s) => s.sortDirection);
|
||||
const setSortDirection = useTranscriptViewStore((s) => s.setSortDirection);
|
||||
const eventRefs = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -212,6 +217,26 @@ export function AgentTranscriptDialog({
|
||||
return items.filter((item) => selectedTools.has(itemFilterKey(item)));
|
||||
}, [items, selectedTools]);
|
||||
|
||||
// Apply user-chosen sort direction. Reverse is a pure presentation concern —
|
||||
// the underlying timeline (and its seq numbers) is untouched, so copy/filter
|
||||
// and segment navigation continue to work against the same data.
|
||||
const displayItems = useMemo(
|
||||
() => (sortDirection === "newest_first" ? [...filteredItems].reverse() : filteredItems),
|
||||
[filteredItems, sortDirection],
|
||||
);
|
||||
|
||||
// Toggling direction is a manual user action; jump the scroll container back
|
||||
// to the top so the newest end of the timeline (per the chosen direction) is
|
||||
// immediately visible. Avoids stranding the user mid-scroll on the wrong end.
|
||||
const handleSortDirectionChange = useCallback(
|
||||
(dir: typeof sortDirection) => {
|
||||
if (dir === sortDirection) return;
|
||||
setSortDirection(dir);
|
||||
scrollContainerRef.current?.scrollTo({ top: 0 });
|
||||
},
|
||||
[sortDirection, setSortDirection],
|
||||
);
|
||||
|
||||
// Fetch agent and runtime metadata when dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -249,9 +274,10 @@ export function AgentTranscriptDialog({
|
||||
eventRefs.current.get(seq)?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, []);
|
||||
|
||||
// Copy all events as text (uses filtered items)
|
||||
// Copy all events as text. Use the displayed order so users get the same
|
||||
// sequence they see on screen — matters when sort is set to newest-first.
|
||||
const handleCopyAll = useCallback(() => {
|
||||
const text = filteredItems
|
||||
const text = displayItems
|
||||
.map((item) => {
|
||||
const label = getEventLabel(item);
|
||||
const summary = getEventSummary(item);
|
||||
@@ -262,7 +288,7 @@ export function AgentTranscriptDialog({
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}, [filteredItems]);
|
||||
}, [displayItems]);
|
||||
|
||||
// Toggle tool filter
|
||||
const toggleTool = useCallback((tool: string) => {
|
||||
@@ -336,6 +362,17 @@ export function AgentTranscriptDialog({
|
||||
{statusBadge}
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{items.length > 1 && (
|
||||
<SortDirectionToggle
|
||||
value={sortDirection}
|
||||
onChange={handleSortDirectionChange}
|
||||
labels={{
|
||||
chronological: t(($) => $.transcript.sort_chronological),
|
||||
newestFirst: t(($) => $.transcript.sort_newest_first),
|
||||
ariaLabel: t(($) => $.transcript.sort_label),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{filterOptions.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
@@ -449,10 +486,10 @@ export function AgentTranscriptDialog({
|
||||
</div>
|
||||
|
||||
{/* ── Timeline progress bar ─────────────────────────────── */}
|
||||
{filteredItems.length > 0 && (
|
||||
{displayItems.length > 0 && (
|
||||
<div className="border-b px-4 py-2.5 shrink-0">
|
||||
<TimelineBar
|
||||
items={filteredItems}
|
||||
items={displayItems}
|
||||
selectedSeq={selectedSeq}
|
||||
onSegmentClick={handleSegmentClick}
|
||||
/>
|
||||
@@ -471,7 +508,7 @@ export function AgentTranscriptDialog({
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto min-h-0"
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
{displayItems.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
{isLive ? (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -484,7 +521,7 @@ export function AgentTranscriptDialog({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredItems.map((item) => (
|
||||
{displayItems.map((item) => (
|
||||
<TranscriptEventRow
|
||||
key={item.seq}
|
||||
ref={(el) => {
|
||||
@@ -503,6 +540,55 @@ export function AgentTranscriptDialog({
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Sort direction toggle ──────────────────────────────────────────────────
|
||||
|
||||
interface SortDirectionToggleProps {
|
||||
value: TranscriptSortDirection;
|
||||
onChange: (dir: TranscriptSortDirection) => void;
|
||||
labels: { chronological: string; newestFirst: string; ariaLabel: string };
|
||||
}
|
||||
|
||||
function SortDirectionToggle({ value, onChange, labels }: SortDirectionToggleProps) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={labels.ariaLabel}
|
||||
className="inline-flex items-center rounded border bg-muted/40 p-0.5 text-xs"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={value === "chronological"}
|
||||
title={labels.chronological}
|
||||
onClick={() => onChange("chronological")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
|
||||
value === "chronological"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ArrowDownNarrowWide className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{labels.chronological}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={value === "newest_first"}
|
||||
title={labels.newestFirst}
|
||||
onClick={() => onChange("newest_first")}
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded px-1.5 py-0.5 transition-colors",
|
||||
value === "newest_first"
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<ArrowUpNarrowWide className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{labels.newestFirst}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Metadata chip ──────────────────────────────────────────────────────────
|
||||
|
||||
function MetadataChip({ icon, children }: { icon?: React.ReactNode; children: React.ReactNode }) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { BarChart3, FolderKanban, Users } from "lucide-react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -12,10 +12,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
agentListOptions,
|
||||
squadListOptions,
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import {
|
||||
dashboardUsageDailyOptions,
|
||||
@@ -31,14 +28,19 @@ import {
|
||||
DailyTokensChart,
|
||||
DailyTimeChart,
|
||||
DailyTasksChart,
|
||||
WeeklyCostChart,
|
||||
WeeklyTokensChart,
|
||||
WeeklyTimeChart,
|
||||
WeeklyTasksChart,
|
||||
} from "../../runtimes/components/charts";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
TimezoneSelect,
|
||||
browserTimezone,
|
||||
} from "../../common/timezone-select";
|
||||
import { formatTokens } from "../../runtimes/utils";
|
||||
addDaysIso,
|
||||
aggregateByWeek,
|
||||
formatTokens,
|
||||
todayIso,
|
||||
} from "../../runtimes/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
aggregateAgentTokens,
|
||||
@@ -46,28 +48,48 @@ import {
|
||||
aggregateDailyTasks,
|
||||
aggregateDailyTime,
|
||||
aggregateDailyTokens,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
computeDailyTotals,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
type AgentDashboardRow,
|
||||
} from "../utils";
|
||||
|
||||
// One-place source of truth for the period selector. Matches the runtime
|
||||
// detail page so users see the same three options across the dashboards.
|
||||
// Period selector — mirrors the runtime detail page so users see the same
|
||||
// option set across both dashboards. `dims` declares which dimensions each
|
||||
// range is allowed in: 1d / 7d at the weekly grain collapse to a single bar,
|
||||
// 180d at the daily grain is 180 unreadable bars, so each end of the range
|
||||
// belongs to a single dimension. Switching dimensions resets `days` if the
|
||||
// current value isn't in the new dimension's allowed set (see
|
||||
// `handleDimChange` below).
|
||||
//
|
||||
// 1d semantic: "today" (the natural calendar day from 00:00 in UTC, matching
|
||||
// the rollup's bucket_date axis), not "the last 24 hours". The client-side
|
||||
// `dailyCutoffIso` filter below enforces this even at the midnight edge.
|
||||
const TIME_RANGES = [
|
||||
{ label: "7d", days: 7 },
|
||||
{ label: "30d", days: 30 },
|
||||
{ label: "90d", days: 90 },
|
||||
{ label: "1d", days: 1, dims: ["daily"] as const },
|
||||
{ label: "7d", days: 7, dims: ["daily"] as const },
|
||||
{ label: "30d", days: 30, dims: ["daily", "weekly"] as const },
|
||||
{ label: "90d", days: 90, dims: ["daily", "weekly"] as const },
|
||||
{ label: "180d", days: 180, dims: ["weekly"] as const },
|
||||
] as const;
|
||||
type TimeRange = (typeof TIME_RANGES)[number]["days"];
|
||||
type Dim = "daily" | "weekly";
|
||||
|
||||
const DEFAULT_DAYS_BY_DIM: Record<Dim, TimeRange> = {
|
||||
daily: 30,
|
||||
weekly: 90,
|
||||
};
|
||||
|
||||
function rangesForDim(dim: Dim) {
|
||||
return TIME_RANGES.filter((r) => (r.dims as readonly string[]).includes(dim));
|
||||
}
|
||||
|
||||
// Sentinel for "no project filter" — kept distinct from the empty string
|
||||
// so it survives a refactor that ever lets a project be slug-keyed.
|
||||
const ALL_PROJECTS = "__all__";
|
||||
|
||||
// Sentinel for "no squad filter" — same pattern as ALL_PROJECTS.
|
||||
const ALL_SQUADS = "__all__";
|
||||
|
||||
// Stable references — `data ?? []` would create a new empty array on
|
||||
// every render while the query is loading, which breaks useMemo's
|
||||
// reference-equality dep check and trips the exhaustive-deps lint rule.
|
||||
@@ -81,6 +103,14 @@ function fmtMoney(n: number): string {
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// Weekly aggregation is locked to UTC: the dashboard daily rollup buckets
|
||||
// data by UTC `bucket_date` (and the raw fallback queries by `DATE(...)`,
|
||||
// also UTC), so any other zone for client-side week boundaries would put
|
||||
// cross-midnight rows into the wrong calendar week. Runtime-detail can use
|
||||
// the runtime's IANA tz because its rollup is materialized in that tz; the
|
||||
// workspace rollup has no equivalent, so weekly is UTC-only here.
|
||||
const WEEK_TZ = "UTC";
|
||||
|
||||
// Local segmented control — same visual language the runtime usage section
|
||||
// uses for its period / tab toggles. shadcn's Tabs is wired for full tab
|
||||
// pages with ARIA semantics the compact toolbar pill doesn't need.
|
||||
@@ -126,15 +156,19 @@ function Segmented<T extends string | number>({
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { t } = useT("usage");
|
||||
const { t: tRuntimes } = useT("runtimes");
|
||||
const wsId = useWorkspaceId();
|
||||
const [dim, setDim] = useState<Dim>("daily");
|
||||
const [days, setDays] = useState<TimeRange>(30);
|
||||
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
|
||||
const [squadValue, setSquadValue] = useState<string>(ALL_SQUADS);
|
||||
// Default to the browser's resolved zone so day-boundary buckets match the
|
||||
// user's local clock on first render. Pure client-state — the rollup queries
|
||||
// are zone-agnostic today; this is the UI affordance the user can pin.
|
||||
const [timezone, setTimezone] = useState<string>(() => browserTimezone());
|
||||
|
||||
const allowedRanges = rangesForDim(dim);
|
||||
const handleDimChange = (next: Dim) => {
|
||||
setDim(next);
|
||||
const stillAllowed = (rangesForDim(next) as readonly { days: number }[]).some(
|
||||
(r) => r.days === days,
|
||||
);
|
||||
if (!stillAllowed) setDays(DEFAULT_DAYS_BY_DIM[next]);
|
||||
};
|
||||
|
||||
// The user can save model prices from the runtimes page; re-render when
|
||||
// they do so the dashboard reflects the new rates.
|
||||
@@ -142,7 +176,6 @@ export function DashboardPage() {
|
||||
|
||||
const { data: projects = [] } = useQuery(projectListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
|
||||
// Validate the picked project against the current workspace's list. A
|
||||
// stale UUID — left over from a project that's been deleted, or from the
|
||||
@@ -155,23 +188,22 @@ export function DashboardPage() {
|
||||
return projects.some((p) => p.id === projectValue) ? projectValue : null;
|
||||
}, [projectValue, projects]);
|
||||
|
||||
// Same stale-UUID guard as projectId — see comment above.
|
||||
const squadId = useMemo(() => {
|
||||
if (squadValue === ALL_SQUADS) return null;
|
||||
return squads.some((s) => s.id === squadValue) ? squadValue : null;
|
||||
}, [squadValue, squads]);
|
||||
// The weekly chart paints `ceil(days / 7)` trailing calendar weeks anchored
|
||||
// at today-in-UTC. In the worst case (today = Sunday) the leftmost Monday
|
||||
// sits `weekCount * 7 - 1` days back, so a vanilla `days=30` request would
|
||||
// silently truncate the leftmost bucket. Over-fetch the per-date queries
|
||||
// to cover the full first week; the per-agent rollups stay at `days` so
|
||||
// KPI/leaderboard labels (e.g. "Tasks · 30D") keep their advertised window.
|
||||
const weekCount = Math.max(1, Math.ceil(days / 7));
|
||||
const chartFetchDays = dim === "weekly" ? weekCount * 7 : days;
|
||||
|
||||
const dailyQuery = useQuery(
|
||||
dashboardUsageDailyOptions(wsId, days, projectId, squadId),
|
||||
);
|
||||
const byAgentQuery = useQuery(
|
||||
dashboardUsageByAgentOptions(wsId, days, projectId, squadId),
|
||||
);
|
||||
const runTimeQuery = useQuery(
|
||||
dashboardAgentRunTimeOptions(wsId, days, projectId, squadId),
|
||||
dashboardUsageDailyOptions(wsId, chartFetchDays, projectId),
|
||||
);
|
||||
const byAgentQuery = useQuery(dashboardUsageByAgentOptions(wsId, days, projectId));
|
||||
const runTimeQuery = useQuery(dashboardAgentRunTimeOptions(wsId, days, projectId));
|
||||
const runTimeDailyQuery = useQuery(
|
||||
dashboardRunTimeDailyOptions(wsId, days, projectId, squadId),
|
||||
dashboardRunTimeDailyOptions(wsId, chartFetchDays, projectId),
|
||||
);
|
||||
|
||||
const dailyUsage = dailyQuery.data ?? EMPTY_DAILY;
|
||||
@@ -179,6 +211,26 @@ export function DashboardPage() {
|
||||
const runTimeRows = runTimeQuery.data ?? EMPTY_RUNTIME;
|
||||
const runTimeDailyRows = runTimeDailyQuery.data ?? EMPTY_RUNTIME_DAILY;
|
||||
|
||||
// Daily-aggregation surfaces (cost/tokens/time/tasks KPIs and the Daily
|
||||
// trend chart) re-scope to the user-selected `days` even when we
|
||||
// over-fetched for the weekly chart. UTC matches the bucket_date the
|
||||
// backend filters on, so the cutoff lands on the same calendar boundary
|
||||
// the rollup used. Applied in both dims so 1d strictly means "today" even
|
||||
// at the midnight UTC edge where the server's wall-clock cutoff would
|
||||
// otherwise include yesterday.
|
||||
const dailyCutoffIso = useMemo(
|
||||
() => addDaysIso(todayIso(WEEK_TZ), -(days - 1)),
|
||||
[days],
|
||||
);
|
||||
const dailyUsageInWindow = useMemo(
|
||||
() => dailyUsage.filter((u) => u.date >= dailyCutoffIso),
|
||||
[dailyUsage, dailyCutoffIso],
|
||||
);
|
||||
const runTimeDailyInWindow = useMemo(
|
||||
() => runTimeDailyRows.filter((r) => r.date >= dailyCutoffIso),
|
||||
[runTimeDailyRows, dailyCutoffIso],
|
||||
);
|
||||
|
||||
const isLoading =
|
||||
dailyQuery.isLoading ||
|
||||
byAgentQuery.isLoading ||
|
||||
@@ -196,16 +248,46 @@ export function DashboardPage() {
|
||||
runTimeDailyRows.length === 0;
|
||||
|
||||
// Cost / token math — re-derived when usage, days, or pricings change.
|
||||
const totals = useMemo(() => computeDailyTotals(dailyUsage), [dailyUsage]);
|
||||
const dailyCost = useMemo(() => aggregateDailyCost(dailyUsage), [dailyUsage]);
|
||||
const dailyTokens = useMemo(() => aggregateDailyTokens(dailyUsage), [dailyUsage]);
|
||||
const totals = useMemo(
|
||||
() => computeDailyTotals(dailyUsageInWindow),
|
||||
[dailyUsageInWindow],
|
||||
);
|
||||
const dailyCost = useMemo(
|
||||
() => aggregateDailyCost(dailyUsageInWindow),
|
||||
[dailyUsageInWindow],
|
||||
);
|
||||
const dailyTokens = useMemo(
|
||||
() => aggregateDailyTokens(dailyUsageInWindow),
|
||||
[dailyUsageInWindow],
|
||||
);
|
||||
const dailyTime = useMemo(
|
||||
() => aggregateDailyTime(runTimeDailyRows),
|
||||
[runTimeDailyRows],
|
||||
() => aggregateDailyTime(runTimeDailyInWindow),
|
||||
[runTimeDailyInWindow],
|
||||
);
|
||||
const dailyTasks = useMemo(
|
||||
() => aggregateDailyTasks(runTimeDailyRows),
|
||||
[runTimeDailyRows],
|
||||
() => aggregateDailyTasks(runTimeDailyInWindow),
|
||||
[runTimeDailyInWindow],
|
||||
);
|
||||
|
||||
// Weekly aggregates — built from the over-fetched per-date queries so the
|
||||
// leftmost trailing week always has data even when the user-selected `days`
|
||||
// (e.g. 30D) is shorter than the chart's `weekCount * 7` span. Buckets are
|
||||
// pre-zeroed inside the helpers, so sparse weeks render as empty bars
|
||||
// instead of being dropped (MUL-2382 weekly window scoping). Locked to
|
||||
// UTC so the week boundaries match the backend's UTC `bucket_date`.
|
||||
const weekly = useMemo(
|
||||
() => aggregateByWeek(dailyUsage, WEEK_TZ, weekCount),
|
||||
[dailyUsage, weekCount],
|
||||
);
|
||||
const weeklyCost = weekly.weeklyCostStack;
|
||||
const weeklyTokens = weekly.weeklyTokens;
|
||||
const weeklyTime = useMemo(
|
||||
() => aggregateWeeklyTime(runTimeDailyRows, WEEK_TZ, weekCount),
|
||||
[runTimeDailyRows, weekCount],
|
||||
);
|
||||
const weeklyTasks = useMemo(
|
||||
() => aggregateWeeklyTasks(runTimeDailyRows, WEEK_TZ, weekCount),
|
||||
[runTimeDailyRows, weekCount],
|
||||
);
|
||||
const agentTokenRows = useMemo(
|
||||
() => aggregateAgentTokens(byAgentUsage),
|
||||
@@ -232,12 +314,10 @@ export function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter, range
|
||||
switch, timezone select) overflows the single h-12 row on narrow
|
||||
and medium widths once the timezone picker is added — letting the
|
||||
right cluster wrap underneath keeps every control reachable
|
||||
without an off-screen bleed. Wider viewports still render the
|
||||
original single row. */}
|
||||
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter,
|
||||
dimension switch, range switch) wraps on narrow viewports so every
|
||||
control stays reachable. Wider viewports still render the original
|
||||
single row. */}
|
||||
<PageHeader className="h-auto min-h-12 flex-wrap justify-between gap-y-1.5 px-5 py-1.5 sm:py-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
@@ -249,21 +329,18 @@ export function DashboardPage() {
|
||||
value={projectValue}
|
||||
onChange={setProjectValue}
|
||||
/>
|
||||
<SquadFilter
|
||||
squads={squads}
|
||||
value={squadValue}
|
||||
onChange={setSquadValue}
|
||||
<Segmented
|
||||
value={dim}
|
||||
onChange={handleDimChange}
|
||||
options={[
|
||||
{ label: t(($) => $.dim.daily), value: "daily" as const },
|
||||
{ label: t(($) => $.dim.weekly), value: "weekly" as const },
|
||||
]}
|
||||
/>
|
||||
<Segmented
|
||||
value={days}
|
||||
onChange={setDays}
|
||||
options={TIME_RANGES.map((r) => ({ label: r.label, value: r.days }))}
|
||||
/>
|
||||
<TimezoneSelect
|
||||
value={timezone}
|
||||
onValueChange={setTimezone}
|
||||
browserSuffix={tRuntimes(($) => $.detail.timezone_browser_suffix)}
|
||||
triggerClassName="rounded-md font-mono text-xs"
|
||||
options={allowedRanges.map((r) => ({ label: r.label, value: r.days }))}
|
||||
/>
|
||||
</div>
|
||||
</PageHeader>
|
||||
@@ -315,14 +392,21 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Daily trend chart — toggle picks Tokens / Cost / Time /
|
||||
Tasks. All four share the same x-axis (date) so the user
|
||||
can mentally overlay them by switching the toggle. */}
|
||||
<DailyTrendBlock
|
||||
{/* Trend chart — toggle picks Tokens / Cost / Time / Tasks
|
||||
and the parent's dim selector decides whether the bars are
|
||||
per-day or per-calendar-week. All four metrics share the
|
||||
same x-axis so the user can mentally overlay them by
|
||||
flipping the toggle. */}
|
||||
<TrendBlock
|
||||
dim={dim}
|
||||
dailyCost={dailyCost}
|
||||
dailyTokens={dailyTokens}
|
||||
dailyTime={dailyTime}
|
||||
dailyTasks={dailyTasks}
|
||||
weeklyCost={weeklyCost}
|
||||
weeklyTokens={weeklyTokens}
|
||||
weeklyTime={weeklyTime}
|
||||
weeklyTasks={weeklyTasks}
|
||||
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
|
||||
/>
|
||||
|
||||
@@ -398,81 +482,29 @@ function ProjectFilter({
|
||||
);
|
||||
}
|
||||
|
||||
function SquadFilter({
|
||||
squads,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
squads: { id: string; name: string }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useT("usage");
|
||||
const allLabel = t(($) => $.filter.all_squads);
|
||||
const selected = squads.find((s) => s.id === value);
|
||||
const selectedTitle =
|
||||
value === ALL_SQUADS ? allLabel : selected?.name ?? allLabel;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v ?? ALL_SQUADS)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-[160px]">
|
||||
<SelectValue>
|
||||
{() => (
|
||||
<>
|
||||
{selected ? (
|
||||
<ActorAvatar
|
||||
actorType="squad"
|
||||
actorId={selected.id}
|
||||
size={14}
|
||||
profileLink={false}
|
||||
/>
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{selectedTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
{/* alignItemWithTrigger=false: same viewport-clipping reason as ProjectFilter. */}
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="max-h-72">
|
||||
<SelectItem value={ALL_SQUADS}>
|
||||
<Users className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{allLabel}</span>
|
||||
</SelectItem>
|
||||
{squads.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<ActorAvatar
|
||||
actorType="squad"
|
||||
actorId={s.id}
|
||||
size={14}
|
||||
profileLink={false}
|
||||
className="self-center"
|
||||
/>
|
||||
<span className="truncate">{s.name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
type DailyMetric = "tokens" | "cost" | "time" | "tasks";
|
||||
|
||||
function DailyTrendBlock({
|
||||
function TrendBlock({
|
||||
dim,
|
||||
dailyCost,
|
||||
dailyTokens,
|
||||
dailyTime,
|
||||
dailyTasks,
|
||||
weeklyCost,
|
||||
weeklyTokens,
|
||||
weeklyTime,
|
||||
weeklyTasks,
|
||||
lessThanMinuteLabel,
|
||||
}: {
|
||||
dim: Dim;
|
||||
dailyCost: ReturnType<typeof aggregateDailyCost>;
|
||||
dailyTokens: ReturnType<typeof aggregateDailyTokens>;
|
||||
dailyTime: ReturnType<typeof aggregateDailyTime>;
|
||||
dailyTasks: ReturnType<typeof aggregateDailyTasks>;
|
||||
weeklyCost: ReturnType<typeof aggregateByWeek>["weeklyCostStack"];
|
||||
weeklyTokens: ReturnType<typeof aggregateByWeek>["weeklyTokens"];
|
||||
weeklyTime: ReturnType<typeof aggregateWeeklyTime>;
|
||||
weeklyTasks: ReturnType<typeof aggregateWeeklyTasks>;
|
||||
lessThanMinuteLabel: string;
|
||||
}) {
|
||||
const { t } = useT("usage");
|
||||
@@ -481,13 +513,18 @@ function DailyTrendBlock({
|
||||
// Empty-state is per-metric so each toggle option independently decides
|
||||
// whether it has data — e.g. tokens recorded but no terminal runs yet
|
||||
// should show Tokens normally while Time / Tasks fall through to empty.
|
||||
const totalCost = dailyCost.reduce((sum, d) => sum + d.total, 0);
|
||||
const totalTokens = dailyTokens.reduce(
|
||||
const costData = dim === "weekly" ? weeklyCost : dailyCost;
|
||||
const tokensData = dim === "weekly" ? weeklyTokens : dailyTokens;
|
||||
const timeData = dim === "weekly" ? weeklyTime : dailyTime;
|
||||
const tasksData = dim === "weekly" ? weeklyTasks : dailyTasks;
|
||||
|
||||
const totalCost = costData.reduce((sum, d) => sum + d.total, 0);
|
||||
const totalTokens = tokensData.reduce(
|
||||
(sum, d) => sum + d.input + d.output + d.cacheRead + d.cacheWrite,
|
||||
0,
|
||||
);
|
||||
const totalSeconds = dailyTime.reduce((sum, d) => sum + d.totalSeconds, 0);
|
||||
const totalTasks = dailyTasks.reduce(
|
||||
const totalSeconds = timeData.reduce((sum, d) => sum + d.totalSeconds, 0);
|
||||
const totalTasks = tasksData.reduce(
|
||||
(sum, d) => sum + d.completed + d.failed,
|
||||
0,
|
||||
);
|
||||
@@ -501,13 +538,21 @@ function DailyTrendBlock({
|
||||
: totalTasks === 0;
|
||||
|
||||
const title =
|
||||
metric === "cost"
|
||||
? t(($) => $.daily.title_cost)
|
||||
: metric === "tokens"
|
||||
? t(($) => $.daily.title_tokens)
|
||||
: metric === "time"
|
||||
? t(($) => $.daily.title_time)
|
||||
: t(($) => $.daily.title_tasks);
|
||||
dim === "weekly"
|
||||
? metric === "cost"
|
||||
? t(($) => $.weekly.title_cost)
|
||||
: metric === "tokens"
|
||||
? t(($) => $.weekly.title_tokens)
|
||||
: metric === "time"
|
||||
? t(($) => $.weekly.title_time)
|
||||
: t(($) => $.weekly.title_tasks)
|
||||
: metric === "cost"
|
||||
? t(($) => $.daily.title_cost)
|
||||
: metric === "tokens"
|
||||
? t(($) => $.daily.title_tokens)
|
||||
: metric === "time"
|
||||
? t(($) => $.daily.title_time)
|
||||
: t(($) => $.daily.title_tasks);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
@@ -532,6 +577,20 @@ function DailyTrendBlock({
|
||||
{t(($) => $.daily.no_data)}
|
||||
</p>
|
||||
</div>
|
||||
) : dim === "weekly" ? (
|
||||
metric === "cost" ? (
|
||||
<WeeklyCostChart data={weeklyCost} />
|
||||
) : metric === "tokens" ? (
|
||||
<WeeklyTokensChart data={weeklyTokens} />
|
||||
) : metric === "time" ? (
|
||||
<WeeklyTimeChart
|
||||
data={weeklyTime}
|
||||
formatY={(s) => formatDuration(s, lessThanMinuteLabel)}
|
||||
formatTooltip={(s) => formatDuration(s, lessThanMinuteLabel)}
|
||||
/>
|
||||
) : (
|
||||
<WeeklyTasksChart data={weeklyTasks} />
|
||||
)
|
||||
) : metric === "cost" ? (
|
||||
<DailyCostChart data={dailyCost} />
|
||||
) : metric === "tokens" ? (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
aggregateAgentTokens,
|
||||
aggregateDailyCost,
|
||||
aggregateWeeklyTasks,
|
||||
aggregateWeeklyTime,
|
||||
computeDailyTotals,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
@@ -211,3 +213,95 @@ describe("formatDuration", () => {
|
||||
expect(formatDuration(0.4, "<1m")).toBe("<1m");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weekly run-time / tasks aggregation. Mirrors the runtimes-side
|
||||
// aggregateByWeek tests: trailing N calendar weeks anchored at today-in-tz,
|
||||
// pre-zeroed buckets, partial-week metadata, and rows outside the window
|
||||
// dropped. We assert the same invariants on the workspace dashboard helpers
|
||||
// so all four metrics behave consistently when the user toggles Weekly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("aggregateWeeklyTime", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("folds per-day run-time rows into Mon-anchored weekly totals", () => {
|
||||
// 2026-05-19 is a Tuesday → current week is Mon=05-18..Sun=05-24.
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
|
||||
const rows = [
|
||||
{ date: "2026-05-11", total_seconds: 100, task_count: 0, failed_count: 0 },
|
||||
{ date: "2026-05-17", total_seconds: 50, task_count: 0, failed_count: 0 },
|
||||
{ date: "2026-05-18", total_seconds: 25, task_count: 0, failed_count: 0 },
|
||||
];
|
||||
const result = aggregateWeeklyTime(rows, "UTC", 2);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0]).toMatchObject({
|
||||
weekStart: "2026-05-11",
|
||||
weekEnd: "2026-05-17",
|
||||
totalSeconds: 150,
|
||||
partial: false,
|
||||
daysCovered: 7,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
weekStart: "2026-05-18",
|
||||
totalSeconds: 25,
|
||||
partial: true,
|
||||
daysCovered: 2, // Mon + Tue
|
||||
});
|
||||
});
|
||||
|
||||
it("drops rows that fall outside the trailing window and keeps empty buckets", () => {
|
||||
// Same MUL-2382 sparse-data regression we caught on the runtimes side:
|
||||
// an old populated week must not surface when the requested window
|
||||
// doesn't include it; in-range empty weeks must remain as zero buckets.
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
|
||||
const rows = [
|
||||
// 2026-04-13 is a Monday — exactly one week earlier than the oldest
|
||||
// in-range week (Mon=04-20) for a 5-week trailing window.
|
||||
{ date: "2026-04-13", total_seconds: 999, task_count: 0, failed_count: 0 },
|
||||
];
|
||||
const result = aggregateWeeklyTime(rows, "UTC", 5);
|
||||
expect(result.map((w) => w.weekStart)).toEqual([
|
||||
"2026-04-20",
|
||||
"2026-04-27",
|
||||
"2026-05-04",
|
||||
"2026-05-11",
|
||||
"2026-05-18",
|
||||
]);
|
||||
for (const w of result) expect(w.totalSeconds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aggregateWeeklyTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("splits completed and failed counts per calendar week", () => {
|
||||
vi.setSystemTime(new Date("2026-05-19T12:00:00Z"));
|
||||
const rows = [
|
||||
{ date: "2026-05-12", total_seconds: 0, task_count: 5, failed_count: 1 },
|
||||
{ date: "2026-05-18", total_seconds: 0, task_count: 3, failed_count: 0 },
|
||||
];
|
||||
const result = aggregateWeeklyTasks(rows, "UTC", 2);
|
||||
expect(result[0]).toMatchObject({
|
||||
weekStart: "2026-05-11",
|
||||
completed: 4,
|
||||
failed: 1,
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
weekStart: "2026-05-18",
|
||||
completed: 3,
|
||||
failed: 0,
|
||||
partial: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,20 @@ import type {
|
||||
DashboardAgentRunTime,
|
||||
DashboardRunTimeDaily,
|
||||
} from "@multica/core/types";
|
||||
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
|
||||
import {
|
||||
addDaysIso,
|
||||
estimateCost,
|
||||
estimateCostBreakdown,
|
||||
formatShortDate,
|
||||
todayIso,
|
||||
weekStartIso,
|
||||
type DailyTokenData,
|
||||
} from "../runtimes/utils";
|
||||
import type {
|
||||
DailyTimeData,
|
||||
DailyTasksData,
|
||||
WeeklyTimeData,
|
||||
WeeklyTasksData,
|
||||
} from "../runtimes/components/charts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -217,6 +227,103 @@ export function mergeAgentDashboardRows(
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Weekly fold for run-time + tasks. Mirrors `aggregateByWeek` in
|
||||
// `runtimes/utils.ts` which already covers cost / tokens — same calendar
|
||||
// week semantics (Mon–Sun anchored at today-in-tz), same pre-zeroed buckets,
|
||||
// same partial-week metadata. Workspace dashboard uses the user-chosen
|
||||
// timezone here; the runtime page uses the runtime's IANA tz. Behaviour is
|
||||
// identical apart from where the tz comes from.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface WeekShell {
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
label: string;
|
||||
rangeLabel: string;
|
||||
partial: boolean;
|
||||
daysCovered: number;
|
||||
}
|
||||
|
||||
// Build N trailing calendar week shells anchored at today-in-tz. Each shell
|
||||
// carries the labels and partial-week metadata the chart components consume;
|
||||
// downstream aggregators fold their own per-week values onto the matching
|
||||
// shell.
|
||||
function buildWeekShells(tz: string, weekCount: number): WeekShell[] {
|
||||
const count = Math.max(1, Math.floor(weekCount));
|
||||
const today = todayIso(tz);
|
||||
const currentWeekStart = weekStartIso(today);
|
||||
const firstWeekStart = addDaysIso(currentWeekStart, -(count - 1) * 7);
|
||||
const shells: WeekShell[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const weekStart = addDaysIso(firstWeekStart, i * 7);
|
||||
const weekEnd = addDaysIso(weekStart, 6);
|
||||
const partial = today < weekEnd;
|
||||
// Inclusive count of how many days of this week have actually elapsed.
|
||||
// Closed weeks sit at 7; the current week reports 1..6.
|
||||
const clampedToday =
|
||||
today < weekStart ? weekStart : today < weekEnd ? today : weekEnd;
|
||||
const elapsed = Math.min(7, Math.max(1, diffDaysIso(weekStart, clampedToday) + 1));
|
||||
shells.push({
|
||||
weekStart,
|
||||
weekEnd,
|
||||
label: formatShortDate(weekStart),
|
||||
rangeLabel: `${formatShortDate(weekStart)} – ${formatShortDate(weekEnd)}`,
|
||||
partial,
|
||||
daysCovered: partial ? elapsed : 7,
|
||||
});
|
||||
}
|
||||
return shells;
|
||||
}
|
||||
|
||||
function diffDaysIso(from: string, to: string): number {
|
||||
const [y1, m1, d1] = from.split("-").map(Number);
|
||||
const [y2, m2, d2] = to.split("-").map(Number);
|
||||
const a = Date.UTC(y1 ?? 1970, (m1 ?? 1) - 1, d1 ?? 1);
|
||||
const b = Date.UTC(y2 ?? 1970, (m2 ?? 1) - 1, d2 ?? 1);
|
||||
return Math.round((b - a) / 86_400_000);
|
||||
}
|
||||
|
||||
export function aggregateWeeklyTime(
|
||||
rows: DashboardRunTimeDaily[],
|
||||
tz: string,
|
||||
weekCount: number,
|
||||
): WeeklyTimeData[] {
|
||||
const shells = buildWeekShells(tz, weekCount);
|
||||
const totals = new Map<string, number>();
|
||||
for (const shell of shells) totals.set(shell.weekStart, 0);
|
||||
for (const r of rows) {
|
||||
const wkStart = weekStartIso(r.date);
|
||||
if (!totals.has(wkStart)) continue;
|
||||
totals.set(wkStart, (totals.get(wkStart) ?? 0) + r.total_seconds);
|
||||
}
|
||||
return shells.map((s) => ({ ...s, totalSeconds: totals.get(s.weekStart) ?? 0 }));
|
||||
}
|
||||
|
||||
export function aggregateWeeklyTasks(
|
||||
rows: DashboardRunTimeDaily[],
|
||||
tz: string,
|
||||
weekCount: number,
|
||||
): WeeklyTasksData[] {
|
||||
const shells = buildWeekShells(tz, weekCount);
|
||||
const buckets = new Map<string, { completed: number; failed: number }>();
|
||||
for (const shell of shells)
|
||||
buckets.set(shell.weekStart, { completed: 0, failed: 0 });
|
||||
for (const r of rows) {
|
||||
const wkStart = weekStartIso(r.date);
|
||||
const bucket = buckets.get(wkStart);
|
||||
if (!bucket) continue;
|
||||
const failed = r.failed_count;
|
||||
const completed = Math.max(0, r.task_count - failed);
|
||||
bucket.completed += completed;
|
||||
bucket.failed += failed;
|
||||
}
|
||||
return shells.map((s) => {
|
||||
const b = buckets.get(s.weekStart) ?? { completed: 0, failed: 0 };
|
||||
return { ...s, completed: b.completed, failed: b.failed };
|
||||
});
|
||||
}
|
||||
|
||||
// Per-date run-time rows → one row per date with `totalSeconds` for the
|
||||
// DailyTimeChart. Sorted ascending so the x-axis reads oldest-to-newest,
|
||||
// matching the cost / tokens aggregators.
|
||||
|
||||
141
packages/views/editor/attachment-card.test.tsx
Normal file
141
packages/views/editor/attachment-card.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
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";
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("AttachmentCard — chrome row", () => {
|
||||
it("renders chrome only and never an inline iframe (HTML rich preview lives in HtmlAttachmentPreview)", () => {
|
||||
render(
|
||||
<AttachmentCard
|
||||
filename="report.html"
|
||||
contentType="text/html"
|
||||
attachmentId="att-1"
|
||||
href="https://cdn.example/report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("report.html")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
|
||||
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 — text kinds need an attachmentId,
|
||||
// otherwise tryOpen rejects and the click becomes 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("shows the Eye button for an html source when an attachmentId is available", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
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 Eye and Download buttons while uploading", () => {
|
||||
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();
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
138
packages/views/editor/attachment-card.tsx
Normal file
138
packages/views/editor/attachment-card.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentCard — shared file-card row UI (icon + filename + Eye + Download).
|
||||
*
|
||||
* Subcomponent of the unified `<Attachment>` dispatcher (see attachment.tsx).
|
||||
* Rendered for every attachment kind that does not have a richer inline
|
||||
* renderer (image / html). Kind-aware routing lives in `<Attachment>` — keep
|
||||
* that decision out of this file so this stays a single-purpose row UI.
|
||||
*/
|
||||
|
||||
import { Download, Eye, FileText, Loader2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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 when the preview proxy is ID-keyed (text kinds
|
||||
* like markdown / html / text). Media kinds (pdf/video/audio) preview from
|
||||
* the URL alone.
|
||||
*/
|
||||
attachmentId?: string;
|
||||
/** Download URL — used 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;
|
||||
}
|
||||
|
||||
export function AttachmentCard({
|
||||
filename,
|
||||
contentType = "",
|
||||
attachmentId,
|
||||
href,
|
||||
uploading,
|
||||
onPreview,
|
||||
onDownload,
|
||||
}: 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);
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<AttachmentCardChrome
|
||||
filename={filename}
|
||||
uploading={uploading}
|
||||
canPreview={canPreview}
|
||||
canDownload={!!href}
|
||||
onPreview={onPreview}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,37 @@ vi.mock("./use-download-attachment", () => ({
|
||||
useDownloadAttachment: () => downloadMock,
|
||||
}));
|
||||
|
||||
// Module-level flags toggled per-test: simulate desktop (openInNewTab
|
||||
// adapter present) vs web (omitted), and the no-slug case where the
|
||||
// modal sits outside a workspace route.
|
||||
const { openInNewTabMock, getShareableUrlMock, navState, slugState } =
|
||||
vi.hoisted(() => ({
|
||||
openInNewTabMock: vi.fn(),
|
||||
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
|
||||
navState: { hasOpenInNewTab: true },
|
||||
slugState: { value: "acme" as string | null },
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/acme/issues",
|
||||
searchParams: new URLSearchParams(),
|
||||
...(navState.hasOpenInNewTab ? { openInNewTab: openInNewTabMock } : {}),
|
||||
getShareableUrl: getShareableUrlMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@multica/core/paths")>();
|
||||
return {
|
||||
...actual,
|
||||
useWorkspaceSlug: () => slugState.value,
|
||||
};
|
||||
});
|
||||
|
||||
// ReadonlyContent has a heavy import surface (lowlight + KaTeX + Mermaid).
|
||||
// Stub it so the markdown dispatch test only verifies wiring.
|
||||
vi.mock("./readonly-content", () => ({
|
||||
@@ -71,6 +102,7 @@ vi.mock("../i18n", () => ({
|
||||
preview_unsupported: "This file type can't be previewed.",
|
||||
close: "Close",
|
||||
download_failed: "",
|
||||
open_in_new_tab: "Open in new tab",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -113,6 +145,8 @@ function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
navState.hasOpenInNewTab = true;
|
||||
slugState.value = "acme";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -120,6 +154,28 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("AttachmentPreviewModal — dispatch", () => {
|
||||
it("renders an <img> centered in the modal for image content types", () => {
|
||||
const att = makeAttachment({ filename: "shot.png", content_type: "image/png" });
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
const img = document.querySelector("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img?.getAttribute("src")).toBe(att.download_url);
|
||||
expect(img?.getAttribute("alt")).toBe(att.filename);
|
||||
});
|
||||
|
||||
it("renders an <img> from a URL-only source for image filenames", () => {
|
||||
const url = "https://cdn.example.test/orphan.png?Signature=s";
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "url", url, filename: "orphan.png" }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const img = document.querySelector("img");
|
||||
expect(img?.getAttribute("src")).toBe(url);
|
||||
});
|
||||
|
||||
it("renders a PDF iframe pointing at the signed download URL", () => {
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
@@ -159,7 +215,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 +226,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>");
|
||||
});
|
||||
});
|
||||
@@ -315,6 +374,114 @@ describe("AttachmentPreviewModal — URL-only source", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentPreviewModal — open-in-new-tab (HTML only)", () => {
|
||||
it("renders the open-in-new-tab button in the header for HTML attachments", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>hi</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const att = makeAttachment({
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "full", attachment: att }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTitle("Open in new tab")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("invokes navigation.openInNewTab with the preview path when available (desktop)", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>hi</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const att = makeAttachment({
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "full", attachment: att }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTitle("Open in new tab"));
|
||||
expect(openInNewTabMock).toHaveBeenCalledWith(
|
||||
"/acme/attachments/att-1/preview?name=report.html",
|
||||
"report.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to window.open against the shareable URL on web", async () => {
|
||||
navState.hasOpenInNewTab = false;
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>hi</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, "open")
|
||||
.mockImplementation(() => null);
|
||||
const att = makeAttachment({
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "full", attachment: att }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByTitle("Open in new tab"));
|
||||
expect(openInNewTabMock).not.toHaveBeenCalled();
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
"https://app.example/acme/attachments/att-1/preview?name=report.html",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not render the new-tab button for non-HTML kinds", () => {
|
||||
const att = makeAttachment({
|
||||
filename: "manual.pdf",
|
||||
content_type: "application/pdf",
|
||||
});
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "full", attachment: att }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle("Open in new tab")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the new-tab button when there is no workspace slug", async () => {
|
||||
slugState.value = null;
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>hi</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const att = makeAttachment({
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
});
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "full", attachment: att }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle("Open in new tab")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAttachmentPreview — tryOpen gate", () => {
|
||||
it("accepts a full attachment for a media kind", () => {
|
||||
const { result } = renderHook(() => useAttachmentPreview());
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
/**
|
||||
* AttachmentPreviewModal — full-screen inline preview for an attachment.
|
||||
*
|
||||
* Sibling to the existing `ImageLightbox` (extensions/image-view.tsx) which
|
||||
* keeps owning images. This modal handles 6 other PreviewKinds:
|
||||
* Single modal for every previewable kind. Handles 7 PreviewKinds:
|
||||
*
|
||||
* - image : <img className="object-contain"> centered in the modal frame.
|
||||
* Replaces the previous standalone ImageLightbox.
|
||||
* - pdf : <iframe src={download_url}> — relies on Chromium's PDFium
|
||||
* plugin. On desktop, requires webPreferences.plugins=true
|
||||
* (see apps/desktop/src/main/index.ts).
|
||||
@@ -15,10 +16,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,19 +38,15 @@ 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, ExternalLink, FileText, Loader2, X } from "lucide-react";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { paths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
import { useT } from "../i18n";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { openExternal } from "../platform";
|
||||
import { ReadonlyContent } from "./readonly-content";
|
||||
import {
|
||||
@@ -56,6 +55,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)
|
||||
@@ -77,7 +78,7 @@ export type PreviewSource =
|
||||
|
||||
// PreviewKinds that can render from a URL-only source. Text-based kinds
|
||||
// (markdown / html / text) need the /content proxy which is ID-keyed.
|
||||
const URL_ONLY_KINDS = new Set<PreviewKind>(["pdf", "video", "audio"]);
|
||||
const URL_ONLY_KINDS = new Set<PreviewKind>(["image", "pdf", "video", "audio"]);
|
||||
|
||||
// Normalized view used everywhere downstream of `useAttachmentPreview`.
|
||||
// `attachmentId === null` signals URL-only mode (download falls back to
|
||||
@@ -180,6 +181,10 @@ export function AttachmentPreviewModal({
|
||||
const { t } = useT("editor");
|
||||
const download = useDownloadAttachment();
|
||||
const state = normalize(source);
|
||||
// useWorkspaceSlug (not useWorkspacePaths) — returns null outside a
|
||||
// workspace route instead of throwing, so the new-tab button just hides.
|
||||
const slug = useWorkspaceSlug();
|
||||
const navigation = useNavigation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -203,6 +208,25 @@ export function AttachmentPreviewModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Open-in-new-tab mirrors HtmlAttachmentPreview's inline toolbar: only the
|
||||
// `html` kind has a dedicated full-page route (/attachments/{id}/preview).
|
||||
// Gated on slug + attachmentId for the same reason — URL-only sources
|
||||
// can't address the /content proxy the page relies on.
|
||||
const canOpenInNewTab = kind === "html" && !!slug && !!state.attachmentId;
|
||||
const handleOpenInNewTab = () => {
|
||||
if (!slug || !state.attachmentId) return;
|
||||
const nameQuery = state.filename
|
||||
? `?name=${encodeURIComponent(state.filename)}`
|
||||
: "";
|
||||
const path = `${paths.workspace(slug).attachmentPreview(state.attachmentId)}${nameQuery}`;
|
||||
if (navigation.openInNewTab) {
|
||||
navigation.openInNewTab(path, state.filename);
|
||||
return;
|
||||
}
|
||||
const url = navigation.getShareableUrl(path);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
@@ -228,6 +252,17 @@ export function AttachmentPreviewModal({
|
||||
{state.contentType || "—"}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
{canOpenInNewTab && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.open_in_new_tab)}
|
||||
aria-label={t(($) => $.attachment.open_in_new_tab)}
|
||||
onClick={handleOpenInNewTab}
|
||||
>
|
||||
<ExternalLink className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
@@ -309,6 +344,16 @@ function PreviewContent({
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "image":
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black/40 p-4">
|
||||
<img
|
||||
src={state.mediaUrl}
|
||||
alt={state.filename}
|
||||
className="max-h-full max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "pdf":
|
||||
return (
|
||||
<iframe
|
||||
@@ -355,7 +400,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 +418,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 +447,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 +485,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
298
packages/views/editor/attachment.test.tsx
Normal file
298
packages/views/editor/attachment.test.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Attachment as AttachmentRecord } from "@multica/core/types";
|
||||
|
||||
const {
|
||||
getAttachmentTextContentMock,
|
||||
downloadMock,
|
||||
openExternalMock,
|
||||
openByUrlMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
downloadMock: vi.fn(),
|
||||
openExternalMock: vi.fn(),
|
||||
openByUrlMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("./use-download-attachment", () => ({
|
||||
useDownloadAttachment: () => downloadMock,
|
||||
}));
|
||||
|
||||
vi.mock("../platform", () => ({
|
||||
openExternal: openExternalMock,
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: {
|
||||
view: "View",
|
||||
download: "Download",
|
||||
copy_link: "Copy link",
|
||||
copy_link_failed: "Copy failed",
|
||||
link_copied: "Link copied",
|
||||
delete: "Delete",
|
||||
},
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
preview_unsupported: "This file type can't be previewed.",
|
||||
preview_too_large: "File is too large to preview.",
|
||||
open_in_new_tab: "Open in new tab",
|
||||
close: "Close",
|
||||
},
|
||||
file_card: { uploading: "Uploading {{filename}}" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/acme/issues",
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: vi.fn(),
|
||||
getShareableUrl: (p: string) => `https://app.example${p}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@multica/core/paths")>();
|
||||
return {
|
||||
...actual,
|
||||
useWorkspaceSlug: () => "acme",
|
||||
useWorkspacePaths: () => actual.paths.workspace("acme"),
|
||||
};
|
||||
});
|
||||
|
||||
// Resolver mock — feeds the test-scoped attachments[] into the
|
||||
// useAttachmentDownloadResolver hook the component reads.
|
||||
const resolverState: { attachments: AttachmentRecord[] } = { attachments: [] };
|
||||
vi.mock("./attachment-download-context", () => ({
|
||||
useAttachmentDownloadResolver: () => ({
|
||||
resolveAttachmentId: (url: string) =>
|
||||
resolverState.attachments.find((a) => a.url === url)?.id,
|
||||
resolveAttachment: (url: string) =>
|
||||
resolverState.attachments.find((a) => a.url === url),
|
||||
openByUrl: openByUrlMock,
|
||||
}),
|
||||
AttachmentDownloadProvider: ({ children }: { children: ReactNode }) =>
|
||||
<>{children}</>,
|
||||
}));
|
||||
|
||||
import { Attachment } from "./attachment";
|
||||
|
||||
function makeRecord(overrides: Partial<AttachmentRecord> = {}): AttachmentRecord {
|
||||
return {
|
||||
id: "att-1",
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "u-1",
|
||||
filename: "shot.png",
|
||||
url: "https://cdn.example.test/att-1.png",
|
||||
download_url: "https://cdn.example.test/att-1.png?Signature=s",
|
||||
content_type: "image/png",
|
||||
size_bytes: 1024,
|
||||
created_at: "2026-05-13T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolverState.attachments = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Attachment — image dispatch", () => {
|
||||
it("record image renders <img> with hover toolbar (View/Download/Copy)", () => {
|
||||
const att = makeRecord();
|
||||
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
|
||||
const img = document.querySelector("img");
|
||||
expect(img).toBeTruthy();
|
||||
expect(img?.getAttribute("src")).toBe(att.url);
|
||||
expect(img?.getAttribute("alt")).toBe("shot.png");
|
||||
expect(screen.getByTitle("View")).toBeTruthy();
|
||||
expect(screen.getByTitle("Download")).toBeTruthy();
|
||||
expect(screen.getByTitle("Copy link")).toBeTruthy();
|
||||
// Trash only shows in editable mode.
|
||||
expect(screen.queryByTitle("Delete")).toBeNull();
|
||||
});
|
||||
|
||||
it("editable image shows Trash button and wires onDelete", () => {
|
||||
const att = makeRecord();
|
||||
const onDelete = vi.fn();
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{ kind: "record", attachment: att }}
|
||||
editable
|
||||
onDelete={onDelete}
|
||||
/>,
|
||||
);
|
||||
const trash = screen.getByTitle("Delete");
|
||||
fireEvent.click(trash);
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("url-only image resolves to a record via context and uses its id for download", () => {
|
||||
const att = makeRecord({
|
||||
filename: "from-resolver.png",
|
||||
url: "https://cdn.example.test/from-resolver.png",
|
||||
});
|
||||
resolverState.attachments = [att];
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: att.url,
|
||||
filename: "from-resolver.png",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const img = document.querySelector("img");
|
||||
expect(img?.getAttribute("src")).toBe(att.url);
|
||||
fireEvent.click(screen.getByTitle("Download"));
|
||||
expect(downloadMock).toHaveBeenCalledWith("att-1");
|
||||
});
|
||||
|
||||
it("forceKind=image renders as image even when filename is empty (markdown  regression)", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: "https://external.example/no-ext-here",
|
||||
filename: "",
|
||||
forceKind: "image",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// Without forceKind the empty filename would fall through to AttachmentCard.
|
||||
// With forceKind="image" it must render as an <img>.
|
||||
expect(document.querySelector("img")).toBeTruthy();
|
||||
expect(screen.queryByText("Uploading")).toBeNull();
|
||||
});
|
||||
|
||||
it("external image (no resolver match) renders <img> and falls back to openByUrl on Download", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: "https://external.example/foo.png",
|
||||
filename: "foo.png",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
const img = document.querySelector("img");
|
||||
expect(img?.getAttribute("src")).toBe("https://external.example/foo.png");
|
||||
fireEvent.click(screen.getByTitle("Download"));
|
||||
expect(openByUrlMock).toHaveBeenCalledWith("https://external.example/foo.png");
|
||||
expect(downloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uploading image renders no toolbar (loader state)", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: "blob://local",
|
||||
filename: "in-flight.png",
|
||||
uploading: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTitle("View")).toBeNull();
|
||||
expect(screen.queryByTitle("Download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attachment — html dispatch", () => {
|
||||
it("record html with attachmentId renders HtmlAttachmentPreview (no file-card chrome)", () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const att = makeRecord({
|
||||
filename: "report.html",
|
||||
content_type: "text/html",
|
||||
url: "https://cdn.example.test/report.html",
|
||||
});
|
||||
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
|
||||
// HtmlAttachmentPreview hides the filename row.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
expect(screen.getByTitle("Preview")).toBeTruthy();
|
||||
expect(screen.getByTitle("Download")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("url-only html (no resolver match) falls back to AttachmentCard chrome", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: "https://external.example/report.html",
|
||||
filename: "report.html",
|
||||
contentType: "text/html",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
// Without an attachment id the /content proxy is unreachable, so we
|
||||
// show the chrome instead of the iframe.
|
||||
expect(screen.getByText("report.html")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attachment — file-card dispatch", () => {
|
||||
it("record pdf renders the file-card chrome (filename + Preview/Download)", () => {
|
||||
const att = makeRecord({
|
||||
filename: "manual.pdf",
|
||||
content_type: "application/pdf",
|
||||
});
|
||||
renderWithQuery(<Attachment attachment={{ kind: "record", attachment: att }} />);
|
||||
expect(screen.getByText("manual.pdf")).toBeTruthy();
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
expect(document.querySelector("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("uploading file-card surfaces the uploading template, no Preview/Download", () => {
|
||||
renderWithQuery(
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: "blob://local",
|
||||
filename: "in-flight.zip",
|
||||
uploading: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Uploading {{filename}}")).toBeTruthy();
|
||||
// Preview/Download chrome is hidden while uploading.
|
||||
expect(screen.queryByTitle("Preview")).toBeNull();
|
||||
expect(screen.queryByTitle("Download")).toBeNull();
|
||||
});
|
||||
});
|
||||
342
packages/views/editor/attachment.tsx
Normal file
342
packages/views/editor/attachment.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Attachment — single unified renderer for every attachment surface.
|
||||
*
|
||||
* Takes one attachment-shaped input (a full record, a URL-only reference, or
|
||||
* an in-flight upload) and dispatches by PreviewKind:
|
||||
*
|
||||
* - image → ImageAttachmentView (figure + hover toolbar + lightbox via
|
||||
* the shared AttachmentPreviewModal)
|
||||
* - html → HtmlAttachmentPreview (inline iframe + hover toolbar)
|
||||
* - others → AttachmentCard (icon + filename + Eye/Download row)
|
||||
*
|
||||
* Call sites:
|
||||
* - extensions/file-card.tsx FileCardView (Tiptap NodeView)
|
||||
* - extensions/image-view.tsx ImageView (Tiptap NodeView)
|
||||
* - readonly-content.tsx (markdown img + fileCard div renderers)
|
||||
* - issues/components/comment-card.tsx AttachmentList (standalone fallback)
|
||||
* - common/markdown.tsx (chat / skill viewer Markdown wrapper)
|
||||
*
|
||||
* The component owns its own preview modal and download dispatcher — callers
|
||||
* just pass `attachment` and (for editor surfaces) optional editor chrome
|
||||
* hints (selected, editable, onDelete).
|
||||
*/
|
||||
|
||||
import {
|
||||
Download,
|
||||
Link as LinkIcon,
|
||||
Maximize2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import type { Attachment as AttachmentRecord } from "@multica/core/types";
|
||||
import { useT } from "../i18n";
|
||||
import { useAttachmentDownloadResolver } from "./attachment-download-context";
|
||||
import { useAttachmentPreview } from "./attachment-preview-modal";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { AttachmentCard } from "./attachment-card";
|
||||
import { HtmlAttachmentPreview } from "./html-attachment-preview";
|
||||
import { getPreviewKind, type PreviewKind } from "./utils/preview";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type AttachmentInput =
|
||||
// Server response in hand — full record. Used by AttachmentList and any
|
||||
// caller iterating a server-returned attachments[] array.
|
||||
| { kind: "record"; attachment: AttachmentRecord }
|
||||
// Markdown / Tiptap inline: only a URL + filename. Resolves to a full
|
||||
// record via the surrounding AttachmentDownloadProvider when available;
|
||||
// otherwise renders in URL-only mode (media types still preview from URL,
|
||||
// text types fall back to a download CTA).
|
||||
| {
|
||||
kind: "url";
|
||||
url: string;
|
||||
filename: string;
|
||||
contentType?: string;
|
||||
/** Editor in-flight state. Renders a loader placeholder. */
|
||||
uploading?: boolean;
|
||||
/**
|
||||
* Structural hint from the call site: "this slot is definitionally an
|
||||
* image / file / ...". Bypasses `getPreviewKind` autodetect, which
|
||||
* needs a filename or content-type and falls back to the file-card
|
||||
* chrome when neither is available. Required for callers that KNOW
|
||||
* the kind from context (markdown `![]()` is always an image; Tiptap
|
||||
* image NodeView is always an image) but receive only a URL with an
|
||||
* empty `alt`/`filename`.
|
||||
*/
|
||||
forceKind?: PreviewKind;
|
||||
};
|
||||
|
||||
export interface AttachmentProps {
|
||||
attachment: AttachmentInput;
|
||||
/** Editor hint — when true, the image toolbar exposes Trash. */
|
||||
editable?: boolean;
|
||||
/** Editor hint — applies the "selected" visual to the image figure. */
|
||||
selected?: boolean;
|
||||
/** Editor hint — wired to Tiptap deleteNode(). */
|
||||
onDelete?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface Normalized {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
url: string;
|
||||
attachmentId?: string;
|
||||
record?: AttachmentRecord;
|
||||
uploading: boolean;
|
||||
}
|
||||
|
||||
function normalize(
|
||||
input: AttachmentInput,
|
||||
resolve: (url: string) => AttachmentRecord | undefined,
|
||||
): Normalized {
|
||||
if (input.kind === "record") {
|
||||
return {
|
||||
filename: input.attachment.filename,
|
||||
contentType: input.attachment.content_type,
|
||||
url: input.attachment.url,
|
||||
attachmentId: input.attachment.id,
|
||||
record: input.attachment,
|
||||
uploading: false,
|
||||
};
|
||||
}
|
||||
const record = input.url ? resolve(input.url) : undefined;
|
||||
return {
|
||||
filename: input.filename || record?.filename || "",
|
||||
contentType: input.contentType || record?.content_type || "",
|
||||
url: input.url,
|
||||
attachmentId: record?.id,
|
||||
record,
|
||||
uploading: !!input.uploading,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function Attachment({
|
||||
attachment,
|
||||
editable,
|
||||
selected,
|
||||
onDelete,
|
||||
className,
|
||||
}: AttachmentProps) {
|
||||
const { resolveAttachment, openByUrl } = useAttachmentDownloadResolver();
|
||||
const download = useDownloadAttachment();
|
||||
const preview = useAttachmentPreview();
|
||||
|
||||
const state = normalize(attachment, resolveAttachment);
|
||||
const forceKind =
|
||||
attachment.kind === "url" ? attachment.forceKind : undefined;
|
||||
const kind =
|
||||
forceKind ??
|
||||
(state.filename || state.contentType
|
||||
? getPreviewKind(state.contentType, state.filename)
|
||||
: null);
|
||||
|
||||
const openPreview = () => {
|
||||
if (state.record) {
|
||||
preview.tryOpen({ kind: "full", attachment: state.record });
|
||||
return;
|
||||
}
|
||||
if (state.url) {
|
||||
preview.tryOpen({
|
||||
kind: "url",
|
||||
url: state.url,
|
||||
filename: state.filename,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (state.attachmentId) {
|
||||
download(state.attachmentId);
|
||||
return;
|
||||
}
|
||||
if (state.url) openByUrl(state.url);
|
||||
};
|
||||
|
||||
if (kind === "image") {
|
||||
return (
|
||||
<>
|
||||
<ImageAttachmentView
|
||||
src={state.url}
|
||||
alt={state.filename}
|
||||
uploading={state.uploading}
|
||||
editable={editable}
|
||||
selected={selected}
|
||||
onView={openPreview}
|
||||
onDownload={handleDownload}
|
||||
onDelete={onDelete}
|
||||
className={className}
|
||||
/>
|
||||
{preview.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "html" && state.attachmentId && !state.uploading) {
|
||||
return (
|
||||
<>
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId={state.attachmentId}
|
||||
filename={state.filename}
|
||||
onPreview={openPreview}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
{preview.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AttachmentCard
|
||||
filename={state.filename}
|
||||
contentType={state.contentType}
|
||||
attachmentId={state.attachmentId}
|
||||
href={state.url || undefined}
|
||||
uploading={state.uploading}
|
||||
onPreview={openPreview}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
{preview.modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ImageAttachmentView — inline image with hover toolbar
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Self-contained Tailwind: works inside the editor surface (where the legacy
|
||||
// `.rich-text-editor .image-figure` CSS in content-editor.css continues to
|
||||
// apply for backward compatibility) AND in standalone surfaces (chat
|
||||
// messages, comment-card AttachmentList) that don't carry that scope.
|
||||
|
||||
interface ImageAttachmentViewProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
uploading: boolean;
|
||||
editable?: boolean;
|
||||
selected?: boolean;
|
||||
onView: () => void;
|
||||
onDownload: () => void;
|
||||
onDelete?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ImageAttachmentView({
|
||||
src,
|
||||
alt,
|
||||
uploading,
|
||||
editable,
|
||||
selected,
|
||||
onView,
|
||||
onDownload,
|
||||
onDelete,
|
||||
className,
|
||||
}: ImageAttachmentViewProps) {
|
||||
const { t } = useT("editor");
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success(t(($) => $.image.link_copied));
|
||||
} catch {
|
||||
toast.error(t(($) => $.image.copy_link_failed));
|
||||
}
|
||||
};
|
||||
|
||||
// Click on figure opens the preview only in non-editor surfaces — inside
|
||||
// the editor we let ProseMirror own the click for selection / cursor
|
||||
// placement and route preview through the explicit Maximize button.
|
||||
const figureOnClick = !editable && !uploading ? onView : undefined;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"image-node group/image relative inline-block max-w-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"image-figure relative inline-block max-w-full rounded-md transition-shadow",
|
||||
selected && editable && "image-selected ring-2 ring-primary",
|
||||
!editable && !uploading && "cursor-zoom-in",
|
||||
)}
|
||||
onClick={figureOnClick}
|
||||
>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={cn(
|
||||
"image-content block max-w-full rounded-md",
|
||||
uploading && "image-uploading opacity-60",
|
||||
)}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
// Defensive: an image input without a URL is degenerate, but
|
||||
// emitting nothing leaves no anchor for the toolbar. Render a
|
||||
// small placeholder so the surface is still recognizable.
|
||||
<span className="block h-20 w-32 rounded-md bg-muted" />
|
||||
)}
|
||||
{!uploading && src && (
|
||||
<span
|
||||
className="image-toolbar absolute right-2 top-2 flex items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 opacity-0 shadow-sm transition-opacity group-hover/image:opacity-100"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t(($) => $.image.view)}
|
||||
aria-label={t(($) => $.image.view)}
|
||||
onClick={onView}
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onClick={onDownload}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t(($) => $.image.copy_link)}
|
||||
aria-label={t(($) => $.image.copy_link)}
|
||||
onClick={handleCopyLink}
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</button>
|
||||
{editable && onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
title={t(($) => $.image.delete)}
|
||||
aria-label={t(($) => $.image.delete)}
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
56
packages/views/editor/code-block-static.tsx
Normal file
56
packages/views/editor/code-block-static.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
"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";
|
||||
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, ">");
|
||||
}
|
||||
@@ -315,6 +315,12 @@
|
||||
|
||||
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
|
||||
|
||||
/* XML / HTML — lowlight emits .hljs-tag for `<` `>` brackets and .hljs-name
|
||||
for the element name. Without these rules, HTML source renders mostly in
|
||||
the default text color and looks unhighlighted. */
|
||||
.rich-text-editor .hljs-tag { color: var(--muted-foreground); }
|
||||
.rich-text-editor .hljs-name { color: oklch(0.55 0.16 255); }
|
||||
|
||||
/* Dark mode overrides */
|
||||
.dark .rich-text-editor .hljs-keyword,
|
||||
.dark .rich-text-editor .hljs-selector-tag,
|
||||
@@ -341,6 +347,8 @@
|
||||
|
||||
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
|
||||
|
||||
.dark .rich-text-editor .hljs-name { color: oklch(0.72 0.14 255); }
|
||||
|
||||
/* Tables */
|
||||
.rich-text-editor .tableWrapper {
|
||||
overflow-x: auto;
|
||||
|
||||
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>
|
||||
|
||||
129
packages/views/editor/extensions/file-card.test.tsx
Normal file
129
packages/views/editor/extensions/file-card.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Tiptap NodeView primitives can't be instantiated without a full editor.
|
||||
// Stub the wrapper so FileCardView renders as a plain React component and
|
||||
// the DOM can be inspected directly.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
NodeViewWrapper: ({ children, ...rest }: any) => <div {...rest}>{children}</div>,
|
||||
}));
|
||||
|
||||
const { getAttachmentTextContentMock, resolveAttachmentMock, openByUrlMock, tryOpenMock } =
|
||||
vi.hoisted(() => ({
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
resolveAttachmentMock: vi.fn(),
|
||||
openByUrlMock: vi.fn(),
|
||||
tryOpenMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: class extends Error {},
|
||||
PreviewUnsupportedError: class extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("../attachment-download-context", () => ({
|
||||
useAttachmentDownloadResolver: () => ({
|
||||
openByUrl: openByUrlMock,
|
||||
resolveAttachment: resolveAttachmentMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../attachment-preview-modal", () => ({
|
||||
useAttachmentPreview: () => ({ tryOpen: tryOpenMock, open: vi.fn(), modal: null }),
|
||||
}));
|
||||
|
||||
// HtmlAttachmentPreview (the kind="html" route through AttachmentBlock) now
|
||||
// reads useNavigation() + useWorkspaceSlug() for its Open-in-new-tab button.
|
||||
// Provide minimal mocks so the component renders without a real provider.
|
||||
vi.mock("../../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/acme/issues",
|
||||
searchParams: new URLSearchParams(),
|
||||
openInNewTab: vi.fn(),
|
||||
getShareableUrl: (p: string) => `https://app.example${p}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/paths", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@multica/core/paths")>();
|
||||
return {
|
||||
...actual,
|
||||
useWorkspaceSlug: () => "acme",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: { download: "Download" },
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
open_in_new_tab: "Open in new tab",
|
||||
},
|
||||
code_block: { copy_code: "Copy code" },
|
||||
file_card: { uploading: "Uploading {{filename}}" },
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { FileCardView } from "./file-card";
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("FileCardView — HTML attachment routes through AttachmentBlock to iframe", () => {
|
||||
// Regression pin for file-card.tsx:59. The NodeView must render through
|
||||
// <AttachmentBlock>, not the older <AttachmentCard>. If someone reverts that
|
||||
// line, the dispatcher's html+attachmentId branch is bypassed and the user
|
||||
// is left with the file-card chrome — exactly the bug MUL-2330 surfaced.
|
||||
it("renders an iframe (no file-card chrome) when the node resolves to an HTML attachment", async () => {
|
||||
resolveAttachmentMock.mockReturnValue({
|
||||
id: "att-1",
|
||||
content_type: "text/html",
|
||||
url: "/uploads/report.html",
|
||||
filename: "report.html",
|
||||
});
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
|
||||
const node = {
|
||||
attrs: {
|
||||
href: "/uploads/report.html",
|
||||
filename: "report.html",
|
||||
uploading: false,
|
||||
},
|
||||
} as any;
|
||||
|
||||
renderWithQuery(<FileCardView node={node} {...({} as any)} />);
|
||||
|
||||
const frame = await waitFor(() => {
|
||||
const f = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(f).toBeTruthy();
|
||||
return f!;
|
||||
});
|
||||
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
|
||||
// The AttachmentCard chrome surfaces the filename as text inside its row.
|
||||
// HtmlAttachmentPreview replaces the chrome entirely, so the filename
|
||||
// must not appear as visible text.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -17,12 +17,8 @@
|
||||
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 { Attachment } from "../attachment";
|
||||
|
||||
const FILE_CARD_MARKDOWN_RE = new RegExp(
|
||||
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
|
||||
@@ -30,94 +26,21 @@ const FILE_CARD_MARKDOWN_RE = new RegExp(
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// React NodeView — thin wrapper, all rendering lives in <Attachment>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// React NodeView
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function FileCardView({ node }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
export function FileCardView({ node }: NodeViewProps) {
|
||||
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.
|
||||
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) {
|
||||
preview.tryOpen({ kind: "full", attachment });
|
||||
} else if (href) {
|
||||
preview.tryOpen({ kind: "url", url: href, filename });
|
||||
}
|
||||
};
|
||||
|
||||
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}>
|
||||
<Attachment
|
||||
attachment={{ kind: "url", url: href, filename, uploading }}
|
||||
/>
|
||||
</div>
|
||||
{preview.modal}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,148 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
/**
|
||||
* ImageView — Tiptap NodeView for the image node.
|
||||
*
|
||||
* Thin wrapper around the unified `<Attachment>` dispatcher. All rendering
|
||||
* (figure, hover toolbar, lightbox/preview) lives there. The NodeView only
|
||||
* forwards Tiptap's editor-context hints (editable, selected, deleteNode).
|
||||
*/
|
||||
|
||||
import { NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import {
|
||||
Maximize2,
|
||||
Download,
|
||||
Link as LinkIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lightbox — full-screen image preview (ESC or click backdrop to close)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ImageLightbox({
|
||||
src,
|
||||
alt,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 cursor-zoom-out"
|
||||
onClick={onClose}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg object-contain"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Image NodeView — renders img with hover toolbar + lightbox
|
||||
// ---------------------------------------------------------------------------
|
||||
import { Attachment } from "../attachment";
|
||||
|
||||
function ImageView({ node, editor, selected, deleteNode }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
const src = node.attrs.src as string;
|
||||
const src = (node.attrs.src as string) || "";
|
||||
const alt = (node.attrs.alt as string) || "";
|
||||
const title = node.attrs.title as string | undefined;
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
const { openByUrl } = useAttachmentDownloadResolver();
|
||||
|
||||
const [lightbox, setLightbox] = useState(false);
|
||||
const isEditable = editor.isEditable;
|
||||
|
||||
const handleView = () => setLightbox(true);
|
||||
|
||||
const handleDownload = () => {
|
||||
// Cross-origin CDN images can't be fetched as blob (CORS),
|
||||
// and <a download> is ignored for cross-origin URLs.
|
||||
// Re-sign through the provider when the src maps to a known
|
||||
// attachment; otherwise just open externally.
|
||||
openByUrl(src);
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(src);
|
||||
toast.success(t(($) => $.image.link_copied));
|
||||
} catch {
|
||||
toast.error(t(($) => $.image.copy_link_failed));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="image-node">
|
||||
<figure
|
||||
className={cn(
|
||||
"image-figure",
|
||||
selected && isEditable && "image-selected",
|
||||
)}
|
||||
contentEditable={false}
|
||||
onClick={!isEditable && !uploading ? handleView : undefined}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
title={title || undefined}
|
||||
className={cn("image-content", uploading && "image-uploading")}
|
||||
draggable={false}
|
||||
/>
|
||||
{!uploading && (
|
||||
<div
|
||||
className="image-toolbar"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" onClick={handleView} title={t(($) => $.image.view)}>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
<button type="button" onClick={handleDownload} title={t(($) => $.image.download)}>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
title={t(($) => $.image.copy_link)}
|
||||
>
|
||||
<LinkIcon className="size-3.5" />
|
||||
</button>
|
||||
{isEditable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteNode()}
|
||||
title={t(($) => $.image.delete)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</figure>
|
||||
{lightbox && (
|
||||
<ImageLightbox
|
||||
src={src}
|
||||
alt={alt}
|
||||
onClose={() => setLightbox(false)}
|
||||
/>
|
||||
)}
|
||||
<Attachment
|
||||
attachment={{
|
||||
kind: "url",
|
||||
url: src,
|
||||
filename: alt,
|
||||
uploading,
|
||||
// Tiptap image node is structurally an image regardless of alt.
|
||||
forceKind: "image",
|
||||
}}
|
||||
editable={editor.isEditable}
|
||||
selected={selected}
|
||||
onDelete={() => deleteNode()}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export { ImageView, ImageLightbox };
|
||||
export { ImageView };
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
272
packages/views/editor/html-attachment-preview.test.tsx
Normal file
272
packages/views/editor/html-attachment-preview.test.tsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
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…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
open_in_new_tab: "Open in new tab",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Module-level flag toggled per-test to simulate desktop (openInNewTab
|
||||
// present) vs web (omitted) adapters. vi.hoisted so the mock factory can
|
||||
// close over it.
|
||||
const { openInNewTabMock, getShareableUrlMock, navState } = vi.hoisted(() => ({
|
||||
openInNewTabMock: vi.fn(),
|
||||
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
|
||||
navState: { hasOpenInNewTab: true },
|
||||
}));
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/acme/issues",
|
||||
searchParams: new URLSearchParams(),
|
||||
...(navState.hasOpenInNewTab ? { openInNewTab: openInNewTabMock } : {}),
|
||||
getShareableUrl: getShareableUrlMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Slug is required for the new-tab path to be built. The component reads
|
||||
// it from useWorkspaceSlug() on @multica/core/paths — stub to return a
|
||||
// fixed slug so the tests do not need a WorkspaceSlugProvider tree.
|
||||
vi.mock("@multica/core/paths", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@multica/core/paths")>();
|
||||
return {
|
||||
...actual,
|
||||
useWorkspaceSlug: () => "acme",
|
||||
useWorkspacePaths: () => actual.paths.workspace("acme"),
|
||||
};
|
||||
});
|
||||
|
||||
import { HtmlAttachmentPreview } from "./html-attachment-preview";
|
||||
|
||||
function renderWithQuery(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
navState.hasOpenInNewTab = true;
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("HtmlAttachmentPreview — visual shell (does not use file-card chrome)", () => {
|
||||
it("does not render the filename row that AttachmentCard chrome would render", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector("iframe")).toBeTruthy();
|
||||
});
|
||||
// The chrome row would surface the filename as text; we replace that
|
||||
// entirely with an iframe + floating toolbar.
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders iframe with sandbox='allow-scripts' and srcdoc when text loads", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>chart goes here</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
// Critical: sandbox must not include allow-same-origin, otherwise the
|
||||
// sandbox is defeated per the HTML spec.
|
||||
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
||||
expect(frame?.getAttribute("srcdoc")).toBe("<p>chart goes here</p>");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("HtmlAttachmentPreview — toolbar actions", () => {
|
||||
it("invokes onPreview when Maximize is clicked", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const onPreview = vi.fn();
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={onPreview}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTitle("Preview")).toBeTruthy());
|
||||
fireEvent.mouseDown(screen.getByTitle("Preview"));
|
||||
expect(onPreview).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("invokes onDownload when Download is clicked", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const onDownload = vi.fn();
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={onDownload}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTitle("Download")).toBeTruthy());
|
||||
fireEvent.mouseDown(screen.getByTitle("Download"));
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render a Copy code button — attachments are files, not source snippets", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(document.querySelector("iframe")).toBeTruthy());
|
||||
expect(screen.queryByTitle("Copy code")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes navigation.openInNewTab with the preview path when available (desktop)", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Open in new tab")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByTitle("Open in new tab"));
|
||||
expect(openInNewTabMock).toHaveBeenCalledWith(
|
||||
"/acme/attachments/att-1/preview?name=report.html",
|
||||
"report.html",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to window.open against the shareable URL when openInNewTab is absent (web)", async () => {
|
||||
navState.hasOpenInNewTab = false;
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>ok</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const windowOpenSpy = vi
|
||||
.spyOn(window, "open")
|
||||
.mockImplementation(() => null);
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={() => {}}
|
||||
onDownload={() => {}}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTitle("Open in new tab")).toBeTruthy(),
|
||||
);
|
||||
fireEvent.mouseDown(screen.getByTitle("Open in new tab"));
|
||||
expect(openInNewTabMock).not.toHaveBeenCalled();
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
"https://app.example/acme/attachments/att-1/preview?name=report.html",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar", () => {
|
||||
it("keeps Preview and Download enabled when fetch errors", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("nope"));
|
||||
const onPreview = vi.fn();
|
||||
const onDownload = vi.fn();
|
||||
renderWithQuery(
|
||||
<HtmlAttachmentPreview
|
||||
attachmentId="att-1"
|
||||
filename="report.html"
|
||||
onPreview={onPreview}
|
||||
onDownload={onDownload}
|
||||
/>,
|
||||
);
|
||||
// Wait for the error placeholder — guarantees the query has settled.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId("html-attachment-preview-error"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
// Critical: the figure does NOT collapse, and the chrome row is NOT
|
||||
// rendered as a fallback. Preview and Download stay reachable.
|
||||
expect(document.querySelector("iframe")).toBeNull();
|
||||
expect(screen.queryByText("report.html")).toBeNull();
|
||||
|
||||
const previewBtn = screen.getByTitle("Preview") as HTMLButtonElement;
|
||||
const downloadBtn = screen.getByTitle("Download") as HTMLButtonElement;
|
||||
const openInNewTabBtn = screen.getByTitle(
|
||||
"Open in new tab",
|
||||
) as HTMLButtonElement;
|
||||
expect(previewBtn.disabled).toBe(false);
|
||||
expect(downloadBtn.disabled).toBe(false);
|
||||
expect(openInNewTabBtn.disabled).toBe(false);
|
||||
|
||||
fireEvent.mouseDown(previewBtn);
|
||||
expect(onPreview).toHaveBeenCalled();
|
||||
fireEvent.mouseDown(downloadBtn);
|
||||
expect(onDownload).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user