mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-22 23:19:17 +02:00
Compare commits
93 Commits
fix/table-
...
v0.3.24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5eb778532 | ||
|
|
c27d35b7fe | ||
|
|
5a3324e886 | ||
|
|
3077810049 | ||
|
|
a08281a1b2 | ||
|
|
23eba24076 | ||
|
|
0f36c88855 | ||
|
|
77ac17ef49 | ||
|
|
af146b6dc7 | ||
|
|
41586f1499 | ||
|
|
59263df748 | ||
|
|
d26cac0008 | ||
|
|
6f2e9aa7a8 | ||
|
|
acb1c3fb64 | ||
|
|
114a1ffb8f | ||
|
|
eb6dffdbc6 | ||
|
|
6e010320f8 | ||
|
|
3030c803bf | ||
|
|
6bb8cac9ea | ||
|
|
64ce459e30 | ||
|
|
1f5cb51d4e | ||
|
|
52e76e7b23 | ||
|
|
32dac3dd57 | ||
|
|
1f8f3e8037 | ||
|
|
f46b929ebc | ||
|
|
89ada0ee81 | ||
|
|
1272311ebe | ||
|
|
18a58e80c0 | ||
|
|
2c0f6edca8 | ||
|
|
3aaca155e7 | ||
|
|
4f1797598e | ||
|
|
8ba1ef2dce | ||
|
|
097064ed0e | ||
|
|
089832d6ec | ||
|
|
c222088262 | ||
|
|
79394ee057 | ||
|
|
241a3582cf | ||
|
|
7c71007e6e | ||
|
|
2f24057bc2 | ||
|
|
1afa493165 | ||
|
|
f2e72577b2 | ||
|
|
12c2d58e18 | ||
|
|
7d30ef1c67 | ||
|
|
3ce4cf6f2f | ||
|
|
93541be975 | ||
|
|
76c687d39a | ||
|
|
f9c193e06b | ||
|
|
0e31a9ca58 | ||
|
|
71eb938a67 | ||
|
|
4df6c1468d | ||
|
|
8ea8048005 | ||
|
|
ea4f816ce2 | ||
|
|
7bd99c3c87 | ||
|
|
40b318e3e0 | ||
|
|
90fafab33a | ||
|
|
2ab7b5b7af | ||
|
|
63cf0ed308 | ||
|
|
9a7eebb194 | ||
|
|
a4fb84d5ac | ||
|
|
6c17771cce | ||
|
|
34d4cd3a28 | ||
|
|
5b7eb9ad20 | ||
|
|
04a0677704 | ||
|
|
f415099c4a | ||
|
|
ef08d8584c | ||
|
|
70b90d287c | ||
|
|
fa15041864 | ||
|
|
7db3e507d1 | ||
|
|
7d28b5a040 | ||
|
|
be00801acf | ||
|
|
c8ab73d38d | ||
|
|
99afb82c50 | ||
|
|
d2a03b8edc | ||
|
|
4594c776e1 | ||
|
|
9439a85aa6 | ||
|
|
f37d71a443 | ||
|
|
9f720a401c | ||
|
|
c510515da7 | ||
|
|
21ff178ac0 | ||
|
|
5c136f8557 | ||
|
|
5957454dd9 | ||
|
|
0985bad9fd | ||
|
|
6acca84c28 | ||
|
|
0cbb834f96 | ||
|
|
8151f60c6c | ||
|
|
e4ec9dc425 | ||
|
|
5480c69c9e | ||
|
|
7d719cfbbe | ||
|
|
a0b63462d0 | ||
|
|
d66730ecdb | ||
|
|
2754b7d7d8 | ||
|
|
f2ba3c8f1a | ||
|
|
dc129b1178 |
25
.env.example
25
.env.example
@@ -21,11 +21,16 @@ APP_ENV=
|
||||
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
|
||||
MULTICA_DEV_VERIFICATION_CODE=
|
||||
PORT=8080
|
||||
# Optional aliases for the local/self-host backend port. If one is set, it
|
||||
# takes precedence over PORT in compose, Makefile, and installer helpers.
|
||||
# BACKEND_PORT=8080
|
||||
# Docker Compose consumes flat port values. Set BACKEND_PORT directly to
|
||||
# override the backend host port.
|
||||
BACKEND_PORT=8080
|
||||
# Optional aliases for local/self-host backend port helpers outside compose.
|
||||
# API_PORT=8080
|
||||
# SERVER_PORT=8080
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
|
||||
# Prometheus metrics are disabled by default. When enabled, bind to loopback
|
||||
# unless you protect the listener with private networking, allowlists, or
|
||||
# proxy auth. Do not expose this endpoint through the public app/API ingress.
|
||||
@@ -35,9 +40,9 @@ JWT_SECRET=change-me-in-production
|
||||
# Derived by Makefile / local scripts from the backend port.
|
||||
# Set explicitly only when the daemon reaches the API through a different URL.
|
||||
# MULTICA_SERVER_URL=ws://localhost:8080/ws
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Set explicitly only when the app's public URL differs from local frontend.
|
||||
# MULTICA_APP_URL=http://localhost:3000
|
||||
MULTICA_APP_URL=${FRONTEND_ORIGIN}
|
||||
# Public URL the API is reachable at from the open internet (no trailing
|
||||
# slash). Used to mint absolute webhook URLs for autopilot webhook
|
||||
# triggers and to show correct daemon setup commands in the web UI. Leave
|
||||
@@ -112,9 +117,9 @@ SMTP_EHLO_NAME=
|
||||
# rebuild is needed.
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
|
||||
# Set explicitly only when your OAuth callback URL differs from local frontend.
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback
|
||||
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
|
||||
|
||||
# S3 / CloudFront
|
||||
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
|
||||
@@ -122,6 +127,8 @@ GOOGLE_CLIENT_SECRET=
|
||||
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
|
||||
S3_BUCKET=
|
||||
S3_REGION=us-west-2
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
|
||||
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
|
||||
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
|
||||
@@ -228,10 +235,6 @@ MULTICA_LARK_HTTP_BASE_URL=
|
||||
MULTICA_LARK_CALLBACK_BASE_URL=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
|
||||
# Set explicitly only when serving frontend on a different origin/domain.
|
||||
# FRONTEND_ORIGIN=http://localhost:3000
|
||||
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
|
||||
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
|
||||
NEXT_PUBLIC_API_URL=
|
||||
|
||||
90
apps/desktop/src/main/freeze-breadcrumb.test.ts
Normal file
90
apps/desktop/src/main/freeze-breadcrumb.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
type FreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Each test gets its own temp dir so the on-disk breadcrumb is isolated.
|
||||
const dirs: string[] = [];
|
||||
function tempFile(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "freeze-breadcrumb-"));
|
||||
dirs.push(dir);
|
||||
return join(dir, "last-client-failure.json");
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const sample: FreezeBreadcrumb = {
|
||||
kind: "unresponsive",
|
||||
context: { desktopRoute: { path: "/acme/issues" } },
|
||||
ts: 1_700_000_000_000,
|
||||
version: "0.3.1",
|
||||
};
|
||||
|
||||
describe("freeze breadcrumb round-trip", () => {
|
||||
it("writes then reads back the breadcrumb", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
});
|
||||
|
||||
it("read clears the file so a failure reports exactly once", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toEqual(sample);
|
||||
expect(existsSync(file)).toBe(false);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearFreezeBreadcrumb removes a pending breadcrumb (hang recovered)", () => {
|
||||
const file = tempFile();
|
||||
writeFreezeBreadcrumb(file, sample);
|
||||
clearFreezeBreadcrumb(file);
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The breadcrumb crosses a process boundary (main writes, renderer flushes via
|
||||
// IPC) and lives across app versions — a future write shape or a corrupt file
|
||||
// must never throw into boot. CLAUDE.md "API Response Compatibility".
|
||||
describe("freeze breadcrumb defends against malformed input", () => {
|
||||
it("returns null when no file exists", () => {
|
||||
expect(readAndClearFreezeBreadcrumb(tempFile())).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on corrupt JSON", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "{ not valid json", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is missing", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ ts: 1, version: "x" }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when `kind` is the wrong type", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, JSON.stringify({ kind: 42, context: {} }), "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on a JSON null payload", () => {
|
||||
const file = tempFile();
|
||||
writeFileSync(file, "null", "utf8");
|
||||
expect(readAndClearFreezeBreadcrumb(file)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearing a non-existent file is a no-op, never throws", () => {
|
||||
expect(() => clearFreezeBreadcrumb(tempFile())).not.toThrow();
|
||||
});
|
||||
});
|
||||
76
apps/desktop/src/main/freeze-breadcrumb.ts
Normal file
76
apps/desktop/src/main/freeze-breadcrumb.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { writeFileSync, readFileSync, rmSync } from "node:fs";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
// When the renderer truly hangs or its process dies, it can't send telemetry
|
||||
// itself — the thread is blocked or gone. The main process (always alive) is
|
||||
// the only watcher that can react, but during the hang it can't reach the
|
||||
// renderer's posthog-js either. So it writes a breadcrumb to disk; the next
|
||||
// time a renderer boots, it reads + clears the file and reports the event.
|
||||
// This survives even a force-quit, which is the whole point.
|
||||
|
||||
export type { FreezeBreadcrumb };
|
||||
|
||||
/**
|
||||
* Best-effort write. A breadcrumb we can't persist is lost, never fatal.
|
||||
*
|
||||
* Known limitation: this is a single slot — last write wins. Multiple failures
|
||||
* within one session collapse to the last one, so per-session failure counts
|
||||
* are undercounted. Acceptable for now: telemetry aggregates presence and
|
||||
* frequency across users, not exhaustive per-session sequences. Upgrade to an
|
||||
* append/ring buffer if per-session failure chains become a question.
|
||||
*/
|
||||
export function writeFreezeBreadcrumb(filePath: string, breadcrumb: FreezeBreadcrumb): void {
|
||||
try {
|
||||
writeFileSync(filePath, JSON.stringify(breadcrumb), "utf8");
|
||||
} catch {
|
||||
// Disk full / permissions — drop silently.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a persisted breadcrumb. Called when the renderer recovers from a hang
|
||||
* (a `responsive` event after `unresponsive`): the breadcrumb was written
|
||||
* pre-emptively while the thread was stuck, but since it came back, the
|
||||
* in-thread long-task watchdog already reports it — keeping the breadcrumb
|
||||
* would double-count it AND mislabel a recovered window as `recovered: false`.
|
||||
* Best-effort; a stale breadcrumb only costs one duplicate report.
|
||||
*/
|
||||
export function clearFreezeBreadcrumb(filePath: string): void {
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// Nothing to clear / permissions — ignore.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the breadcrumb and delete it in the same call, so a failure is reported
|
||||
* exactly once. Returns null when there's no breadcrumb (the normal case) or
|
||||
* when the file is unreadable / corrupt.
|
||||
*/
|
||||
export function readAndClearFreezeBreadcrumb(filePath: string): FreezeBreadcrumb | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
rmSync(filePath, { force: true });
|
||||
} catch {
|
||||
// If we can't delete it we'd re-report next launch; acceptable over throwing.
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
typeof (parsed as FreezeBreadcrumb).kind === "string"
|
||||
) {
|
||||
return parsed as FreezeBreadcrumb;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON — drop.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -13,11 +13,21 @@ import { installNavigationGestures } from "./navigation-gestures";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
sanitizeRendererRouteContext,
|
||||
type RendererRouteContext,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
createElectronReloadPrompt,
|
||||
installRendererRecoveryHandlers,
|
||||
type RendererRecoveryWindow,
|
||||
} from "./renderer-recovery";
|
||||
import {
|
||||
writeFreezeBreadcrumb,
|
||||
readAndClearFreezeBreadcrumb,
|
||||
clearFreezeBreadcrumb,
|
||||
} from "./freeze-breadcrumb";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
@@ -61,7 +71,15 @@ if (process.platform !== "win32") {
|
||||
|
||||
const PROTOCOL = "multica";
|
||||
|
||||
// Where the main process parks a freeze/crash breadcrumb until the next
|
||||
// renderer boot flushes it to telemetry. Lives in userData so it survives a
|
||||
// force-quit. Resolved lazily — app.getPath is only valid after `ready`.
|
||||
function freezeBreadcrumbPath(): string {
|
||||
return join(app.getPath("userData"), "last-client-failure.json");
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let latestRendererRouteContext: RendererRouteContext | null = null;
|
||||
let runtimeConfigResult: RuntimeConfigResult = {
|
||||
ok: false,
|
||||
error: { message: "Runtime config has not loaded yet" },
|
||||
@@ -166,9 +184,13 @@ function createWindow(): void {
|
||||
},
|
||||
});
|
||||
const window = mainWindow;
|
||||
latestRendererRouteContext = null;
|
||||
|
||||
window.on("closed", () => {
|
||||
if (mainWindow === window) mainWindow = null;
|
||||
if (mainWindow === window) {
|
||||
mainWindow = null;
|
||||
latestRendererRouteContext = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Strip Origin header from WebSocket upgrade requests so the server's
|
||||
@@ -204,10 +226,14 @@ function createWindow(): void {
|
||||
|
||||
// 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.
|
||||
// anything we own here (reload-block, zoom, tab-close) is the sole handler
|
||||
// for that combination — no double-fire with the macOS default View menu.
|
||||
window.webContents.on("before-input-event", (event, input) => {
|
||||
if (handleAppShortcut(input, window.webContents)) {
|
||||
const result = handleAppShortcut(input, window.webContents);
|
||||
if (result === "close-tab") {
|
||||
event.preventDefault();
|
||||
window.webContents.send("tab:close-active");
|
||||
} else if (result) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
@@ -255,6 +281,27 @@ function createWindow(): void {
|
||||
showReloadPrompt: createElectronReloadPrompt((options) =>
|
||||
dialog.showMessageBox(window, options),
|
||||
),
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl: window.webContents.getURL(),
|
||||
...(latestRendererRouteContext
|
||||
? { desktopRoute: latestRendererRouteContext }
|
||||
: {}),
|
||||
}),
|
||||
// Only persist in production: a true hang/crash can't report itself, so we
|
||||
// write a breadcrumb and the next renderer boot flushes it to PostHog. Dev
|
||||
// is excluded to keep field telemetry clean.
|
||||
persistBreadcrumb: is.dev
|
||||
? undefined
|
||||
: (payload) =>
|
||||
writeFreezeBreadcrumb(freezeBreadcrumbPath(), {
|
||||
kind: payload.kind,
|
||||
context: payload.context,
|
||||
ts: Date.now(),
|
||||
version: getAppVersion(),
|
||||
}),
|
||||
clearBreadcrumb: is.dev
|
||||
? undefined
|
||||
: () => clearFreezeBreadcrumb(freezeBreadcrumbPath()),
|
||||
});
|
||||
|
||||
installContextMenu(window.webContents);
|
||||
@@ -370,6 +417,11 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
// Renderer requests window close (e.g. Cmd+W on last tab).
|
||||
ipcMain.on("window:close", () => {
|
||||
mainWindow?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
@@ -388,6 +440,14 @@ if (!gotTheLock) {
|
||||
event.returnValue = { version: getAppVersion(), os };
|
||||
});
|
||||
|
||||
// Sync IPC: read + clear any freeze/crash breadcrumb left by a previous
|
||||
// session. The renderer flushes it to telemetry on boot (it couldn't be
|
||||
// reported when it happened — the renderer was hung or gone). Read-and-
|
||||
// clear so a failure reports exactly once.
|
||||
ipcMain.on("freeze:get-last", (event) => {
|
||||
event.returnValue = readAndClearFreezeBreadcrumb(freezeBreadcrumbPath());
|
||||
});
|
||||
|
||||
// Sync IPC: preload exposes the validated runtime config before renderer
|
||||
// boot. If desktop.json exists but is invalid, renderer receives the
|
||||
// blocking error and must not silently fall back to the cloud defaults.
|
||||
@@ -395,6 +455,13 @@ if (!gotTheLock) {
|
||||
event.returnValue = runtimeConfigResult;
|
||||
});
|
||||
|
||||
ipcMain.on(RENDERER_ROUTE_CONTEXT_CHANNEL, (event, context: unknown) => {
|
||||
if (!mainWindow || event.sender !== mainWindow.webContents) return;
|
||||
const sanitized = sanitizeRendererRouteContext(context);
|
||||
if (!sanitized) return;
|
||||
latestRendererRouteContext = sanitized;
|
||||
});
|
||||
|
||||
// IPC: toggle immersive mode — hides the macOS traffic lights so full-screen
|
||||
// modals (e.g. create-workspace) can place UI in the top-left corner
|
||||
// without fighting the native window controls' hit-test.
|
||||
|
||||
@@ -14,13 +14,14 @@ function makeWc(initialLevel = 0) {
|
||||
|
||||
function key(
|
||||
k: string,
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta">> = {},
|
||||
mods: Partial<Pick<ShortcutInput, "control" | "meta" | "shift">> = {},
|
||||
): ShortcutInput {
|
||||
return {
|
||||
type: "keyDown",
|
||||
key: k,
|
||||
control: false,
|
||||
meta: false,
|
||||
shift: false,
|
||||
...mods,
|
||||
};
|
||||
}
|
||||
@@ -150,3 +151,36 @@ describe("handleAppShortcut — unrelated keys pass through", () => {
|
||||
expect(handleAppShortcut(key("k", { meta: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAppShortcut — close tab (Cmd/Ctrl+W)", () => {
|
||||
it('returns "close-tab" on Cmd+W (macOS)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Cmd+W uppercase', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true }), wc, "darwin")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it('returns "close-tab" on Ctrl+W (Linux/Windows)', () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "linux")).toBe("close-tab");
|
||||
expect(handleAppShortcut(key("w", { control: true }), wc, "win32")).toBe("close-tab");
|
||||
});
|
||||
|
||||
it("does not trigger without Cmd/Ctrl modifier", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("w"), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Cmd+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { meta: true, shift: true }), wc, "darwin")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trigger on Ctrl+Shift+W (reserved for close-window)", () => {
|
||||
const wc = makeWc();
|
||||
expect(handleAppShortcut(key("W", { control: true, shift: true }), wc, "linux")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ShortcutInput = {
|
||||
key: string;
|
||||
control: boolean;
|
||||
meta: boolean;
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
|
||||
@@ -34,11 +35,19 @@ const ZOOM_MAX = 4.5;
|
||||
* Handling the shortcuts here gives identical behavior on every platform
|
||||
* and every layout.
|
||||
*/
|
||||
/**
|
||||
* Result of handleAppShortcut:
|
||||
* - `false`: not handled, let Electron continue
|
||||
* - `true`: handled (preventDefault), no further action
|
||||
* - `"close-tab"`: Cmd/Ctrl+W intercepted — caller should send IPC to renderer
|
||||
*/
|
||||
export type ShortcutResult = boolean | "close-tab";
|
||||
|
||||
export function handleAppShortcut(
|
||||
input: ShortcutInput,
|
||||
webContents: ZoomTarget,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): boolean {
|
||||
): ShortcutResult {
|
||||
if (input.type !== "keyDown") return false;
|
||||
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
|
||||
|
||||
@@ -70,5 +79,12 @@ export function handleAppShortcut(
|
||||
return true;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl + W → close active tab (or window if last tab).
|
||||
// Cmd/Ctrl + Shift + W is reserved for "close window" — do not intercept.
|
||||
// Return a signal so the caller can send IPC to the renderer.
|
||||
if (input.key.toLowerCase() === "w" && !input.shift) {
|
||||
return "close-tab";
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
import { createElectronReloadPrompt, installRendererRecoveryHandlers } from "./renderer-recovery";
|
||||
|
||||
type Handler = (...args: unknown[]) => void;
|
||||
|
||||
@@ -83,10 +83,50 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
const desktopRoute = {
|
||||
surface: "tab",
|
||||
path: "/acme/issues/MUL-3239",
|
||||
workspaceSlug: "acme",
|
||||
tabId: "tab-1",
|
||||
reportedAt: "2026-06-15T00:00:00.000Z",
|
||||
};
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => ({
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
}),
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({
|
||||
kind: "unresponsive",
|
||||
context: {
|
||||
windowUrl:
|
||||
"file:///Applications/Multica.app/Contents/Resources/app.asar/index.html",
|
||||
desktopRoute,
|
||||
},
|
||||
});
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps prompting when diagnostic context collection fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const showReloadPrompt = vi.fn(async () => "dismiss" as const);
|
||||
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext: () => {
|
||||
throw new Error("diagnostics unavailable");
|
||||
},
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
|
||||
@@ -94,7 +134,6 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(showReloadPrompt).toHaveBeenCalledWith({ kind: "unresponsive", context: {} });
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps dev diagnostics non-prompting", async () => {
|
||||
@@ -109,4 +148,124 @@ describe("installRendererRecoveryHandlers", () => {
|
||||
expect(showReloadPrompt).not.toHaveBeenCalled();
|
||||
expect(fixture.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows actionable recovery guidance before diagnostic details", async () => {
|
||||
let detail = "";
|
||||
const showMessageBox = vi.fn(
|
||||
async (options: { title: string; message: string; detail: string }) => {
|
||||
detail = options.detail;
|
||||
return { response: 1 };
|
||||
},
|
||||
);
|
||||
const showReloadPrompt = createElectronReloadPrompt(showMessageBox);
|
||||
|
||||
await showReloadPrompt({ kind: "unresponsive", context: {} });
|
||||
|
||||
expect(showMessageBox).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Multica needs to reload",
|
||||
message: "The desktop window has been stuck for a few seconds.",
|
||||
detail: expect.stringContaining(
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(detail).toContain("what you were doing right before this message appeared");
|
||||
expect(detail).toContain("Activity Monitor sample");
|
||||
expect(detail).toContain("Diagnostic details:\nkind: unresponsive\ncontext: {}");
|
||||
});
|
||||
});
|
||||
|
||||
describe("freeze/crash breadcrumb state machine", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
function install(fixture: ReturnType<typeof makeWindow>) {
|
||||
const persistBreadcrumb = vi.fn();
|
||||
const clearBreadcrumb = vi.fn();
|
||||
installRendererRecoveryHandlers(fixture.window, {
|
||||
isDev: false,
|
||||
showReloadPrompt: vi.fn(async () => "dismiss" as const),
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
unresponsivePromptDelayMs: 100,
|
||||
});
|
||||
return { persistBreadcrumb, clearBreadcrumb };
|
||||
}
|
||||
|
||||
it("a sustained hang writes exactly one unresponsive breadcrumb", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "unresponsive" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recovering after a written breadcrumb clears it (no double-count, no false recovered:false)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
expect(clearBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("recovering before the delay never writes a breadcrumb, so nothing to clear", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
fixture.windowHandlers.get("responsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a hang that never recovers (force-quit) keeps its breadcrumb for next-boot reporting", async () => {
|
||||
vi.useFakeTimers();
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.windowHandlers.get("unresponsive")?.();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
// No "responsive" ever fires — the breadcrumb must survive uncleared.
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a recoverable crash writes a breadcrumb and never clears it (a dead process never recovers)", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb, clearBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "crashed" });
|
||||
|
||||
expect(persistBreadcrumb).toHaveBeenCalledTimes(1);
|
||||
expect(persistBreadcrumb).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ kind: "render-process-gone" }),
|
||||
);
|
||||
expect(clearBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a clean (non-crash) renderer exit writes no breadcrumb", () => {
|
||||
const fixture = makeWindow();
|
||||
const { persistBreadcrumb } = install(fixture);
|
||||
|
||||
fixture.webContentsHandlers.get("render-process-gone")?.({}, { reason: "clean-exit" });
|
||||
|
||||
expect(persistBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,22 @@ type ReloadPromptResult = "reload" | "dismiss";
|
||||
type RendererRecoveryOptions = {
|
||||
isDev: boolean;
|
||||
showReloadPrompt: (payload: ReloadPromptPayload) => Promise<ReloadPromptResult>;
|
||||
getDiagnosticContext?: () => Record<string, unknown>;
|
||||
/**
|
||||
* Persist a freeze/crash breadcrumb to disk. The renderer can't report a
|
||||
* true hang or process death itself (blocked / gone), so the main process
|
||||
* writes it here and the next renderer boot flushes it to telemetry. Omit
|
||||
* in dev to keep field telemetry clean.
|
||||
*/
|
||||
persistBreadcrumb?: (payload: ReloadPromptPayload) => void;
|
||||
/**
|
||||
* Delete a previously-persisted unresponsive breadcrumb. Called when the
|
||||
* renderer recovers (`responsive` after `unresponsive`): the window came
|
||||
* back, so the in-thread watchdog reports the freeze and the breadcrumb
|
||||
* would only double-count it. Crash breadcrumbs are never cleared — a dead
|
||||
* process never recovers.
|
||||
*/
|
||||
clearBreadcrumb?: () => void;
|
||||
log?: (tag: string, ...args: unknown[]) => void;
|
||||
unresponsivePromptDelayMs?: number;
|
||||
};
|
||||
@@ -26,11 +42,21 @@ export function installRendererRecoveryHandlers(
|
||||
{
|
||||
isDev,
|
||||
showReloadPrompt,
|
||||
getDiagnosticContext,
|
||||
persistBreadcrumb,
|
||||
clearBreadcrumb,
|
||||
log = defaultDevLog,
|
||||
unresponsivePromptDelayMs = 1500,
|
||||
}: RendererRecoveryOptions,
|
||||
) {
|
||||
let unresponsivePromptTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// True once a breadcrumb has been written for the current hang. A later
|
||||
// `responsive` clears it; only a hang that never returns survives to report.
|
||||
let unresponsiveBreadcrumbWritten = false;
|
||||
const mergeDiagnosticContext = (context: Record<string, unknown>) => ({
|
||||
...readDiagnosticContext(getDiagnosticContext),
|
||||
...context,
|
||||
});
|
||||
const maybePromptReload = (payload: ReloadPromptPayload) => {
|
||||
if (isDev) return;
|
||||
void showReloadPrompt(payload).then((result) => {
|
||||
@@ -43,14 +69,23 @@ export function installRendererRecoveryHandlers(
|
||||
window.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (isDev) log("process-gone", JSON.stringify(details));
|
||||
if (!isRecoverableRendererExit(details)) return;
|
||||
maybePromptReload({ kind: "render-process-gone", context: { details } });
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "render-process-gone",
|
||||
context: mergeDiagnosticContext({ details }),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
maybePromptReload(payload);
|
||||
});
|
||||
|
||||
// preload-error intentionally does NOT persist a breadcrumb: it's a startup
|
||||
// failure of the preload script itself, and the breadcrumb-flush path depends
|
||||
// on that same preload exposing `getLastFreeze` — if preload is broken, the
|
||||
// next boot couldn't read it back anyway. We only prompt for reload here.
|
||||
window.webContents.on("preload-error", (_event, preloadPath, error) => {
|
||||
if (isDev) log("preload-error", `path=${preloadPath} err=${formatError(error)}`);
|
||||
maybePromptReload({
|
||||
kind: "preload-error",
|
||||
context: { preloadPath, error: formatError(error) },
|
||||
context: mergeDiagnosticContext({ preloadPath, error: formatError(error) }),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,14 +93,27 @@ export function installRendererRecoveryHandlers(
|
||||
if (isDev || unresponsivePromptTimer) return;
|
||||
unresponsivePromptTimer = setTimeout(() => {
|
||||
unresponsivePromptTimer = null;
|
||||
maybePromptReload({ kind: "unresponsive", context: {} });
|
||||
const payload: ReloadPromptPayload = {
|
||||
kind: "unresponsive",
|
||||
context: mergeDiagnosticContext({}),
|
||||
};
|
||||
persistBreadcrumb?.(payload);
|
||||
unresponsiveBreadcrumbWritten = true;
|
||||
maybePromptReload(payload);
|
||||
}, unresponsivePromptDelayMs);
|
||||
});
|
||||
|
||||
window.on("responsive", () => {
|
||||
if (!unresponsivePromptTimer) return;
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
if (unresponsivePromptTimer) {
|
||||
clearTimeout(unresponsivePromptTimer);
|
||||
unresponsivePromptTimer = null;
|
||||
}
|
||||
// The window came back: drop any breadcrumb written during this hang so it
|
||||
// isn't re-reported (and mislabeled `recovered: false`) on next boot.
|
||||
if (unresponsiveBreadcrumbWritten) {
|
||||
clearBreadcrumb?.();
|
||||
unresponsiveBreadcrumbWritten = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -109,18 +157,30 @@ function isRecoverableRendererExit(details: unknown) {
|
||||
function rendererRecoveryMessage(kind: ReloadPromptPayload["kind"]) {
|
||||
switch (kind) {
|
||||
case "render-process-gone":
|
||||
return "The desktop renderer process stopped responding or crashed.";
|
||||
return "The desktop window stopped unexpectedly.";
|
||||
case "preload-error":
|
||||
return "The desktop preload script failed before the app could start.";
|
||||
return "The desktop window could not finish starting.";
|
||||
case "unresponsive":
|
||||
return "The desktop window is not responding.";
|
||||
return "The desktop window has been stuck for a few seconds.";
|
||||
}
|
||||
}
|
||||
|
||||
function rendererRecoveryDetail(payload: ReloadPromptPayload) {
|
||||
const guidance = [
|
||||
"Click Reload to refresh this window and keep using Multica.",
|
||||
"If this keeps happening, please tell us what you were doing right before this message appeared and whether Reload recovered the window.",
|
||||
];
|
||||
|
||||
if (payload.kind === "unresponsive") {
|
||||
guidance.push(
|
||||
"For macOS reports, an Activity Monitor sample of the Multica Helper (Renderer) process helps us find what blocked the app.",
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
"Reloading is the safest recovery path for this window.",
|
||||
...guidance,
|
||||
"",
|
||||
"Diagnostic details:",
|
||||
`kind: ${payload.kind}`,
|
||||
`context: ${JSON.stringify(payload.context)}`,
|
||||
].join("\n");
|
||||
@@ -130,6 +190,17 @@ function defaultDevLog(tag: string, ...args: unknown[]) {
|
||||
process.stderr.write(`[renderer ${tag}] ${args.map(String).join(" ")}\n`);
|
||||
}
|
||||
|
||||
function readDiagnosticContext(
|
||||
getDiagnosticContext: (() => Record<string, unknown>) | undefined,
|
||||
) {
|
||||
if (!getDiagnosticContext) return {};
|
||||
try {
|
||||
return getDiagnosticContext();
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function formatError(error: unknown) {
|
||||
return error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
12
apps/desktop/src/preload/index.d.ts
vendored
12
apps/desktop/src/preload/index.d.ts
vendored
@@ -1,6 +1,8 @@
|
||||
import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { NavigationGesture } from "../shared/navigation-gestures";
|
||||
import type { RendererRouteContextInput } from "../shared/renderer-route-context";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
|
||||
interface DesktopAPI {
|
||||
/** App version + normalized OS, captured synchronously at preload time. */
|
||||
@@ -14,6 +16,9 @@ interface DesktopAPI {
|
||||
onSystemLocaleChanged: (callback: (locale: string) => void) => () => void;
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig: RuntimeConfigResult;
|
||||
/** Read + clear any freeze/crash breadcrumb from a previous session, so the
|
||||
* renderer can flush it to telemetry on boot. Null when nothing's pending. */
|
||||
getLastFreeze: () => FreezeBreadcrumb | null;
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
@@ -45,6 +50,8 @@ interface DesktopAPI {
|
||||
) => () => void;
|
||||
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
|
||||
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) => void;
|
||||
/** Open the OS folder picker and return the chosen absolute path.
|
||||
* Used by the Project settings "Add local directory" flow. */
|
||||
pickDirectory: (
|
||||
@@ -71,6 +78,11 @@ interface DesktopAPI {
|
||||
| "error";
|
||||
error?: string;
|
||||
}>;
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* Returns an unsubscribe function. */
|
||||
onCloseActiveTab: (callback: () => void) => () => void;
|
||||
/** Ask the main process to close the window. */
|
||||
closeWindow: () => void;
|
||||
}
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import { electronAPI } from "@electron-toolkit/preload";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
import type { FreezeBreadcrumb } from "../shared/freeze-breadcrumb";
|
||||
import {
|
||||
RENDERER_ROUTE_CONTEXT_CHANNEL,
|
||||
type RendererRouteContextInput,
|
||||
} from "../shared/renderer-route-context";
|
||||
import {
|
||||
isNavigationGesture,
|
||||
NAVIGATION_GESTURE_CHANNEL,
|
||||
@@ -74,6 +79,16 @@ const desktopAPI = {
|
||||
},
|
||||
/** Validated runtime endpoint config, or a blocking config error. */
|
||||
runtimeConfig,
|
||||
/** Read + clear any freeze/crash breadcrumb left by a previous session, so
|
||||
* the renderer can flush it to telemetry on boot. Returns null when there's
|
||||
* nothing pending (the normal case). */
|
||||
getLastFreeze: (): FreezeBreadcrumb | null => {
|
||||
try {
|
||||
return ipcRenderer.sendSync("freeze:get-last") as FreezeBreadcrumb | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
/** Listen for auth token delivered via deep link */
|
||||
onAuthToken: (callback: (token: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, token: string) =>
|
||||
@@ -156,12 +171,27 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
|
||||
};
|
||||
},
|
||||
/** Report the renderer's memory-router path for recovery diagnostics. */
|
||||
setRendererRouteContext: (context: RendererRouteContextInput) =>
|
||||
ipcRenderer.send(RENDERER_ROUTE_CONTEXT_CHANNEL, context),
|
||||
/** Open the OS folder picker and return the chosen absolute path. */
|
||||
pickDirectory: (defaultPath?: string) =>
|
||||
ipcRenderer.invoke("local-directory:pick", defaultPath),
|
||||
/** Validate that a path is an existing readable+writable directory. */
|
||||
validateLocalDirectory: (path: string) =>
|
||||
ipcRenderer.invoke("local-directory:validate", path),
|
||||
/** Listen for Cmd/Ctrl+W tab-close requests from the main process.
|
||||
* The renderer should close the active tab; if it was the last tab,
|
||||
* call `closeWindow()` to dismiss the window. Returns an unsubscribe fn. */
|
||||
onCloseActiveTab: (callback: () => void) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("tab:close-active", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("tab:close-active", handler);
|
||||
};
|
||||
},
|
||||
/** Ask the main process to close the window (used after closing the last tab). */
|
||||
closeWindow: () => ipcRenderer.send("window:close"),
|
||||
};
|
||||
|
||||
interface DaemonStatus {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
import { useDaemonIPCBridge } from "./platform/daemon-ipc-bridge";
|
||||
import { createDesktopLocaleAdapter } from "./platform/i18n-adapter";
|
||||
import { captureEvent } from "@multica/core/analytics";
|
||||
import { RESOURCES } from "@multica/views/locales";
|
||||
|
||||
// BCP-47 region tags for the <html lang> attribute, mirroring
|
||||
@@ -34,10 +35,42 @@ const HTML_LANG: Record<SupportedLocale, string> = {
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+W: close the active tab. When the last real tab is closed
|
||||
* (or no tabs/workspace exist — e.g. login page), close the window.
|
||||
*
|
||||
* Mounted at the App root so every renderer state — including login,
|
||||
* loading, onboarding, and runtime-config errors — has a working Cmd+W
|
||||
* handler. Without this, states outside the tab shell would swallow the
|
||||
* shortcut and do nothing.
|
||||
*/
|
||||
function useCmdWCloseTab() {
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onCloseActiveTab(() => {
|
||||
const store = useTabStore.getState();
|
||||
const { activeWorkspaceSlug, byWorkspace } = store;
|
||||
if (!activeWorkspaceSlug) {
|
||||
// No workspace — nothing to close, dismiss the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
const group = byWorkspace[activeWorkspaceSlug];
|
||||
if (!group || group.tabs.length <= 1) {
|
||||
// Last tab (or no tabs) — close the window.
|
||||
window.desktopAPI.closeWindow();
|
||||
return;
|
||||
}
|
||||
// Multiple tabs — close the active one.
|
||||
store.closeActiveTab();
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const qc = useQueryClient();
|
||||
|
||||
// Deep-link login runs loginWithToken → syncToken → listWorkspaces →
|
||||
// setQueryData sequentially. loginWithToken sets user+isLoading=false
|
||||
// as soon as getMe resolves, which would cause DesktopShell to mount
|
||||
@@ -298,6 +331,28 @@ export default function App() {
|
||||
const { version, os } = window.desktopAPI.appInfo;
|
||||
const systemLocale = window.desktopAPI.systemLocale;
|
||||
const runtimeConfigResult = window.desktopAPI.runtimeConfig;
|
||||
useCmdWCloseTab();
|
||||
|
||||
// Flush a freeze/crash breadcrumb the main process parked from a previous
|
||||
// session. A true hang or process death can't report itself when it happens
|
||||
// (the renderer is blocked or gone), so the main process persists it and we
|
||||
// emit it here on the next boot. The in-thread, recoverable freeze tier is
|
||||
// handled separately by the shared watchdog in CoreProvider.
|
||||
useEffect(() => {
|
||||
const last = window.desktopAPI.getLastFreeze();
|
||||
if (!last) return;
|
||||
const crashed = last.kind === "render-process-gone";
|
||||
captureEvent(crashed ? "client_crash" : "client_unresponsive", {
|
||||
// Spread context FIRST so our explicit fields below always win — a
|
||||
// future context key (e.g. its own `source`) must not silently override.
|
||||
...last.context,
|
||||
source: crashed ? "render-process-gone" : "main-unresponsive",
|
||||
recovered: false,
|
||||
breadcrumb_ts: last.ts,
|
||||
crashed_version: last.version,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Stable identity reference so downstream effects (WS reconnect) don't
|
||||
// tear down on every parent render.
|
||||
const identity = useMemo(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useTabStore,
|
||||
} from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore, type WindowOverlay } from "@/stores/window-overlay-store";
|
||||
import type { RendererRouteContextInput } from "../../../shared/renderer-route-context";
|
||||
|
||||
/**
|
||||
* Fires a PostHog $pageview whenever the user's visible surface changes,
|
||||
@@ -90,6 +91,16 @@ export function PageviewTracker() {
|
||||
const last = lastSurfaceRef.current;
|
||||
const next = { kind, key, path };
|
||||
|
||||
const routeContext: RendererRouteContextInput = {
|
||||
surface: kind,
|
||||
path,
|
||||
};
|
||||
if (kind === "tab") {
|
||||
routeContext.workspaceSlug = activeWorkspaceSlug ?? undefined;
|
||||
routeContext.tabId = activeTabId ?? undefined;
|
||||
}
|
||||
reportRendererRouteContext(routeContext);
|
||||
|
||||
if (kind === "tab" && key !== null) {
|
||||
const knownPath = observed.get(key);
|
||||
const isReactivation =
|
||||
@@ -112,6 +123,13 @@ export function PageviewTracker() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function reportRendererRouteContext(context: RendererRouteContextInput) {
|
||||
const desktopAPI = window.desktopAPI as
|
||||
| { setRendererRouteContext?: (context: RendererRouteContextInput) => void }
|
||||
| undefined;
|
||||
desktopAPI?.setRendererRouteContext?.(context);
|
||||
}
|
||||
|
||||
function overlayPath(overlay: WindowOverlay): string {
|
||||
switch (overlay.type) {
|
||||
case "new-workspace":
|
||||
|
||||
16
apps/desktop/src/shared/freeze-breadcrumb.ts
Normal file
16
apps/desktop/src/shared/freeze-breadcrumb.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* A freeze/crash breadcrumb persisted by the main process and flushed to
|
||||
* telemetry by the next renderer boot. Shared across main, preload, and
|
||||
* renderer because all three touch it. See main/freeze-breadcrumb.ts for the
|
||||
* read/write logic and the rationale.
|
||||
*/
|
||||
export interface FreezeBreadcrumb {
|
||||
/** "unresponsive" (hang) or "render-process-gone" (crash). */
|
||||
kind: string;
|
||||
/** Diagnostic context captured at failure time (route, window url, …). */
|
||||
context: Record<string, unknown>;
|
||||
/** Epoch ms when the failure was recorded. */
|
||||
ts: number;
|
||||
/** App version at failure time. */
|
||||
version: string;
|
||||
}
|
||||
51
apps/desktop/src/shared/renderer-route-context.ts
Normal file
51
apps/desktop/src/shared/renderer-route-context.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export const RENDERER_ROUTE_CONTEXT_CHANNEL = "renderer:route-context";
|
||||
|
||||
export type RendererRouteSurface = "login" | "overlay" | "tab";
|
||||
|
||||
export type RendererRouteContextInput = {
|
||||
surface: RendererRouteSurface;
|
||||
path: string;
|
||||
workspaceSlug?: string;
|
||||
tabId?: string;
|
||||
};
|
||||
|
||||
export type RendererRouteContext = RendererRouteContextInput & {
|
||||
reportedAt: string;
|
||||
};
|
||||
|
||||
const MAX_ROUTE_CONTEXT_STRING_LENGTH = 512;
|
||||
|
||||
export function sanitizeRendererRouteContext(
|
||||
value: unknown,
|
||||
reportedAt = new Date(),
|
||||
): RendererRouteContext | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
|
||||
const input = value as Record<string, unknown>;
|
||||
if (!isRendererRouteSurface(input.surface)) return null;
|
||||
|
||||
const path = sanitizeString(input.path);
|
||||
if (!path) return null;
|
||||
|
||||
const workspaceSlug = sanitizeString(input.workspaceSlug);
|
||||
const tabId = sanitizeString(input.tabId);
|
||||
|
||||
return {
|
||||
surface: input.surface,
|
||||
path,
|
||||
...(workspaceSlug ? { workspaceSlug } : {}),
|
||||
...(tabId ? { tabId } : {}),
|
||||
reportedAt: reportedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function isRendererRouteSurface(value: unknown): value is RendererRouteSurface {
|
||||
return value === "login" || value === "overlay" || value === "tab";
|
||||
}
|
||||
|
||||
function sanitizeString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.slice(0, MAX_ROUTE_CONTEXT_STRING_LENGTH);
|
||||
}
|
||||
@@ -79,6 +79,19 @@ CI やヘッドレス環境では、ブラウザフローをスキップでき
|
||||
| `multica skill import ...` | GitHub、ClawHub、またはローカルマシンからスキルをインポート |
|
||||
| `multica skill files ...` | ネスト: スキルのファイルを管理 |
|
||||
|
||||
### スキルインポートの競合
|
||||
|
||||
`multica skill import --url <url>` の既定値は `--on-conflict fail` です。同じ名前のスキルがすでに存在する場合、コマンドは構造化された `conflict` 結果で終了し、ワークスペースは変更されません。
|
||||
|
||||
既存スキルの作成者で、スキル ID とエージェントの紐付けを維持したまま内容を置き換える場合は `--on-conflict overwrite` を使います。既存スキルを残してコピーを取り込む場合は `--on-conflict rename` を使うと、`-2` のような接尾辞が自動で付きます。同名の項目を単に飛ばす場合は `--on-conflict skip` を使います。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## スクワッド
|
||||
|
||||
| コマンド | 用途 |
|
||||
|
||||
@@ -79,6 +79,19 @@ CI나 headless 환경에서는 브라우저 플로우를 건너뛰세요. 웹
|
||||
| `multica skill import ...` | GitHub, ClawHub, 또는 로컬 기기에서 스킬 가져오기 |
|
||||
| `multica skill files ...` | 중첩: 스킬의 파일 관리 |
|
||||
|
||||
### 스킬 가져오기 충돌
|
||||
|
||||
`multica skill import --url <url>`의 기본값은 `--on-conflict fail`입니다. 같은 이름의 스킬이 이미 있으면 명령은 구조화된 `conflict` 결과로 종료되며 워크스페이스를 변경하지 않습니다.
|
||||
|
||||
기존 스킬을 만든 사용자이고, 스킬 ID와 에이전트 연결은 유지한 채 내용을 바꾸려면 `--on-conflict overwrite`를 사용하세요. 기존 스킬을 그대로 두고 복사본을 가져오려면 `--on-conflict rename`을 사용하면 `-2` 같은 접미사가 자동으로 붙습니다. 같은 이름의 항목을 그냥 건너뛰려면 `--on-conflict skip`을 사용하세요.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 스쿼드
|
||||
|
||||
| 명령어 | 용도 |
|
||||
|
||||
@@ -79,6 +79,25 @@ For the difference between token types, see [Authentication and tokens](/auth-to
|
||||
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
|
||||
| `multica skill files ...` | Nested: manage a skill's files |
|
||||
|
||||
### Skill import conflicts
|
||||
|
||||
`multica skill import --url <url>` defaults to `--on-conflict fail`. If a skill
|
||||
with the same name already exists, the command exits with a structured
|
||||
`conflict` result and does not change the workspace.
|
||||
|
||||
Use `--on-conflict overwrite` when you created the existing skill and want to
|
||||
replace its content while preserving its ID and agent bindings. Use
|
||||
`--on-conflict rename` to import a copy with an automatic suffix such as `-2`.
|
||||
Use `--on-conflict skip` to leave the existing skill untouched and report
|
||||
`skipped`.
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## Squads
|
||||
|
||||
| Command | Purpose |
|
||||
|
||||
@@ -79,6 +79,19 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
|
||||
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
|
||||
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
|
||||
|
||||
### Skill 导入冲突
|
||||
|
||||
`multica skill import --url <url>` 默认等同于 `--on-conflict fail`。如果工作区里已经有同名 Skill,命令会返回结构化 `conflict` 结果并退出,不会修改工作区。
|
||||
|
||||
如果你是已有 Skill 的 creator,并且想用新导入内容覆盖它,同时保留原 Skill 的 ID 和 agent 绑定,用 `--on-conflict overwrite`。如果想保留已有 Skill、另存一份,用 `--on-conflict rename`,系统会自动加 `-2` 这类后缀。如果只是批量导入时遇到同名项就跳过,用 `--on-conflict skip`。
|
||||
|
||||
```bash
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict overwrite
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict rename
|
||||
multica skill import --url https://skills.sh/acme/repo/review-helper --on-conflict skip
|
||||
```
|
||||
|
||||
## 小队
|
||||
|
||||
| 命令 | 用途 |
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## イシューを参照する
|
||||
|
||||
別のイシューをリンクするには、`MUL-123` のようにそのイシューキーを入力してください。Multica はコメント内で実在するイシューキーを解決し、内部的に `mention://issue/<uuid>` リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
別のイシューをリンクするには、コメントの mention ピッカーからそのイシューを選択してください。Multica はイシューリンクを明示的な `[MUL-123](mention://issue/<uuid>)` mention リンクとして保存します。イシューリンクは単なる相互参照にすぎません。人に通知を送ることはなく、エージェントをトリガーすることもありません。
|
||||
|
||||
通常は `[MUL-123](mention://issue/<uuid>)` を手で書く必要はありません。その形式は、Multica がキーを解決した後に使う標準的な内部表現です。
|
||||
`MUL-123` のような裸のイシューキーを入力しても、通常のテキストのまま残ります。そのため、`feature/MUL-123` のようなコメント内のブランチ名やパスも書き換えられません。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown の強調は CommonMark のルールに従います。太字テキストが句読点や閉じ引用符で終わり、その直後に韓国語の助詞が続く場合、閉じの `**` が認識されないことがあります。
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 이슈 참조하기
|
||||
|
||||
다른 이슈를 링크하려면 `MUL-123`처럼 이슈 키를 입력하세요. Multica는 댓글에서 실제 존재하는 이슈 키를 해석하여 내부적으로 `mention://issue/<uuid>` 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
다른 이슈를 링크하려면 댓글 mention 선택기에서 해당 이슈를 선택하세요. Multica는 이슈 링크를 명시적인 `[MUL-123](mention://issue/<uuid>)` mention 링크로 저장합니다. 이슈 링크는 단순한 상호 참조일 뿐입니다. 사람에게 알림을 보내지 않으며 에이전트를 트리거하지도 않습니다.
|
||||
|
||||
보통은 `[MUL-123](mention://issue/<uuid>)`을 직접 손으로 작성할 필요가 없습니다. 그 형식은 Multica가 키를 해석한 뒤에 사용하는 표준 내부 표현입니다.
|
||||
`MUL-123` 같은 bare 이슈 키를 입력하면 일반 텍스트로 유지됩니다. 따라서 `feature/MUL-123` 같은 댓글 안의 브랜치 이름과 경로도 다시 작성되지 않습니다.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 강조는 CommonMark 규칙을 따릅니다. 굵은 텍스트가 문장 부호나 닫는 따옴표로 끝나고 그 뒤에 한국어 조사가 바로 이어지면, 닫는 `**`가 인식되지 않을 수 있습니다.
|
||||
|
||||
@@ -39,9 +39,9 @@ Mentioning the same person multiple times in one comment still produces **only o
|
||||
|
||||
## Referencing issues
|
||||
|
||||
To link another issue, type its issue key, such as `MUL-123`. Multica resolves real issue keys in comments and stores them as an internal `mention://issue/<uuid>` link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
To link another issue, choose it from the comment mention picker. Multica stores issue links as an explicit `[MUL-123](mention://issue/<uuid>)` mention link. Issue links are cross-references only: they do not notify people and they do not trigger agents.
|
||||
|
||||
You normally do not need to write `[MUL-123](mention://issue/<uuid>)` by hand. That format is the canonical internal representation after Multica has resolved the key.
|
||||
Typing a bare issue key, such as `MUL-123`, keeps it as plain text. This also keeps branch names and paths, such as `feature/MUL-123`, from being rewritten inside comments.
|
||||
|
||||
<Callout type="info">
|
||||
Markdown emphasis follows CommonMark rules. When bold text ends with punctuation or a closing quote and is immediately followed by a Korean particle, the closing `**` may not be recognized.
|
||||
|
||||
@@ -39,9 +39,9 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
## 引用 issue
|
||||
|
||||
要链接另一个 issue,直接输入它的 issue key,例如 `MUL-123`。Multica 会在评论中解析真实存在的 issue key,并把它存成内部的 `mention://issue/<uuid>` 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
要链接另一个 issue,请在评论的 mention 选择器里选择它。Multica 会把 issue 链接存成显式的 `[MUL-123](mention://issue/<uuid>)` mention 链接。Issue 链接只是交叉引用:不会通知成员,也不会触发智能体。
|
||||
|
||||
通常不需要手写 `[MUL-123](mention://issue/<uuid>)`。这是 Multica 解析 key 之后使用的内部规范格式。
|
||||
直接输入裸 issue key,例如 `MUL-123`,会保持为普通文本。这样评论里的分支名和路径,例如 `feature/MUL-123`,也不会被改写。
|
||||
|
||||
<Callout type="info">
|
||||
Markdown 加粗遵循 CommonMark 规则。当加粗文本以标点或闭引号结尾,并且后面紧跟韩语助词时,结尾的 `**` 可能不会被识别。
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要な場合は Claude Code か ACP 系列のいずれかを選んでください。
|
||||
よりきめ細かい承認ゲートを備えた JSON-RPC 2.0 のトランスポートです。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開し、古いまたは存在しない thread では新しい thread にフォールバックします。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ multica daemon restart
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
Cursor エディタに対応する CLI です。**セッション再開は動作しません** — Cursor の CLI がセッション id を返さないため、再開時に渡す値は常に無効です。
|
||||
Cursor エディタに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent は stream-json イベントで `session_id` を返し、Multica は次回実行時に `--resume <id>` でそれを渡します。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code 또는 ACP 계열 중 하나를 선택하세요.
|
||||
더 세분화된 승인 게이트를 갖춘 JSON-RPC 2.0 전송 방식입니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개하며, 오래되었거나 없는 thread는 새 thread로 폴백합니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ multica daemon restart
|
||||
|
||||
### Cursor (Anysphere)
|
||||
|
||||
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 작동하지 않습니다** — Cursor의 CLI가 세션 id를 반환하지 않으므로 재개 시 전달하는 값은 항상 유효하지 않습니다.
|
||||
Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent는 stream-json 이벤트에서 `session_id`를 반환하고, Multica는 다음 실행 때 이를 `--resume <id>`로 전달합니다.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ The most complete integration. Session resumption works, MCP works, and it consu
|
||||
|
||||
### Codex (OpenAI)
|
||||
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — pick Claude Code or one of the ACP family if you need resume.
|
||||
JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; stale or missing threads fall back to a fresh thread.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ JSON-RPC 2.0 transport with finer-grained approval gates. MCP config is written
|
||||
|
||||
### 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.
|
||||
The CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: Multica reads `session_id` from the stream-json events and passes it back with `--resume <id>`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -48,7 +48,7 @@ multica daemon restart
|
||||
|
||||
### Codex(OpenAI)
|
||||
|
||||
JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话续接的代码在,但调不到** —— 要续接的话选 Claude Code 或 ACP 系列。
|
||||
JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话续接可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;thread 过期或不存在时会回退到新 thread。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
@@ -58,7 +58,7 @@ JSON-RPC 2.0 传输,审批粒度更细。MCP 配置会写入单次任务的 `$
|
||||
|
||||
### Cursor(Anysphere)
|
||||
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接是坏的** —— Cursor CLI 不返回 session id,你传过去的续接 id 永远无效。
|
||||
Cursor 编辑器的 CLI 对应物。**会话续接可用**——当前 Cursor Agent 会在 stream-json 事件里返回 `session_id`,Multica 会在下一次运行时用 `--resume <id>` 传回去。
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|
||||
@@ -14,16 +14,16 @@ Multica は **12 個の AI コーディングツール**を標準でサポート
|
||||
| ツール | ベンダー | セッション再開 | MCP | スキル注入パス | モデル選択 |
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 動的探索(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | **✅(実際に使用する唯一のツール)** | `.claude/skills/` | 静的 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ コードは存在するが到達不可 | ❌ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静的 + flag |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静的 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静的(アカウントの権限で決定) |
|
||||
| **Cursor** | Anysphere | ⚠️ コードは存在するが使用不可 | ❌ | `.cursor/skills/` | 動的探索 |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 動的探索 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静的 |
|
||||
| **Hermes** | Nous Research | ✅ | ❌ | `.agent_context/skills/`(フォールバック) | 動的探索 |
|
||||
| **Kimi** | Moonshot | ✅ | ❌ | `.kimi/skills/` | 動的探索 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ❌ | `.kiro/skills/` | 動的探索 |
|
||||
| **OpenCode** | SST | ✅ | ❌ | `.opencode/skills/` | 動的探索 |
|
||||
| **OpenClaw** | オープンソース | ✅ | ❌ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | 動的探索 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 動的探索 |
|
||||
| **Kiro CLI** | Amazon | ✅ | ✅ | `.kiro/skills/` | 動的探索 |
|
||||
| **OpenCode** | SST | ✅ | ✅ | `.opencode/skills/` | 動的探索 + variant |
|
||||
| **OpenClaw** | オープンソース | ✅ | ✅ | `.agent_context/skills/`(フォールバック) | エージェントにバインドされ、タスクごとに切り替え不可 |
|
||||
| **Pi** | Inflection AI | ✅(セッションがファイルパス) | ❌ | `.pi/skills/` | 動的探索 |
|
||||
|
||||
## 各ツールの用途
|
||||
@@ -34,11 +34,11 @@ Google が提供します。CLI バイナリ名は `agy` です。Google の Ant
|
||||
|
||||
### Claude Code
|
||||
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、**11 個の中で MCP 構成を本当に読み取る唯一のツール**であり、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
Anthropic が提供します。**新規ユーザーにとって第一の選択肢**であり、最も完成度の高い機能セットを備えています: セッション再開が実際に動作し、MCP 構成を読み取り、`--max-turns` や `--append-system-prompt` のような細かな調整 flag をサポートします。Anthropic API キーが必要です。
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。**セッション再開のコードは存在しますが、現在は到達できません** — 再開が必要なら、Claude Code または ACP 系のいずれかを選んでください。
|
||||
OpenAI が提供します。JSON-RPC 2.0 を使用し、ステートフルな能力がより強く、よりきめ細かい承認メカニズム(`exec_command` および `patch_apply` に対する手動承認)を備えています。MCP 構成はタスクごとの `$CODEX_HOME/config.toml` に書き込まれます。**セッション再開は動作します** — Multica は Codex app-server の `thread/resume` で再開します。保存済み thread が見つからない、または古い場合は、新しい thread にフォールバックしてタスクを続行します。
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub が提供します。モデルルーティングは GitHub アカウン
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開のコードは存在しますが、実際には動作しません** — Cursor CLI のイベントストリームがセッション ID を返さないため、渡す再開値は常に無効です。再開が必要なら、別のものを選んでください。
|
||||
Anysphere が提供し、Cursor エディターに対応する CLI です。**セッション再開は動作します** — 現在の Cursor Agent の stream-json イベントには `session_id` が含まれ、Multica は次回実行時に `--resume <id>` でそれを渡します。MCP 構成はタスクワークスペースの `.cursor/mcp.json` に書き込まれ、Cursor のプロジェクト approval ファイルはタスクごとの `CURSOR_DATA_DIR` 配下に置かれるため、管理対象 MCP server はユーザーのグローバル Cursor approvals に依存しません。
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -54,23 +54,23 @@ Google が提供し、Gemini 2.5 および 3 シリーズをサポートしま
|
||||
|
||||
### Hermes
|
||||
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作します。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
Nous Research が提供します。ACP プロトコルを使用します(Kimi とトランスポート層を共有します)。セッション再開が動作し、MCP 構成は ACP `mcpServers` として渡されます。しかし**スキル注入パスは専用のものではなく汎用のフォールバック**(`.agent_context/skills/`)です — Hermes CLI 自体がこのパスを読み取らない場合、スキルが適用されないことがあります。テストで確認してください。
|
||||
|
||||
### Kimi
|
||||
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有しますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
Moonshot が提供し、中国市場を対象としています。Hermes と ACP プロトコルを共有し、MCP 構成も ACP `mcpServers` として渡されますが、スキルパス `.kimi/skills/` は Kimi CLI のネイティブな探索メカニズムであり、Hermes のフォールバックとは異なります。
|
||||
|
||||
### Kiro CLI
|
||||
|
||||
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
|
||||
Amazon が提供します。`kiro-cli acp` を通じて stdio 上で ACP を使用します。セッション再開は ACP `session/load` で動作し、MCP 構成は ACP `mcpServers` として渡され、モデル選択は `session/set_model` で動作し、スキルはプロジェクトレベルのネイティブ探索のために `.kiro/skills/` にコピーされます。
|
||||
|
||||
### OpenCode
|
||||
|
||||
SST が提供するオープンソースです。利用可能なモデルを動的に探索します(CLI の構成ファイルをスキャン)。セッション再開が動作します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
|
||||
SST が提供するオープンソースです。利用可能なモデルと model variant を動的に探索します(CLI の構成ファイルをスキャン)。セッション再開が動作し、エージェントの `mcp_config` フィールドを消費します。Multica は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン注入するため、エージェントの MCP server はタスク workdir の `opencode.json`(エージェントまたはユーザーが所有するファイル)を書き換えずに OpenCode に届きます。モデルが variant を公開している場合、Multica はそれをエージェントの thinking selector として表示し、選択値を `opencode run --variant` で OpenCode に渡します。**自分のモデルカタログをカスタマイズしたい、いじるのが好きなユーザーに適しています。**
|
||||
|
||||
### OpenClaw
|
||||
|
||||
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。**モデルはエージェント層にバインドされます**(`openclaw agents add --model`) — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
|
||||
オープンソースプロジェクトであり、CLI エージェントオーケストレーターです。MCP 構成は Multica のタスクごとの config wrapper 経由で書き込まれます。**モデルはエージェント層にバインドされます**(`openclaw agents add --model`) — タスクごとに上書きできません。構成は厳格に制御されます: ユーザーは `--model` や `--system-prompt` を渡せず、エージェント登録時の構成が決定します。
|
||||
|
||||
### Pi
|
||||
|
||||
@@ -82,18 +82,19 @@ Inflection AI が提供し、ミニマルです。**セッション再開の方
|
||||
|
||||
| 状態 | ツール | 意味 |
|
||||
|---|---|---|
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ⚠️ コードは存在するが到達不可 | Codex, Cursor | コードに再開パスがありますが、実際には到達しません(Codex は静かにフォールバックし、Cursor はセッション id を返しません) — **未サポートとみなしてください** |
|
||||
| ✅ 実際に動作 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 再開 id を渡すと以前のコンテキストから続行します |
|
||||
| ❌ なし | Gemini | CLI に再開メカニズムがありません |
|
||||
|
||||
**意思決定のために**: ワークフローでエージェントがタスク間でコンテキストを保持する必要がある場合(失敗時のリトライ、手動の再実行、対話的な反復)、✅ の行にあるツールだけを選んでください。
|
||||
|
||||
## MCP 構成: Claude Code だけが実際に読み取る
|
||||
## MCP 構成: ツールごとの対応
|
||||
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは Claude Code だけです**。残りの 11 個はこのフィールドを受け取りますが、**完全に無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
**12 個のツールのうち、`mcp_config` を実際に消費するのは 8 個です: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。残りの 4 個はこのフィールドを受け取りますが、**無視します** — エラーも警告もなく、構成はただ効果を発揮しません。
|
||||
|
||||
接続方式はツールごとに異なります: Claude Code は `--mcp-config` と `--strict-mcp-config` で受け取り、Codex は daemon 管理の `mcp_servers` ブロックをタスクごとの `$CODEX_HOME/config.toml` に書き込み、Cursor は `.cursor/mcp.json` とタスクごとの `CURSOR_DATA_DIR` 配下のプロジェクト approval を書き込みます。Hermes、Kimi、Kiro CLI は ACP `mcpServers` で受け取ります。OpenCode は `OPENCODE_CONFIG_CONTENT` 環境変数でインライン構成を受け取り、OpenClaw は Multica のタスクごとの config wrapper 経由で `mcp.servers` を受け取ります。OpenCode の経路はプロジェクトの `opencode.json` を書き換えません。
|
||||
|
||||
<Callout type="warning">
|
||||
エージェント構成で `mcp_config` を設定しても、Claude Code 以外のツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。現在、MCP 連携は Claude Code のみをカバーしています。
|
||||
エージェント構成で `mcp_config` を設定しても、MCP 列に ✅ がないツールを選んだ場合、MCP サーバーはそのエージェントに**何の効果**も及ぼしません。MCP 連携はツールごとに実装されています。
|
||||
</Callout>
|
||||
|
||||
## スキルファイルが置かれる場所
|
||||
|
||||
@@ -15,9 +15,9 @@ Multica는 **12개의 AI 코딩 도구**를 기본 지원합니다. 이들은
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | 동적 탐색(`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 정적 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ 코드는 존재하지만 도달 불가 | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 정적 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 정적 (계정 권한으로 결정) |
|
||||
| **Cursor** | Anysphere | ⚠️ 코드는 존재하지만 사용 불가 | ❌ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 동적 탐색 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 정적 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | 동적 탐색 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 동적 탐색 |
|
||||
@@ -38,7 +38,7 @@ Anthropic에서 제공합니다. **신규 사용자에게 첫 번째 선택지**
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개 코드는 존재하지만 현재 도달할 수 없습니다** — 재개가 필요하다면 Claude Code나 ACP 계열 중 하나를 선택하세요.
|
||||
OpenAI에서 제공합니다. JSON-RPC 2.0을 사용하고, 상태 유지 능력이 더 강하며, 더 세밀한 승인 메커니즘(`exec_command` 및 `patch_apply`에 대한 수동 승인)을 갖추고 있습니다. MCP 구성은 작업별 `$CODEX_HOME/config.toml`에 기록됩니다. **세션 재개가 동작합니다** — Multica는 Codex app-server의 `thread/resume`으로 재개합니다. 저장된 thread가 없거나 오래된 경우에는 새 thread로 폴백해 작업을 계속 실행합니다.
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub에서 제공합니다. 모델 라우팅은 GitHub 계정 권한을 거칩
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개 코드는 존재하지만 실제로는 동작하지 않습니다** — Cursor CLI 이벤트 스트림이 세션 ID를 반환하지 않으므로, 전달하는 재개 값은 항상 무효입니다. 재개가 필요하다면 다른 것을 선택하세요.
|
||||
Anysphere에서 제공하며, Cursor 에디터에 대응하는 CLI입니다. **세션 재개가 동작합니다** — 현재 Cursor Agent의 stream-json 이벤트에는 `session_id`가 포함되며, Multica는 다음 실행 때 이를 `--resume <id>`로 다시 전달합니다. MCP 구성은 작업 워크스페이스의 `.cursor/mcp.json`에 기록되고, Cursor의 프로젝트 approval 파일은 작업별 `CURSOR_DATA_DIR` 아래에 기록되므로, 관리되는 MCP 서버는 사용자의 전역 Cursor approval에 의존하지 않습니다.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,17 +82,16 @@ Inflection AI에서 제공하며, 미니멀합니다. **세션 재개 방식이
|
||||
|
||||
| 상태 | 도구 | 의미 |
|
||||
|---|---|---|
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ⚠️ 코드는 존재하지만 도달 불가 | Codex, Cursor | 코드에 재개 경로가 있지만 실제로는 도달하지 않습니다(Codex는 조용히 폴백하고, Cursor는 세션 id를 반환하지 않습니다) — **미지원으로 간주하세요** |
|
||||
| ✅ 실제로 동작 | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | 재개 id를 전달하면 이전 컨텍스트에서 이어집니다 |
|
||||
| ❌ 없음 | Gemini | CLI에 재개 메커니즘이 없습니다 |
|
||||
|
||||
**의사결정을 위해**: 워크플로에서 에이전트가 작업 간에 컨텍스트를 유지해야 한다면(실패 재시도, 수동 재실행, 대화형 반복), ✅ 행에 있는 도구만 선택하세요.
|
||||
|
||||
## MCP 구성: 도구별 지원
|
||||
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 7개입니다: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 5개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
**12개 도구 중 `mcp_config`를 실제로 소비하는 것은 8개입니다: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw**. 나머지 4개는 이 필드를 받아들이지만 **무시합니다** — 오류도, 경고도 없으며, 구성이 그저 효과를 내지 못합니다.
|
||||
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
각 도구의 연결 방식은 다릅니다: Claude Code는 `--mcp-config`와 `--strict-mcp-config`로 받고, Codex는 데몬이 관리하는 `mcp_servers` 블록을 작업별 `$CODEX_HOME/config.toml`에 기록하며, Cursor는 `.cursor/mcp.json`과 작업별 `CURSOR_DATA_DIR` 아래의 프로젝트 approval을 기록합니다. Hermes/Kimi/Kiro CLI는 ACP `mcpServers`로 받습니다. OpenCode는 `OPENCODE_CONFIG_CONTENT` 환경 변수로 인라인 구성을 받고, OpenClaw는 Multica의 작업별 config wrapper를 통해 `mcp.servers`를 받습니다. OpenCode 경로는 프로젝트의 `opencode.json`을 다시 쓰지 않습니다.
|
||||
|
||||
<Callout type="warning">
|
||||
에이전트 구성에서 `mcp_config`를 설정했더라도 MCP 열에 ✅가 없는 도구를 선택하면, MCP 서버가 해당 에이전트에 **아무런 효과**도 미치지 않습니다. MCP 연동은 도구별로 구현됩니다.
|
||||
|
||||
@@ -15,9 +15,9 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅ (`--conversation <id>`) | ❌ | `.agents/skills/` | Dynamic discovery (`agy models`) |
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | Static + flag |
|
||||
| **Codex** | OpenAI | ⚠️ Code exists but unreachable | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | Static |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | Static (determined by account entitlement) |
|
||||
| **Cursor** | Anysphere | ⚠️ Code exists but unusable | ❌ | `.cursor/skills/` | Dynamic discovery |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | Dynamic discovery |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | Static |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback) | Dynamic discovery |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | Dynamic discovery |
|
||||
@@ -38,7 +38,7 @@ From Anthropic. **First choice for new users** — the most complete feature set
|
||||
|
||||
### Codex
|
||||
|
||||
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption code exists but is currently unreachable** — if you need resume, pick Claude Code or one of the ACP family.
|
||||
From OpenAI. Uses JSON-RPC 2.0, has stronger statefulness, and a finer-grained approve mechanism (manual approval for `exec_command` and `patch_apply`). MCP config is materialized into the per-task `$CODEX_HOME/config.toml`. **Session resumption works** through Codex app-server `thread/resume`; if the saved thread is missing or stale, Multica falls back to a fresh thread so the task can still run.
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ From GitHub. Model routing goes through your GitHub account entitlement — the
|
||||
|
||||
### Cursor
|
||||
|
||||
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption code exists but doesn't actually work** — the Cursor CLI event stream doesn't return a session ID, so any resume value you pass is always invalid. If you need resume, pick something else.
|
||||
From Anysphere, the CLI counterpart to the Cursor editor. **Session resumption works** with current Cursor Agent releases: the stream-json event includes a `session_id`, and Multica passes it back with `--resume <id>` on the next run. MCP config is materialized into the task workspace's `.cursor/mcp.json`, with Cursor's project approval file written under a per-task `CURSOR_DATA_DIR` so managed MCP servers do not depend on the user's global Cursor approvals.
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,17 +82,16 @@ The session resumption mechanism is covered in [Tasks](/tasks#can-a-task-continu
|
||||
|
||||
| Status | Tools | Meaning |
|
||||
|---|---|---|
|
||||
| ✅ Really works | Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ⚠️ Code exists but unreachable | Codex, Cursor | Resume paths exist in the code but aren't actually reached (Codex silently falls back; Cursor doesn't return session id) — **treat as unsupported** |
|
||||
| ✅ Really works | Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi | Pass the resume id and it continues from the previous context |
|
||||
| ❌ None | Gemini | The CLI has no resume mechanism |
|
||||
|
||||
**For your decision**: if your workflow needs agents to preserve context across tasks (failure retries, manual reruns, conversational iteration), pick only from the ✅ row.
|
||||
|
||||
## MCP configuration: provider-specific support
|
||||
|
||||
**Of the 12 tools, seven consume `mcp_config`: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other five accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
**Of the 12 tools, eight consume `mcp_config`: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw**. The other four accept the field but **ignore it** — no error, no warning, the config just has no effect.
|
||||
|
||||
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
The runtime paths are provider-specific: Claude Code receives it through `--mcp-config` paired with `--strict-mcp-config`; Codex writes a daemon-managed `mcp_servers` block into the per-task `$CODEX_HOME/config.toml`; Cursor writes `.cursor/mcp.json` plus per-task project approvals under `CURSOR_DATA_DIR`; Hermes, Kimi, and Kiro CLI receive ACP `mcpServers`; OpenCode receives inline config through `OPENCODE_CONFIG_CONTENT`; OpenClaw receives `mcp.servers` through Multica's per-task config wrapper. OpenCode's path does **not** rewrite the project's `opencode.json`.
|
||||
|
||||
<Callout type="warning">
|
||||
If you set `mcp_config` in an agent configuration but pick a tool not marked ✅ in the MCP column, your MCP servers have **no effect** on that agent. MCP integration is provider-specific.
|
||||
|
||||
@@ -15,9 +15,9 @@ Multica 内置支持 **12 款 AI 编程工具**。它们都实现了同一套接
|
||||
|---|---|---|---|---|---|
|
||||
| **Antigravity** | Google | ✅(`--conversation <id>`)| ❌ | `.agents/skills/` | 动态发现(`agy models`)|
|
||||
| **Claude Code** | Anthropic | ✅ | ✅ | `.claude/skills/` | 静态 + flag |
|
||||
| **Codex** | OpenAI | ⚠️ 代码存在但不可达 | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Codex** | OpenAI | ✅ | ✅ | `$CODEX_HOME/skills/` | 静态 |
|
||||
| **Copilot** | GitHub | ✅ | ❌ | `.github/skills/` | 静态(账号权益决定)|
|
||||
| **Cursor** | Anysphere | ⚠️ 代码存在但不可用 | ❌ | `.cursor/skills/` | 动态发现 |
|
||||
| **Cursor** | Anysphere | ✅ | ✅ | `.cursor/skills/` | 动态发现 |
|
||||
| **Gemini** | Google | ❌ | ❌ | `.agent_context/skills/` | 静态 |
|
||||
| **Hermes** | Nous Research | ✅ | ✅ | `.agent_context/skills/` (fallback)| 动态发现 |
|
||||
| **Kimi** | Moonshot | ✅ | ✅ | `.kimi/skills/` | 动态发现 |
|
||||
@@ -38,7 +38,7 @@ Anthropic 出品。**新用户首选**——功能最完整:会话恢复真用
|
||||
|
||||
### Codex
|
||||
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复代码存在但当前不可达**——如果你需要 resume,选 Claude Code 或 ACP 系列。
|
||||
OpenAI 出品。使用 JSON-RPC 2.0 协议,状态化更强,approve 机制更细(手动批准 `exec_command` 和 `patch_apply`)。MCP 配置会写入单次任务的 `$CODEX_HOME/config.toml`。**会话恢复可用**——Multica 通过 Codex app-server 的 `thread/resume` 续接;如果已保存的 thread 不存在或过期,会回退到新 thread,让任务继续执行。
|
||||
|
||||
### Copilot
|
||||
|
||||
@@ -46,7 +46,7 @@ GitHub 出品。模型路由走你的 GitHub 账号权益——工具自己不
|
||||
|
||||
### Cursor
|
||||
|
||||
Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复代码存在但实际不工作**——Cursor CLI 的事件流里不回传 session ID,所以你传的 resume 值永远无效。如果要 resume,选别的。
|
||||
Anysphere 出品,Cursor 编辑器的 CLI 对应物。**会话恢复可用**——当前 Cursor Agent 的 stream-json 事件会返回 `session_id`,Multica 会在下一次运行时通过 `--resume <id>` 传回去。MCP 配置会写入任务工作区的 `.cursor/mcp.json`,Cursor 的项目 approval 文件写在单次任务的 `CURSOR_DATA_DIR` 下,因此托管的 MCP server 不依赖用户全局 Cursor approvals。
|
||||
|
||||
### Gemini
|
||||
|
||||
@@ -82,17 +82,16 @@ Inflection AI 出品,极简主义。**会话恢复机制特殊**——session
|
||||
|
||||
| 状态 | 工具 | 含义 |
|
||||
|---|---|---|
|
||||
| ✅ 真用 | Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ⚠️ 代码存在但不可达 | Codex、Cursor | 代码里有 resume 路径但实际走不到(Codex 静默回落、Cursor session id 不回传)—— **当作不支持** |
|
||||
| ✅ 真用 | Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi | 传 resume id,会从上次上下文接着继续 |
|
||||
| ❌ 无 | Gemini | CLI 无 resume 机制 |
|
||||
|
||||
**对你的决策**:如果工作流需要智能体在多次任务之间保持上下文(失败重试、手动重跑、对话式迭代),只选 ✅ 那一行的工具。
|
||||
|
||||
## MCP 配置:按工具不同
|
||||
|
||||
**12 款工具里有 7 款实际消费 `mcp_config`:Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 5 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
**12 款工具里有 8 款实际消费 `mcp_config`:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw**。其他 4 款会接收这个字段但**忽略**——不报错、不警告,只是配置不生效。
|
||||
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
各工具的接入方式不同:Claude Code 通过 `--mcp-config` 加 `--strict-mcp-config` 接收;Codex 会把 daemon 管理的 `mcp_servers` block 写入单次任务的 `$CODEX_HOME/config.toml`;Cursor 会写入 `.cursor/mcp.json`,并把项目 approval 写到单次任务的 `CURSOR_DATA_DIR`;Hermes、Kimi、Kiro CLI 通过 ACP `mcpServers` 接收;OpenCode 通过 `OPENCODE_CONFIG_CONTENT` 环境变量内联接收;OpenClaw 通过 Multica 的单次任务配置 wrapper 接收 `mcp.servers`。OpenCode 这条路径**不会**改写项目里的 `opencode.json`。
|
||||
|
||||
<Callout type="warning">
|
||||
如果你在智能体配置里设置了 `mcp_config`,但选了矩阵 MCP 列没有标 ✅ 的工具,你的 MCP server 对这个智能体**没有效果**。MCP 集成是按工具实现的。
|
||||
|
||||
@@ -54,7 +54,7 @@ GitHub や ClawHub からインポートしたスキルには、スクリプト
|
||||
- **スキル** = 構造化された**ナレッジパック**(静的なコンテンツ + 指示)。エージェントはスキルを読んで「問題 X を見たら、こう考えてこう行動する」を学びます。
|
||||
- **MCP**(Model Context Protocol)= **ツールチャネル**。エージェントは MCP を使って外部サービス(データベース、ファイルシステム、サードパーティ API)に接続し、それらを**呼び出します**。
|
||||
|
||||
この 2 つは相互補完的です。現在の Multica では、MCP のサポートを**実際に使うのは Claude Code だけ**です — 他のツールは MCP 設定を受け取りはしますが、実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
|
||||
この 2 つは相互補完的です。現在の Multica では、MCP サポートは**ツールごとに実装されています**: Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw は `mcp_config` を使用し、他のツールはこのフィールドを受け取っても実際には使いません。MCP 専用のセクションは今後のリリースで追加される予定です。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ GitHub나 ClawHub에서 가져온 스킬에는 스크립트와 실행 가능한
|
||||
- **스킬** = 구조화된 **지식 팩**(정적 콘텐츠 + 지침). 에이전트는 스킬을 읽어 "문제 X를 만나면 이렇게 생각하고 이렇게 처리하라"를 학습합니다.
|
||||
- **MCP**(Model Context Protocol) = **도구 채널**. 에이전트는 MCP를 사용해 외부 서비스(데이터베이스, 파일 시스템, 서드파티 API)에 연결하고 이를 **호출**합니다.
|
||||
|
||||
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
|
||||
이 둘은 상호 보완적입니다. 현재 Multica에서 MCP 지원은 **도구별로 구현됩니다**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw는 `mcp_config`를 사용하고, 다른 도구들은 이 필드를 받더라도 실제로 사용하지 않습니다. MCP 전용 섹션은 추후 릴리스에서 추가될 예정입니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Both augment what an agent can do, but in different directions:
|
||||
- **Skill** = a structured **knowledge pack** (static content + instructions). The agent reads a skill to learn "when I see problem X, here's how to think and what to do."
|
||||
- **MCP** (Model Context Protocol) = a **tool channel**. The agent uses MCP to connect to external services (databases, filesystems, third-party APIs) and **invoke** them.
|
||||
|
||||
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
|
||||
The two are complementary. In Multica today, MCP support is **provider-specific**: Claude Code, Codex, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, and OpenClaw consume `mcp_config`; other tools receive the field but don't actually use it. A dedicated MCP section will come in a later release.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ Skill 导入后需要**挂载到具体的智能体**才会生效。一个智能
|
||||
- **Skill** = 结构化的**知识包**(静态内容 + 指令)。智能体读 Skill 来学"遇到 X 类问题该怎么想、怎么做"。
|
||||
- **MCP**(Model Context Protocol)= **工具通道**。智能体通过 MCP 连外部服务(数据库、文件系统、第三方 API)并**调用**它们。
|
||||
|
||||
两者可以同时用。目前 Multica 的 MCP 支持是**按工具实现**的:Claude Code、Codex、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw 会消费 `mcp_config`;其他工具会接收到这个字段但不会实际用。MCP 的专题会在后续版本展开。
|
||||
两者可以同时用。目前 Multica 的 MCP 支持是**按工具实现**的:Claude Code、Codex、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw 会消费 `mcp_config`;其他工具会接收到这个字段但不会实际用。MCP 的专题会在后续版本展开。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -105,8 +105,7 @@ Multica はタスク中にセッション ID を**2 回**固定します: 開始
|
||||
|
||||
ただし、**実際にどの AI コーディングツールがこれをサポートするか**は大きく異なります。
|
||||
|
||||
- ✅ **実際にサポート** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **コードはあるが使用不可** — Codex, Cursor
|
||||
- ✅ **実際にサポート** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ❌ **サポートなし** — Gemini
|
||||
|
||||
[プロバイダー対応表 → セッション再開](/providers#session-resumption-who-really-supports-it)を参照してください。
|
||||
|
||||
@@ -105,8 +105,7 @@ Multica는 작업 중에 세션 ID를 **두 번** 고정합니다: 시작 시
|
||||
|
||||
하지만 **실제로 어떤 AI 코딩 도구가 이를 지원하는지**는 크게 다릅니다:
|
||||
|
||||
- ✅ **실제 지원** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **코드는 있지만 사용 불가** — Codex, Cursor
|
||||
- ✅ **실제 지원** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ❌ **지원 안 함** — Gemini
|
||||
|
||||
[제공자 매트릭스 → 세션 재개](/providers#session-resumption-who-really-supports-it)를 참고하세요.
|
||||
|
||||
@@ -105,8 +105,7 @@ Multica pins the session ID **twice** during a task: once at the start (when the
|
||||
|
||||
But **which AI coding tools actually support this** varies a lot:
|
||||
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Copilot, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ⚠️ **Code exists but unusable** — Codex, Cursor
|
||||
- ✅ **Real support** — Antigravity, Claude Code, Codex, Copilot, Cursor, Hermes, Kimi, Kiro CLI, OpenCode, OpenClaw, Pi
|
||||
- ❌ **No support** — Gemini
|
||||
|
||||
See [Providers Matrix → Session resumption](/providers#session-resumption-who-really-supports-it).
|
||||
|
||||
@@ -107,8 +107,7 @@ Multica 在任务过程中**两次**保存会话 ID——任务一开始(AI
|
||||
|
||||
但**哪些 AI 编程工具真的支持**差别很大:
|
||||
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Copilot、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ⚠️ **代码看起来支持但实际不可用**——Codex、Cursor
|
||||
- ✅ **真支持**——Antigravity、Claude Code、Codex、Copilot、Cursor、Hermes、Kimi、Kiro CLI、OpenCode、OpenClaw、Pi
|
||||
- ❌ **不支持**——Gemini
|
||||
|
||||
详见 [Providers Matrix → 会话恢复](/providers#会话恢复谁真的支持)。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Agent Runs sheet — presented as a formSheet by the parent Stack. Two
|
||||
* sections: Active (queued/dispatched/running, created_at desc) and Past
|
||||
* (failed → cancelled → completed, completed_at desc within each). Empty
|
||||
* (completed_at desc, status rank as tiebreaker). Empty
|
||||
* sections hide entirely.
|
||||
*
|
||||
* Both entry points (the in-card AgentActivityRow and the Stack-header
|
||||
@@ -58,9 +58,9 @@ export default function IssueRunsRoute() {
|
||||
t.status === "cancelled",
|
||||
);
|
||||
return filtered.sort((a, b) => {
|
||||
const ord = PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
|
||||
if (ord !== 0) return ord;
|
||||
return (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
|
||||
const timeDiff = (b.completed_at ?? "").localeCompare(a.completed_at ?? "");
|
||||
if (timeDiff !== 0) return timeDiff;
|
||||
return PAST_STATUS_ORDER[a.status] - PAST_STATUS_ORDER[b.status];
|
||||
});
|
||||
}, [allTasks]);
|
||||
|
||||
|
||||
54
apps/mobile/lib/inbox-display.test.ts
Normal file
54
apps/mobile/lib/inbox-display.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem } from "@multica/core/types";
|
||||
import { deduplicateInboxItems } from "./inbox-display";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
id: "inbox-1",
|
||||
workspace_id: "workspace-1",
|
||||
recipient_type: "member",
|
||||
recipient_id: "member-1",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
type: "new_comment",
|
||||
severity: "info",
|
||||
issue_id: "issue-1",
|
||||
title: "Issue title",
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deduplicateInboxItems", () => {
|
||||
it("keeps the newest issue row while preserving an older comment anchor", () => {
|
||||
const merged = deduplicateInboxItems([
|
||||
item({
|
||||
id: "comment-notification",
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: { comment_id: "comment-1" },
|
||||
}),
|
||||
item({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
created_at: "2026-06-15T08:01:00Z",
|
||||
details: { from: "in_progress", to: "in_review" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
details: {
|
||||
from: "in_progress",
|
||||
to: "in_review",
|
||||
comment_id: "comment-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -62,7 +62,9 @@ export function getInboxDisplayTitle(item: InboxItem): string {
|
||||
* 2. Group by `issue_id` (fall back to `id` for items with no issue
|
||||
* attached — e.g. quick_create_failed).
|
||||
* 3. In each group, keep the newest by `created_at`.
|
||||
* 4. Sort the result newest-first.
|
||||
* 4. Preserve the newest grouped `comment_id` anchor when the newest row
|
||||
* is a later status/metadata event for the same issue.
|
||||
* 5. Sort the result newest-first.
|
||||
*/
|
||||
export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
const active = items.filter((i) => !i.archived);
|
||||
@@ -79,7 +81,22 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (group[0]) merged.push(group[0]);
|
||||
const newest = group[0];
|
||||
if (!newest) continue;
|
||||
|
||||
const commentId =
|
||||
newest.details?.comment_id ??
|
||||
group.find((item) => item.details?.comment_id)?.details?.comment_id;
|
||||
|
||||
if (commentId && newest.details?.comment_id !== commentId) {
|
||||
merged.push({
|
||||
...newest,
|
||||
details: { ...(newest.details ?? {}), comment_id: commentId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(newest);
|
||||
}
|
||||
return merged.sort(
|
||||
(a, b) =>
|
||||
|
||||
@@ -125,11 +125,18 @@ function LoginPageContent() {
|
||||
router.push(await resolveLoggedInDestination(qc, onboarded, list));
|
||||
};
|
||||
|
||||
// Build Google OAuth state: encode platform + next URL so the callback
|
||||
// can redirect to the right place after login.
|
||||
// Build Google OAuth state: encode platform, next URL, and CLI callback
|
||||
// params so the callback can redirect to the right place after login.
|
||||
// CLI callback/state must survive the Google OAuth round-trip so the
|
||||
// post-login callback page can redirect the JWT back to the CLI's local
|
||||
// HTTP listener (critical for headless / WSL2 environments).
|
||||
const googleState = [
|
||||
platform === "desktop" ? "platform:desktop" : "",
|
||||
nextUrl ? `next:${nextUrl}` : "",
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? `cli_callback:${encodeURIComponent(cliCallbackRaw)}`
|
||||
: "",
|
||||
cliState ? `cli_state:${encodeURIComponent(cliState)}` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",") || undefined;
|
||||
|
||||
@@ -197,6 +197,104 @@ describe("CallbackPage", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to CLI callback with token when state contains valid cli_callback", async () => {
|
||||
const { api: mockedApi } = await import("@multica/core/api");
|
||||
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
mockSearchParams.set(
|
||||
"state",
|
||||
"cli_callback:http://127.0.0.1:46233/callback,cli_state:abc123",
|
||||
);
|
||||
mockGoogleLogin.mockResolvedValue({ token: "cli-jwt-token" });
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoogleLogin).toHaveBeenCalledWith(
|
||||
"test-code",
|
||||
expect.stringContaining("/auth/callback"),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:46233/callback?token=cli-jwt-token&state=abc123",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("falls through to normal web flow when state contains invalid cli_callback", async () => {
|
||||
mockSearchParams.set("state", "cli_callback:https://evil.com/callback");
|
||||
mockLoginWithGoogle.mockResolvedValue(makeUser());
|
||||
mockListWorkspaces.mockResolvedValue([]);
|
||||
mockListMyInvitations.mockResolvedValue([]);
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Normal web flow: loginWithGoogle is called (not googleLogin)
|
||||
expect(mockLoginWithGoogle).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith(paths.onboarding());
|
||||
});
|
||||
});
|
||||
|
||||
it("redirects to CLI callback even when state also contains platform:desktop", async () => {
|
||||
// cli_callback takes precedence over platform:desktop — the CLI flow
|
||||
// is a specific user intent that should not be derailed by desktop flag.
|
||||
const { api: mockedApi } = await import("@multica/core/api");
|
||||
const mockGoogleLogin = mockedApi.googleLogin as ReturnType<typeof vi.fn>;
|
||||
|
||||
const hrefSetter = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: { ...originalLocation, set href(value: string) { hrefSetter(value); } },
|
||||
});
|
||||
|
||||
try {
|
||||
mockSearchParams.set(
|
||||
"state",
|
||||
"platform:desktop,cli_callback:http://localhost:12345/callback,cli_state:mystate",
|
||||
);
|
||||
mockGoogleLogin.mockResolvedValue({ token: "mixed-jwt" });
|
||||
|
||||
render(<CallbackPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGoogleLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hrefSetter).toHaveBeenCalledWith(
|
||||
"http://localhost:12345/callback?token=mixed-jwt&state=mystate",
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
Object.defineProperty(window, "location", {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("onboarded users with missing source land in the workspace; the source-backfill modal is mounted there", async () => {
|
||||
// Source attribution backfill is now an in-workspace modal — see
|
||||
// `<SourceBackfillModal />` mounted inside `DashboardLayout`. The
|
||||
|
||||
@@ -7,6 +7,7 @@ import { sanitizeNextUrl, useAuthStore } from "@multica/core/auth";
|
||||
import { workspaceKeys } from "@multica/core/workspace/queries";
|
||||
import { paths, resolvePostAuthDestination } from "@multica/core/paths";
|
||||
import { api } from "@multica/core/api";
|
||||
import { validateCliCallback, redirectToCliCallback } from "@multica/views/auth";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
@@ -46,9 +47,39 @@ function CallbackContent() {
|
||||
// so an attacker-controlled `state=next:https://evil` cannot redirect here.
|
||||
const nextUrl = sanitizeNextUrl(nextPart ? nextPart.slice(5) : null);
|
||||
|
||||
// CLI callback params — carried across the Google OAuth round-trip so
|
||||
// headless/WSL2 `multica login` can receive the JWT after browser-based
|
||||
// Google auth completes.
|
||||
const cliCallbackPart = stateParts.find((p) => p.startsWith("cli_callback:"));
|
||||
const cliStatePart = stateParts.find((p) => p.startsWith("cli_state:"));
|
||||
const cliCallbackRaw = cliCallbackPart
|
||||
? decodeURIComponent(cliCallbackPart.slice("cli_callback:".length))
|
||||
: null;
|
||||
const cliState = cliStatePart
|
||||
? decodeURIComponent(cliStatePart.slice("cli_state:".length))
|
||||
: "";
|
||||
|
||||
const redirectUri = `${window.location.origin}/auth/callback`;
|
||||
|
||||
if (isDesktop) {
|
||||
// Validate the CLI callback URL before redirecting — the state parameter
|
||||
// passes through Google OAuth and must be treated as attacker-controlled.
|
||||
const cliCallback =
|
||||
cliCallbackRaw && validateCliCallback(cliCallbackRaw)
|
||||
? cliCallbackRaw
|
||||
: null;
|
||||
|
||||
if (cliCallback) {
|
||||
// CLI login flow: exchange the Google code for a JWT, then redirect the
|
||||
// token back to the CLI's local HTTP listener (e.g. WSL2 host).
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
.then(({ token }) => {
|
||||
redirectToCliCallback(cliCallback, token, cliState);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Login failed");
|
||||
});
|
||||
} else if (isDesktop) {
|
||||
// Desktop flow: exchange code for token, then redirect via deep link
|
||||
api
|
||||
.googleLogin(code, redirectUri)
|
||||
|
||||
58
apps/web/app/global-error.tsx
Normal file
58
apps/web/app/global-error.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { captureException } from "@multica/core/analytics";
|
||||
|
||||
/**
|
||||
* Route-level error boundary for the web app. Next.js renders this (replacing
|
||||
* the root layout) when an error escapes everything below it — the full-page
|
||||
* white-screen case. React catches these before they reach window.onerror, so
|
||||
* posthog-js's automatic exception capture never sees them; we report them
|
||||
* explicitly here. Section-level failures are handled in place by
|
||||
* `@multica/ui` ErrorBoundary and don't reach this far.
|
||||
*/
|
||||
export default function GlobalError({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
captureException(error, { source: "global-error", digest: error.digest });
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<html>
|
||||
<body
|
||||
style={{
|
||||
display: "flex",
|
||||
minHeight: "100vh",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontFamily: "system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 420, textAlign: "center" }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</h1>
|
||||
<p style={{ marginTop: 8, color: "#666" }}>
|
||||
The page hit an unexpected error. Try reloading.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
style={{
|
||||
marginTop: 16,
|
||||
padding: "8px 16px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #ccc",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -293,21 +293,140 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-09",
|
||||
title: "More Reliable Agents, Attachments, and Issue Threads",
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "Custom Runtimes",
|
||||
changes: [],
|
||||
features: [
|
||||
"Teams can create custom runtimes so agents use the right local tools and models",
|
||||
"CLI agent create and update now supports thinking level",
|
||||
],
|
||||
improvements: [
|
||||
"Runtime profiles sync faster and prefer the best match for the current environment",
|
||||
"Client error and freeze reports now group duplicates",
|
||||
"Issue trigger previews are easier to read",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 email delivery is more reliable",
|
||||
"GitHub installation context and pending CI display are more reliable",
|
||||
"Codex runs fail quickly when the app server exits",
|
||||
"Self-healing runtimes can be deleted again, and incompatible models are cleared on runtime switch",
|
||||
"Unknown Issue icons and plain filenames are handled safely",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue Date Filters and More Stable Agent Runs",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issues can now be filtered by created or updated date, with quick ranges and custom date selections",
|
||||
"Command line users can now delete runtimes with safer defaults and an explicit option for related data",
|
||||
"Lark connections can now use network proxies, helping teams in restricted network environments connect reliably",
|
||||
],
|
||||
improvements: [
|
||||
"Web and desktop failures are now easier to investigate with clearer reports for errors, freezes, and crashes",
|
||||
"Project rows, comment previews, and comment composers are more consistent and easier to use",
|
||||
],
|
||||
fixes: [
|
||||
"Reply and edit previews now show the right agents or squads before a comment is saved",
|
||||
"Plain Issue IDs in comments now stay as text unless they are intentionally linked",
|
||||
"Google sign-in from command line login now returns to the command line correctly after browser authentication",
|
||||
"Chat file uploads wait until an active agent is ready, avoiding failed uploads during loading",
|
||||
"Transcript actions remain visible on touch devices where hover is unavailable",
|
||||
"Agent instructions for posting comments now avoid shell formatting problems that could drop assignees, projects, or other fields",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "Faster Lists, Easier Runtime Setup, and Safer Issue Editing",
|
||||
changes: [],
|
||||
features: [
|
||||
"Agents, autopilots, projects, runtimes, skills, and squads now use a faster, more consistent list experience with clearer rows, filters, selections, and actions",
|
||||
"The command line can now manage workspace repositories, so local agents can pick up project repo context more easily",
|
||||
"Cursor and OpenClaw are easier to set up: Cursor connection settings can be managed for you, and OpenClaw can connect through an existing gateway",
|
||||
"When editing a comment, you can preview and control which agents or squads will run before saving",
|
||||
],
|
||||
improvements: [
|
||||
"Desktop recovery prompts now include more page context, making stuck-window reports easier to understand",
|
||||
"Long Issues and inbox views now keep their scroll position and comment anchors more reliably when you navigate away and return",
|
||||
"Cursor usage and billing details are clearer for Composer, cached inputs, and newer Cursor agent output",
|
||||
],
|
||||
fixes: [
|
||||
"Issue attachments, inline images, and file cards are more reliable across web, desktop, mobile, and shared token links",
|
||||
"The editor and read-only Issue content now handle dollar amounts and email links more predictably",
|
||||
"Desktop Cmd+W now closes the active tab first, then the window when no tab can be closed",
|
||||
"Self-hosted Docker Compose uploads and default settings fail less often, with missing values caught earlier",
|
||||
"Agent tasks now stop safely when their run credentials are invalid",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy can now run local Multica agents, with its available model and effort choices shown automatically",
|
||||
"Quick-created Issues now keep uploaded files attached from the first draft through the final Issue",
|
||||
],
|
||||
improvements: [
|
||||
"Skill import conflicts are clearer: locked skills show a person's name instead of an internal ID, and a single overwrite now completes in one click",
|
||||
"Desktop recovery prompts now explain what happened first and give clearer details to include when reporting a stuck window",
|
||||
"Views that sort or filter people by signup time can now load faster",
|
||||
],
|
||||
fixes: [
|
||||
"Chat now keeps messages and drafts in sync when sending, stopping, or recovering from a failed send",
|
||||
"Lark account binding now works reliably for users who are already signed in, and sign-in returns to the binding page",
|
||||
"Local agent runs no longer announce that work has started before the task folder is ready",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "Skill Imports, Cleaner Run History, and Resilient Agents",
|
||||
changes: [],
|
||||
features: [
|
||||
"Skill imports now let you choose what happens when a skill already exists: stop, replace it, save a renamed copy, or skip it",
|
||||
"Import results now clearly show which skills were added, updated, skipped, blocked by a conflict, or could not be imported",
|
||||
],
|
||||
improvements: [
|
||||
"Execution logs now show the newest past runs first on web and mobile, so recent progress is easier to scan",
|
||||
"Changelog content was cleaned up so the latest release notes stay grouped under the right release",
|
||||
],
|
||||
fixes: [
|
||||
"Issue thread replies now stay in the order they arrived, even when a slower agent reply lands later",
|
||||
"Agents can recover when a saved session disappears, starting fresh instead of failing again on every mention",
|
||||
"Reviving an Issue from a new workspace folder now starts a fresh session instead of retrying one that only existed in the old folder",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "Safer Comment Triggers, Reliable Agents, and Attachments",
|
||||
changes: [],
|
||||
features: [
|
||||
"Comment boxes now show which agents or squads will start work before you send, with controls to avoid accidental runs",
|
||||
"Run transcripts now include timestamps, making agent progress and handoffs easier to review",
|
||||
"Autopilot detail pages now show who created each autopilot",
|
||||
"Claude Fable 5 is now available in Multica's supported model and pricing list",
|
||||
"Issue conversations can now resolve a specific reply, making long threads easier to close while keeping the final answer visible",
|
||||
"Lark and Feishu conversations now show a typing reaction while Multica is preparing a reply, then clear it before the answer is sent",
|
||||
"Agent runs now know who started each task, making handoffs, audit trails, and privacy-aware behavior more accurate",
|
||||
"OpenClaw users can point Multica at a custom app location and data folder from their local configuration",
|
||||
],
|
||||
improvements: [
|
||||
"Comment trigger indicators are quieter, clearer, and less likely to crowd long agent names",
|
||||
"Desktop now disables daemon start and stop controls when the daemon is managed outside Multica, such as in WSL2",
|
||||
"The active agent indicator in an Issue header is easier to read, with motion only while work is running and clearer queued wording otherwise",
|
||||
"The CLI now gives clearer guidance around common errors, sign-in problems, and project setup values",
|
||||
],
|
||||
fixes: [
|
||||
"Inline images and files in Issue descriptions now stay visible across web and desktop after reloads",
|
||||
"Each Issue discussion thread now keeps only one resolved answer at a time, so replacing the conclusion is consistent for everyone",
|
||||
"Issue pages refresh their data after realtime reconnects, avoiding stale timelines after a connection drop",
|
||||
"Agent task initiator history now works more reliably for older task records",
|
||||
"Sticky Issue comments keep a cleaner visual edge while scrolling",
|
||||
"Newly posted attachments now use stable private download links, so images and files stay visible after temporary upload links expire",
|
||||
"Autopilot runs started from newly created Issues now fail cleanly when the assigned task cannot complete, instead of staying stuck",
|
||||
"Inbox deep links now scroll inside the Issue timeline without pushing the desktop window out of place",
|
||||
|
||||
@@ -269,21 +269,140 @@ export function createJaDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-09",
|
||||
title: "より安定したエージェント、添付ファイル、イシューの会話",
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "カスタムランタイム",
|
||||
changes: [],
|
||||
features: [
|
||||
"チームはカスタムランタイムで、エージェントに合うローカルツールとモデルを使えます。",
|
||||
"コマンドラインでエージェントを作成または更新するときに思考レベルを選べます。",
|
||||
],
|
||||
improvements: [
|
||||
"ランタイムプロファイルの同期が速くなり、現在の環境に合うものが優先されます。",
|
||||
"クライアントのエラーやフリーズ報告の重複が減りました。",
|
||||
"Issue コメントのトリガープレビューが読みやすくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 メールの代替送信がより安定しました。",
|
||||
"GitHub のインストール情報と CI 待機表示がより安定しました。",
|
||||
"Codex サービスが終了したときはすばやく失敗します。",
|
||||
"自己修復ランタイムを再び削除でき、合わないモデル選択は整理されます。",
|
||||
"不明な Issue アイコンと通常のファイル名リンクを安全に扱います。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue の日付フィルターとエージェント実行の安定性向上",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue を作成日または更新日で絞り込めるようになり、クイック期間やカスタム日付を選べます。",
|
||||
"コマンドラインからランタイムを削除できるようになり、既定の動作はより安全で、関連データの扱いも明示的に選べます。",
|
||||
"Lark 接続がネットワークプロキシを利用できるようになり、制限のあるネットワーク環境でも接続しやすくなりました。",
|
||||
],
|
||||
improvements: [
|
||||
"Web とデスクトップのエラー、フリーズ、クラッシュ報告が分かりやすくなり、原因調査がしやすくなりました。",
|
||||
"プロジェクト行、コメントのプレビュー、コメント作成まわりの操作がより一貫して使いやすくなりました。",
|
||||
],
|
||||
fixes: [
|
||||
"返信やコメント編集を保存する前に、どのエージェントやスクワッドが動き始めるかをより正確に確認できます。",
|
||||
"コメント内の通常の Issue 番号は、明示的にリンクしない限り通常のテキストのまま残ります。",
|
||||
"コマンドラインログインで Google ログインを使った場合も、ブラウザー認証後に正しくコマンドラインへ戻ります。",
|
||||
"チャットのファイルアップロードはアクティブなエージェントの準備ができてから有効になり、読み込み中の失敗を防ぎます。",
|
||||
"ホバーできないタッチ端末でも、実行履歴の操作ボタンが表示されます。",
|
||||
"エージェントがコメントを投稿するとき、担当者、プロジェクト、その他の項目がコマンド形式の問題で抜ける可能性が減りました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "より速いリスト体験、使いやすい実行設定、安全な Issue 編集",
|
||||
changes: [],
|
||||
features: [
|
||||
"エージェント、オートパイロット、プロジェクト、ランタイム、スキル、スクワッドのリストがより速く一貫した体験になり、行表示、絞り込み、選択、操作が分かりやすくなりました。",
|
||||
"コマンドラインからワークスペースのリポジトリを管理できるようになり、ローカルエージェントがプロジェクトのリポジトリ情報を受け取りやすくなりました。",
|
||||
"Cursor と OpenClaw の設定が簡単になりました。Cursor の接続設定は Multica に任せられ、OpenClaw は既存のゲートウェイにも接続できます。",
|
||||
"コメントを編集するとき、保存前にどのエージェントやスクワッドが動き始めるかをプレビューして制御できます。",
|
||||
],
|
||||
improvements: [
|
||||
"デスクトップの復旧案内にページの文脈が増え、固まったウィンドウを報告するときに状況を伝えやすくなりました。",
|
||||
"長い Issue と受信箱ビューでは、別の場所へ移動して戻ったときにスクロール位置やコメント位置がより安定して保たれます。",
|
||||
"Cursor の Composer、キャッシュ入力、新しい Cursor エージェント出力で、使用量と請求情報がより分かりやすく表示されます。",
|
||||
],
|
||||
fixes: [
|
||||
"Issue の添付ファイル、本文内画像、ファイルカードは、Web、デスクトップ、モバイル、トークン付き共有リンクでより安定して開けるようになりました。",
|
||||
"エディターと読み取り専用の Issue 内容で、ドル金額とメールリンクがより安定して扱われます。",
|
||||
"デスクトップの Cmd+W は、まず現在のタブを閉じ、閉じられるタブがない場合にウィンドウを閉じます。",
|
||||
"セルフホストの Docker Compose アップロードと既定設定は失敗しにくくなり、足りない設定値も早めに見つかります。",
|
||||
"実行に必要な認証情報が無効な場合、エージェントタスクは安全に停止するようになりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy でローカルの Multica エージェントを動かせるようになり、利用できるモデルと実行の強さが自動で表示されます。",
|
||||
"クイック作成した Issue では、下書きでアップロードしたファイルが最終的な Issue まで保持されます。",
|
||||
],
|
||||
improvements: [
|
||||
"スキル取り込みの競合が分かりやすくなり、ロックされたスキルには内部 ID ではなくメンバー名が表示され、単体の上書きも 1 クリックで完了します。",
|
||||
"デスクトップの復旧案内は、まず何が起きたかを説明し、固まったウィンドウを報告するときに含める情報も分かりやすくなりました。",
|
||||
"登録日時でメンバーを並べ替えたり絞り込んだりする画面が、より速く読み込まれるようになりました。",
|
||||
],
|
||||
fixes: [
|
||||
"チャットの送信、停止、送信失敗からの復旧時に、メッセージと下書きがより安定して同期されます。",
|
||||
"Lark アカウント連携は、すでにサインイン済みのユーザーでも安定して完了し、サインイン後も連携ページに戻ります。",
|
||||
"ローカルエージェントの実行は、タスクフォルダの準備が終わる前に開始済みとして表示されなくなりました。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "スキルのインポート、実行履歴、より安定したエージェント",
|
||||
changes: [],
|
||||
features: [
|
||||
"スキルのインポート時に同じスキルがすでにある場合、停止、置き換え、別名で保存、スキップを選べるようになりました。",
|
||||
"インポート結果では、追加、更新、スキップ、競合、失敗したスキルがわかりやすく表示されます。",
|
||||
],
|
||||
improvements: [
|
||||
"Web とモバイルの実行履歴は新しい過去実行を先に表示するため、最近の進捗を追いやすくなりました。",
|
||||
"変更履歴の内容を整理し、最新のリリースノートが正しいバージョンにまとまるようにしました。",
|
||||
],
|
||||
fixes: [
|
||||
"イシューの返信は到着した順番のまま表示され、遅れて届いたエージェント返信が途中に割り込まなくなりました。",
|
||||
"保存済みセッションが失われた場合でも、エージェントは新しく開始して復旧でき、以後のメンションで失敗し続けません。",
|
||||
"新しい作業フォルダーからイシューを再開すると、古いフォルダーにだけ存在したセッションではなく新しいセッションで始まります。",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "より安全なコメントトリガー、安定したエージェントと添付ファイル",
|
||||
changes: [],
|
||||
features: [
|
||||
"コメント入力欄では、送信前にどのエージェントやスクワッドが動き始めるかを確認でき、誤って実行することを避けられます。",
|
||||
"実行記録に時刻が表示されるようになり、エージェントの進捗や引き継ぎを振り返りやすくなりました。",
|
||||
"オートパイロット詳細ページで、誰が作成したかを確認できるようになりました。",
|
||||
"Claude Fable 5 が Multica の対応モデルと料金一覧に加わりました。",
|
||||
"イシューの会話で特定の返信を解決として残せるようになり、長いスレッドを閉じても結論を確認しやすくなりました。",
|
||||
"Lark と Feishu の会話では、Multica が返信を準備している間に入力中のリアクションを表示し、返信前に自動で消します。",
|
||||
"エージェント実行は、誰がそのタスクを始めたかを把握できるようになり、引き継ぎ、監査、プライバシーに配慮した動作がより正確になります。",
|
||||
"OpenClaw ユーザーは、ローカル設定から独自のアプリ場所とデータフォルダーを指定できます。",
|
||||
],
|
||||
improvements: [
|
||||
"コメントトリガーの表示はより控えめで読みやすく、長いエージェント名でも混み合いにくくなりました。",
|
||||
"WSL2 など Multica の外でデーモンが管理されている場合、デスクトップは開始と停止の操作を無効にします。",
|
||||
"イシュー上部のアクティブなエージェント表示は、実行中だけ動き、待機中は待機状態を明確に示すため、読み取りやすくなりました。",
|
||||
"CLI は、よくあるエラー、サインインの問題、プロジェクト設定の値について、よりわかりやすく案内します。",
|
||||
],
|
||||
fixes: [
|
||||
"イシュー説明内の画像とファイルは、Web とデスクトップのどちらでも再読み込み後に表示され続けます。",
|
||||
"各イシュー会話スレッドは解決済みの回答を 1 つだけ保持するため、結論を置き換えたときの表示が全員でそろいます。",
|
||||
"リアルタイム接続が復帰したあと、イシュー画面はデータを更新し、古いタイムラインが残りにくくなりました。",
|
||||
"エージェントタスクの開始者履歴が、古いタスク記録でもより信頼できるようになりました。",
|
||||
"スクロール中の固定イシューコメントの境界がよりきれいに表示されます。",
|
||||
"新しく投稿された添付ファイルは安定した非公開ダウンロードリンクを使うため、一時的なアップロードリンクが期限切れになっても画像やファイルを表示できます。",
|
||||
"新規イシューから始まったオートパイロット実行は、割り当てられたタスクが完了できない場合に正しく失敗し、進行中のまま残りません。",
|
||||
"受信箱からコメントリンクを開いたとき、デスクトップ画面全体ではなくイシューのタイムラインだけがスクロールします。",
|
||||
|
||||
@@ -268,21 +268,140 @@ export function createKoDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-09",
|
||||
title: "더 안정적인 에이전트, 첨부 파일, 이슈 대화",
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "사용자 지정 런타임",
|
||||
changes: [],
|
||||
features: [
|
||||
"팀은 사용자 지정 런타임으로 에이전트에 맞는 로컬 도구와 모델을 사용할 수 있습니다.",
|
||||
"명령줄에서 에이전트를 만들거나 업데이트할 때 사고 수준을 선택할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"런타임 프로필이 더 빠르게 동기화되고 현재 환경에 맞게 우선 적용됩니다.",
|
||||
"클라이언트 오류와 멈춤 보고의 중복이 줄었습니다.",
|
||||
"Issue 댓글 트리거 미리보기가 더 읽기 쉬워졌습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 메일의 대체 전송이 더 안정적입니다.",
|
||||
"GitHub 설치 맥락과 CI 대기 상태 표시가 더 안정적입니다.",
|
||||
"Codex 서비스가 종료되면 빠르게 실패합니다.",
|
||||
"셀프 힐링 런타임을 다시 삭제할 수 있고, 맞지 않는 모델 선택은 정리됩니다.",
|
||||
"알 수 없는 Issue 아이콘과 일반 파일 이름 링크 처리가 더 안전해졌습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue 날짜 필터와 더 안정적인 에이전트 실행",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue를 생성일 또는 수정일 기준으로 필터링할 수 있으며, 빠른 기간과 사용자 지정 날짜를 선택할 수 있습니다.",
|
||||
"명령줄에서 런타임을 삭제할 수 있고, 기본 동작은 더 안전하며 관련 데이터 처리도 명시적으로 선택할 수 있습니다.",
|
||||
"Lark 연결이 네트워크 프록시를 사용할 수 있어 제한된 네트워크 환경에서도 더 안정적으로 연결됩니다.",
|
||||
],
|
||||
improvements: [
|
||||
"웹과 데스크톱의 오류, 멈춤, 충돌 보고가 더 명확해져 문제를 조사하기 쉬워졌습니다.",
|
||||
"프로젝트 행, 댓글 미리보기, 댓글 작성기가 더 일관되고 사용하기 쉬워졌습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"답글과 댓글 편집을 저장하기 전에 어떤 에이전트나 스쿼드가 실행될지 더 정확하게 미리 보여줍니다.",
|
||||
"댓글의 일반 Issue 번호는 명시적으로 링크하지 않는 한 일반 텍스트로 유지됩니다.",
|
||||
"명령줄 로그인에서 Google 로그인을 사용해도 브라우저 인증 후 명령줄로 올바르게 돌아옵니다.",
|
||||
"채팅 파일 업로드는 활성 에이전트가 준비된 뒤에만 열려 로딩 중 실패를 줄입니다.",
|
||||
"호버가 없는 터치 기기에서도 실행 기록 작업 버튼이 계속 보입니다.",
|
||||
"에이전트가 댓글을 게시할 때 담당자, 프로젝트, 기타 필드가 명령 형식 문제로 누락될 가능성이 줄었습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "더 빠른 목록 경험, 쉬운 실행 설정, 안전한 Issue 편집",
|
||||
changes: [],
|
||||
features: [
|
||||
"에이전트, 오토파일럿, 프로젝트, 런타임, 스킬, 스쿼드의 목록이 더 빠르고 일관된 경험으로 바뀌어 행, 필터, 선택, 작업이 더 명확해졌습니다.",
|
||||
"명령줄에서 워크스페이스 저장소를 관리할 수 있어 로컬 에이전트가 프로젝트 저장소 정보를 더 쉽게 가져올 수 있습니다.",
|
||||
"Cursor와 OpenClaw 설정이 더 쉬워졌습니다. Cursor 연결 설정은 Multica가 관리할 수 있고, OpenClaw는 기존 게이트웨이에 연결할 수 있습니다.",
|
||||
"댓글을 편집할 때 저장하기 전에 어떤 에이전트나 스쿼드가 실행될지 미리 보고 제어할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"데스크톱 복구 안내에 페이지 맥락이 더 많이 포함되어 멈춘 창의 상황을 설명하기 쉬워졌습니다.",
|
||||
"긴 Issue와 받은함 보기에서 다른 곳으로 이동했다가 돌아와도 스크롤 위치와 댓글 위치가 더 안정적으로 유지됩니다.",
|
||||
"Cursor의 Composer, 캐시 입력, 새로운 Cursor 에이전트 출력에서 사용량과 청구 정보가 더 명확하게 표시됩니다.",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 첨부 파일, 본문 이미지, 파일 카드가 웹, 데스크톱, 모바일, 토큰 공유 링크에서 더 안정적으로 열립니다.",
|
||||
"편집기와 읽기 전용 Issue 내용에서 달러 금액과 이메일 링크가 더 안정적으로 처리됩니다.",
|
||||
"데스크톱 Cmd+W는 먼저 활성 탭을 닫고, 닫을 탭이 없을 때 창을 닫습니다.",
|
||||
"셀프 호스트 Docker Compose 업로드와 기본 설정이 덜 실패하고, 빠진 설정값을 더 일찍 확인합니다.",
|
||||
"실행에 필요한 인증 정보가 유효하지 않으면 에이전트 작업이 안전하게 중단됩니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy로 로컬 Multica 에이전트를 실행할 수 있으며, 사용할 수 있는 모델과 실행 강도 선택지가 자동으로 표시됩니다.",
|
||||
"빠르게 만든 Issue에서도 초안에서 올린 파일이 최종 Issue까지 함께 유지됩니다.",
|
||||
],
|
||||
improvements: [
|
||||
"스킬 가져오기 충돌이 더 이해하기 쉬워졌습니다. 잠긴 스킬은 내부 ID 대신 멤버 이름을 보여주고, 단일 덮어쓰기도 한 번의 클릭으로 끝납니다.",
|
||||
"데스크톱 복구 안내가 먼저 무슨 일이 있었는지 설명하고, 멈춘 창을 신고할 때 포함할 정보를 더 명확하게 보여줍니다.",
|
||||
"가입 시간으로 멤버를 정렬하거나 필터링하는 화면이 더 빠르게 로드될 수 있습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"채팅을 보내거나 중지하거나 전송 실패에서 복구할 때 메시지와 초안이 더 안정적으로 동기화됩니다.",
|
||||
"Lark 계정 연결은 이미 로그인한 사용자에게도 안정적으로 완료되며, 로그인 후에도 연결 페이지로 돌아옵니다.",
|
||||
"로컬 에이전트 실행은 작업 폴더가 준비되기 전에 시작된 것으로 표시되지 않습니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "스킬 가져오기, 실행 기록, 더 안정적인 에이전트",
|
||||
changes: [],
|
||||
features: [
|
||||
"스킬을 가져올 때 같은 스킬이 이미 있으면 중단, 교체, 이름을 바꿔 저장, 건너뛰기 중에서 선택할 수 있습니다.",
|
||||
"가져오기 결과에서 추가, 업데이트, 건너뜀, 충돌, 실패한 스킬을 더 명확하게 확인할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"웹과 모바일 실행 기록은 최신 과거 실행을 먼저 보여 주어 최근 진행 상황을 더 쉽게 확인할 수 있습니다.",
|
||||
"변경 로그 콘텐츠를 정리해 최신 릴리스 노트가 올바른 버전에 묶이도록 했습니다.",
|
||||
],
|
||||
fixes: [
|
||||
"이슈 스레드 답글은 도착한 순서대로 표시되어, 늦게 도착한 에이전트 답글이 중간에 끼어들지 않습니다.",
|
||||
"저장된 세션이 사라져도 에이전트가 새로 시작해 복구할 수 있어, 이후 멘션마다 계속 실패하지 않습니다.",
|
||||
"새 작업 폴더에서 이슈를 다시 시작할 때 이전 폴더에만 있던 세션을 재시도하지 않고 새 세션으로 시작합니다.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "더 안전한 댓글 트리거, 안정적인 에이전트와 첨부 파일",
|
||||
changes: [],
|
||||
features: [
|
||||
"댓글 입력창에서 보내기 전에 어떤 에이전트나 스쿼드가 작업을 시작할지 확인하고, 실수로 실행되는 일을 줄일 수 있습니다.",
|
||||
"실행 기록에 시간이 표시되어 에이전트 진행 상황과 인계를 더 쉽게 검토할 수 있습니다.",
|
||||
"오토파일럿 상세 페이지에서 누가 만들었는지 확인할 수 있습니다.",
|
||||
"Claude Fable 5가 Multica의 지원 모델과 가격 목록에 추가되었습니다.",
|
||||
"이슈 대화에서 특정 답글을 해결 답변으로 남길 수 있어, 긴 스레드를 접어도 결론을 더 쉽게 확인할 수 있습니다.",
|
||||
"Lark와 Feishu 대화는 Multica가 답변을 준비하는 동안 입력 중 반응을 표시하고, 답변을 보내기 전에 자동으로 지웁니다.",
|
||||
"에이전트 실행은 각 작업을 누가 시작했는지 알 수 있어 인계, 감사, 개인정보를 고려한 동작이 더 정확해집니다.",
|
||||
"OpenClaw 사용자는 로컬 설정에서 사용자 지정 앱 위치와 데이터 폴더를 지정할 수 있습니다.",
|
||||
],
|
||||
improvements: [
|
||||
"댓글 트리거 표시가 더 조용하고 명확해졌으며, 긴 에이전트 이름도 덜 비좁게 보입니다.",
|
||||
"WSL2처럼 Multica 밖에서 데몬을 관리하는 경우 데스크톱은 시작과 중지 조작을 비활성화합니다.",
|
||||
"이슈 헤더의 활성 에이전트 표시가 더 읽기 쉬워졌으며, 실제 실행 중일 때만 움직이고 대기 중일 때는 대기 상태를 명확히 보여 줍니다.",
|
||||
"CLI는 흔한 오류, 로그인 문제, 프로젝트 설정 값에 대해 더 명확하게 안내합니다.",
|
||||
],
|
||||
fixes: [
|
||||
"이슈 설명의 이미지와 파일은 웹과 데스크톱에서 다시 열어도 계속 표시됩니다.",
|
||||
"각 이슈 대화 스레드는 해결 답변을 하나만 유지해 결론을 바꿀 때 모두에게 일관되게 보입니다.",
|
||||
"실시간 연결이 복구된 뒤 이슈 화면이 데이터를 새로고침해 오래된 타임라인이 남지 않습니다.",
|
||||
"에이전트 작업을 시작한 사람의 기록이 오래된 작업에서도 더 안정적으로 유지됩니다.",
|
||||
"스크롤 중 고정된 이슈 댓글의 가장자리가 더 깔끔하게 보입니다.",
|
||||
"새로 올린 첨부 파일은 안정적인 비공개 다운로드 링크를 사용해 임시 업로드 링크가 만료된 뒤에도 이미지와 파일이 계속 표시됩니다.",
|
||||
"새 이슈에서 시작된 오토파일럿 실행은 배정된 작업이 완료되지 못하면 올바르게 실패 처리되어 진행 중에 멈춰 있지 않습니다.",
|
||||
"받은함에서 댓글 링크를 열 때 데스크톱 화면 전체가 밀리지 않고 이슈 타임라인 안에서만 스크롤됩니다.",
|
||||
|
||||
@@ -293,21 +293,140 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-09",
|
||||
title: "身份上下文优化、附件稳定性和 Issue 讨论升级",
|
||||
version: "0.3.24",
|
||||
date: "2026-06-17",
|
||||
title: "自定义运行时",
|
||||
changes: [],
|
||||
features: [
|
||||
"团队可以创建自定义运行时,让智能体按环境使用合适的本地工具和模型",
|
||||
"命令行创建和更新智能体时可以选择思考强度",
|
||||
],
|
||||
improvements: [
|
||||
"运行时配置会更快同步到应用,并优先匹配当前环境",
|
||||
"客户端错误和卡顿反馈会合并重复信息",
|
||||
"Issue 评论触发预览文案更清楚",
|
||||
],
|
||||
fixes: [
|
||||
"Office 365 邮件的备用发送方式更稳定",
|
||||
"GitHub 安装上下文和 CI 等待状态显示更可靠",
|
||||
"Codex 服务退出时会快速失败",
|
||||
"自修复运行时可再次删除,切换运行时时会清理不兼容模型",
|
||||
"未知 Issue 图标和普通文件名链接识别更安全",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.23",
|
||||
date: "2026-06-16",
|
||||
title: "Issue 日期筛选和提高智能体运行稳定性",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 现在可以按创建时间或更新时间筛选,支持快捷时间范围和自定义日期",
|
||||
"命令行现在可以删除运行环境,默认行为更安全,也可以明确选择是否连带处理相关数据",
|
||||
"Lark 连接现在可以使用网络代理,受限网络环境下的团队也能更稳定地连接",
|
||||
],
|
||||
improvements: [
|
||||
"网页端和桌面端的错误、卡顿和崩溃现在更容易定位,问题反馈会带上更清楚的信息",
|
||||
"项目列表行、评论预览和评论编辑器体验更一致,导航和附件操作更顺手",
|
||||
],
|
||||
fixes: [
|
||||
"回复和编辑评论前,现在会更准确地预览哪些智能体或小队会开始运行",
|
||||
"评论里的普通 Issue 编号会保持为普通文字,只有明确插入链接时才会变成链接",
|
||||
"通过命令行登录并选择 Google 登录时,浏览器认证完成后现在会正确回到命令行",
|
||||
"聊天上传文件会等到当前智能体准备好后再开放,避免加载过程中上传失败",
|
||||
"触屏设备上不需要悬停也能看到运行记录里的操作按钮",
|
||||
"智能体发布评论的指令更稳,不容易因为命令格式问题漏掉指派人、项目或其他字段",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.22",
|
||||
date: "2026-06-15",
|
||||
title: "更快的列表体验、更顺手的运行配置和更安全的 Issue 编辑",
|
||||
changes: [],
|
||||
features: [
|
||||
"智能体、自动任务、项目、运行环境、技能和小队的列表体验更快也更一致,行内容、筛选、选择和操作都更清楚",
|
||||
"命令行现在可以管理工作区仓库,本地智能体更容易拿到项目仓库上下文",
|
||||
"Cursor 和 OpenClaw 更容易配置:Cursor 连接设置可以由 Multica 托管,OpenClaw 也可以连接已有网关",
|
||||
"编辑评论时,可以在保存前预览并控制哪些智能体或小队会开始运行",
|
||||
],
|
||||
improvements: [
|
||||
"桌面端恢复提示会带上更多页面上下文,反馈卡住窗口时更容易说清发生位置",
|
||||
"长 Issue 和收件箱视图在离开后返回时,会更稳定地保留滚动位置和评论锚点",
|
||||
"Cursor 的 Composer、缓存输入和新版 Cursor 智能体输出会展示更清楚的用量和计费信息",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 附件、正文图片和文件卡片在网页端、桌面端、移动端以及令牌分享链接里更稳定可用",
|
||||
"编辑器和只读 Issue 内容会更稳定地处理美元金额和邮箱链接",
|
||||
"桌面端 Cmd+W 现在会先关闭当前标签页,无法关闭标签页时再关闭窗口",
|
||||
"自托管 Docker Compose 上传和默认配置更少失败,缺失的配置值也会更早暴露",
|
||||
"智能体任务遇到无效运行凭证时,会安全停止而不是继续执行",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.21",
|
||||
date: "2026-06-12",
|
||||
title: "CodeBuddy Runtime",
|
||||
changes: [],
|
||||
features: [
|
||||
"CodeBuddy 现在可以驱动本地 Multica 智能体,并会自动显示可用的模型和投入强度选项",
|
||||
"快速创建 Issue 时上传的文件现在会从草稿一直带到最终创建的 Issue 里",
|
||||
],
|
||||
improvements: [
|
||||
"技能导入冲突更容易理解:锁定的技能会显示成员名称,不再显示内部 ID;单个覆盖也可以一键完成",
|
||||
"桌面端恢复提示会先说明发生了什么,并给出更清楚的窗口卡住反馈信息",
|
||||
"按注册时间排序或筛选成员的页面现在加载更快",
|
||||
],
|
||||
fixes: [
|
||||
"聊天在发送、停止或发送失败恢复时,会更稳定地同步消息和草稿",
|
||||
"Lark 账号绑定现在对已登录用户也能稳定完成,登录后也会回到绑定页面",
|
||||
"本地智能体运行不会再在任务文件夹准备好之前就显示已经开始",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.20",
|
||||
date: "2026-06-11",
|
||||
title: "技能导入、运行记录和更稳定的智能体",
|
||||
changes: [],
|
||||
features: [
|
||||
"导入技能时,如果同名技能已存在,现在可以选择停止、替换、另存为新名称或跳过",
|
||||
"导入结果会清楚显示哪些技能已新增、已更新、已跳过、发生冲突或导入失败",
|
||||
],
|
||||
improvements: [
|
||||
"网页端和移动端的执行记录现在会优先显示最新的历史运行,更容易看清最近进展",
|
||||
"更新日志内容已整理,最新发布内容会归在正确的版本下",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 讨论里的回复现在会按到达顺序显示,即使较慢的智能体回复稍后才出现,也不会插到前面",
|
||||
"当已保存的会话失效时,智能体可以自动重新开始,不会在后续每次提及时反复失败",
|
||||
"从新的工作目录重新唤起 Issue 时,现在会开始新会话,不会继续尝试只存在于旧目录里的会话",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.3.19",
|
||||
date: "2026-06-10",
|
||||
title: "更安全的评论触发、更稳定的智能体和附件",
|
||||
changes: [],
|
||||
features: [
|
||||
"评论输入框现在会在发送前显示哪些智能体或小队会开始工作,也可以避免误触发运行",
|
||||
"智能体运行记录现在会显示时间点,回看进度和交接信息更清楚",
|
||||
"自动任务详情页现在会显示创建人",
|
||||
"Claude Fable 5 现在已加入 Multica 支持的模型和价格列表",
|
||||
"Issue 讨论可以把某一条回复设为解决结论,长讨论收起后也能直接看到最终答案",
|
||||
"在 Lark 和飞书里和 Multica 对话时,会显示等待中的输入状态,回复发出后自动清除",
|
||||
"每次智能体任务都会带上真实发起人信息,交接、审计和权限判断更准确",
|
||||
"OpenClaw 可以从本地配置中读取自定义程序位置和数据目录",
|
||||
],
|
||||
improvements: [
|
||||
"评论触发提示更安静、更清楚,遇到较长的智能体名称时也不容易拥挤",
|
||||
"桌面端在守护进程由 Multica 之外的环境管理时,会禁用启动和停止控制,例如 WSL2 场景",
|
||||
"Issue 顶部的智能体状态更容易区分:运行中才显示动效,等待中会明确显示排队状态",
|
||||
"命令行会直接说明常见错误、登录问题和项目配置问题的处理方式",
|
||||
],
|
||||
fixes: [
|
||||
"Issue 描述里的图片和文件在网页端和桌面端重新打开后都会保持可见",
|
||||
"每个 Issue 讨论线程现在只会保留一个解决结论,替换结论时所有人看到的状态更一致",
|
||||
"实时连接断开并恢复后,Issue 页面会刷新数据,避免时间线停留在旧状态",
|
||||
"智能体任务的发起人历史在较早任务记录上也会更可靠",
|
||||
"滚动时置顶的 Issue 评论边缘显示更干净",
|
||||
"新上传的附件会使用稳定的私有下载链接,临时上传链接过期后图片和文件仍能正常显示",
|
||||
"自动任务通过新建 Issue 启动后,如果对应的智能体任务失败,会同步标记为失败,不会一直卡在进行中",
|
||||
"从收件箱打开评论链接时,只会滚动 Issue 时间线,不会把桌面窗口内容顶出可见区域",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# docker compose -f docker-compose.selfhost.yml up -d
|
||||
#
|
||||
# Frontend: http://localhost:${FRONTEND_PORT:-3000}
|
||||
# Backend: http://localhost:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}
|
||||
# Backend: http://localhost:${BACKEND_PORT:-8080}
|
||||
|
||||
name: multica
|
||||
|
||||
@@ -44,7 +44,7 @@ services:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "127.0.0.1:${BACKEND_PORT:-${API_PORT:-${SERVER_PORT:-${PORT:-8080}}}}:8080"
|
||||
- "127.0.0.1:${BACKEND_PORT:-8080}:8080"
|
||||
volumes:
|
||||
- backend_uploads:/app/data/uploads
|
||||
environment:
|
||||
@@ -52,7 +52,7 @@ services:
|
||||
PORT: "8080"
|
||||
METRICS_ADDR: ${METRICS_ADDR:-}
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:${FRONTEND_PORT:-3000}}
|
||||
FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:-http://localhost:3000}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
||||
RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-noreply@multica.ai}
|
||||
@@ -65,10 +65,12 @@ services:
|
||||
SMTP_EHLO_NAME: ${SMTP_EHLO_NAME:-}
|
||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:${FRONTEND_PORT:-3000}/auth/callback}
|
||||
GOOGLE_REDIRECT_URI: ${GOOGLE_REDIRECT_URI:-http://localhost:3000/auth/callback}
|
||||
S3_BUCKET: ${S3_BUCKET:-}
|
||||
S3_REGION: ${S3_REGION:-us-west-2}
|
||||
AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
|
||||
ATTACHMENT_DOWNLOAD_MODE: ${ATTACHMENT_DOWNLOAD_MODE:-auto}
|
||||
ATTACHMENT_DOWNLOAD_URL_TTL: ${ATTACHMENT_DOWNLOAD_URL_TTL:-30m}
|
||||
CLOUDFRONT_DOMAIN: ${CLOUDFRONT_DOMAIN:-}
|
||||
@@ -77,7 +79,7 @@ services:
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-}
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:${FRONTEND_PORT:-3000}}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
|
||||
@@ -534,10 +534,8 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
| Provider | 厂商 | Session Resume | MCP | Skill 注入路径 | custom_args | 备注 |
|
||||
- 每个 provider 一小段(80-150 字):核心定位 + 用户画像 + 官网链接 + Multica 兼容性
|
||||
- **Session resume 精确现状**:
|
||||
- ✅ 真用:Claude / Hermes / Kimi / OpenCode / Copilot
|
||||
- ⚠️ Codex:代码有 thread/resume 但 unreachable(future feature)
|
||||
- ❌ 不支持:Pi / Gemini / OpenClaw
|
||||
- ❓ 未审:Cursor
|
||||
- ✅ 真用:Antigravity / Claude / Codex / Copilot / Cursor / Hermes / Kimi / Kiro CLI / OpenCode / OpenClaw / Pi
|
||||
- ❌ 不支持:Gemini
|
||||
- **不写**: provider 官方使用文档(外链)、MCP 协议本身
|
||||
- **写前要验证**:
|
||||
- 认领者**必须逐个打开 `server/pkg/agent/*.go`** 确认
|
||||
@@ -546,7 +544,7 @@ multica issue assign <issue-id> --agent <agent-slug>
|
||||
- **⚠️ 动笔前必读**:
|
||||
- ⚠️ 这是最容易过时的一页,provider 代码频繁变动
|
||||
- 精确到 "代码里这个 flag 传给这个 CLI" 级别,不模糊说"支持"
|
||||
- Codex "unreachable" 状态必须明确(不是承诺)
|
||||
- Codex fallback 语义必须明确:`thread/resume` 可用,但 stale / missing thread 会回退到 fresh thread
|
||||
- **Owner**: –
|
||||
|
||||
---
|
||||
|
||||
@@ -1,36 +1,34 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
import { createTestApi, loginAsDefault, openWorkspaceMenu, waitForPageText } from "./helpers";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("login page renders correctly", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.goto("/login", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Sign in to Multica");
|
||||
|
||||
await expect(page.locator("h1")).toContainText("Multica");
|
||||
await expect(page.locator('input[placeholder="Email"]')).toBeVisible();
|
||||
await expect(page.locator('input[placeholder="Name"]')).toBeVisible();
|
||||
await expect(page.locator('button[type="submit"]')).toContainText(
|
||||
"Sign in",
|
||||
);
|
||||
await expect(page.getByText("Sign in to Multica")).toBeVisible();
|
||||
await expect(page.getByRole("textbox", { name: "Email" })).toBeVisible();
|
||||
await expect(page.getByPlaceholder("you@example.com")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("login and redirect to /issues", async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
const workspaceSlug = await loginAsDefault(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/issues/);
|
||||
await expect(page.locator("text=All Issues")).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`/${workspaceSlug}/issues$`));
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("unauthenticated user is redirected to /login", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.evaluate(() => {
|
||||
localStorage.removeItem("multica_token");
|
||||
});
|
||||
const api = await createTestApi();
|
||||
const [workspace] = await api.getWorkspaces();
|
||||
if (!workspace) {
|
||||
throw new Error("E2E workspace was not created");
|
||||
}
|
||||
|
||||
// Visit a workspace-scoped route; DashboardGuard should redirect to /login.
|
||||
// The slug here need not exist — the guard runs before workspace resolution
|
||||
// for unauthenticated users.
|
||||
await page.goto("/e2e-workspace/issues");
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
await page.goto(`/${workspace.slug}/issues`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForURL("**/login", { timeout: 10000, waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Sign in to Multica");
|
||||
});
|
||||
|
||||
test("logout redirects to /login", async ({ page }) => {
|
||||
@@ -39,10 +37,10 @@ test.describe("Authentication", () => {
|
||||
// Open the workspace dropdown menu
|
||||
await openWorkspaceMenu(page);
|
||||
|
||||
// Click Sign out
|
||||
await page.locator("text=Sign out").click();
|
||||
await page.getByRole("menuitem", { name: "Log out" }).click();
|
||||
|
||||
await page.waitForURL("**/login", { timeout: 10000 });
|
||||
await page.waitForURL("**/login", { timeout: 10000, waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Sign in to Multica");
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe("Chat attachments", () => {
|
||||
|
||||
const userRow = await pgc.query(
|
||||
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
|
||||
["e2e@multica.ai"],
|
||||
[api.getEmail()],
|
||||
);
|
||||
if (userRow.rows.length === 0) throw new Error("e2e user missing");
|
||||
const userId = userRow.rows[0].id as string;
|
||||
|
||||
@@ -1,40 +1,44 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { createTestApi, loginAsDefault } from "./helpers";
|
||||
import { createTestApi, loginAsDefault, waitForPageText } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
test.describe("Comments", () => {
|
||||
let api: TestApiClient;
|
||||
let issueId: string;
|
||||
let issueTitle: string;
|
||||
let workspaceSlug: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
api = await createTestApi();
|
||||
await api.createIssue("E2E Comment Test " + Date.now());
|
||||
await loginAsDefault(page);
|
||||
issueTitle = "E2E Comment Test " + Date.now();
|
||||
const issue = await api.createIssue(issueTitle);
|
||||
issueId = issue.id;
|
||||
workspaceSlug = await loginAsDefault(page);
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
await api.cleanup();
|
||||
if (api) {
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("can add a comment on an issue", async ({ page }) => {
|
||||
// Wait for issues to load and click first one. `*=` matches both legacy
|
||||
// `/issues/{id}` and URL-refactored `/{slug}/issues/{id}` hrefs.
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await page.goto(`/${workspaceSlug}/issues/${issueId}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, issueTitle);
|
||||
|
||||
// Wait for issue detail to load
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
|
||||
// Type a comment
|
||||
const commentText = "E2E comment " + Date.now();
|
||||
const commentInput = page.locator(
|
||||
'input[placeholder="Leave a comment..."]',
|
||||
);
|
||||
await commentInput.fill(commentText);
|
||||
const editor = page
|
||||
.locator('.ProseMirror[data-placeholder="Leave a comment..."], .ProseMirror:has([data-placeholder="Leave a comment..."])')
|
||||
.first();
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.click({ force: true });
|
||||
await editor.fill(commentText);
|
||||
|
||||
// Submit the comment
|
||||
await page.locator('form button[type="submit"]').last().click();
|
||||
await page.keyboard.press("ControlOrMeta+Enter");
|
||||
|
||||
// Comment should appear in the activity section
|
||||
await expect(page.locator(`text=${commentText}`)).toBeVisible({
|
||||
@@ -43,15 +47,18 @@ test.describe("Comments", () => {
|
||||
});
|
||||
|
||||
test("comment submit button is disabled when empty", async ({ page }) => {
|
||||
const issueLink = page.locator('a[href*="/issues/"]').first();
|
||||
await expect(issueLink).toBeVisible({ timeout: 5000 });
|
||||
await issueLink.click();
|
||||
await page.waitForURL(/\/issues\/[\w-]+/);
|
||||
await page.goto(`/${workspaceSlug}/issues/${issueId}`, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, issueTitle);
|
||||
|
||||
await expect(page.locator("text=Properties")).toBeVisible();
|
||||
|
||||
// Submit button should be disabled when input is empty
|
||||
const submitBtn = page.locator('form button[type="submit"]').last();
|
||||
const editor = page
|
||||
.locator('.ProseMirror[data-placeholder="Leave a comment..."], .ProseMirror:has([data-placeholder="Leave a comment..."])')
|
||||
.first();
|
||||
await expect(editor).toBeVisible();
|
||||
const composer = editor.locator("xpath=ancestor::div[contains(@class, 'rounded-lg')][1]");
|
||||
const submitBtn = composer.locator("button:has(svg.lucide-arrow-up)").last();
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export class TestApiClient {
|
||||
private token: string | null = null;
|
||||
private workspaceSlug: string | null = null;
|
||||
private workspaceId: string | null = null;
|
||||
private email: string | null = null;
|
||||
private createdIssueIds: string[] = [];
|
||||
|
||||
async login(email: string, name: string) {
|
||||
@@ -52,11 +53,14 @@ export class TestApiClient {
|
||||
throw new Error(`No verification code found for ${email}`);
|
||||
}
|
||||
|
||||
const configuredDevCode = process.env.MULTICA_DEV_VERIFICATION_CODE?.trim();
|
||||
const code = configuredDevCode || result.rows[0].code;
|
||||
|
||||
// Step 3: Verify code to get JWT
|
||||
const verifyRes = await fetch(`${API_BASE}/auth/verify-code`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, code: result.rows[0].code }),
|
||||
body: JSON.stringify({ email, code }),
|
||||
});
|
||||
if (!verifyRes.ok) {
|
||||
throw new Error(`verify-code failed: ${verifyRes.status}`);
|
||||
@@ -64,6 +68,7 @@ export class TestApiClient {
|
||||
const data = await verifyRes.json();
|
||||
|
||||
this.token = data.token;
|
||||
this.email = email;
|
||||
|
||||
// Update user name if needed
|
||||
if (name && data.user?.name !== name) {
|
||||
@@ -110,6 +115,7 @@ export class TestApiClient {
|
||||
if (res.ok) {
|
||||
const created = (await res.json()) as TestWorkspace;
|
||||
this.workspaceId = created.id;
|
||||
this.workspaceSlug = created.slug;
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -117,12 +123,40 @@ export class TestApiClient {
|
||||
const created = refreshed.find((item) => item.slug === slug) ?? refreshed[0];
|
||||
if (created) {
|
||||
this.workspaceId = created.id;
|
||||
this.workspaceSlug = created.slug;
|
||||
return created;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to ensure workspace ${slug}: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
async markUserOnboarded() {
|
||||
if (!this.email) {
|
||||
throw new Error("Cannot mark E2E user onboarded before login");
|
||||
}
|
||||
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(
|
||||
`
|
||||
UPDATE "user"
|
||||
SET
|
||||
onboarded_at = COALESCE(onboarded_at, now()),
|
||||
onboarding_questionnaire = COALESCE(onboarding_questionnaire, '{}'::jsonb)
|
||||
|| '{"source":["friends_colleagues"],"source_other":null,"source_skipped":false}'::jsonb
|
||||
WHERE email = $1
|
||||
`,
|
||||
[this.email],
|
||||
);
|
||||
if (result.rowCount !== 1) {
|
||||
throw new Error(`Failed to mark E2E user onboarded: ${this.email}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
async createIssue(title: string, opts?: Record<string, unknown>) {
|
||||
const res = await this.authedFetch("/api/issues", {
|
||||
method: "POST",
|
||||
@@ -153,6 +187,13 @@ export class TestApiClient {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
getEmail() {
|
||||
if (!this.email) {
|
||||
throw new Error("Test API client is not logged in");
|
||||
}
|
||||
return this.email;
|
||||
}
|
||||
|
||||
private async authedFetch(path: string, init?: RequestInit) {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
|
||||
@@ -1,9 +1,31 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
|
||||
const DEFAULT_E2E_NAME = "E2E User";
|
||||
const DEFAULT_E2E_EMAIL = "e2e@multica.ai";
|
||||
const DEFAULT_E2E_WORKSPACE = "e2e-workspace";
|
||||
const E2E_WORKER = process.env.TEST_PARALLEL_INDEX ?? process.env.TEST_WORKER_INDEX ?? "0";
|
||||
const E2E_RUN_ID = process.env.E2E_RUN_ID ?? `${Date.now().toString(36)}-${process.pid.toString(36)}`;
|
||||
const DEFAULT_E2E_EMAIL = `e2e-${E2E_WORKER}-${E2E_RUN_ID}@multica.ai`;
|
||||
const DEFAULT_E2E_WORKSPACE = `e2e-workspace-${E2E_WORKER}-${E2E_RUN_ID}`;
|
||||
|
||||
async function waitForIssuesPage(page: Page) {
|
||||
await waitForPageText(page, "New Issue");
|
||||
await expect(page.getByRole("button", { name: "New Issue" })).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function waitForPageText(page: Page, text: string, timeout = 30000) {
|
||||
await page.waitForFunction(
|
||||
(expected) => document.body?.innerText.includes(expected),
|
||||
text,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
export async function reloadAppPage(page: Page) {
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Issues");
|
||||
}
|
||||
|
||||
/**
|
||||
* Log in as the default E2E user and ensure the workspace exists first.
|
||||
@@ -16,17 +38,22 @@ export async function loginAsDefault(page: Page): Promise<string> {
|
||||
const api = new TestApiClient();
|
||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||
const workspace = await api.ensureWorkspace(
|
||||
"E2E Workspace",
|
||||
`E2E Workspace ${E2E_WORKER}`,
|
||||
DEFAULT_E2E_WORKSPACE,
|
||||
);
|
||||
await api.markUserOnboarded();
|
||||
|
||||
const token = api.getToken();
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
if (!token) {
|
||||
throw new Error("E2E login did not return an auth token");
|
||||
}
|
||||
|
||||
await page.addInitScript((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
localStorage.setItem("multica:chat:isOpen", "false");
|
||||
}, token);
|
||||
await page.goto(`/${workspace.slug}/issues`);
|
||||
await page.waitForURL("**/issues", { timeout: 10000 });
|
||||
await page.goto(`/${workspace.slug}/issues`, { waitUntil: "domcontentloaded" });
|
||||
await waitForIssuesPage(page);
|
||||
return workspace.slug;
|
||||
}
|
||||
|
||||
@@ -37,13 +64,27 @@ export async function loginAsDefault(page: Page): Promise<string> {
|
||||
export async function createTestApi(): Promise<TestApiClient> {
|
||||
const api = new TestApiClient();
|
||||
await api.login(DEFAULT_E2E_EMAIL, DEFAULT_E2E_NAME);
|
||||
await api.ensureWorkspace("E2E Workspace", DEFAULT_E2E_WORKSPACE);
|
||||
await api.ensureWorkspace(`E2E Workspace ${E2E_WORKER}`, DEFAULT_E2E_WORKSPACE);
|
||||
await api.markUserOnboarded();
|
||||
return api;
|
||||
}
|
||||
|
||||
export async function preferManualCreateMode(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem(
|
||||
"multica_create_mode",
|
||||
JSON.stringify({ state: { lastMode: "manual" }, version: 0 }),
|
||||
);
|
||||
});
|
||||
await reloadAppPage(page);
|
||||
await waitForIssuesPage(page);
|
||||
}
|
||||
|
||||
export async function openWorkspaceMenu(page: Page) {
|
||||
// Click the workspace switcher button (has ChevronDown icon)
|
||||
await page.locator("aside button").first().click();
|
||||
const workspaceButton = page.getByRole("button", { name: /E2E Workspace/ }).first();
|
||||
await expect(workspaceButton).toBeVisible({ timeout: 15000 });
|
||||
await workspaceButton.click();
|
||||
// Wait for dropdown to appear
|
||||
await page.locator('[class*="popover"]').waitFor({ state: "visible" });
|
||||
await expect(page.locator('[class*="popover"]')).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, createTestApi } from "./helpers";
|
||||
import pg from "pg";
|
||||
import { loginAsDefault, createTestApi, preferManualCreateMode, reloadAppPage } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||
|
||||
async function setIssueTimestamps(
|
||||
issueId: string,
|
||||
timestamps: { createdAt: Date; updatedAt?: Date },
|
||||
) {
|
||||
const client = new pg.Client(DATABASE_URL);
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE issue
|
||||
SET created_at = $2, updated_at = $3
|
||||
WHERE id = $1
|
||||
`,
|
||||
[
|
||||
issueId,
|
||||
timestamps.createdAt.toISOString(),
|
||||
(timestamps.updatedAt ?? timestamps.createdAt).toISOString(),
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Issues", () => {
|
||||
let api: TestApiClient;
|
||||
|
||||
@@ -18,7 +46,7 @@ test.describe("Issues", () => {
|
||||
|
||||
test("issues page loads with board view", async ({ page }) => {
|
||||
await api.createIssue("E2E Board View " + Date.now());
|
||||
await page.reload();
|
||||
await reloadAppPage(page);
|
||||
|
||||
// Board columns should be visible
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
@@ -29,7 +57,7 @@ test.describe("Issues", () => {
|
||||
test("can switch from board to list view", async ({ page }) => {
|
||||
const title = "E2E List Switch " + Date.now();
|
||||
await api.createIssue(title);
|
||||
await page.reload();
|
||||
await reloadAppPage(page);
|
||||
await expect(page.locator("text=Backlog")).toBeVisible();
|
||||
|
||||
// Switch to list view
|
||||
@@ -37,7 +65,77 @@ test.describe("Issues", () => {
|
||||
await expect(page.getByText(title)).toBeVisible();
|
||||
});
|
||||
|
||||
test("can filter issues by created and updated dates", async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const todayTitle = `E2E Date Today ${suffix}`;
|
||||
const oldTitle = `E2E Date Old ${suffix}`;
|
||||
const updatedTodayTitle = `E2E Date Updated Today ${suffix}`;
|
||||
await api.createIssue(todayTitle);
|
||||
const oldIssue = await api.createIssue(oldTitle);
|
||||
const updatedTodayIssue = await api.createIssue(updatedTodayTitle);
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 8);
|
||||
await setIssueTimestamps(oldIssue.id, { createdAt: oldDate });
|
||||
await setIssueTimestamps(updatedTodayIssue.id, {
|
||||
createdAt: oldDate,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await reloadAppPage(page);
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeVisible();
|
||||
await expect(page.getByText(updatedTodayTitle)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /filter/i }).click();
|
||||
await page.getByRole("menuitem", { name: /^Date\b/ }).hover();
|
||||
await page.getByRole("menuitem", { name: "Today" }).click();
|
||||
|
||||
await expect(page.getByRole("button", { name: /1 filter/i })).toBeVisible();
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
|
||||
await expect(page.getByText(updatedTodayTitle)).toBeHidden({ timeout: 10000 });
|
||||
|
||||
await page.getByRole("button", { name: /1 filter/i }).click();
|
||||
const dateFilterItem = page.getByRole("menuitem", { name: /^Date\b/ });
|
||||
await dateFilterItem.focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
const updatedDateField = page.getByRole("menuitemradio", { name: "Updated" });
|
||||
await expect(updatedDateField).toBeVisible();
|
||||
await updatedDateField.press("Enter");
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(updatedTodayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("can filter issues by custom created date", async ({ page }) => {
|
||||
const suffix = Date.now();
|
||||
const todayTitle = `E2E Date Custom Today ${suffix}`;
|
||||
const oldTitle = `E2E Date Custom Old ${suffix}`;
|
||||
await api.createIssue(todayTitle);
|
||||
const oldIssue = await api.createIssue(oldTitle);
|
||||
const oldDate = new Date();
|
||||
oldDate.setDate(oldDate.getDate() - 8);
|
||||
await setIssueTimestamps(oldIssue.id, { createdAt: oldDate });
|
||||
|
||||
await reloadAppPage(page);
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /filter/i }).click();
|
||||
await page.getByRole("menuitem", { name: /^Date\b/ }).hover();
|
||||
const customDateButton = page.getByRole("button", { name: "Custom date or range" });
|
||||
await expect(customDateButton).toBeVisible();
|
||||
await customDateButton.click();
|
||||
const todayDataDay = await page.evaluate(() => new Date().toLocaleDateString());
|
||||
await page.locator(`[data-day="${todayDataDay}"]`).click();
|
||||
await page.getByRole("button", { name: "Apply" }).click();
|
||||
await expect(page.getByText(todayTitle)).toBeVisible();
|
||||
await expect(page.getByText(oldTitle)).toBeHidden({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test("can create a new issue", async ({ page }) => {
|
||||
await preferManualCreateMode(page);
|
||||
|
||||
const newIssueButton = page.getByRole("button", { name: "New Issue" });
|
||||
await expect(newIssueButton).toBeVisible();
|
||||
await newIssueButton.click();
|
||||
@@ -63,7 +161,7 @@ test.describe("Issues", () => {
|
||||
const issue = await api.createIssue("E2E Detail Test " + Date.now());
|
||||
|
||||
// Reload to see the new issue
|
||||
await page.reload();
|
||||
await reloadAppPage(page);
|
||||
|
||||
// Navigate to the issue detail. Use a suffix match so the selector works
|
||||
// whether the href is legacy `/issues/{id}` or URL-refactored
|
||||
@@ -83,6 +181,8 @@ test.describe("Issues", () => {
|
||||
});
|
||||
|
||||
test("can dismiss issue creation", async ({ page }) => {
|
||||
await preferManualCreateMode(page);
|
||||
|
||||
await page.getByRole("button", { name: "New Issue" }).click();
|
||||
|
||||
const titleInput = page.getByRole("textbox", { name: "Issue title" });
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
import { loginAsDefault, waitForPageText } from "./helpers";
|
||||
|
||||
const ROUTE_CHANGE_TIMEOUT = 30000;
|
||||
|
||||
test.describe("Navigation", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAsDefault(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
});
|
||||
|
||||
test("sidebar navigation works", async ({ page }) => {
|
||||
// Click Inbox
|
||||
await page.locator("nav a", { hasText: "Inbox" }).click();
|
||||
await page.waitForURL("**/inbox");
|
||||
await expect(page).toHaveURL(/\/inbox/);
|
||||
await page.getByRole("link", { name: "Inbox" }).click();
|
||||
await expect(page).toHaveURL(/\/inbox/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Inbox");
|
||||
|
||||
// Click Agents
|
||||
await page.locator("nav a", { hasText: "Agents" }).click();
|
||||
await page.waitForURL("**/agents");
|
||||
await expect(page).toHaveURL(/\/agents/);
|
||||
await page.getByRole("link", { name: "Agents" }).click();
|
||||
await expect(page).toHaveURL(/\/agents/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Agents");
|
||||
|
||||
// Click Issues
|
||||
await page.locator("nav a", { hasText: "Issues" }).click();
|
||||
await page.waitForURL("**/issues");
|
||||
await expect(page).toHaveURL(/\/issues/);
|
||||
await page.getByRole("link", { name: "Issues", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/issues/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Issues");
|
||||
});
|
||||
|
||||
test("settings page loads via workspace menu", async ({ page }) => {
|
||||
// Settings is inside the workspace dropdown menu
|
||||
await openWorkspaceMenu(page);
|
||||
await page.locator("text=Settings").click();
|
||||
await page.waitForURL("**/settings");
|
||||
test("settings page loads via sidebar", async ({ page }) => {
|
||||
await page.getByRole("link", { name: "Settings", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/settings/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Settings");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Workspace" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Members" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "General" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "Members" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("agents page shows agent list", async ({ page }) => {
|
||||
await page.locator("nav a", { hasText: "Agents" }).click();
|
||||
await page.waitForURL("**/agents");
|
||||
await page.getByRole("link", { name: "Agents" }).click();
|
||||
await expect(page).toHaveURL(/\/agents/, { timeout: ROUTE_CHANGE_TIMEOUT });
|
||||
await waitForPageText(page, "Agents");
|
||||
|
||||
// Should show "Agents" heading
|
||||
await expect(page.locator("text=Agents").first()).toBeVisible();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { TestApiClient } from "./fixtures";
|
||||
import { waitForPageText } from "./helpers";
|
||||
|
||||
// Smoke test for Onboarding V2: verifies the new per-question flow
|
||||
// renders and captures screenshots for review. Uses a unique email
|
||||
@@ -16,12 +17,11 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
await api.login(EMAIL, "OBv2 Tester");
|
||||
const token = api.getToken();
|
||||
|
||||
await page.goto("/login");
|
||||
await page.evaluate((t) => {
|
||||
await page.addInitScript((t) => {
|
||||
localStorage.setItem("multica_token", t);
|
||||
}, token);
|
||||
await page.goto("/onboarding");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Continue on web");
|
||||
|
||||
// 1. Welcome screen
|
||||
await expect(page.getByRole("button", { name: "Continue on web" })).toBeVisible({ timeout: 15000 });
|
||||
@@ -32,7 +32,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 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 expect(page.getByText(/Step 1 of \d+/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/02-source.png` });
|
||||
|
||||
@@ -42,7 +42,7 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 3. Role step
|
||||
await expect(page.getByText("Which best describes you?")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(`Step 2 of 6`)).toBeVisible();
|
||||
await expect(page.getByText(/Step 2 of \d+/)).toBeVisible();
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: `${SHOTS_DIR}/03-role.png` });
|
||||
|
||||
@@ -51,12 +51,12 @@ test("onboarding v2 — welcome → source → role → use_case (skip path)", a
|
||||
|
||||
// 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 expect(page.getByText(/Step 3 of \d+/)).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("checkbox", { name: /Ship code with AI agents/i }).click();
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
// 5. Workspace step (legacy)
|
||||
@@ -69,10 +69,9 @@ test("onboarding v2 — rage-skip all 3 questions", async ({ page }) => {
|
||||
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.addInitScript((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "Continue on web");
|
||||
|
||||
await page.getByRole("button", { name: "Continue on web" }).click();
|
||||
await expect(page.getByText("How did you hear about Multica?")).toBeVisible({ timeout: 10000 });
|
||||
@@ -97,10 +96,9 @@ test("onboarding v2 — zh-Hans renders Chinese labels", async ({ page, context
|
||||
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.addInitScript((t) => localStorage.setItem("multica_token", t), token);
|
||||
await page.goto("/onboarding", { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "在 web 端继续");
|
||||
|
||||
await page.getByRole("button").first().click().catch(() => {});
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { loginAsDefault, openWorkspaceMenu } from "./helpers";
|
||||
import { loginAsDefault, waitForPageText } from "./helpers";
|
||||
|
||||
test.describe("Settings", () => {
|
||||
test("updating workspace name reflects in sidebar immediately", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAsDefault(page);
|
||||
const workspaceSlug = await loginAsDefault(page);
|
||||
|
||||
// Read the current workspace name from the sidebar
|
||||
const sidebarName = page.locator("aside button").first();
|
||||
const originalName = await sidebarName.innerText();
|
||||
const sidebarName = page.getByRole("button", { name: /E2E Workspace/ }).first();
|
||||
const originalName = (await sidebarName.innerText()).split("\n").pop()?.trim() ?? "E2E Workspace";
|
||||
|
||||
// Navigate to settings
|
||||
await openWorkspaceMenu(page);
|
||||
await page.locator("text=Settings").click();
|
||||
await page.waitForURL("**/settings");
|
||||
await page.goto(`/${workspaceSlug}/settings?tab=workspace`, { waitUntil: "domcontentloaded" });
|
||||
await waitForPageText(page, "General");
|
||||
|
||||
// Change workspace name
|
||||
const nameInput = page
|
||||
@@ -27,16 +25,16 @@ test.describe("Settings", () => {
|
||||
// Save
|
||||
await page.locator("button", { hasText: "Save" }).click();
|
||||
|
||||
// Wait for "Saved!" confirmation
|
||||
await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Sidebar should reflect the new name WITHOUT page refresh
|
||||
await expect(sidebarName).toContainText(newName);
|
||||
await expect(page.getByRole("button", { name: new RegExp(newName) }).first()).toBeVisible();
|
||||
|
||||
// Restore original name so other tests aren't affected
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(originalName.trim());
|
||||
await page.locator("button", { hasText: "Save" }).click();
|
||||
await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText("Workspace settings saved").first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole("button", { name: new RegExp(originalName) }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,3 +8,4 @@ export * from "./constants";
|
||||
export * from "./visibility-label";
|
||||
export * from "./use-workspace-agent-availability";
|
||||
export * from "./mcp-support";
|
||||
export * from "./openclaw-runtime-config";
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
// forwards MCP servers to the underlying CLI. The MCP config tab is hidden
|
||||
// for every other provider so a user can't save a value the runtime will
|
||||
// silently ignore. Keep this list in sync with the backends in
|
||||
// `server/pkg/agent/` that read `ExecOptions.McpConfig`, plus the OpenClaw
|
||||
// per-task wrapper preparer in `server/internal/daemon/execenv/` which
|
||||
// materialises `mcp.servers` into the synthesised config rather than going
|
||||
// through ExecOptions.
|
||||
// `server/pkg/agent/` that read `ExecOptions.McpConfig`, plus providers whose
|
||||
// per-task preparers in `server/internal/daemon/execenv/` materialise MCP
|
||||
// config for CLIs that do not receive it through ExecOptions.
|
||||
const MCP_SUPPORTED_PROVIDERS = new Set([
|
||||
"claude",
|
||||
"codex",
|
||||
"cursor",
|
||||
"hermes",
|
||||
"kimi",
|
||||
"kiro",
|
||||
|
||||
63
packages/core/agents/openclaw-runtime-config.test.ts
Normal file
63
packages/core/agents/openclaw-runtime-config.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
OPENCLAW_GATEWAY_TOKEN_MASK,
|
||||
serializeOpenclawRuntimeConfig,
|
||||
} from "./openclaw-runtime-config";
|
||||
|
||||
describe("serializeOpenclawRuntimeConfig", () => {
|
||||
it("keeps the masked gateway token sentinel so the API can preserve the persisted token", () => {
|
||||
expect(
|
||||
serializeOpenclawRuntimeConfig({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
token: OPENCLAW_GATEWAY_TOKEN_MASK,
|
||||
tls: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
token: OPENCLAW_GATEWAY_TOKEN_MASK,
|
||||
tls: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an empty gateway token so users can clear a persisted token", () => {
|
||||
expect(
|
||||
serializeOpenclawRuntimeConfig({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
host: "gw.internal",
|
||||
port: 18789,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through a real gateway token value", () => {
|
||||
expect(
|
||||
serializeOpenclawRuntimeConfig({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
token: "rotated-secret",
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
mode: "gateway",
|
||||
gateway: {
|
||||
token: "rotated-secret",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
94
packages/core/agents/openclaw-runtime-config.ts
Normal file
94
packages/core/agents/openclaw-runtime-config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// OpenClaw-specific `runtime_config` schema (issue #3260).
|
||||
//
|
||||
// Stored under `agent.runtime_config` as freeform JSONB; only meaningful for
|
||||
// agents whose runtime provider is openclaw. The daemon decodes the same
|
||||
// schema in `server/internal/daemon/openclaw_runtime_config.go` — keep both
|
||||
// sides in lockstep when changing field names.
|
||||
|
||||
export type OpenclawRoutingMode = "local" | "gateway";
|
||||
|
||||
export interface OpenclawGatewayPin {
|
||||
host?: string;
|
||||
port?: number;
|
||||
token?: string;
|
||||
tls?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenclawRuntimeConfig {
|
||||
mode?: OpenclawRoutingMode;
|
||||
gateway?: OpenclawGatewayPin;
|
||||
}
|
||||
|
||||
// Sentinel the API substitutes for a non-empty `gateway.token` on every read.
|
||||
// When the form re-submits the same sentinel, the backend's matching
|
||||
// preserve hook restores the persisted token instead of overwriting it.
|
||||
// Mirrors `runtimeConfigGatewayTokenMask` in server/internal/handler/agent.go.
|
||||
export const OPENCLAW_GATEWAY_TOKEN_MASK = "***";
|
||||
|
||||
// Parse an arbitrary runtime_config payload into the typed schema. Unknown
|
||||
// keys are dropped, malformed payloads collapse to an empty object. The form
|
||||
// never throws on bad input — invalid configs simply render as defaults so
|
||||
// the user can correct them without a JSON parse error blocking the UI.
|
||||
export function parseOpenclawRuntimeConfig(
|
||||
raw: unknown,
|
||||
): OpenclawRuntimeConfig {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const root = raw as Record<string, unknown>;
|
||||
const out: OpenclawRuntimeConfig = {};
|
||||
if (root.mode === "local" || root.mode === "gateway") {
|
||||
out.mode = root.mode;
|
||||
}
|
||||
if (root.gateway && typeof root.gateway === "object" && !Array.isArray(root.gateway)) {
|
||||
const gw = root.gateway as Record<string, unknown>;
|
||||
const pin: OpenclawGatewayPin = {};
|
||||
if (typeof gw.host === "string" && gw.host !== "") pin.host = gw.host;
|
||||
if (typeof gw.port === "number" && Number.isFinite(gw.port) && gw.port > 0) pin.port = gw.port;
|
||||
if (typeof gw.token === "string" && gw.token !== "") pin.token = gw.token;
|
||||
if (typeof gw.tls === "boolean") pin.tls = gw.tls;
|
||||
if (Object.keys(pin).length > 0) out.gateway = pin;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Render the typed form state back into the wire shape the API accepts.
|
||||
// Empty gateway sub-objects collapse to `undefined` so the wire payload
|
||||
// only carries fields the user actually populated — partial pins (host+port
|
||||
// only, etc.) work as documented.
|
||||
export function serializeOpenclawRuntimeConfig(
|
||||
cfg: OpenclawRuntimeConfig,
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (cfg.mode) out.mode = cfg.mode;
|
||||
if (cfg.gateway) {
|
||||
const gw: Record<string, unknown> = {};
|
||||
if (cfg.gateway.host) gw.host = cfg.gateway.host;
|
||||
if (cfg.gateway.port) gw.port = cfg.gateway.port;
|
||||
if (cfg.gateway.tls) gw.tls = true;
|
||||
// The mask sentinel is the explicit "keep persisted token" signal for
|
||||
// the API. Omitting the field means "clear/no token" for partial
|
||||
// gateway pins, so the sentinel must survive serialization.
|
||||
if (cfg.gateway.token) {
|
||||
gw.token = cfg.gateway.token;
|
||||
}
|
||||
if (Object.keys(gw).length > 0) out.gateway = gw;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Stable shallow equality across two parsed configs, used by the form's
|
||||
// dirty detector. Treats absent gateway block and an empty gateway block as
|
||||
// identical so toggling between local/gateway without filling endpoint
|
||||
// fields doesn't surface a spurious "unsaved changes" notice.
|
||||
export function openclawRuntimeConfigEquals(
|
||||
a: OpenclawRuntimeConfig,
|
||||
b: OpenclawRuntimeConfig,
|
||||
): boolean {
|
||||
if ((a.mode ?? "local") !== (b.mode ?? "local")) return false;
|
||||
const aGw = a.gateway ?? {};
|
||||
const bGw = b.gateway ?? {};
|
||||
if ((aGw.host ?? "") !== (bGw.host ?? "")) return false;
|
||||
if ((aGw.port ?? 0) !== (bGw.port ?? 0)) return false;
|
||||
if ((aGw.token ?? "") !== (bGw.token ?? "")) return false;
|
||||
if (Boolean(aGw.tls) !== Boolean(bGw.tls)) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
export {
|
||||
useAgentsViewStore,
|
||||
AGENT_SCOPES,
|
||||
AGENT_SORT_DEFAULT_DIRECTION,
|
||||
AGENT_DEFAULT_HIDDEN_COLUMNS,
|
||||
EMPTY_AGENT_FILTERS,
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
type AgentSortField,
|
||||
type AgentSortDirection,
|
||||
type AgentColumnKey,
|
||||
type AgentListFilters,
|
||||
} from "./view-store";
|
||||
export {
|
||||
useTranscriptViewStore,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("useAgentsViewStore", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("all");
|
||||
});
|
||||
|
||||
it("partialize persists only scope under the workspace-namespaced key", async () => {
|
||||
it("partialize persists only view prefs (no actions) under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useAgentsViewStore.getState().setScope("all");
|
||||
@@ -52,7 +52,14 @@ describe("useAgentsViewStore", () => {
|
||||
const raw = localStorage.getItem("multica_agents_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ scope: "all" });
|
||||
expect(Object.keys(parsed.state).sort()).toEqual([
|
||||
"filters",
|
||||
"hiddenColumns",
|
||||
"scope",
|
||||
"sortDirection",
|
||||
"sortField",
|
||||
]);
|
||||
expect(parsed.state.scope).toBe("all");
|
||||
});
|
||||
|
||||
it("rehydrates a different saved scope on workspace switch", async () => {
|
||||
@@ -93,4 +100,25 @@ describe("useAgentsViewStore", () => {
|
||||
expect(useAgentsViewStore.getState().scope).toBe("mine");
|
||||
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("backfills new filter dimensions when rehydrating a pre-owners payload", async () => {
|
||||
// A payload persisted before the `owners` filter existed must not drop
|
||||
// the key to undefined (the agents list filter predicate reads
|
||||
// `filters.owners.length` and would crash).
|
||||
localStorage.setItem(
|
||||
"multica_agents_view:acme",
|
||||
JSON.stringify({
|
||||
state: { filters: { availability: ["online"], runtimes: [] } },
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const filters = useAgentsViewStore.getState().filters;
|
||||
expect(filters.owners).toEqual([]);
|
||||
expect(filters.availability).toEqual(["online"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,30 +8,181 @@ import {
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
export type AgentsScope = "mine" | "all";
|
||||
// View preferences for the agents list page: scope, sort, column visibility,
|
||||
// and filters. Persisted per workspace, per user/device. Row selection is
|
||||
// session-scoped on purpose (same rationale as the skills/autopilots view
|
||||
// stores).
|
||||
|
||||
// Scope mixes the ownership lens (mine/all) with the archived lifecycle
|
||||
// stage. Impure on paper, but the three are mutually exclusive in practice
|
||||
// and "mine" is the historical product default; the archived view ignores
|
||||
// the ownership lens entirely (showing only *my* archived agents would hide
|
||||
// other people's archived agents with no UI to explain why).
|
||||
export type AgentsScope = "mine" | "all" | "archived";
|
||||
|
||||
export const AGENT_SCOPES: AgentsScope[] = ["mine", "all", "archived"];
|
||||
|
||||
export type AgentSortField = "lastActive" | "name" | "runs" | "created";
|
||||
|
||||
export type AgentSortDirection = "asc" | "desc";
|
||||
|
||||
/** Per-field direction applied when the user switches TO that field. */
|
||||
export const AGENT_SORT_DEFAULT_DIRECTION: Record<
|
||||
AgentSortField,
|
||||
AgentSortDirection
|
||||
> = {
|
||||
lastActive: "desc",
|
||||
name: "asc",
|
||||
runs: "desc",
|
||||
created: "desc",
|
||||
};
|
||||
|
||||
/** Multi-select filter state. Empty array per dimension = inactive. */
|
||||
export interface AgentListFilters {
|
||||
/** AgentAvailability values (online / unstable / offline). */
|
||||
availability: string[];
|
||||
/** Runtime ids. */
|
||||
runtimes: string[];
|
||||
/** Owner user ids. Owner is the same person-axis as the Mine scope: the
|
||||
* "mine" scope is the clean no-filter personal view, and applying any
|
||||
* filter (owner or otherwise) leaves it for "all" — see setScope /
|
||||
* toggleFilter. So owner-as-filter and Mine never coexist, which keeps
|
||||
* the axis orthogonal (no "mine + owner=someone-else = empty" state). */
|
||||
owners: string[];
|
||||
/** Runtime-native model identifiers (e.g. claude / codex / gpt-…). */
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_AGENT_FILTERS: AgentListFilters = {
|
||||
availability: [],
|
||||
runtimes: [],
|
||||
owners: [],
|
||||
models: [],
|
||||
};
|
||||
|
||||
// User-hideable columns. Name and the structural columns (checkbox, kebab)
|
||||
// are always visible.
|
||||
export type AgentColumnKey =
|
||||
| "status"
|
||||
| "owner"
|
||||
| "runtime"
|
||||
| "lastActive"
|
||||
| "runs"
|
||||
| "model"
|
||||
| "created";
|
||||
|
||||
/** Model and created are opt-in: hidden until the user enables them. Owner
|
||||
* is shown by default (the user wants to see who owns each agent). */
|
||||
export const AGENT_DEFAULT_HIDDEN_COLUMNS: AgentColumnKey[] = [
|
||||
"model",
|
||||
"created",
|
||||
];
|
||||
|
||||
export interface AgentsViewState {
|
||||
scope: AgentsScope;
|
||||
sortField: AgentSortField;
|
||||
sortDirection: AgentSortDirection;
|
||||
hiddenColumns: AgentColumnKey[];
|
||||
filters: AgentListFilters;
|
||||
setScope: (scope: AgentsScope) => void;
|
||||
/** Header click: toggles direction on the active field, otherwise switches
|
||||
* to the field with its default direction. */
|
||||
toggleSort: (field: AgentSortField) => void;
|
||||
/** Display panel select: switches field (default direction), no toggle. */
|
||||
setSortField: (field: AgentSortField) => void;
|
||||
setSortDirection: (direction: AgentSortDirection) => void;
|
||||
toggleColumn: (key: AgentColumnKey) => void;
|
||||
toggleFilter: (key: keyof AgentListFilters, value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
// "mine" is the historical default — most members care about their own
|
||||
// agents first; admins flip to "all".
|
||||
scope: "mine" as AgentsScope,
|
||||
sortField: "lastActive" as AgentSortField,
|
||||
sortDirection: AGENT_SORT_DEFAULT_DIRECTION.lastActive,
|
||||
hiddenColumns: AGENT_DEFAULT_HIDDEN_COLUMNS,
|
||||
filters: EMPTY_AGENT_FILTERS,
|
||||
};
|
||||
|
||||
export const useAgentsViewStore = create<AgentsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
scope: "mine",
|
||||
setScope: (scope) => set({ scope }),
|
||||
...DEFAULTS,
|
||||
// "Mine" is the clean personal view: entering it clears all filters,
|
||||
// so Mine never carries filters. Switching to all/archived leaves
|
||||
// filters intact (you can carry "owner = Bob" between them).
|
||||
setScope: (scope) =>
|
||||
set(scope === "mine" ? { scope, filters: EMPTY_AGENT_FILTERS } : { scope }),
|
||||
toggleSort: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {
|
||||
sortDirection: state.sortDirection === "asc" ? "desc" : "asc",
|
||||
}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: AGENT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortField: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: AGENT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortDirection: (direction) => set({ sortDirection: direction }),
|
||||
toggleColumn: (key) =>
|
||||
set((state) => ({
|
||||
hiddenColumns: state.hiddenColumns.includes(key)
|
||||
? state.hiddenColumns.filter((k) => k !== key)
|
||||
: [...state.hiddenColumns, key],
|
||||
})),
|
||||
toggleFilter: (key, value) =>
|
||||
set((state) => {
|
||||
const list = state.filters[key] as string[];
|
||||
const next = list.includes(value)
|
||||
? list.filter((v) => v !== value)
|
||||
: [...list, value];
|
||||
// Applying any filter leaves the clean "mine" view for "all" —
|
||||
// Mine is the no-filter mode (see setScope). Archived keeps its
|
||||
// own scope (it can carry filters).
|
||||
const scope = state.scope === "mine" ? "all" : state.scope;
|
||||
return { scope, filters: { ...state.filters, [key]: next } };
|
||||
}),
|
||||
clearFilters: () => set({ filters: EMPTY_AGENT_FILTERS }),
|
||||
}),
|
||||
{
|
||||
name: "multica_agents_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ scope: state.scope }),
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({
|
||||
scope: state.scope,
|
||||
sortField: state.sortField,
|
||||
sortDirection: state.sortDirection,
|
||||
hiddenColumns: state.hiddenColumns,
|
||||
filters: state.filters,
|
||||
}),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the default "mine" instead of leaving the previous workspace's in-
|
||||
// memory scope in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak "all" across workspaces.
|
||||
// the defaults instead of leaving the previous workspace's in-memory
|
||||
// view state in place. Default merge keeps current state when
|
||||
// persisted is undefined, which would leak state across workspaces.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, scope: "mine" };
|
||||
return { ...current, ...(persisted as Partial<AgentsViewState>) };
|
||||
if (!persisted) return { ...current, ...DEFAULTS };
|
||||
const p = persisted as Partial<AgentsViewState>;
|
||||
// Deep-merge filters so a payload persisted before a new filter
|
||||
// dimension existed (e.g. `owners`) still gets that key's default
|
||||
// instead of dropping it to `undefined` and crashing `.length`.
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
filters: { ...EMPTY_AGENT_FILTERS, ...(p.filters ?? {}) },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
234
packages/core/analytics/exception-dedupe.test.ts
Normal file
234
packages/core/analytics/exception-dedupe.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { shouldDropException } from "./exception-dedupe";
|
||||
|
||||
const STORAGE_KEY = "mc_exc_fp";
|
||||
|
||||
// In-memory sessionStorage stand-in. Optional flags let a test force getItem /
|
||||
// setItem to throw (quota, disabled storage) so we can assert the fail-open
|
||||
// direction.
|
||||
function makeStorage(opts: { throwOnGet?: boolean; throwOnSet?: boolean } = {}) {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
data,
|
||||
getItem(k: string): string | null {
|
||||
if (opts.throwOnGet) throw new Error("getItem blocked");
|
||||
return data.has(k) ? data.get(k)! : null;
|
||||
},
|
||||
setItem(k: string, v: string): void {
|
||||
if (opts.throwOnSet) throw new Error("quota exceeded");
|
||||
data.set(k, v);
|
||||
},
|
||||
removeItem(k: string): void {
|
||||
data.delete(k);
|
||||
},
|
||||
clear(): void {
|
||||
data.clear();
|
||||
},
|
||||
key(i: number): string | null {
|
||||
return Array.from(data.keys())[i] ?? null;
|
||||
},
|
||||
get length(): number {
|
||||
return data.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Build a redacted-shape `$exception` properties object. By the time dedupe
|
||||
// runs, redactExceptionProperties has already scrubbed value/message.
|
||||
function exc(o: {
|
||||
type?: string;
|
||||
value?: string;
|
||||
frames?: Array<Record<string, unknown>> | null;
|
||||
} = {}): Record<string, unknown> {
|
||||
const entry: Record<string, unknown> = {
|
||||
type: o.type ?? "TypeError",
|
||||
value: o.value ?? "boom",
|
||||
};
|
||||
if (o.frames !== null) {
|
||||
entry.stacktrace = {
|
||||
type: "raw",
|
||||
frames: o.frames ?? [
|
||||
{ filename: "app.tsx", function: "render", lineno: 10, colno: 5 },
|
||||
],
|
||||
};
|
||||
}
|
||||
return { $exception_list: [entry] };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldDropException — per-fingerprint limit", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage());
|
||||
});
|
||||
|
||||
it("keeps the first 3 of a fingerprint and drops from the 4th", () => {
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(true);
|
||||
expect(shouldDropException(exc())).toBe(true);
|
||||
});
|
||||
|
||||
it("treats different fingerprints independently — one does not drop the other", () => {
|
||||
// Exhaust fingerprint A.
|
||||
const a = () => exc({ type: "TypeError", value: "a" });
|
||||
const b = () => exc({ type: "RangeError", value: "b" });
|
||||
shouldDropException(a());
|
||||
shouldDropException(a());
|
||||
shouldDropException(a());
|
||||
expect(shouldDropException(a())).toBe(true); // A fused
|
||||
// B is untouched.
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(false);
|
||||
expect(shouldDropException(b())).toBe(true);
|
||||
});
|
||||
|
||||
it("discriminates on colno (minified bundles collapse statements onto one line)", () => {
|
||||
const at = (colno: number) =>
|
||||
exc({ frames: [{ filename: "b.js", function: "x", lineno: 1, colno }] });
|
||||
// Same file/line/function, different column → distinct fingerprints, so
|
||||
// each keeps its own first-3 budget.
|
||||
shouldDropException(at(10));
|
||||
shouldDropException(at(10));
|
||||
shouldDropException(at(10));
|
||||
expect(shouldDropException(at(10))).toBe(true);
|
||||
expect(shouldDropException(at(20))).toBe(false);
|
||||
});
|
||||
|
||||
it("stores only a hash + counter — no raw value reaches storage", () => {
|
||||
const storage = makeStorage();
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
shouldDropException(exc({ value: "secret-marker-12345" }));
|
||||
const blob = storage.data.get(STORAGE_KEY) ?? "";
|
||||
expect(blob).not.toContain("secret-marker-12345");
|
||||
expect(blob).not.toContain("app.tsx");
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — degraded frames", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage());
|
||||
});
|
||||
|
||||
it("tolerates missing lineno/colno/function and still dedupes", () => {
|
||||
const partial = () => exc({ frames: [{ filename: "only-file.js" }] });
|
||||
expect(() => shouldDropException(partial())).not.toThrow();
|
||||
shouldDropException(partial());
|
||||
shouldDropException(partial());
|
||||
expect(shouldDropException(partial())).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates no stacktrace at all (fingerprints on type + value)", () => {
|
||||
const noframes = () => exc({ frames: null });
|
||||
shouldDropException(noframes());
|
||||
shouldDropException(noframes());
|
||||
shouldDropException(noframes());
|
||||
expect(shouldDropException(noframes())).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps events with no usable signal (empty type/value/frames)", () => {
|
||||
const empty = { $exception_list: [{ type: "", value: "" }] };
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false);
|
||||
expect(shouldDropException(empty)).toBe(false); // never fused — no fingerprint
|
||||
});
|
||||
|
||||
it("is safe on undefined / malformed properties", () => {
|
||||
expect(shouldDropException(undefined)).toBe(false);
|
||||
expect(
|
||||
shouldDropException({ $exception_list: "nope" as unknown as [] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — storage fail-open", () => {
|
||||
it("fails open when sessionStorage is undefined (SSR)", () => {
|
||||
vi.stubGlobal("sessionStorage", undefined);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open when accessing sessionStorage throws (sandboxed iframe)", () => {
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("blocked by sandbox");
|
||||
},
|
||||
});
|
||||
try {
|
||||
expect(() => shouldDropException(exc())).not.toThrow();
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
} finally {
|
||||
// Remove the throwing getter so it doesn't leak into other tests.
|
||||
Object.defineProperty(globalThis, "sessionStorage", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("fails open when getItem throws", () => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage({ throwOnGet: true }));
|
||||
expect(() => shouldDropException(exc())).not.toThrow();
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
});
|
||||
|
||||
it("fails open on a corrupted JSON blob and re-seeds clean state", () => {
|
||||
const storage = makeStorage();
|
||||
storage.data.set(STORAGE_KEY, "{not valid json");
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
// Blob is now valid JSON again with this fingerprint counted once.
|
||||
const reseeded = JSON.parse(storage.data.get(STORAGE_KEY)!);
|
||||
expect(typeof reseeded).toBe("object");
|
||||
expect(Object.values(reseeded)).toEqual([1]);
|
||||
});
|
||||
|
||||
it("setItem failure under-counts (fewer drops), never over-drops", () => {
|
||||
vi.stubGlobal("sessionStorage", makeStorage({ throwOnSet: true }));
|
||||
// Persisting the increment always fails, so the counter never advances and
|
||||
// no event is ever dropped — the required "less drop" direction.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(shouldDropException(exc())).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldDropException — distinct-fingerprint cap", () => {
|
||||
it("keeps (does not track) a new fingerprint once the cap is reached", () => {
|
||||
const storage = makeStorage();
|
||||
// Seed 50 distinct fingerprints already at count 1.
|
||||
const seed: Record<string, number> = {};
|
||||
for (let i = 0; i < 50; i++) seed[`seed-${i}`] = 1;
|
||||
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
// The 51st, brand-new fingerprint is kept and NOT added to the blob.
|
||||
expect(shouldDropException(exc({ value: "fingerprint-51" }))).toBe(false);
|
||||
const after = JSON.parse(storage.data.get(STORAGE_KEY)!);
|
||||
expect(Object.keys(after)).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("still fuses a fingerprint that is already tracked at the cap", () => {
|
||||
const storage = makeStorage();
|
||||
const seed: Record<string, number> = {};
|
||||
for (let i = 0; i < 49; i++) seed[`seed-${i}`] = 1;
|
||||
vi.stubGlobal("sessionStorage", storage);
|
||||
|
||||
// Track a real one to reach 50 distinct, exhausting its budget.
|
||||
const target = () => exc({ value: "tracked-at-cap" });
|
||||
storage.data.set(STORAGE_KEY, JSON.stringify(seed));
|
||||
shouldDropException(target()); // 50th distinct, count 1
|
||||
shouldDropException(target()); // 2
|
||||
shouldDropException(target()); // 3
|
||||
expect(shouldDropException(target())).toBe(true); // fused despite cap
|
||||
});
|
||||
});
|
||||
193
packages/core/analytics/exception-dedupe.ts
Normal file
193
packages/core/analytics/exception-dedupe.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Session-scoped dedupe / throttle for `$exception` events.
|
||||
//
|
||||
// Runs in posthog-js `before_send` AFTER `redactExceptionProperties`, so the
|
||||
// fingerprint is built purely from already-redacted fields — no raw message,
|
||||
// value, or PII is ever written to storage (only a hash + a small counter).
|
||||
//
|
||||
// The fuse: keep the first EXCEPTION_SAMPLE_LIMIT of each (tab-session,
|
||||
// fingerprint) pair and drop the rest. One runaway error — a render loop, a
|
||||
// polling fetch that keeps throwing — otherwise emits 100+ identical
|
||||
// `$exception` events per session (MUL-3331 / MUL-3330). Different fingerprints
|
||||
// never affect each other.
|
||||
//
|
||||
// Safety invariant (load-bearing): `before_send` must never throw — a throw
|
||||
// there breaks ALL event delivery — and every storage failure must fail OPEN.
|
||||
// When in doubt we KEEP the event: emitting a duplicate is cheap, silently
|
||||
// dropping a real first-occurrence error is not. setItem failures therefore
|
||||
// only ever under-count (fewer drops), never over-drop.
|
||||
//
|
||||
// Scope is the browser tab session (`sessionStorage`): cleared when the tab
|
||||
// closes, isolated per tab. This is intentionally NOT the posthog 30-min
|
||||
// session — see the dedupe discussion on MUL-3331.
|
||||
|
||||
const STORAGE_KEY = "mc_exc_fp";
|
||||
// Keep the first N of each fingerprint per session, drop from N+1.
|
||||
const EXCEPTION_SAMPLE_LIMIT = 3;
|
||||
// Cap distinct fingerprints tracked per session so a session that throws many
|
||||
// *different* errors can't grow the blob without bound. Past the cap, new
|
||||
// fingerprints are not tracked and fail open (kept).
|
||||
const MAX_FINGERPRINTS = 50;
|
||||
|
||||
type FingerprintCounts = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Decide whether this already-redacted `$exception` event should be dropped as
|
||||
* a session-level duplicate. Returns `true` to drop, `false` to keep.
|
||||
*
|
||||
* Never throws. Any missing fingerprint signal, unavailable/corrupt storage, or
|
||||
* unexpected error results in `false` (keep) — the fail-open direction.
|
||||
*/
|
||||
export function shouldDropException(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
const fingerprint = buildFingerprint(properties);
|
||||
// Nothing stable to dedupe on → keep.
|
||||
if (fingerprint === null) return false;
|
||||
|
||||
const storage = getSessionStorage();
|
||||
if (!storage) return false;
|
||||
|
||||
// The entire read-decide-write sequence is guarded: a throw anywhere (parse,
|
||||
// getItem, property access) degrades to keep.
|
||||
try {
|
||||
const counts = readCounts(storage);
|
||||
const current = typeof counts[fingerprint] === "number" ? counts[fingerprint] : 0;
|
||||
|
||||
// Already at the limit for this fingerprint → fuse blows, drop.
|
||||
if (current >= EXCEPTION_SAMPLE_LIMIT) return true;
|
||||
|
||||
// A brand-new fingerprint once the cap is reached: don't track it (would
|
||||
// grow the blob), and keep the event.
|
||||
if (current === 0 && Object.keys(counts).length >= MAX_FINGERPRINTS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
counts[fingerprint] = current + 1;
|
||||
try {
|
||||
storage.setItem(STORAGE_KEY, JSON.stringify(counts));
|
||||
} catch {
|
||||
// Persisting the increment failed (quota / disabled). We still keep this
|
||||
// event (return false below). The unpersisted increment only means the
|
||||
// next identical error is also kept — under-counting toward the limit,
|
||||
// i.e. fewer drops, never more. This is the required failure direction.
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and validate the counts blob. A corrupt or unexpected payload is
|
||||
* treated as empty (fail open — this event is kept and re-seeds the blob). */
|
||||
function readCounts(storage: Storage): FingerprintCounts {
|
||||
const raw = storage.getItem(STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as FingerprintCounts;
|
||||
}
|
||||
} catch {
|
||||
// Corrupt JSON blob → start fresh.
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable fingerprint from the redacted exception properties. Uses the
|
||||
* exception type, the redacted message/value, and a single deterministic stack
|
||||
* frame. Returns `null` when there's nothing stable to key on (keep the event).
|
||||
*
|
||||
* Every frame field (`function` / `lineno` / `colno`) is treated as optional
|
||||
* and degrades to empty — minified or partial stacks must not throw or collapse
|
||||
* every error into one bucket via an undefined access.
|
||||
*/
|
||||
function buildFingerprint(properties: Record<string, unknown> | undefined): string | null {
|
||||
if (!properties || typeof properties !== "object") return null;
|
||||
|
||||
const list = properties.$exception_list;
|
||||
const entry =
|
||||
Array.isArray(list) && list.length > 0 && list[0] && typeof list[0] === "object"
|
||||
? (list[0] as Record<string, unknown>)
|
||||
: undefined;
|
||||
|
||||
const type = readString(entry?.type) ?? readString(properties.$exception_type) ?? "";
|
||||
const value =
|
||||
readString(entry?.value) ?? readString(properties.$exception_message) ?? "";
|
||||
const frame = topFrame(entry);
|
||||
|
||||
// No signal at all → don't dedupe.
|
||||
if (type === "" && value === "" && !frame) return null;
|
||||
|
||||
const parts = [type, value];
|
||||
if (frame) {
|
||||
// colno is kept (load-bearing): minified bundles collapse many statements
|
||||
// onto one line, so line alone under-discriminates distinct errors.
|
||||
parts.push(frame.filename, frame.fn, frame.lineno, frame.colno);
|
||||
}
|
||||
return hash(parts.join(""));
|
||||
}
|
||||
|
||||
interface TopFrame {
|
||||
filename: string;
|
||||
fn: string;
|
||||
lineno: string;
|
||||
colno: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a single deterministic stack frame for fingerprinting. We always take
|
||||
* the LAST frame in the array — a fixed end, with NO engine/order detection.
|
||||
* The same error within a session yields the same frames array and therefore
|
||||
* the same chosen frame, which is all the fingerprint needs; we don't care
|
||||
* which end is semantically "topmost". Missing pieces degrade to "".
|
||||
*/
|
||||
function topFrame(entry: Record<string, unknown> | undefined): TopFrame | null {
|
||||
if (!entry) return null;
|
||||
const stacktrace = entry.stacktrace;
|
||||
const frames =
|
||||
stacktrace && typeof stacktrace === "object"
|
||||
? (stacktrace as Record<string, unknown>).frames
|
||||
: undefined;
|
||||
if (!Array.isArray(frames) || frames.length === 0) return null;
|
||||
|
||||
const f = frames[frames.length - 1];
|
||||
if (!f || typeof f !== "object") return null;
|
||||
const frame = f as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
filename: readString(frame.filename) ?? "",
|
||||
fn: readString(frame.function) ?? "",
|
||||
lineno: readNumberAsString(frame.lineno) ?? "",
|
||||
colno: readNumberAsString(frame.colno) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
function readNumberAsString(v: unknown): string | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? String(v) : undefined;
|
||||
}
|
||||
|
||||
/** djb2 — a tiny stable string hash. Only used to bound the storage-key length;
|
||||
* collision risk across a single tab session's exceptions is negligible. */
|
||||
function hash(input: string): string {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h = ((h << 5) + h) ^ input.charCodeAt(i);
|
||||
}
|
||||
return (h >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/** Resolve `sessionStorage`, returning `null` if it is absent (SSR) or throws
|
||||
* on access (sandboxed iframe, storage disabled). */
|
||||
function getSessionStorage(): Storage | null {
|
||||
try {
|
||||
if (typeof sessionStorage === "undefined") return null;
|
||||
return sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ vi.mock("posthog-js", () => {
|
||||
reset: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
};
|
||||
return { default: mock };
|
||||
});
|
||||
@@ -22,10 +23,12 @@ async function loadModule() {
|
||||
init: ReturnType<typeof vi.fn>;
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
reset: ReturnType<typeof vi.fn>;
|
||||
captureException: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
posthog.init.mockClear();
|
||||
posthog.register.mockClear();
|
||||
posthog.reset.mockClear();
|
||||
posthog.captureException.mockClear();
|
||||
return { analytics, posthog };
|
||||
}
|
||||
|
||||
@@ -183,3 +186,105 @@ describe("capturePageview", () => {
|
||||
expect(capture).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureException", () => {
|
||||
it("buffers a pre-init exception and flushes it on init", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
const err = new Error("boom");
|
||||
|
||||
// Before init: buffered, nothing sent yet.
|
||||
analytics.captureException(err, { source: "global-error" });
|
||||
expect(posthog.captureException).not.toHaveBeenCalled();
|
||||
|
||||
// Init flushes the buffer in order.
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(
|
||||
err,
|
||||
expect.objectContaining({ source: "global-error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends immediately once initialized", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
posthog.captureException.mockClear();
|
||||
|
||||
const err = new Error("later");
|
||||
analytics.captureException(err);
|
||||
expect(posthog.captureException).toHaveBeenCalledTimes(1);
|
||||
expect(posthog.captureException).toHaveBeenCalledWith(err, expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe("before_send $exception pipeline", () => {
|
||||
// before_send is registered inside posthog.init's config; pull it back out of
|
||||
// the mock and drive it directly. Dedupe needs a working sessionStorage.
|
||||
function makeMemoryStorage() {
|
||||
const data = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k: string) => (data.has(k) ? data.get(k)! : null),
|
||||
setItem: (k: string, v: string) => void data.set(k, v),
|
||||
removeItem: (k: string) => void data.delete(k),
|
||||
clear: () => data.clear(),
|
||||
key: (i: number) => Array.from(data.keys())[i] ?? null,
|
||||
get length() {
|
||||
return data.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type BeforeSend = (
|
||||
e: { event: string; properties: Record<string, unknown> } | null,
|
||||
) => unknown;
|
||||
|
||||
function getBeforeSend(posthog: { init: ReturnType<typeof vi.fn> }): BeforeSend {
|
||||
const config = posthog.init.mock.calls[0]?.[1] as { before_send: BeforeSend };
|
||||
return config.before_send;
|
||||
}
|
||||
|
||||
function excEvent() {
|
||||
return {
|
||||
event: "$exception",
|
||||
properties: {
|
||||
$exception_list: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "Bad email bob@corp.com",
|
||||
stacktrace: {
|
||||
frames: [{ filename: "a.tsx", function: "f", lineno: 1, colno: 2 }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("sessionStorage", makeMemoryStorage());
|
||||
});
|
||||
|
||||
it("redacts the message, then drops repeats past the per-fingerprint limit", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const beforeSend = getBeforeSend(posthog);
|
||||
|
||||
const first = beforeSend(excEvent()) as { properties: { $exception_list: Array<{ value: string }> } };
|
||||
// Redaction still runs before the fuse.
|
||||
expect(first.properties.$exception_list[0]!.value).toBe("Bad email [redacted]");
|
||||
|
||||
expect(beforeSend(excEvent())).not.toBeNull();
|
||||
expect(beforeSend(excEvent())).not.toBeNull();
|
||||
// 4th identical exception is dropped.
|
||||
expect(beforeSend(excEvent())).toBeNull();
|
||||
});
|
||||
|
||||
it("passes non-$exception events through untouched", async () => {
|
||||
const { analytics, posthog } = await loadModule();
|
||||
analytics.initAnalytics({ key: "k", host: "" });
|
||||
const beforeSend = getBeforeSend(posthog);
|
||||
|
||||
const evt = { event: "$pageview", properties: { $current_url: "/acme/issues" } };
|
||||
expect(beforeSend(evt)).toBe(evt);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { redactExceptionProperties } from "./redact-exception";
|
||||
import { shouldDropException } from "./exception-dedupe";
|
||||
|
||||
export const EVENT_SCHEMA_VERSION = 2;
|
||||
|
||||
@@ -56,7 +58,8 @@ let lastCapturedPath: string | null = null;
|
||||
// buffer stays small (~one step-transition worth).
|
||||
type PendingOp =
|
||||
| { kind: "event"; name: string; props?: Record<string, unknown> }
|
||||
| { kind: "set"; props: Record<string, unknown> };
|
||||
| { kind: "set"; props: Record<string, unknown> }
|
||||
| { kind: "exception"; error: unknown; props?: Record<string, unknown> };
|
||||
const pendingOps: PendingOp[] = [];
|
||||
// Cached super-properties so resetAnalytics() can re-register them after
|
||||
// posthog.reset() wipes the persisted set. Without this, logout / account
|
||||
@@ -142,7 +145,32 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
autocapture: false,
|
||||
capture_heatmaps: false,
|
||||
capture_dead_clicks: false,
|
||||
capture_exceptions: false,
|
||||
// Exception autocapture IS on: posthog-js attaches window.onerror +
|
||||
// unhandledrejection handlers and sends `$exception` events with the
|
||||
// error's stack. Unlike the click/heatmap autocapture above, this is
|
||||
// explicit failure signal (not behavioral noise) and is the one PostHog
|
||||
// surface that natively handles thrown JS errors — see the failure-tier
|
||||
// split in packages/core/diagnostics. (Production builds are minified;
|
||||
// upload source maps to PostHog to de-minify the stacks.)
|
||||
//
|
||||
// Error messages can interpolate user input (a validation error with the
|
||||
// typed value, a URL with a token), so `before_send` scrubs the message
|
||||
// and `$exception_list[].value` before the event leaves the client. Stack
|
||||
// frames (code locations) are kept. See redact-exception.ts.
|
||||
//
|
||||
// After scrubbing, a session-level fuse drops repeats of the same error so
|
||||
// a render loop or a polling fetch that keeps throwing can't emit 100+
|
||||
// identical `$exception` events per session (MUL-3331). The fingerprint is
|
||||
// built only from the already-redacted fields, so no PII reaches storage.
|
||||
// Order matters: redact first, then fingerprint the redacted shape.
|
||||
capture_exceptions: true,
|
||||
before_send: (event) => {
|
||||
if (event && event.event === "$exception") {
|
||||
redactExceptionProperties(event.properties);
|
||||
if (shouldDropException(event.properties)) return null;
|
||||
}
|
||||
return event;
|
||||
},
|
||||
disable_session_recording: true,
|
||||
disable_surveys: true,
|
||||
});
|
||||
@@ -184,6 +212,8 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole
|
||||
const op = pendingOps.shift()!;
|
||||
if (op.kind === "event") {
|
||||
posthog.capture(op.name, withClientEventProperties(op.props));
|
||||
} else if (op.kind === "exception") {
|
||||
posthog.captureException(op.error, withClientEventProperties(op.props));
|
||||
} else {
|
||||
capturePersonSet(op.props);
|
||||
}
|
||||
@@ -250,6 +280,31 @@ export function captureEvent(
|
||||
posthog.capture(name, withClientEventProperties(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a caught exception that never reached `window.onerror` — a React
|
||||
* render-phase error swallowed by an error boundary. Global uncaught errors
|
||||
* and unhandled rejections are already captured automatically by posthog-js
|
||||
* (`capture_exceptions: true`); this wrapper is for the boundary case those
|
||||
* handlers can't see.
|
||||
*
|
||||
* Currently called by the web route-level `global-error`. Section-level
|
||||
* `@multica/ui` ErrorBoundary can opt in by passing `onError={captureException}`
|
||||
* at its call sites; it is not wired app-wide (those failures already degrade
|
||||
* gracefully with fallback UI).
|
||||
*
|
||||
* Calls before initAnalytics() buffer in order, same as captureEvent.
|
||||
*/
|
||||
export function captureException(
|
||||
error: unknown,
|
||||
props?: Record<string, unknown>,
|
||||
): void {
|
||||
if (!initialized) {
|
||||
pendingOps.push({ kind: "exception", error, props });
|
||||
return;
|
||||
}
|
||||
posthog.captureException(error, withClientEventProperties(props));
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (overwrite) person properties on the currently identified user.
|
||||
* Mirrors the backend's `Event.Set` path — keep these aligned so the
|
||||
|
||||
68
packages/core/analytics/redact-exception.test.ts
Normal file
68
packages/core/analytics/redact-exception.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactText, redactExceptionProperties } from "./redact-exception";
|
||||
|
||||
describe("redactText", () => {
|
||||
it("redacts email addresses", () => {
|
||||
expect(redactText("Invalid email: alice@example.com")).toBe(
|
||||
"Invalid email: [redacted]",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips URL query strings that may carry tokens, keeping host + path", () => {
|
||||
expect(
|
||||
redactText("fetch failed https://api.multica.ai/issues?token=abc123secret"),
|
||||
).toBe("fetch failed https://api.multica.ai/issues?[redacted]");
|
||||
});
|
||||
|
||||
it("redacts long opaque tokens (JWT / API key / uuid)", () => {
|
||||
expect(redactText("auth header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9")).toBe(
|
||||
"auth header [redacted]",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the non-sensitive part of a message intact", () => {
|
||||
expect(redactText("Cannot read property 'x' of undefined")).toBe(
|
||||
"Cannot read property 'x' of undefined",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes through non-strings unchanged", () => {
|
||||
expect(redactText(undefined)).toBeUndefined();
|
||||
expect(redactText(42)).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactExceptionProperties", () => {
|
||||
it("scrubs the message and each $exception_list value, leaving frames untouched", () => {
|
||||
const props = {
|
||||
$exception_message: "Bad email bob@corp.com",
|
||||
$exception_list: [
|
||||
{
|
||||
type: "TypeError",
|
||||
value: "Token leaked: ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
stacktrace: { frames: [{ filename: "app.tsx", lineno: 5, function: "render" }] },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
redactExceptionProperties(props);
|
||||
|
||||
const entry = props.$exception_list[0]!;
|
||||
expect(props.$exception_message).toBe("Bad email [redacted]");
|
||||
expect(entry.value).toBe("Token leaked: [redacted]");
|
||||
// Frames are code locations, not user data — left intact.
|
||||
expect(entry.stacktrace.frames[0]).toEqual({
|
||||
filename: "app.tsx",
|
||||
lineno: 5,
|
||||
function: "render",
|
||||
});
|
||||
expect(entry.type).toBe("TypeError");
|
||||
});
|
||||
|
||||
it("is safe on undefined / malformed properties", () => {
|
||||
expect(redactExceptionProperties(undefined)).toBeUndefined();
|
||||
expect(() =>
|
||||
redactExceptionProperties({ $exception_list: "not-an-array" as unknown as [] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
61
packages/core/analytics/redact-exception.ts
Normal file
61
packages/core/analytics/redact-exception.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// PII scrubbing for `$exception` events before they leave the client.
|
||||
//
|
||||
// Exception autocapture (`capture_exceptions: true`) sends the error message
|
||||
// and stack. Stack frames are code locations (file / line / function) and are
|
||||
// safe, but a message often interpolates user input — a validation error with
|
||||
// the typed value, a parse error with the raw text, a network error with a URL
|
||||
// that may carry a token. We keep the diagnostic shape (type + frames + the
|
||||
// non-sensitive part of the message) and redact the patterns that carry user
|
||||
// data. Wired as posthog-js `before_send`; see initAnalytics.
|
||||
|
||||
const REDACTED = "[redacted]";
|
||||
|
||||
// Order matters: strip query strings before the generic long-token rule, so a
|
||||
// URL's host isn't itself shredded.
|
||||
const PATTERNS: Array<[RegExp, string]> = [
|
||||
// Emails.
|
||||
[/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi, REDACTED],
|
||||
// URL query/fragment (may carry tokens / PII) — keep scheme+host+path.
|
||||
[/((?:https?|file|multica):\/\/[^\s?#]*)[?#]\S*/gi, `$1?${REDACTED}`],
|
||||
// Long opaque tokens: JWTs, API keys, UUIDs, session ids (24+ chars).
|
||||
[/\b[A-Za-z0-9_-]{24,}\b/g, REDACTED],
|
||||
];
|
||||
|
||||
/** Redact PII-ish substrings from a free-text string. */
|
||||
export function redactText(input: unknown): unknown {
|
||||
if (typeof input !== "string" || input.length === 0) return input;
|
||||
let out = input;
|
||||
for (const [pattern, replacement] of PATTERNS) {
|
||||
out = out.replace(pattern, replacement);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact the user-facing strings on a `$exception` event's properties in
|
||||
* place: the top-level message and every entry's `value` in `$exception_list`.
|
||||
* Types and stack frames are left untouched (code locations, not user data).
|
||||
* Returns the same properties object for chaining.
|
||||
*/
|
||||
export function redactExceptionProperties(
|
||||
properties: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!properties || typeof properties !== "object") return properties;
|
||||
|
||||
if ("$exception_message" in properties) {
|
||||
properties.$exception_message = redactText(properties.$exception_message);
|
||||
}
|
||||
|
||||
const list = properties.$exception_list;
|
||||
if (Array.isArray(list)) {
|
||||
for (const entry of list) {
|
||||
if (entry && typeof entry === "object" && "value" in entry) {
|
||||
(entry as { value: unknown }).value = redactText(
|
||||
(entry as { value: unknown }).value,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
@@ -177,11 +177,29 @@ describe("ApiClient", () => {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({
|
||||
id: "comment-1",
|
||||
issue_id: "issue-1",
|
||||
author_type: "member",
|
||||
author_id: "user-1",
|
||||
content: "updated",
|
||||
type: "comment",
|
||||
parent_id: null,
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
created_at: "2026-06-05T00:00:00Z",
|
||||
updated_at: "2026-06-05T00:01:00Z",
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.previewCommentTriggers("issue-1", "hello", "parent-1");
|
||||
await client.previewCommentTriggers("issue-1", "hello", "parent-1", "comment-1");
|
||||
await client.createComment(
|
||||
"issue-1",
|
||||
"hello",
|
||||
@@ -190,6 +208,7 @@ describe("ApiClient", () => {
|
||||
["attachment-1"],
|
||||
["agent-1"],
|
||||
);
|
||||
await client.updateComment("comment-1", "updated", ["attachment-1"], ["agent-1"]);
|
||||
|
||||
expect(fetchMock.mock.calls.map(([url, init]) => ({
|
||||
url,
|
||||
@@ -199,7 +218,7 @@ describe("ApiClient", () => {
|
||||
{
|
||||
url: "https://api.example.test/api/issues/issue-1/comments/trigger-preview",
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content: "hello", parent_id: "parent-1" }),
|
||||
body: JSON.stringify({ content: "hello", parent_id: "parent-1", editing_comment_id: "comment-1" }),
|
||||
},
|
||||
{
|
||||
url: "https://api.example.test/api/issues/issue-1/comments",
|
||||
@@ -212,6 +231,15 @@ describe("ApiClient", () => {
|
||||
suppress_agent_ids: ["agent-1"],
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: "https://api.example.test/api/comments/comment-1",
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
content: "updated",
|
||||
attachment_ids: ["attachment-1"],
|
||||
suppress_agent_ids: ["agent-1"],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -487,6 +515,109 @@ describe("ApiClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelTaskById response parsing", () => {
|
||||
const taskResponse = {
|
||||
id: "task-1",
|
||||
agent_id: "agent-1",
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: "",
|
||||
status: "cancelled",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: "2026-06-12T06:40:00Z",
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2026-06-12T06:39:00Z",
|
||||
};
|
||||
|
||||
it("parses the cancelled chat message payload", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
...taskResponse,
|
||||
cancelled_chat_message: {
|
||||
chat_session_id: "session-1",
|
||||
message_id: "message-1",
|
||||
content: "restore me",
|
||||
restore_to_input: true,
|
||||
},
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const result = await client.cancelTaskById("task-1");
|
||||
|
||||
expect(fetchMock.mock.calls[0]).toMatchObject([
|
||||
"https://api.example.test/api/tasks/task-1/cancel",
|
||||
{ method: "POST" },
|
||||
]);
|
||||
expect(result.cancelled_chat_message).toEqual({
|
||||
chat_session_id: "session-1",
|
||||
message_id: "message-1",
|
||||
content: "restore me",
|
||||
restore_to_input: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a null cancelled chat message as absent", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({
|
||||
...taskResponse,
|
||||
cancelled_chat_message: null,
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const result = await client.cancelTaskById("task-1");
|
||||
|
||||
expect(result.id).toBe("task-1");
|
||||
expect(result.cancelled_chat_message).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a missing task id", { ...taskResponse, id: undefined }],
|
||||
[
|
||||
"a malformed cancelled chat message",
|
||||
{
|
||||
...taskResponse,
|
||||
cancelled_chat_message: {
|
||||
chat_session_id: "session-1",
|
||||
message_id: "message-1",
|
||||
content: "restore me",
|
||||
restore_to_input: "true",
|
||||
},
|
||||
},
|
||||
],
|
||||
["a null body", null],
|
||||
])("falls back for %s", async (_label, body) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const result = await client.cancelTaskById("task-1");
|
||||
|
||||
expect(result.id).toBe("");
|
||||
expect(result.cancelled_chat_message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat attachment wiring", () => {
|
||||
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -24,6 +24,9 @@ import type {
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
AgentRuntime,
|
||||
RuntimeProfile,
|
||||
CreateRuntimeProfileRequest,
|
||||
UpdateRuntimeProfileRequest,
|
||||
InboxItem,
|
||||
IssueSubscriber,
|
||||
Comment,
|
||||
@@ -66,6 +69,7 @@ import type {
|
||||
ChatPendingTask,
|
||||
PendingChatTasksResponse,
|
||||
SendChatMessageResponse,
|
||||
CancelTaskResponse,
|
||||
Project,
|
||||
CreateProjectRequest,
|
||||
UpdateProjectRequest,
|
||||
@@ -132,6 +136,7 @@ import {
|
||||
AgentTemplateSchema,
|
||||
AgentTemplateSummaryListSchema,
|
||||
AttachmentResponseSchema,
|
||||
CancelTaskResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CommentTriggerPreviewSchema,
|
||||
@@ -161,6 +166,8 @@ import {
|
||||
AppConfigSchema,
|
||||
type AppConfigResponse,
|
||||
GroupedIssuesResponseSchema,
|
||||
ListAutopilotsResponseSchema,
|
||||
EMPTY_LIST_AUTOPILOTS_RESPONSE,
|
||||
ListIssuesResponseSchema,
|
||||
ListWebhookDeliveriesResponseSchema,
|
||||
RuntimeHourlyActivityListSchema,
|
||||
@@ -190,6 +197,7 @@ import {
|
||||
EMPTY_CREATE_BILLING_CHECKOUT_SESSION_RESPONSE,
|
||||
EMPTY_BILLING_CHECKOUT_SESSION_STATUS,
|
||||
EMPTY_CREATE_BILLING_PORTAL_SESSION_RESPONSE,
|
||||
EMPTY_CANCEL_TASK_RESPONSE,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -484,6 +492,9 @@ export class ApiClient {
|
||||
}
|
||||
if (params?.open_only) search.set("open_only", "true");
|
||||
if (params?.scheduled) search.set("scheduled", "true");
|
||||
if (params?.date_field) search.set("date_field", params.date_field);
|
||||
if (params?.date_start) search.set("date_start", params.date_start);
|
||||
if (params?.date_end) search.set("date_end", params.date_end);
|
||||
if (params?.sort_by) search.set("sort", params.sort_by);
|
||||
if (params?.sort_direction) search.set("direction", params.sort_direction);
|
||||
const path = `/api/issues?${search}`;
|
||||
@@ -521,6 +532,9 @@ export class ApiClient {
|
||||
if (params.label_ids?.length) search.set("label_ids", params.label_ids.join(","));
|
||||
if (params.group_assignee_type) search.set("group_assignee_type", params.group_assignee_type);
|
||||
if (params.group_assignee_id) search.set("group_assignee_id", params.group_assignee_id);
|
||||
if (params.date_field) search.set("date_field", params.date_field);
|
||||
if (params.date_start) search.set("date_start", params.date_start);
|
||||
if (params.date_end) search.set("date_end", params.date_end);
|
||||
if (params.sort_by) search.set("sort", params.sort_by);
|
||||
if (params.sort_direction) search.set("direction", params.sort_direction);
|
||||
const raw = await this.fetch<unknown>(`/api/issues/grouped?${search}`);
|
||||
@@ -562,6 +576,7 @@ export class ApiClient {
|
||||
prompt: string;
|
||||
project_id?: string | null;
|
||||
parent_issue_id?: string | null;
|
||||
attachment_ids?: string[];
|
||||
}): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
@@ -657,12 +672,13 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async previewCommentTriggers(issueId: string, content: string, parentId?: string): Promise<CommentTriggerPreview> {
|
||||
async previewCommentTriggers(issueId: string, content: string, parentId?: string, editingCommentId?: string): Promise<CommentTriggerPreview> {
|
||||
const raw = await this.fetch<unknown>(`/api/issues/${issueId}/comments/trigger-preview`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
...(parentId ? { parent_id: parentId } : {}),
|
||||
...(editingCommentId ? { editing_comment_id: editingCommentId } : {}),
|
||||
}),
|
||||
});
|
||||
return parseWithFallback(raw, CommentTriggerPreviewSchema, { agents: [] }, {
|
||||
@@ -683,10 +699,14 @@ export class ApiClient {
|
||||
return this.fetch("/api/assignee-frequency");
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
|
||||
async updateComment(commentId: string, content: string, attachmentIds?: string[], suppressAgentIds?: string[]): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
attachment_ids: attachmentIds,
|
||||
...(suppressAgentIds?.length ? { suppress_agent_ids: suppressAgentIds } : {}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1081,6 +1101,61 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Custom runtime profiles (MUL-3284). All workspace-scoped: the caller
|
||||
// passes the workspace id the same way the runtimes list resolves it.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
async listRuntimeProfiles(workspaceId: string): Promise<RuntimeProfile[]> {
|
||||
const res = await this.fetch<{ runtime_profiles?: RuntimeProfile[] }>(
|
||||
`/api/workspaces/${workspaceId}/runtime-profiles`,
|
||||
);
|
||||
return res.runtime_profiles ?? [];
|
||||
}
|
||||
|
||||
async getRuntimeProfile(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
): Promise<RuntimeProfile> {
|
||||
return this.fetch(
|
||||
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createRuntimeProfile(
|
||||
workspaceId: string,
|
||||
body: CreateRuntimeProfileRequest,
|
||||
): Promise<RuntimeProfile> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/runtime-profiles`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async updateRuntimeProfile(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
patch: UpdateRuntimeProfileRequest,
|
||||
): Promise<RuntimeProfile> {
|
||||
return this.fetch(
|
||||
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async deleteRuntimeProfile(
|
||||
workspaceId: string,
|
||||
profileId: string,
|
||||
): Promise<void> {
|
||||
await this.fetch(
|
||||
`/api/workspaces/${workspaceId}/runtime-profiles/${profileId}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
async getRuntimeUsage(
|
||||
runtimeId: string,
|
||||
params?: { days?: number; tz?: string },
|
||||
@@ -1541,6 +1616,16 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Incremental attach: POST /skills/add only inserts the given ids (the
|
||||
// server upserts with ON CONFLICT DO NOTHING), so callers don't need to
|
||||
// read the agent's current skill set first.
|
||||
async addAgentSkills(agentId: string, data: SetAgentSkillsRequest): Promise<void> {
|
||||
await this.fetch(`/api/agents/${agentId}/skills/add`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Personal Access Tokens
|
||||
async listPersonalAccessTokens(): Promise<PersonalAccessToken[]> {
|
||||
return this.fetch("/api/tokens");
|
||||
@@ -1683,8 +1768,11 @@ export class ApiClient {
|
||||
await this.fetch(`/api/chat/sessions/${sessionId}/read`, { method: "POST" });
|
||||
}
|
||||
|
||||
async cancelTaskById(taskId: string): Promise<void> {
|
||||
await this.fetch(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||||
async cancelTaskById(taskId: string): Promise<CancelTaskResponse> {
|
||||
const raw = await this.fetch<unknown>(`/api/tasks/${taskId}/cancel`, { method: "POST" });
|
||||
return parseWithFallback(raw, CancelTaskResponseSchema, EMPTY_CANCEL_TASK_RESPONSE, {
|
||||
endpoint: "POST /api/tasks/{taskId}/cancel",
|
||||
});
|
||||
}
|
||||
|
||||
async listAttachments(issueId: string): Promise<Attachment[]> {
|
||||
@@ -1935,7 +2023,13 @@ export class ApiClient {
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.status) search.set("status", params.status);
|
||||
return this.fetch(`/api/autopilots?${search}`);
|
||||
const raw = await this.fetch<unknown>(`/api/autopilots?${search}`);
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
ListAutopilotsResponseSchema,
|
||||
EMPTY_LIST_AUTOPILOTS_RESPONSE as ListAutopilotsResponse,
|
||||
{ endpoint: "GET /api/autopilots" },
|
||||
);
|
||||
}
|
||||
|
||||
async getAutopilot(id: string): Promise<GetAutopilotResponse> {
|
||||
|
||||
@@ -91,6 +91,67 @@ describe("ApiClient schema fallback", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("listAutopilots", () => {
|
||||
const baseAutopilot = {
|
||||
id: "ap-1",
|
||||
workspace_id: "ws-1",
|
||||
title: "Daily triage",
|
||||
description: null,
|
||||
assignee_id: "agent-1",
|
||||
status: "active",
|
||||
execution_mode: "run_only",
|
||||
issue_title_template: null,
|
||||
created_by_type: "member",
|
||||
created_by_id: "user-1",
|
||||
last_run_at: null,
|
||||
created_at: "2026-06-01T00:00:00Z",
|
||||
updated_at: "2026-06-01T00:00:00Z",
|
||||
};
|
||||
|
||||
it("falls back to an empty list when the response is malformed", async () => {
|
||||
stubFetchJson({ autopilots: "not-an-array", total: 1 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilots();
|
||||
expect(res).toEqual({ autopilots: [], total: 0 });
|
||||
});
|
||||
|
||||
it("accepts an old-server row without assignee_type or derived fields", async () => {
|
||||
// Pre-MUL-2429 servers omit assignee_type; servers older than the
|
||||
// list-derived-fields change omit trigger_kinds/next_run_at/
|
||||
// last_run_status. Both must parse, not fall back.
|
||||
stubFetchJson({ autopilots: [baseAutopilot], total: 1 });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilots();
|
||||
expect(res.autopilots).toHaveLength(1);
|
||||
expect(res.autopilots[0]?.assignee_type).toBe("agent");
|
||||
expect(res.autopilots[0]?.trigger_kinds).toBeUndefined();
|
||||
expect(res.autopilots[0]?.last_run_status).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes derived fields through and tolerates enum drift", async () => {
|
||||
stubFetchJson({
|
||||
autopilots: [
|
||||
{
|
||||
...baseAutopilot,
|
||||
assignee_type: "squad",
|
||||
trigger_kinds: ["schedule", "some_future_kind"],
|
||||
next_run_at: "2026-06-13T09:00:00Z",
|
||||
last_run_status: "some_future_status",
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const res = await client.listAutopilots();
|
||||
expect(res.autopilots[0]?.trigger_kinds).toEqual([
|
||||
"schedule",
|
||||
"some_future_kind",
|
||||
]);
|
||||
expect(res.autopilots[0]?.next_run_at).toBe("2026-06-13T09:00:00Z");
|
||||
expect(res.autopilots[0]?.last_run_status).toBe("some_future_status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getConfig", () => {
|
||||
it("drops malformed daemon setup URLs instead of throwing", async () => {
|
||||
stubFetchJson({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AppConfigSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
@@ -270,3 +271,24 @@ describe("dashboard + runtime usage schema drift", () => {
|
||||
expect((parsed[0] as Record<string, unknown>).region).toBe("us-east");
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppConfigSchema cdn_signed drift", () => {
|
||||
it("defaults cdn_signed to false when the server omits it (pre-MUL-3254 servers)", () => {
|
||||
const parsed = AppConfigSchema.parse({ cdn_domain: "cdn.example.com" });
|
||||
expect(parsed.cdn_signed).toBe(false);
|
||||
});
|
||||
|
||||
it("coerces a malformed cdn_signed to false instead of failing the whole config", () => {
|
||||
const parsed = AppConfigSchema.parse({
|
||||
cdn_domain: "cdn.example.com",
|
||||
cdn_signed: "yes",
|
||||
});
|
||||
expect(parsed.cdn_signed).toBe(false);
|
||||
expect(parsed.cdn_domain).toBe("cdn.example.com");
|
||||
});
|
||||
|
||||
it("keeps cdn_signed=true from a signing-enabled server", () => {
|
||||
const parsed = AppConfigSchema.parse({ cdn_signed: true });
|
||||
expect(parsed.cdn_signed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
BillingPriceTier,
|
||||
BillingTopupsPage,
|
||||
BillingTransactionsPage,
|
||||
CancelTaskResponse,
|
||||
CreateAgentFromTemplateResponse,
|
||||
CreateBillingCheckoutSessionResponse,
|
||||
CreateBillingPortalSessionResponse,
|
||||
@@ -25,6 +26,11 @@ import type { CloudRuntimeNode } from "../runtimes/cloud-runtime";
|
||||
|
||||
export interface AppConfigResponse {
|
||||
cdn_domain: string;
|
||||
// True when the CDN domain serves private content via time-bounded signed
|
||||
// URLs (CloudFront signing) — raw storage URLs on that domain are NOT
|
||||
// publicly fetchable and must not be used as native media sources
|
||||
// (MUL-3254). Older servers omit the field; treat that as false.
|
||||
cdn_signed?: boolean;
|
||||
allow_signup: boolean;
|
||||
google_client_id?: string;
|
||||
posthog_key?: string;
|
||||
@@ -163,6 +169,7 @@ const BooleanWithDefaultSchema = (fallback: boolean) =>
|
||||
|
||||
export const AppConfigSchema = z.object({
|
||||
cdn_domain: z.string().default(""),
|
||||
cdn_signed: BooleanWithDefaultSchema(false),
|
||||
allow_signup: BooleanWithDefaultSchema(true),
|
||||
google_client_id: OptionalStringSchema,
|
||||
posthog_key: OptionalStringSchema,
|
||||
@@ -175,6 +182,7 @@ export const AppConfigSchema = z.object({
|
||||
|
||||
export const EMPTY_APP_CONFIG: AppConfigResponse = {
|
||||
cdn_domain: "",
|
||||
cdn_signed: false,
|
||||
allow_signup: true,
|
||||
google_client_id: "",
|
||||
daemon_server_url: "",
|
||||
@@ -420,6 +428,67 @@ const RuntimeUsageByHourSchema = z.object({
|
||||
|
||||
export const RuntimeUsageByHourListSchema = z.array(RuntimeUsageByHourSchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task cancellation (`POST /api/tasks/:id/cancel`)
|
||||
//
|
||||
// This response is consumed directly by chat recovery. The embedded task
|
||||
// object stays loose so daemon/runtime fields can drift, but the optional
|
||||
// `cancelled_chat_message` payload must be well-formed before the UI deletes
|
||||
// a message from cache or restores text into the input.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AgentTaskResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
agent_id: z.string().default(""),
|
||||
runtime_id: z.string().default(""),
|
||||
issue_id: z.string().default(""),
|
||||
status: z.string().default("cancelled"),
|
||||
priority: z.number().default(0),
|
||||
dispatched_at: z.string().nullable().default(null),
|
||||
started_at: z.string().nullable().default(null),
|
||||
completed_at: z.string().nullable().default(null),
|
||||
result: z.unknown().default(null),
|
||||
error: z.string().nullable().default(null),
|
||||
failure_reason: z.string().optional(),
|
||||
created_at: z.string().default(""),
|
||||
chat_session_id: z.string().optional(),
|
||||
autopilot_run_id: z.string().optional(),
|
||||
parent_task_id: z.string().optional(),
|
||||
attempt: z.number().optional(),
|
||||
trigger_comment_id: z.string().optional(),
|
||||
trigger_summary: z.string().optional(),
|
||||
kind: z.string().optional(),
|
||||
work_dir: z.string().optional(),
|
||||
relative_work_dir: z.string().optional(),
|
||||
}).loose();
|
||||
|
||||
const CancelledChatMessageSchema = z.object({
|
||||
chat_session_id: z.string(),
|
||||
message_id: z.string(),
|
||||
content: z.string(),
|
||||
restore_to_input: z.boolean().default(false),
|
||||
}).loose();
|
||||
|
||||
export const CancelTaskResponseSchema = AgentTaskResponseSchema.extend({
|
||||
cancelled_chat_message: CancelledChatMessageSchema.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_CANCEL_TASK_RESPONSE: CancelTaskResponse = {
|
||||
id: "",
|
||||
agent_id: "",
|
||||
runtime_id: "",
|
||||
issue_id: "",
|
||||
status: "cancelled",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: null,
|
||||
completed_at: null,
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
@@ -666,6 +735,47 @@ export const EMPTY_LIST_WEBHOOK_DELIVERIES_RESPONSE: ListWebhookDeliveriesRespon
|
||||
total: 0,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Autopilot list schema. Enums (`status`, `execution_mode`, `trigger_kinds`,
|
||||
// `last_run_status`) stay `z.string()` so future server-side values degrade
|
||||
// to a generic UI fallback. The three derived fields (trigger_kinds /
|
||||
// next_run_at / last_run_status) are list-endpoint-only and absent on older
|
||||
// servers — optional by contract, the list renders "—" without them.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AutopilotListItemSchema = z.object({
|
||||
id: z.string(),
|
||||
workspace_id: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
project_id: z.string().nullable().optional(),
|
||||
// Older servers (pre-MUL-2429) omit assignee_type; "agent" is the
|
||||
// documented default.
|
||||
assignee_type: z.string().default("agent"),
|
||||
assignee_id: z.string(),
|
||||
status: z.string(),
|
||||
execution_mode: z.string(),
|
||||
issue_title_template: z.string().nullable().optional(),
|
||||
created_by_type: z.string(),
|
||||
created_by_id: z.string(),
|
||||
last_run_at: z.string().nullable().optional(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
trigger_kinds: z.array(z.string()).optional(),
|
||||
next_run_at: z.string().nullable().optional(),
|
||||
last_run_status: z.string().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const ListAutopilotsResponseSchema = z.object({
|
||||
autopilots: z.array(AutopilotListItemSchema).default([]),
|
||||
total: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_LIST_AUTOPILOTS_RESPONSE = {
|
||||
autopilots: [],
|
||||
total: 0,
|
||||
};
|
||||
|
||||
export const EMPTY_WEBHOOK_DELIVERY: WebhookDelivery = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
|
||||
13
packages/core/autopilots/stores/index.ts
Normal file
13
packages/core/autopilots/stores/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
useAutopilotsViewStore,
|
||||
AUTOPILOT_SCOPES,
|
||||
AUTOPILOT_SORT_DEFAULT_DIRECTION,
|
||||
AUTOPILOT_DEFAULT_HIDDEN_COLUMNS,
|
||||
EMPTY_AUTOPILOT_FILTERS,
|
||||
type AutopilotScope,
|
||||
type AutopilotSortField,
|
||||
type AutopilotSortDirection,
|
||||
type AutopilotColumnKey,
|
||||
type AutopilotListFilters,
|
||||
type AutopilotsViewState,
|
||||
} from "./view-store";
|
||||
176
packages/core/autopilots/stores/view-store.ts
Normal file
176
packages/core/autopilots/stores/view-store.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// View preferences for the autopilots list page: scope, sort, column
|
||||
// visibility, and filters. Persisted per workspace (workspace-aware storage),
|
||||
// per user/device (localStorage). Search text and row selection are
|
||||
// deliberately NOT stored — they are session-scoped (same rationale as the
|
||||
// skills view store).
|
||||
|
||||
// Status is the promoted SCOPE dimension (lifecycle stage, mutually
|
||||
// exclusive) — it therefore does NOT appear in `filters`; one dimension
|
||||
// lives in exactly one place. "all" = active + paused. There is no
|
||||
// archived scope because the product has no UI archiving flow (the DB
|
||||
// status value exists but nothing in the UI can set it); add the scope
|
||||
// back together with archive actions if that flow ever ships.
|
||||
export type AutopilotScope = "all" | "active" | "paused";
|
||||
|
||||
export const AUTOPILOT_SCOPES: AutopilotScope[] = ["all", "active", "paused"];
|
||||
|
||||
export type AutopilotSortField = "name" | "lastRun" | "nextRun" | "created";
|
||||
|
||||
export type AutopilotSortDirection = "asc" | "desc";
|
||||
|
||||
/** Per-field direction applied when the user switches TO that field. */
|
||||
export const AUTOPILOT_SORT_DEFAULT_DIRECTION: Record<
|
||||
AutopilotSortField,
|
||||
AutopilotSortDirection
|
||||
> = {
|
||||
name: "asc",
|
||||
lastRun: "desc",
|
||||
nextRun: "asc",
|
||||
created: "desc",
|
||||
};
|
||||
|
||||
/** Multi-select filter state. Empty array per dimension = inactive. */
|
||||
export interface AutopilotListFilters {
|
||||
assignees: string[];
|
||||
modes: string[];
|
||||
triggerKinds: string[];
|
||||
creators: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_AUTOPILOT_FILTERS: AutopilotListFilters = {
|
||||
assignees: [],
|
||||
modes: [],
|
||||
triggerKinds: [],
|
||||
creators: [],
|
||||
};
|
||||
|
||||
// User-hideable columns. Name and the structural columns (checkbox, kebab)
|
||||
// are always visible.
|
||||
export type AutopilotColumnKey =
|
||||
| "assignee"
|
||||
| "trigger"
|
||||
| "lastRun"
|
||||
| "nextRun"
|
||||
| "mode"
|
||||
| "creator"
|
||||
| "created";
|
||||
|
||||
/** Mode, creator and created are opt-in: hidden until the user enables them. */
|
||||
export const AUTOPILOT_DEFAULT_HIDDEN_COLUMNS: AutopilotColumnKey[] = [
|
||||
"mode",
|
||||
"creator",
|
||||
"created",
|
||||
];
|
||||
|
||||
export interface AutopilotsViewState {
|
||||
scope: AutopilotScope;
|
||||
sortField: AutopilotSortField;
|
||||
sortDirection: AutopilotSortDirection;
|
||||
hiddenColumns: AutopilotColumnKey[];
|
||||
filters: AutopilotListFilters;
|
||||
setScope: (scope: AutopilotScope) => void;
|
||||
/** Header click: toggles direction on the active field, otherwise switches
|
||||
* to the field with its default direction. */
|
||||
toggleSort: (field: AutopilotSortField) => void;
|
||||
/** Display panel select: switches field (default direction), no toggle. */
|
||||
setSortField: (field: AutopilotSortField) => void;
|
||||
setSortDirection: (direction: AutopilotSortDirection) => void;
|
||||
toggleColumn: (key: AutopilotColumnKey) => void;
|
||||
toggleFilter: (key: keyof AutopilotListFilters, value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
scope: "all" as AutopilotScope,
|
||||
sortField: "lastRun" as AutopilotSortField,
|
||||
sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION.lastRun,
|
||||
hiddenColumns: AUTOPILOT_DEFAULT_HIDDEN_COLUMNS,
|
||||
filters: EMPTY_AUTOPILOT_FILTERS,
|
||||
};
|
||||
|
||||
export const useAutopilotsViewStore = create<AutopilotsViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULTS,
|
||||
setScope: (scope) => set({ scope }),
|
||||
toggleSort: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {
|
||||
sortDirection: state.sortDirection === "asc" ? "desc" : "asc",
|
||||
}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortField: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: AUTOPILOT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortDirection: (direction) => set({ sortDirection: direction }),
|
||||
toggleColumn: (key) =>
|
||||
set((state) => ({
|
||||
hiddenColumns: state.hiddenColumns.includes(key)
|
||||
? state.hiddenColumns.filter((k) => k !== key)
|
||||
: [...state.hiddenColumns, key],
|
||||
})),
|
||||
toggleFilter: (key, value) =>
|
||||
set((state) => {
|
||||
const list = state.filters[key] as string[];
|
||||
const next = list.includes(value)
|
||||
? list.filter((v) => v !== value)
|
||||
: [...list, value];
|
||||
return { filters: { ...state.filters, [key]: next } };
|
||||
}),
|
||||
clearFilters: () => set({ filters: EMPTY_AUTOPILOT_FILTERS }),
|
||||
}),
|
||||
{
|
||||
name: "multica_autopilots_view",
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({
|
||||
scope: state.scope,
|
||||
sortField: state.sortField,
|
||||
sortDirection: state.sortDirection,
|
||||
hiddenColumns: state.hiddenColumns,
|
||||
filters: state.filters,
|
||||
}),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the defaults instead of leaving the previous workspace's in-memory
|
||||
// view state in place (same rationale as the skills view store).
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, ...DEFAULTS };
|
||||
const p = persisted as Partial<AutopilotsViewState>;
|
||||
// Deep-merge filters so a payload persisted before a new filter
|
||||
// dimension existed still gets that key's default instead of
|
||||
// dropping it to undefined (which crashes `.length` reads).
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
filters: { ...EMPTY_AUTOPILOT_FILTERS, ...(p.filters ?? {}) },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() =>
|
||||
useAutopilotsViewStore.persist.rehydrate(),
|
||||
);
|
||||
@@ -1,4 +1,4 @@
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION } from "./store";
|
||||
export { createChatStore, CHAT_MIN_W, CHAT_MIN_H, CHAT_DEFAULT_W, CHAT_DEFAULT_H, DRAFT_NEW_SESSION, newSessionDraftKey } from "./store";
|
||||
export type { ChatStoreOptions, ChatState, ChatTimelineItem } from "./store";
|
||||
export { useRecentContextStore, selectRecentContexts } from "./recent-context-store";
|
||||
export type { RecentContextEntry, RecentContextType } from "./recent-context-store";
|
||||
|
||||
59
packages/core/chat/store.test.ts
Normal file
59
packages/core/chat/store.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createChatStore, newSessionDraftKey } from "./store";
|
||||
import type { StorageAdapter } from "../types";
|
||||
|
||||
function memStorage(): StorageAdapter {
|
||||
const m = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => m.get(k) ?? null,
|
||||
setItem: (k, v) => {
|
||||
m.set(k, v);
|
||||
},
|
||||
removeItem: (k) => {
|
||||
m.delete(k);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("newSessionDraftKey", () => {
|
||||
it("derives a stable per-agent slot for an uncreated chat", () => {
|
||||
expect(newSessionDraftKey("agent-1")).toBe("__new__:agent-1");
|
||||
expect(newSessionDraftKey(null)).toBe("__new__:");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat store — migrateInputDraft", () => {
|
||||
let store: ReturnType<typeof createChatStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createChatStore({ storage: memStorage() });
|
||||
});
|
||||
|
||||
it("moves a draft to the new key and clears the source", () => {
|
||||
const from = newSessionDraftKey("agent-1");
|
||||
store.getState().setInputDraft(from, "!file[x.pdf]()");
|
||||
|
||||
store.getState().migrateInputDraft(from, "session-1");
|
||||
|
||||
const drafts = store.getState().inputDrafts;
|
||||
expect(drafts["session-1"]).toBe("!file[x.pdf]()");
|
||||
// Source slot is cleared so it can't resurface in the next new chat.
|
||||
expect(from in drafts).toBe(false);
|
||||
});
|
||||
|
||||
it("is a no-op when the source draft is absent", () => {
|
||||
store.getState().setInputDraft("session-1", "keep me");
|
||||
|
||||
store.getState().migrateInputDraft(newSessionDraftKey("agent-1"), "session-1");
|
||||
|
||||
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
|
||||
});
|
||||
|
||||
it("is a no-op when from === to", () => {
|
||||
store.getState().setInputDraft("session-1", "keep me");
|
||||
|
||||
store.getState().migrateInputDraft("session-1", "session-1");
|
||||
|
||||
expect(store.getState().inputDrafts["session-1"]).toBe("keep me");
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,16 @@ const SESSION_STORAGE_KEY = "multica:chat:activeSessionId";
|
||||
const DRAFTS_KEY = "multica:chat:drafts";
|
||||
/** Placeholder sessionId for a chat that hasn't been created yet. */
|
||||
export const DRAFT_NEW_SESSION = "__new__";
|
||||
|
||||
/**
|
||||
* Draft storage key for an as-yet-uncreated chat with the given agent.
|
||||
* Shared by ChatInput (which writes the draft) and ensureSession (which
|
||||
* migrates it onto the real session id the moment the session is created),
|
||||
* so the two never disagree on the slot name.
|
||||
*/
|
||||
export function newSessionDraftKey(selectedAgentId: string | null): string {
|
||||
return `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
}
|
||||
const CHAT_WIDTH_KEY = "multica:chat:width";
|
||||
const CHAT_HEIGHT_KEY = "multica:chat:height";
|
||||
const CHAT_EXPANDED_KEY = "multica:chat:expanded";
|
||||
@@ -84,6 +94,14 @@ export interface ChatState {
|
||||
/** sessionId accepts a real session UUID or DRAFT_NEW_SESSION. */
|
||||
setInputDraft: (sessionId: string, draft: string) => void;
|
||||
clearInputDraft: (sessionId: string) => void;
|
||||
/**
|
||||
* Move a draft from one key to another, deleting the source. Used when a
|
||||
* chat session is lazily created: the `__new__:agent` draft is migrated
|
||||
* onto the real sessionId so it isn't stranded under the abandoned key
|
||||
* (which would resurface as a stale draft the next time a new chat opens
|
||||
* for that agent).
|
||||
*/
|
||||
migrateInputDraft: (from: string, to: string) => void;
|
||||
/** Persist raw size and auto-exit expanded mode. */
|
||||
setChatSize: (width: number, height: number) => void;
|
||||
setExpanded: (expanded: boolean) => void;
|
||||
@@ -159,6 +177,19 @@ export function createChatStore(options: ChatStoreOptions) {
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
migrateInputDraft: (from, to) => {
|
||||
if (from === to) return;
|
||||
const current = get().inputDrafts;
|
||||
if (!(from in current)) {
|
||||
logger.debug("migrateInputDraft skipped (no source draft)", { from, to });
|
||||
return;
|
||||
}
|
||||
logger.info("migrateInputDraft", { from, to });
|
||||
const next = { ...current, [to]: current[from]! };
|
||||
delete next[from];
|
||||
writeDrafts(storage, wsKey(DRAFTS_KEY), next);
|
||||
set({ inputDrafts: next });
|
||||
},
|
||||
setChatSize: (w, h) => {
|
||||
logger.debug("setChatSize", { w, h });
|
||||
storage.setItem(CHAT_WIDTH_KEY, String(w));
|
||||
|
||||
@@ -3,6 +3,10 @@ import { useStore } from "zustand";
|
||||
|
||||
interface ConfigState {
|
||||
cdnDomain: string;
|
||||
// True when cdnDomain serves private content via time-bounded signed URLs
|
||||
// (CloudFront signing enabled server-side). Renderers must not treat a raw
|
||||
// storage URL on that domain as a loadable media source (MUL-3254).
|
||||
cdnSigned: boolean;
|
||||
allowSignup: boolean;
|
||||
googleClientId: string;
|
||||
daemonServerUrl: string;
|
||||
@@ -11,7 +15,7 @@ interface ConfigState {
|
||||
// must be hidden. Defaults to false so unknown / older servers behave like
|
||||
// the managed-cloud case.
|
||||
workspaceCreationDisabled: boolean;
|
||||
setCdnDomain: (domain: string) => void;
|
||||
setCdnConfig: (config: { cdnDomain: string; cdnSigned?: boolean }) => void;
|
||||
setAuthConfig: (config: {
|
||||
allowSignup: boolean;
|
||||
googleClientId?: string;
|
||||
@@ -25,12 +29,13 @@ interface ConfigState {
|
||||
|
||||
export const configStore = createStore<ConfigState>((set) => ({
|
||||
cdnDomain: "",
|
||||
cdnSigned: false,
|
||||
allowSignup: true,
|
||||
googleClientId: "",
|
||||
daemonServerUrl: "",
|
||||
daemonAppUrl: "",
|
||||
workspaceCreationDisabled: false,
|
||||
setCdnDomain: (domain) => set({ cdnDomain: domain }),
|
||||
setCdnConfig: ({ cdnDomain, cdnSigned = false }) => set({ cdnDomain, cdnSigned }),
|
||||
setAuthConfig: ({ allowSignup, googleClientId = "", workspaceCreationDisabled = false }) =>
|
||||
set({ allowSignup, googleClientId, workspaceCreationDisabled }),
|
||||
setDaemonConfig: ({ daemonServerUrl = "", daemonAppUrl = "" }) =>
|
||||
|
||||
134
packages/core/diagnostics/freeze-watchdog.test.ts
Normal file
134
packages/core/diagnostics/freeze-watchdog.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../analytics", () => ({ captureEvent: vi.fn() }));
|
||||
|
||||
// A controllable PerformanceObserver stand-in: records the callback so a test
|
||||
// can fire synthetic long-task entries, and counts constructions so we can
|
||||
// assert idempotent install.
|
||||
let lastCallback: ((list: { getEntries: () => Array<{ duration: number }> }) => void) | null;
|
||||
let constructed: number;
|
||||
let observeCalls: number;
|
||||
|
||||
class FakePerformanceObserver {
|
||||
constructor(cb: (list: { getEntries: () => Array<{ duration: number }> }) => void) {
|
||||
constructed += 1;
|
||||
lastCallback = cb;
|
||||
}
|
||||
observe() {
|
||||
observeCalls += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function fireLongTask(duration: number) {
|
||||
lastCallback?.({ getEntries: () => [{ duration }] });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
vi.resetModules();
|
||||
const mod = await import("./freeze-watchdog");
|
||||
const { captureEvent } = await import("../analytics");
|
||||
return {
|
||||
installFreezeWatchdog: mod.installFreezeWatchdog,
|
||||
captureEvent: captureEvent as unknown as ReturnType<typeof vi.fn>,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lastCallback = null;
|
||||
constructed = 0;
|
||||
observeCalls = 0;
|
||||
vi.stubGlobal("window", {});
|
||||
vi.stubGlobal("location", { pathname: "/acme/issues" });
|
||||
vi.stubGlobal("PerformanceObserver", FakePerformanceObserver);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("installFreezeWatchdog", () => {
|
||||
it("reports a long task at or above the 2s threshold with duration + path", async () => {
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
fireLongTask(2300);
|
||||
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
expect(captureEvent).toHaveBeenCalledWith("client_unresponsive", {
|
||||
source: "longtask",
|
||||
duration_ms: 2300,
|
||||
path: "/acme/issues",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores blocks below the threshold (normal render cost)", async () => {
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
fireLongTask(600);
|
||||
fireLongTask(1999);
|
||||
|
||||
expect(captureEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is idempotent — a second install does not add a second observer", async () => {
|
||||
const { installFreezeWatchdog } = await load();
|
||||
installFreezeWatchdog();
|
||||
installFreezeWatchdog();
|
||||
|
||||
expect(constructed).toBe(1);
|
||||
expect(observeCalls).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op on the server (no window)", async () => {
|
||||
vi.stubGlobal("window", undefined);
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
|
||||
expect(() => installFreezeWatchdog()).not.toThrow();
|
||||
expect(constructed).toBe(0);
|
||||
expect(captureEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is a no-op when PerformanceObserver is unavailable", async () => {
|
||||
vi.stubGlobal("PerformanceObserver", undefined);
|
||||
const { installFreezeWatchdog } = await load();
|
||||
|
||||
expect(() => installFreezeWatchdog()).not.toThrow();
|
||||
});
|
||||
|
||||
it("emits at most one client_unresponsive per 60s cooldown window", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
// A sustained freeze arrives as several long-task entries back to back.
|
||||
fireLongTask(2500);
|
||||
fireLongTask(2500);
|
||||
fireLongTask(3000);
|
||||
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("emits again only after the cooldown window elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
||||
const { installFreezeWatchdog, captureEvent } = await load();
|
||||
installFreezeWatchdog();
|
||||
|
||||
fireLongTask(2500);
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Still inside the window → suppressed.
|
||||
vi.advanceTimersByTime(59_999);
|
||||
fireLongTask(2500);
|
||||
expect(captureEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Window elapsed → emits again.
|
||||
vi.advanceTimersByTime(1);
|
||||
fireLongTask(2500);
|
||||
expect(captureEvent).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
72
packages/core/diagnostics/freeze-watchdog.ts
Normal file
72
packages/core/diagnostics/freeze-watchdog.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Client freeze watchdog — shared by web and desktop.
|
||||
//
|
||||
// Installs a long-task observer in the main thread. A "long task" is any
|
||||
// stretch where the thread didn't return to the event loop; the browser
|
||||
// already tracks them and delivers each entry once the thread unblocks, so
|
||||
// even a multi-second freeze reports its duration after the fact. We only
|
||||
// emit for blocks at or above FREEZE_THRESHOLD_MS to keep this to genuine
|
||||
// "almost froze" events, not the normal 50–600ms render cost.
|
||||
//
|
||||
// This is the in-thread, recoverable tier: it catches freezes the thread
|
||||
// survives. A true non-recoverable hang (the thread never unblocks) can only
|
||||
// be caught from outside — on desktop that is the main process `unresponsive`
|
||||
// handler (see apps/desktop renderer-recovery). Web has no free external
|
||||
// watcher, so this observer is its only freeze signal for now.
|
||||
//
|
||||
// The emitted `client_unresponsive` event carries `client_type` automatically
|
||||
// (an analytics super-property), so desktop vs web is queryable without any
|
||||
// platform branch here.
|
||||
|
||||
import { captureEvent } from "../analytics";
|
||||
|
||||
// 2s is well above the normal switch/render cost (measured 50–600ms) and just
|
||||
// under Electron's renderer-hang threshold, so an event here means "the user
|
||||
// felt a real stall" without flooding on routine heavy renders.
|
||||
const FREEZE_THRESHOLD_MS = 2000;
|
||||
|
||||
// A single sustained freeze is delivered by the browser as several separate
|
||||
// long-task entries, so emitting per entry makes client_unresponsive volume
|
||||
// grow without bound with the freeze length (MUL-3331). A global cooldown caps
|
||||
// it to at most one event per window. Module-level (page-lifetime) state is the
|
||||
// right scope here — it matches the `installed` singleton and resets on a full
|
||||
// reload, which is rare and itself a distinct signal. No route bucketing: a
|
||||
// global window is the most direct cap on volume.
|
||||
const COOLDOWN_MS = 60_000;
|
||||
let lastEmitMs = 0;
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Install the long-task observer. Safe to call multiple times (idempotent) and
|
||||
* safe on the server (no-op when `window` / `PerformanceObserver` is absent).
|
||||
* Call once from a client-only effect.
|
||||
*/
|
||||
export function installFreezeWatchdog(): void {
|
||||
if (installed) return;
|
||||
if (typeof window === "undefined") return;
|
||||
if (typeof PerformanceObserver === "undefined") return;
|
||||
installed = true;
|
||||
|
||||
try {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
if (entry.duration < FREEZE_THRESHOLD_MS) continue;
|
||||
// Cooldown is checked only against qualifying freezes, so sub-threshold
|
||||
// long tasks neither emit nor reset the window.
|
||||
const now = Date.now();
|
||||
if (now - lastEmitMs < COOLDOWN_MS) continue;
|
||||
lastEmitMs = now;
|
||||
captureEvent("client_unresponsive", {
|
||||
source: "longtask",
|
||||
duration_ms: Math.round(entry.duration),
|
||||
path: typeof location !== "undefined" ? location.pathname : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
// No `buffered: true` — we only want freezes from now on. Replaying tasks
|
||||
// buffered before install would mislabel slow startup as a runtime freeze.
|
||||
observer.observe({ type: "longtask" });
|
||||
} catch {
|
||||
// longtask entry type unsupported on this engine — nothing else to do.
|
||||
}
|
||||
}
|
||||
74
packages/core/inbox/queries.test.ts
Normal file
74
packages/core/inbox/queries.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { InboxItem } from "../types";
|
||||
import { deduplicateInboxItems } from "./queries";
|
||||
|
||||
function item(overrides: Partial<InboxItem>): InboxItem {
|
||||
return {
|
||||
id: "inbox-1",
|
||||
workspace_id: "workspace-1",
|
||||
recipient_type: "member",
|
||||
recipient_id: "member-1",
|
||||
actor_type: "agent",
|
||||
actor_id: "agent-1",
|
||||
type: "new_comment",
|
||||
severity: "info",
|
||||
issue_id: "issue-1",
|
||||
title: "Issue title",
|
||||
body: null,
|
||||
issue_status: null,
|
||||
read: false,
|
||||
archived: false,
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("deduplicateInboxItems", () => {
|
||||
it("keeps the newest issue row while preserving an older comment anchor", () => {
|
||||
const merged = deduplicateInboxItems([
|
||||
item({
|
||||
id: "comment-notification",
|
||||
type: "new_comment",
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: { comment_id: "comment-1" },
|
||||
}),
|
||||
item({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
created_at: "2026-06-15T08:01:00Z",
|
||||
details: { from: "in_progress", to: "in_review" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]).toMatchObject({
|
||||
id: "status-notification",
|
||||
type: "status_changed",
|
||||
details: {
|
||||
from: "in_progress",
|
||||
to: "in_review",
|
||||
comment_id: "comment-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the newest row's own comment anchor", () => {
|
||||
const merged = deduplicateInboxItems([
|
||||
item({
|
||||
id: "older-comment",
|
||||
created_at: "2026-06-15T08:00:00Z",
|
||||
details: { comment_id: "comment-1" },
|
||||
}),
|
||||
item({
|
||||
id: "newer-comment",
|
||||
created_at: "2026-06-15T08:02:00Z",
|
||||
details: { comment_id: "comment-2" },
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]?.id).toBe("newer-comment");
|
||||
expect(merged[0]?.details?.comment_id).toBe("comment-2");
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,22 @@ export function deduplicateInboxItems(items: InboxItem[]): InboxItem[] {
|
||||
(a, b) =>
|
||||
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||
);
|
||||
if (group[0]) merged.push(group[0]);
|
||||
const newest = group[0];
|
||||
if (!newest) continue;
|
||||
|
||||
const commentId =
|
||||
newest.details?.comment_id ??
|
||||
group.find((item) => item.details?.comment_id)?.details?.comment_id;
|
||||
|
||||
if (commentId && newest.details?.comment_id !== commentId) {
|
||||
merged.push({
|
||||
...newest,
|
||||
details: { ...(newest.details ?? {}), comment_id: commentId },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
merged.push(newest);
|
||||
}
|
||||
return merged.sort(
|
||||
(a, b) =>
|
||||
|
||||
@@ -646,8 +646,17 @@ export function useCreateComment(issueId: string) {
|
||||
export function useUpdateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds: string[] }) =>
|
||||
api.updateComment(commentId, content, attachmentIds),
|
||||
mutationFn: ({
|
||||
commentId,
|
||||
content,
|
||||
attachmentIds,
|
||||
suppressAgentIds,
|
||||
}: {
|
||||
commentId: string;
|
||||
content: string;
|
||||
attachmentIds: string[];
|
||||
suppressAgentIds?: string[];
|
||||
}) => api.updateComment(commentId, content, attachmentIds, suppressAgentIds),
|
||||
onMutate: async ({ commentId, content, attachmentIds }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
|
||||
@@ -13,6 +13,9 @@ import { BOARD_STATUSES } from "./config";
|
||||
export interface IssueSortParam {
|
||||
sort_by?: ListIssuesParams["sort_by"];
|
||||
sort_direction?: ListIssuesParams["sort_direction"];
|
||||
date_field?: ListIssuesParams["date_field"];
|
||||
date_start?: ListIssuesParams["date_start"];
|
||||
date_end?: ListIssuesParams["date_end"];
|
||||
}
|
||||
|
||||
export const issueKeys = {
|
||||
@@ -73,9 +76,13 @@ export const issueKeys = {
|
||||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||||
timeline: (issueId: string) =>
|
||||
[...issueKeys.timelineAll(), issueId] as const,
|
||||
/** Prefix across all issues — WS task lifecycle events invalidate here so
|
||||
* an open composer's trigger preview refreshes when an agent's queue
|
||||
* state changes (the dedup guard makes the answer queue-dependent). */
|
||||
commentTriggerPreviewAll: () => ["issues", "comment-trigger-preview"] as const,
|
||||
/** PREFIX for invalidation — the composer hook appends parent + content signature. */
|
||||
commentTriggerPreview: (issueId: string) =>
|
||||
["issues", "comment-trigger-preview", issueId] as const,
|
||||
[...issueKeys.commentTriggerPreviewAll(), issueId] as const,
|
||||
reactionsAll: () => ["issues", "reactions"] as const,
|
||||
reactions: (issueId: string) =>
|
||||
[...issueKeys.reactionsAll(), issueId] as const,
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useIssueDraftStore } from "./draft-store";
|
||||
import { setCurrentWorkspace } from "../../platform/workspace-storage";
|
||||
|
||||
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
|
||||
|
||||
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
|
||||
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
|
||||
// can round-trip values.
|
||||
beforeAll(() => {
|
||||
if (typeof globalThis.localStorage?.clear !== "function") {
|
||||
const values = new Map<string, string>();
|
||||
const storage: Storage = {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: (k) => values.get(k) ?? null,
|
||||
key: (i) => Array.from(values.keys())[i] ?? null,
|
||||
removeItem: (k) => { values.delete(k); },
|
||||
setItem: (k, v) => { values.set(k, v); },
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
|
||||
}
|
||||
});
|
||||
|
||||
const RESET_STATE = {
|
||||
draft: {
|
||||
@@ -11,6 +34,7 @@ const RESET_STATE = {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
lastAssigneeId: undefined,
|
||||
@@ -46,6 +70,36 @@ describe("issue draft store — last assignee", () => {
|
||||
expect(draft.assigneeId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clearDraft removes persisted draft attachments", () => {
|
||||
const { setDraft, clearDraft } = useIssueDraftStore.getState();
|
||||
|
||||
setDraft({
|
||||
title: "first",
|
||||
attachments: [
|
||||
{
|
||||
id: "11111111-2222-3333-4444-555555555555",
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "alice",
|
||||
filename: "shot.png",
|
||||
url: "https://cdn.example.test/shot.png",
|
||||
download_url: "https://cdn.example.test/shot.png",
|
||||
markdown_url: "https://app.example.test/api/attachments/11111111-2222-3333-4444-555555555555/download",
|
||||
content_type: "image/png",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-06-12T00:00:00Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
clearDraft();
|
||||
|
||||
expect(useIssueDraftStore.getState().draft.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it("setLastAssignee(undefined) lets the user opt back out of a default", () => {
|
||||
const { setLastAssignee, clearDraft } = useIssueDraftStore.getState();
|
||||
|
||||
@@ -59,3 +113,42 @@ describe("issue draft store — last assignee", () => {
|
||||
expect(useIssueDraftStore.getState().draft.assigneeType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue draft store — legacy rehydrate", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setCurrentWorkspace(null, null);
|
||||
});
|
||||
|
||||
it("backfills attachments for drafts persisted before the field existed", async () => {
|
||||
localStorage.setItem(
|
||||
"multica_issue_draft:acme",
|
||||
JSON.stringify({
|
||||
state: {
|
||||
draft: {
|
||||
title: "legacy",
|
||||
description: "body",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
// no `attachments` — written by a build that predates the field
|
||||
},
|
||||
},
|
||||
version: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
await flush();
|
||||
|
||||
const { draft } = useIssueDraftStore.getState();
|
||||
expect(draft.title).toBe("legacy");
|
||||
expect(draft.attachments).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import type { IssueStatus, IssuePriority, IssueAssigneeType } from "../../types";
|
||||
import type { IssueStatus, IssuePriority, IssueAssigneeType, Attachment } from "../../types";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
@@ -13,6 +13,7 @@ interface IssueDraft {
|
||||
assigneeId?: string;
|
||||
startDate: string | null;
|
||||
dueDate: string | null;
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
const EMPTY_DRAFT: IssueDraft = {
|
||||
@@ -24,6 +25,7 @@ const EMPTY_DRAFT: IssueDraft = {
|
||||
assigneeId: undefined,
|
||||
startDate: null,
|
||||
dueDate: null,
|
||||
attachments: [],
|
||||
};
|
||||
|
||||
interface IssueDraftStore {
|
||||
@@ -65,6 +67,18 @@ export const useIssueDraftStore = create<IssueDraftStore>()(
|
||||
{
|
||||
name: "multica_issue_draft",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
// Drafts persisted by older builds predate fields added later (e.g.
|
||||
// `attachments`). Backfill EMPTY_DRAFT defaults on rehydrate so every
|
||||
// read site can rely on the declared IssueDraft shape instead of
|
||||
// re-defending with `?? fallback`.
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = (persistedState ?? {}) as Partial<IssueDraftStore>;
|
||||
return {
|
||||
...currentState,
|
||||
...persisted,
|
||||
draft: { ...EMPTY_DRAFT, ...persisted.draft },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,6 +15,13 @@ export type IssueGrouping = "status" | "assignee";
|
||||
export type SwimlaneGrouping = "parent" | "project" | "assignee";
|
||||
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
|
||||
export type SortDirection = "asc" | "desc";
|
||||
export type IssueDateField = "created_at" | "updated_at";
|
||||
|
||||
export interface IssueDateFilter {
|
||||
field: IssueDateField;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const SWIMLANE_GROUPINGS: SwimlaneGrouping[] = ["parent", "project", "assignee"];
|
||||
|
||||
@@ -70,6 +77,7 @@ export interface IssueViewState {
|
||||
projectFilters: string[];
|
||||
includeNoProject: boolean;
|
||||
labelFilters: string[];
|
||||
dateFilter: IssueDateFilter | null;
|
||||
// When true, the list only shows issues that currently have at least one
|
||||
// agent task in `running` status. Drives the workspace "agents working"
|
||||
// quick filter chip in the issues header. Not persisted across reloads —
|
||||
@@ -103,6 +111,7 @@ export interface IssueViewState {
|
||||
toggleProjectFilter: (projectId: string) => void;
|
||||
toggleNoProject: () => void;
|
||||
toggleLabelFilter: (labelId: string) => void;
|
||||
setDateFilter: (filter: IssueDateFilter | null) => void;
|
||||
toggleAgentRunningFilter: () => void;
|
||||
hideStatus: (status: IssueStatus) => void;
|
||||
showStatus: (status: IssueStatus) => void;
|
||||
@@ -129,6 +138,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
labelFilters: [],
|
||||
dateFilter: null,
|
||||
agentRunningFilter: false,
|
||||
sortBy: "position",
|
||||
sortDirection: "asc",
|
||||
@@ -208,6 +218,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
? state.labelFilters.filter((id) => id !== labelId)
|
||||
: [...state.labelFilters, labelId],
|
||||
})),
|
||||
setDateFilter: (filter) => set({ dateFilter: filter }),
|
||||
toggleAgentRunningFilter: () =>
|
||||
set((state) => ({ agentRunningFilter: !state.agentRunningFilter })),
|
||||
hideStatus: (status) =>
|
||||
@@ -236,6 +247,7 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
|
||||
projectFilters: [],
|
||||
includeNoProject: false,
|
||||
labelFilters: [],
|
||||
dateFilter: null,
|
||||
agentRunningFilter: false,
|
||||
}),
|
||||
setSortBy: (field) => set({ sortBy: field }),
|
||||
@@ -279,6 +291,8 @@ export const viewStorePersistOptions = (name: string) => ({
|
||||
// state changes second-to-second, and a stored toggle would let users
|
||||
// return to an unexplained empty list. Keep it ephemeral. See the
|
||||
// field comment on IssueViewState.
|
||||
// `dateFilter` is also intentionally not persisted: relative presets such
|
||||
// as Today would otherwise become stale after a calendar-day rollover.
|
||||
viewMode: state.viewMode,
|
||||
grouping: state.grouping,
|
||||
statusFilters: state.statusFilters,
|
||||
|
||||
@@ -103,7 +103,9 @@
|
||||
"./i18n/react": "./i18n/react.ts",
|
||||
"./i18n/browser": "./i18n/browser.ts",
|
||||
"./skills": "./skills/index.ts",
|
||||
"./skills/frontmatter": "./skills/frontmatter.ts"
|
||||
"./skills/frontmatter": "./skills/frontmatter.ts",
|
||||
"./skills/stores": "./skills/stores/index.ts",
|
||||
"./autopilots/stores": "./autopilots/stores/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "catalog:",
|
||||
|
||||
@@ -49,7 +49,13 @@ export function AuthInitializer({
|
||||
api
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
if (cfg.cdn_domain) configStore.getState().setCdnDomain(cfg.cdn_domain);
|
||||
if (cfg.cdn_domain) {
|
||||
configStore.getState().setCdnConfig({
|
||||
cdnDomain: cfg.cdn_domain,
|
||||
// Old servers omit this — false keeps the previous behavior.
|
||||
cdnSigned: cfg.cdn_signed === true,
|
||||
});
|
||||
}
|
||||
configStore.getState().setAuthConfig({
|
||||
allowSignup: cfg.allow_signup,
|
||||
googleClientId: cfg.google_client_id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { ApiClient } from "../api/client";
|
||||
import { installFreezeWatchdog } from "../diagnostics/freeze-watchdog";
|
||||
import { setApiInstance, setSchemaLogger } from "../api";
|
||||
import { createAuthStore, registerAuthStore } from "../auth";
|
||||
import { createChatStore, registerChatStore } from "../chat";
|
||||
@@ -80,6 +81,12 @@ export function CoreProvider({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
useMemo(() => initCore(apiBaseUrl, storage, onLogin, onLogout, cookieAuth, identity), []);
|
||||
|
||||
// Client-only freeze watchdog — shared by web and desktop. No-op on the
|
||||
// server and idempotent, so mounting it here covers both apps in one place.
|
||||
useEffect(() => {
|
||||
installFreezeWatchdog();
|
||||
}, []);
|
||||
|
||||
// I18nProvider wraps everything else: server and client must use the same
|
||||
// (locale, resources) to avoid hydration mismatch. Language switching goes
|
||||
// through window.location.reload(), never client-side changeLanguage.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
export { projectKeys, projectListOptions, projectDetailOptions } from "./queries";
|
||||
export { useCreateProject, useUpdateProject, useDeleteProject } from "./mutations";
|
||||
export { useProjectDraftStore } from "./draft-store";
|
||||
export { useProjectViewStore } from "./stores/view-store";
|
||||
export {
|
||||
useProjectViewStore,
|
||||
PROJECT_SORT_DEFAULT_DIRECTION,
|
||||
PROJECT_DEFAULT_HIDDEN_COLUMNS,
|
||||
EMPTY_PROJECT_FILTERS,
|
||||
type ProjectViewMode,
|
||||
type ProjectSortField,
|
||||
type ProjectSortDirection,
|
||||
type ProjectColumnKey,
|
||||
type ProjectListFilters,
|
||||
} from "./stores/view-store";
|
||||
export {
|
||||
projectResourceKeys,
|
||||
projectResourcesOptions,
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("useProjectViewStore", () => {
|
||||
expect(useProjectViewStore.getState().viewMode).toBe("comfortable");
|
||||
});
|
||||
|
||||
it("partialize persists only viewMode under the workspace-namespaced key", async () => {
|
||||
it("partialize persists view prefs (no actions) under the workspace-namespaced key", async () => {
|
||||
setCurrentWorkspace("acme", "ws_a");
|
||||
await flush();
|
||||
useProjectViewStore.getState().setViewMode("comfortable");
|
||||
@@ -52,7 +52,14 @@ describe("useProjectViewStore", () => {
|
||||
const raw = localStorage.getItem("multica_projects_view:acme");
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(parsed.state).toEqual({ viewMode: "comfortable" });
|
||||
expect(Object.keys(parsed.state).sort()).toEqual([
|
||||
"filters",
|
||||
"hiddenColumns",
|
||||
"sortDirection",
|
||||
"sortField",
|
||||
"viewMode",
|
||||
]);
|
||||
expect(parsed.state.viewMode).toBe("comfortable");
|
||||
});
|
||||
|
||||
it("rehydrates a different saved viewMode on workspace switch", async () => {
|
||||
|
||||
@@ -5,29 +5,139 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Projects is the one dual-view list: a dense table (compact) and a card
|
||||
// grid (comfortable), toggled by viewMode. Sort + filters feed both views;
|
||||
// hiddenColumns only applies to the table. No scope (lead is optional and
|
||||
// often an agent, so there's no strong personal axis; status is a 5-value
|
||||
// lifecycle better expressed as a filter). Search stays session-local.
|
||||
export type ProjectViewMode = "compact" | "comfortable";
|
||||
|
||||
export type ProjectSortField = "name" | "priority" | "status" | "progress" | "created";
|
||||
|
||||
export type ProjectSortDirection = "asc" | "desc";
|
||||
|
||||
export const PROJECT_SORT_DEFAULT_DIRECTION: Record<
|
||||
ProjectSortField,
|
||||
ProjectSortDirection
|
||||
> = {
|
||||
name: "asc",
|
||||
priority: "desc",
|
||||
status: "asc",
|
||||
progress: "desc",
|
||||
created: "desc",
|
||||
};
|
||||
|
||||
/** Multi-select filters. Empty array per dimension = inactive. */
|
||||
export interface ProjectListFilters {
|
||||
/** ProjectStatus values. */
|
||||
statuses: string[];
|
||||
/** ProjectPriority values. */
|
||||
priorities: string[];
|
||||
/** Composite "type:id" lead refs (member or agent). */
|
||||
leads: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_PROJECT_FILTERS: ProjectListFilters = {
|
||||
statuses: [],
|
||||
priorities: [],
|
||||
leads: [],
|
||||
};
|
||||
|
||||
// Hideable table columns. Name + status are the always-visible core (status
|
||||
// is the project's defining lifecycle field), so they're not in this set.
|
||||
export type ProjectColumnKey = "priority" | "progress" | "lead" | "issues" | "created";
|
||||
|
||||
/** Issues count is opt-in; the rest show by default (matching the prior
|
||||
* compact table). */
|
||||
export const PROJECT_DEFAULT_HIDDEN_COLUMNS: ProjectColumnKey[] = ["issues"];
|
||||
|
||||
export interface ProjectViewState {
|
||||
viewMode: ProjectViewMode;
|
||||
sortField: ProjectSortField;
|
||||
sortDirection: ProjectSortDirection;
|
||||
hiddenColumns: ProjectColumnKey[];
|
||||
filters: ProjectListFilters;
|
||||
setViewMode: (mode: ProjectViewMode) => void;
|
||||
toggleSort: (field: ProjectSortField) => void;
|
||||
setSortField: (field: ProjectSortField) => void;
|
||||
setSortDirection: (direction: ProjectSortDirection) => void;
|
||||
toggleColumn: (key: ProjectColumnKey) => void;
|
||||
toggleFilter: (key: keyof ProjectListFilters, value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
viewMode: "compact" as ProjectViewMode,
|
||||
sortField: "created" as ProjectSortField,
|
||||
sortDirection: PROJECT_SORT_DEFAULT_DIRECTION.created,
|
||||
hiddenColumns: PROJECT_DEFAULT_HIDDEN_COLUMNS,
|
||||
filters: EMPTY_PROJECT_FILTERS,
|
||||
};
|
||||
|
||||
export const useProjectViewStore = create<ProjectViewState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
viewMode: "compact",
|
||||
...DEFAULTS,
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
toggleSort: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? { sortDirection: state.sortDirection === "asc" ? "desc" : "asc" }
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: PROJECT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortField: (field) =>
|
||||
set((state) =>
|
||||
state.sortField === field
|
||||
? {}
|
||||
: {
|
||||
sortField: field,
|
||||
sortDirection: PROJECT_SORT_DEFAULT_DIRECTION[field],
|
||||
},
|
||||
),
|
||||
setSortDirection: (direction) => set({ sortDirection: direction }),
|
||||
toggleColumn: (key) =>
|
||||
set((state) => ({
|
||||
hiddenColumns: state.hiddenColumns.includes(key)
|
||||
? state.hiddenColumns.filter((k) => k !== key)
|
||||
: [...state.hiddenColumns, key],
|
||||
})),
|
||||
toggleFilter: (key, value) =>
|
||||
set((state) => {
|
||||
const list = state.filters[key] as string[];
|
||||
const next = list.includes(value)
|
||||
? list.filter((v) => v !== value)
|
||||
: [...list, value];
|
||||
return { filters: { ...state.filters, [key]: next } };
|
||||
}),
|
||||
clearFilters: () => set({ filters: EMPTY_PROJECT_FILTERS }),
|
||||
}),
|
||||
{
|
||||
name: "multica_projects_view",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
partialize: (state) => ({ viewMode: state.viewMode }),
|
||||
partialize: (state) => ({
|
||||
viewMode: state.viewMode,
|
||||
sortField: state.sortField,
|
||||
sortDirection: state.sortDirection,
|
||||
hiddenColumns: state.hiddenColumns,
|
||||
filters: state.filters,
|
||||
}),
|
||||
// Deep-merge filters so a payload persisted before a filter dimension
|
||||
// existed still gets that key's default (avoids `.length` on
|
||||
// undefined). Same hardening as the other view stores.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, viewMode: "compact" };
|
||||
return { ...current, ...(persisted as Partial<ProjectViewState>) };
|
||||
if (!persisted) return { ...current, ...DEFAULTS };
|
||||
const p = persisted as Partial<ProjectViewState>;
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
filters: { ...EMPTY_PROJECT_FILTERS, ...(p.filters ?? {}) },
|
||||
};
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());
|
||||
registerForWorkspaceRehydration(() => useProjectViewStore.persist.rehydrate());
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
applyChatDoneToCache,
|
||||
applyWorkspaceUpdatedToCache,
|
||||
handleInboxNew,
|
||||
invalidateChatMessageQueries,
|
||||
resolveInboxSourceSlug,
|
||||
} from "./use-realtime-sync";
|
||||
|
||||
@@ -134,6 +135,18 @@ describe("applyChatDoneToCache", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidateChatMessageQueries", () => {
|
||||
it("invalidates both legacy and paged chat message caches", () => {
|
||||
const qc = createQueryClient();
|
||||
const invalidate = vi.spyOn(qc, "invalidateQueries");
|
||||
|
||||
invalidateChatMessageQueries(qc, sessionId);
|
||||
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messages(sessionId) });
|
||||
expect(invalidate).toHaveBeenCalledWith({ queryKey: chatKeys.messagesPage(sessionId) });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyWorkspaceUpdatedToCache", () => {
|
||||
const wsId = "ws-1";
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user