mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
2 Commits
template-r
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47977212c | ||
|
|
b0b667b907 |
@@ -101,13 +101,6 @@ ALLOWED_ORIGINS=
|
||||
# `Authorization: Bearer <token>`.
|
||||
# REALTIME_METRICS_TOKEN=
|
||||
|
||||
# GitHub App integration (Settings → Integrations "Connect GitHub")
|
||||
# Both must be set for the Connect button to enable and for webhooks to be
|
||||
# accepted; leave empty to disable the integration. See docs/github-integration.
|
||||
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
|
||||
GITHUB_APP_SLUG=
|
||||
GITHUB_WEBHOOK_SECRET=
|
||||
|
||||
# Frontend
|
||||
FRONTEND_PORT=3000
|
||||
FRONTEND_ORIGIN=http://localhost:3000
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -58,6 +58,3 @@ server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
@@ -20,7 +20,7 @@ Turn coding agents into real teammates — assign tasks, track progress, compoun
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
[Website](https://multica.ai) · [Cloud](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [Self-Hosting](SELF_HOSTING.md) · [Contributing](CONTRIBUTING.md)
|
||||
|
||||
**English | [简体中文](README.zh-CN.md)**
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
[](https://github.com/multica-ai/multica/actions/workflows/ci.yml)
|
||||
[](https://github.com/multica-ai/multica/stargazers)
|
||||
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
[官网](https://multica.ai) · [云服务](https://multica.ai/app) · [X](https://x.com/MulticaAI) · [自部署指南](SELF_HOSTING.md) · [参与贡献](CONTRIBUTING.md)
|
||||
|
||||
**[English](README.md) | 简体中文**
|
||||
|
||||
|
||||
@@ -186,47 +186,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -32,34 +32,6 @@ mac:
|
||||
dmg:
|
||||
artifactName: multica-desktop-${version}-mac-${arch}.${ext}
|
||||
linux:
|
||||
# Override the Linux executable name to avoid leaking the scoped npm
|
||||
# package name (`@multica/desktop`) into the installed binary, the
|
||||
# `.desktop` file, and the hicolor icon filename. Without this override
|
||||
# electron-builder defaults `executableName` to the package `name`,
|
||||
# which after slash-stripping becomes `@multicadesktop` — producing
|
||||
# `/usr/share/applications/@multicadesktop.desktop`,
|
||||
# `Icon=@multicadesktop`, and
|
||||
# `/usr/share/icons/hicolor/*/apps/@multicadesktop.png`. The leading `@`
|
||||
# violates the freedesktop desktop-entry naming guidance, so GNOME /
|
||||
# Ubuntu fail to associate the running window with the `.desktop` entry
|
||||
# and fall back to the theme's default app icon (the Settings gear on
|
||||
# Yaru). Forcing `multica` makes every Linux identity slot agree and
|
||||
# matches `StartupWMClass=Multica` (productName-derived).
|
||||
executableName: multica
|
||||
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
|
||||
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
|
||||
# builds resolves to `productName` (`Multica`). Without an explicit
|
||||
# `StartupWMClass`, electron-builder writes `productName` as the default
|
||||
# — making this declaration redundant with current settings — but
|
||||
# pinning the value here turns a silent future drift (e.g. if anyone
|
||||
# renames productName or sets app.setName at boot) into a visible diff
|
||||
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
|
||||
# GNOME associate the running window with the `.desktop` entry and
|
||||
# therefore render the right icon. The post-build verification step in
|
||||
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -10,11 +10,10 @@ export default [
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
// Security: every renderer-controlled URL that reaches the OS shell or the
|
||||
// native download system must flow through the safe wrappers in
|
||||
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
|
||||
// direct shell.openExternal / webContents.downloadURL calls cannot silently
|
||||
// regress the protection.
|
||||
// Security: every renderer-controlled URL that reaches the OS shell must
|
||||
// flow through openExternalSafely in src/main/external-url.ts (scheme
|
||||
// allowlist). Enforce it statically so a direct shell.openExternal call
|
||||
// cannot silently regress the protection.
|
||||
{
|
||||
files: ["src/main/**/*.ts"],
|
||||
rules: {
|
||||
@@ -26,12 +25,6 @@ export default [
|
||||
message:
|
||||
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
|
||||
message:
|
||||
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shell, type BrowserWindow } from "electron";
|
||||
import { shell } from "electron";
|
||||
|
||||
// True when the URL parses and uses http/https — the only schemes we let
|
||||
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
|
||||
@@ -19,19 +19,6 @@ export function openExternalSafely(url: string): Promise<void> | void {
|
||||
return shell.openExternal(url);
|
||||
}
|
||||
|
||||
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
|
||||
// URLs that trigger a native download MUST flow through here; direct calls
|
||||
// to `webContents.downloadURL` elsewhere in the main process are banned by
|
||||
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
|
||||
// Reuses the same http/https allowlist as openExternalSafely.
|
||||
export function downloadURLSafely(win: BrowserWindow, url: string): void {
|
||||
if (getHttpProtocol(url) === null) {
|
||||
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
|
||||
return;
|
||||
}
|
||||
win.webContents.downloadURL(url);
|
||||
}
|
||||
|
||||
function getHttpProtocol(url: string): "http:" | "https:" | null {
|
||||
try {
|
||||
const { protocol } = new URL(url);
|
||||
|
||||
@@ -5,31 +5,16 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
import type { RuntimeConfigResult } from "../shared/runtime-config";
|
||||
|
||||
// Bundled icon used for dock/taskbar branding. macOS/Windows production
|
||||
// builds let the OS pick up the icon from the .app bundle / .exe resources,
|
||||
// but Linux production needs an explicit BrowserWindow `icon` — AppImage
|
||||
// direct-launch doesn't register the .desktop entry, so GNOME has no path
|
||||
// from the running window to the hicolor icon and falls back to the
|
||||
// theme default. Consumed in createWindow() (all platforms in dev, Linux
|
||||
// in prod) and the macOS dev dock branch.
|
||||
//
|
||||
// `asarUnpack: resources/**` in electron-builder.yml extracts the icon to
|
||||
// `app.asar.unpacked/`, but `__dirname` resolves into `app.asar/`. The
|
||||
// Linux native window-icon code path expects a real filesystem path
|
||||
// (unlike Electron's nativeImage loader which transparently reads from
|
||||
// asar), so swap the segment — same pattern as bundledCliPath() in
|
||||
// daemon-manager.ts. In dev `__dirname` has no `app.asar`, so the replace
|
||||
// is a no-op.
|
||||
const BUNDLED_ICON_PATH = join(__dirname, "../../resources/icon.png").replace(
|
||||
"app.asar",
|
||||
"app.asar.unpacked",
|
||||
);
|
||||
// Bundled icon used for dev-mode dock/taskbar branding. In production the
|
||||
// app bundle icon (from electron-builder) wins; this path is only consumed
|
||||
// by the `is.dev` branch below.
|
||||
const DEV_ICON_PATH = join(__dirname, "../../resources/icon.png");
|
||||
|
||||
// macOS/Linux GUI launches inherit a minimal PATH from launchd that omits
|
||||
// the user's shell config (~/.zshrc, Homebrew, nvm, ~/.local/bin, etc.).
|
||||
@@ -121,14 +106,9 @@ function createWindow(): void {
|
||||
trafficLightPosition: { x: 16, y: 13 },
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
// Windows/Linux pick up the window/taskbar icon from this option.
|
||||
// On macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
// Linux production needs this explicitly because AppImage direct-launch
|
||||
// does not install a .desktop entry, so the WM has no other path to
|
||||
// the bundled icon; without it Ubuntu falls back to the theme default.
|
||||
...(is.dev || process.platform === "linux"
|
||||
? { icon: BUNDLED_ICON_PATH }
|
||||
: {}),
|
||||
// Windows/Linux pick up the window/taskbar icon from this option in
|
||||
// dev — on macOS it's ignored (dock comes from app.dock.setIcon below).
|
||||
...(is.dev ? { icon: DEV_ICON_PATH } : {}),
|
||||
webPreferences: {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
@@ -271,7 +251,7 @@ if (!gotTheLock) {
|
||||
// so the Canary dev build is visually distinct from a stock Electron
|
||||
// run. `app.dock` is macOS-only — guard the call.
|
||||
if (is.dev && process.platform === "darwin" && app.dock) {
|
||||
const icon = nativeImage.createFromPath(BUNDLED_ICON_PATH);
|
||||
const icon = nativeImage.createFromPath(DEV_ICON_PATH);
|
||||
if (!icon.isEmpty()) app.dock.setIcon(icon);
|
||||
}
|
||||
|
||||
@@ -288,14 +268,6 @@ if (!gotTheLock) {
|
||||
return openExternalSafely(url);
|
||||
});
|
||||
|
||||
ipcMain.handle("file:download-url", (_event, url: string) => {
|
||||
if (!mainWindow) {
|
||||
console.warn("[download] ignored file:download-url — mainWindow torn down");
|
||||
return;
|
||||
}
|
||||
downloadURLSafely(mainWindow, url);
|
||||
});
|
||||
|
||||
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
|
||||
// preload can attach the values to `desktopAPI.appInfo` before any renderer
|
||||
// code reads them, ensuring the very first HTTP request from the renderer
|
||||
|
||||
3
apps/desktop/src/preload/index.d.ts
vendored
3
apps/desktop/src/preload/index.d.ts
vendored
@@ -19,9 +19,6 @@ interface DesktopAPI {
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Download a file by URL through Electron's native download system.
|
||||
* Shows a native save dialog. On non-desktop platforms this is undefined. */
|
||||
downloadURL: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
setImmersiveMode: (immersive: boolean) => Promise<void>;
|
||||
/** Show a native OS notification for a new inbox item. */
|
||||
|
||||
@@ -89,11 +89,6 @@ const desktopAPI = {
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Download a file by URL through Electron's native download system.
|
||||
* Shows a save dialog and saves to disk. Unlike openExternal, this
|
||||
* avoids browser rendering of HTML files on Linux.
|
||||
* On non-desktop platforms this property is undefined. */
|
||||
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
setImmersiveMode: (immersive: boolean) =>
|
||||
ipcRenderer.invoke("window:setImmersive", immersive),
|
||||
|
||||
@@ -14,7 +14,6 @@ import { AgentDetailPage } from "./pages/agent-detail-page";
|
||||
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
|
||||
import { IssuesPage } from "@multica/views/issues/components";
|
||||
import { ProjectsPage } from "@multica/views/projects/components";
|
||||
import { DashboardPage } from "@multica/views/dashboard";
|
||||
import { AutopilotsPage } from "@multica/views/autopilots/components";
|
||||
import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
@@ -147,11 +146,6 @@ export const appRoutes: RouteObject[] = [
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
handle: { title: "Usage" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
element: (
|
||||
|
||||
@@ -180,61 +180,6 @@ describe("useTabStore actions", () => {
|
||||
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
|
||||
});
|
||||
|
||||
it("defers disposing the closed tab router until after the store update", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
const closingTab = useTabStore
|
||||
.getState()
|
||||
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
|
||||
const dispose = vi.mocked(closingTab!.router.dispose);
|
||||
|
||||
store.closeTab(closedTabId);
|
||||
|
||||
expect(dispose).not.toHaveBeenCalled();
|
||||
expect(
|
||||
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
|
||||
).toBe(false);
|
||||
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(dispose).toHaveBeenCalledOnce();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores router-sync updates from a tab after it has been closed", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
|
||||
|
||||
store.closeTab(closedTabId);
|
||||
const before = useTabStore.getState().byWorkspace.acme;
|
||||
|
||||
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
|
||||
store.updateTabHistory(closedTabId, 1, 2);
|
||||
|
||||
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
|
||||
expect(
|
||||
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not replace the tab group for no-op router-sync updates", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
|
||||
const before = useTabStore.getState().byWorkspace.acme;
|
||||
|
||||
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
|
||||
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
|
||||
|
||||
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
|
||||
});
|
||||
|
||||
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
|
||||
const store = useTabStore.getState();
|
||||
store.switchWorkspace("acme");
|
||||
|
||||
@@ -350,10 +350,7 @@ export const useTabStore = create<TabStore>()(
|
||||
const { slug, group, index } = hit;
|
||||
|
||||
const closing = group.tabs[index];
|
||||
const disposeClosingRouter = () => {
|
||||
// Let React unmount the tab's RouterProvider before disposing it.
|
||||
window.setTimeout(() => closing.router.dispose(), 0);
|
||||
};
|
||||
closing.router.dispose();
|
||||
|
||||
if (group.tabs.length === 1) {
|
||||
// Last tab in this workspace — reseed a default so the workspace
|
||||
@@ -366,7 +363,6 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: [fresh], activeTabId: fresh.id },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -382,7 +378,6 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
},
|
||||
|
||||
setActiveTab(tabId) {
|
||||
@@ -407,13 +402,6 @@ export const useTabStore = create<TabStore>()(
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
const next: Tab = { ...current, ...patch };
|
||||
if (
|
||||
next.path === current.path &&
|
||||
next.title === current.title &&
|
||||
next.icon === current.icon
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
set({
|
||||
@@ -430,12 +418,6 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!hit) return;
|
||||
const { slug, group, index } = hit;
|
||||
const current = group.tabs[index];
|
||||
if (
|
||||
current.historyIndex === historyIndex &&
|
||||
current.historyLength === historyLength
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const next: Tab = { ...current, historyIndex, historyLength };
|
||||
const nextTabs = [...group.tabs];
|
||||
nextTabs[index] = next;
|
||||
|
||||
@@ -25,6 +25,10 @@ An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
@@ -25,6 +25,10 @@ Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
</Callout>
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
@@ -141,22 +141,6 @@ For a full explanation of how each parameter affects daemon behavior, see [Daemo
|
||||
**Leaving `FRONTEND_ORIGIN` unset creates two silent failures**: (1) invite email links point at `https://app.multica.ai` (the hosted domain), and clicking them doesn't bring users back to your self-hosted instance; (2) WebSocket Origin checks fall back to `localhost:3000 / 5173 / 5174`, so every WebSocket connection in a production deployment is rejected and the frontend appears to "lose real-time updates."
|
||||
</Callout>
|
||||
|
||||
## GitHub integration
|
||||
|
||||
The [GitHub PR ↔ issue integration](/github-integration) needs two variables. Set both to enable Connect GitHub in Settings and accept incoming webhooks.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | empty | The slug of your GitHub App (the tail of `https://github.com/apps/<slug>`). Drives the Settings → Integrations install button URL |
|
||||
| `GITHUB_WEBHOOK_SECRET` | empty | The Webhook secret you set on the GitHub App. Used for HMAC-SHA256 verification of every `pull_request` / `installation` delivery, and as the HMAC key for the setup-callback state token |
|
||||
|
||||
**Behavior when either is unset:**
|
||||
|
||||
- `Connect GitHub` in Settings → Integrations is **disabled** and shows a "not configured" hint to admins.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret rather than treating every signature as valid.
|
||||
|
||||
**Note:** `GITHUB_WEBHOOK_SECRET` is reused as the signing key for the install-flow state token, so operators only need to manage one secret. It is **not** the GitHub App's *Client* secret — Client secrets are OAuth-related and not used by this integration. See [GitHub integration → Self-host setup](/github-integration#self-host-setup) for the full walkthrough.
|
||||
|
||||
## Usage analytics
|
||||
|
||||
By default, the server reports to Multica's official PostHog instance. To opt out, set `ANALYTICS_DISABLED=true`.
|
||||
@@ -170,6 +154,5 @@ By default, the server reports to Multica's official PostHog instance. To opt ou
|
||||
## Next
|
||||
|
||||
- [Sign-in and signup configuration](/auth-setup) — how to actually configure the auth-related variables above and where the traps are
|
||||
- [GitHub integration](/github-integration) — how to set up the GitHub App that backs `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET`
|
||||
- [Troubleshooting](/troubleshooting) — symptoms and fixes for common misconfigurations
|
||||
- [Daemon and runtimes](/daemon-runtimes) — what the `MULTICA_DAEMON_*` parameters actually do
|
||||
|
||||
@@ -141,22 +141,6 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
**`FRONTEND_ORIGIN` 不设就有两个静默失败**:(1)邀请邮件里的链接指向 `https://app.multica.ai`(托管版的域名),用户点了跳不回你的 self-host 实例;(2)WebSocket 连接的 Origin 校验回落到 `localhost:3000 / 5173 / 5174`,生产部署的 WebSocket 全部被拒,前端看起来「实时更新不工作」。
|
||||
</Callout>
|
||||
|
||||
## GitHub 集成
|
||||
|
||||
[GitHub PR ↔ issue 集成](/github-integration) 依赖两个环境变量。两个都配上才会启用 Settings 里的 Connect GitHub 并接受 webhook。
|
||||
|
||||
| 环境变量 | 默认值 | 说明 |
|
||||
|---|---|---|
|
||||
| `GITHUB_APP_SLUG` | 空 | 你的 GitHub App slug(`https://github.com/apps/<slug>` 的尾部)。Settings → Integrations 里安装按钮的跳转 URL 用它拼 |
|
||||
| `GITHUB_WEBHOOK_SECRET` | 空 | 你在 GitHub App 上设置的 Webhook secret。每条 `pull_request` / `installation` delivery 都用它做 HMAC-SHA256 校验;同一个值也用作 setup 回调里 state token 的签名密钥 |
|
||||
|
||||
**任一变量未设时:**
|
||||
|
||||
- Settings → Integrations 里 `Connect GitHub` 按钮 **disable**,对 admin 显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——secret 没配置时 Multica 拒绝处理任何 webhook 事件,而不是把所有签名当 valid
|
||||
|
||||
**注意:** `GITHUB_WEBHOOK_SECRET` 同时被复用为 install 流程里 state token 的签名密钥,所以运维只需要维护一个 secret。它**不是** GitHub App 的 *Client* secret——Client secret 是 OAuth 用的,和本集成无关。完整配置流程见 [GitHub 集成 → Self-Host 配置](/github-integration#self-host-配置)。
|
||||
|
||||
## 用量统计
|
||||
|
||||
默认上报到 Multica 官方 PostHog 实例。不想上报就把 `ANALYTICS_DISABLED=true`。
|
||||
@@ -170,6 +154,5 @@ Multica 存储用户上传的附件(评论里的图片、文件等)。**优
|
||||
## 下一步
|
||||
|
||||
- [登录与注册配置](/auth-setup) —— 上面 auth 相关的那几个环境变量怎么真的配、陷阱在哪
|
||||
- [GitHub 集成](/github-integration) —— `GITHUB_APP_SLUG` / `GITHUB_WEBHOOK_SECRET` 背后的 GitHub App 怎么建
|
||||
- [故障排查](/troubleshooting) —— 配错了常见的症状和修复
|
||||
- [守护进程与运行时](/daemon-runtimes) —— `MULTICA_DAEMON_*` 参数的行为含义
|
||||
|
||||
@@ -337,47 +337,16 @@ In production, put a reverse proxy in front of both the backend and frontend to
|
||||
|
||||
### Caddy (Recommended)
|
||||
|
||||
**Single-domain layout** — frontend and backend served on the same hostname (this is what `docker-compose.selfhost.yml` defaults to):
|
||||
|
||||
```
|
||||
multica.example.com {
|
||||
# WebSocket route — must come before the catch-all
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
# Everything else → frontend
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
```
|
||||
|
||||
**Separate-domain layout** — frontend and backend on different hostnames:
|
||||
|
||||
```
|
||||
app.example.com {
|
||||
reverse_proxy localhost:3000
|
||||
}
|
||||
|
||||
api.example.com {
|
||||
@multica_ws path /ws /ws/*
|
||||
handle @multica_ws {
|
||||
reverse_proxy localhost:8080 {
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
|
||||
reverse_proxy localhost:8080
|
||||
}
|
||||
```
|
||||
|
||||
Two non-obvious bits inside the `/ws` block are worth calling out — both are common reasons real-time updates "stop working" on a Caddy-fronted self-host:
|
||||
|
||||
- **`path /ws /ws/*` (not `/ws*`)** — bare `handle /ws` is an exact match, so future path variants under `/ws/` fall through to the frontend block. The obvious shortcut `handle /ws*` overcorrects in the other direction: Caddy's `*` is a glob without a path-segment boundary, so it would also catch unrelated paths like `/ws-foo`, which is a legitimate workspace URL (only the exact slug `ws` is reserved). Listing `/ws` and `/ws/*` explicitly covers both real cases without overreach.
|
||||
- **`flush_interval -1`** — disables response buffering so WebSocket frames are forwarded as soon as they arrive. Without it, frames can sit behind Caddy's default flush window, which looks like delayed comments, missing typing indicators, or "comments only appear after a page refresh."
|
||||
|
||||
### Nginx
|
||||
|
||||
```nginx
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: GitHub integration
|
||||
description: Connect a GitHub App once, then PRs whose branch, title, or body reference an issue identifier auto-attach to that issue — and merging the PR moves the issue to Done.
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
Connect a GitHub account or organization once in **Settings → Integrations**. After that, any pull request whose branch name, title, or body contains an issue identifier (for example `MUL-123`) is **auto-linked** to that [issue](/issues), appears under **Pull requests** in the issue sidebar, and — when the PR is merged — moves the issue to **Done**.
|
||||
|
||||
There is no per-issue setup. The whole flow is identifier-driven.
|
||||
|
||||
## What the integration does
|
||||
|
||||
| Surface | Behavior |
|
||||
|---|---|
|
||||
| **Settings → Integrations** | Workspace admins see a GitHub card with a **Connect GitHub** button. Clicking it opens GitHub's App install page; after install you bounce back to Settings. |
|
||||
| **Issue sidebar → Pull requests** | Every PR auto-linked to this issue, with title, repo, state (`Open` / `Draft` / `Merged` / `Closed`), and author. Click a row to jump to the PR on GitHub. |
|
||||
| **Webhook (background)** | On every `pull_request` event, Multica upserts the PR row, scans the PR for issue identifiers, and (re)builds the link rows. Idempotent — replaying a delivery is a no-op. |
|
||||
| **Auto-status on merge** | When a PR transitions to `merged`, every linked issue not already `Done` or `Cancelled` is moved to `Done`. The status change is timeline-logged with source `github_pr_merged`. |
|
||||
|
||||
Only the PR itself is mirrored. Commits, branch refs without an open PR, and CI check states are **not** modeled. The integration is intentionally narrow.
|
||||
|
||||
## How identifiers are matched
|
||||
|
||||
The webhook extracts identifiers from three fields, in this order: **PR head branch**, **PR title**, **PR body**. The matcher is:
|
||||
|
||||
- Case-insensitive — `mul-123`, `MUL-123`, `Mul-123` all match.
|
||||
- Bounded — a `\b` on the left and a digit anchor on the right keep it from grabbing version numbers like `v1.2-3` or email-style strings.
|
||||
- Workspace-scoped — only matches the workspace's own [issue prefix](/workspaces). `FOO-1` in a workspace whose prefix is `MUL` is ignored, even if the integer matches another issue.
|
||||
- Deduplicated — listing `MUL-1, MUL-1` in the body links the issue once.
|
||||
|
||||
You can reference **multiple issues** in one PR. `Closes MUL-1, MUL-2` links the PR to both, and merging it advances both to `Done`.
|
||||
|
||||
## The auto-merge-to-Done rule
|
||||
|
||||
When a PR's `merged` field flips to `true`, every linked issue is evaluated:
|
||||
|
||||
| Issue current status | Result |
|
||||
|---|---|
|
||||
| `done` | No change (already terminal). |
|
||||
| `cancelled` | **No change** — cancelled means the user explicitly abandoned the work; the integration does not override that signal. |
|
||||
| Anything else (`todo`, `in_progress`, `in_review`, `blocked`, `backlog`) | Moved to `done`. |
|
||||
|
||||
Closing a PR **without** merging it only updates the PR card's state to `Closed`. The linked issues stay where they were — the user is the one who decides what closing-without-merge means.
|
||||
|
||||
<Callout type="info">
|
||||
The action is attributed to the `system` actor on the timeline. Subscribers of the issue receive an inbox notification for the status change, the same way they would if a human had moved it.
|
||||
</Callout>
|
||||
|
||||
## What's not auto-linked
|
||||
|
||||
- **Identifiers in commit messages** — only branch / title / body are scanned. A commit titled `MUL-123: fix login` does not auto-link unless the same string also appears in the PR title or body.
|
||||
- **Identifiers in PR comments** — only the PR's own metadata is scanned; later GitHub comments are ignored.
|
||||
- **PRs in repos the App isn't installed on** — without the App, Multica never receives the webhook.
|
||||
- **Manually linking a PR to an issue** — there is no UI for this yet. If your team's convention puts identifiers in a place Multica isn't reading, add them to the PR title or body.
|
||||
|
||||
## Disconnecting
|
||||
|
||||
In **Settings → Integrations** there is no installation list — you manage existing installations from GitHub directly:
|
||||
|
||||
- **From GitHub** — uninstall the Multica GitHub App at `https://github.com/settings/installations` (personal) or `https://github.com/organizations/<org>/settings/installations` (org). Multica receives the `installation.deleted` webhook and drops the row in real time; any open Settings tab updates without a refresh.
|
||||
- **Disconnect from inside Multica is admin-only** — the Settings card is hidden for non-admins.
|
||||
|
||||
After disconnect, mirrored PR rows stay in the database so historical issue sidebars still show what was linked, but no new webhook events from that installation will be accepted.
|
||||
|
||||
## Permissions and visibility
|
||||
|
||||
- **Connect / disconnect** require workspace **owner or admin**. Members see the card description but no Connect button.
|
||||
- The **Pull requests** sidebar on an issue is visible to anyone who can read the issue — same permissions as the rest of issue detail.
|
||||
- The GitHub App requests **read-only** access to pull requests and metadata. Multica never pushes commits, comments, or status checks back to GitHub.
|
||||
|
||||
## Self-host setup
|
||||
|
||||
If you're running Multica on Multica Cloud, the integration is already configured — skip this section.
|
||||
|
||||
For self-host, you create one GitHub App, point it at your server, and set two environment variables. The whole flow is below.
|
||||
|
||||
### 1. Create a GitHub App
|
||||
|
||||
Go to one of:
|
||||
|
||||
- Personal account → `https://github.com/settings/apps/new`
|
||||
- Organization → `https://github.com/organizations/<org>/settings/apps/new`
|
||||
|
||||
Fill in:
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **GitHub App name** | Anything recognizable, e.g. `Multica` or `Multica (staging)`. |
|
||||
| **Homepage URL** | Your Multica frontend, e.g. `https://multica.example.com`. |
|
||||
| **Callback URL** | Leave blank — Multica doesn't use OAuth user identity. |
|
||||
| **Setup URL** | `https://<api-host>/api/github/setup`. **Check "Redirect on update"**. |
|
||||
| **Webhook → Active** | Enabled. |
|
||||
| **Webhook URL** | `https://<api-host>/api/webhooks/github`. |
|
||||
| **Webhook secret** | Generate a long random string (e.g. `openssl rand -hex 32`). You'll paste the same value into Multica's env in step 2. |
|
||||
| **Permissions → Repository → Pull requests** | **Read-only**. |
|
||||
| **Permissions → Repository → Metadata** | Read-only (mandatory). |
|
||||
| **Subscribe to events** | Tick **Pull request**. |
|
||||
| **Where can this GitHub App be installed?** | Your choice. `Only on this account` is fine for single-org setups. |
|
||||
|
||||
After **Create GitHub App**, note two things from the App's detail page:
|
||||
|
||||
- The **public link** at the top — its tail is the slug. `https://github.com/apps/multica-acme` → slug = `multica-acme`.
|
||||
- The **webhook secret** you just generated (you can't read it back from GitHub later — save it now).
|
||||
|
||||
<Callout type="warning">
|
||||
**Webhook secret ≠ Client secret.** The App settings page has both fields stacked together. The **Webhook secret** is what signs `pull_request` payloads — that's the one Multica needs. The **Client secret** is for OAuth and is not used by this integration. Mixing them up produces a confusing `401 invalid signature` on every webhook delivery.
|
||||
</Callout>
|
||||
|
||||
### 2. Set environment variables
|
||||
|
||||
On the API server:
|
||||
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
```
|
||||
|
||||
Both variables are required. If either is missing:
|
||||
|
||||
- `Connect GitHub` in Settings is **disabled** and shows a "not configured" hint.
|
||||
- The `/api/webhooks/github` endpoint returns **`503 github webhooks not configured`** — Multica refuses to process events with no secret, rather than silently treating every signature as valid.
|
||||
|
||||
`FRONTEND_ORIGIN` must also be set (it already is for any production self-host); the setup callback bounces the user back to `<FRONTEND_ORIGIN>/settings` after install.
|
||||
|
||||
Restart the API after setting the env vars.
|
||||
|
||||
### 3. Run migrations
|
||||
|
||||
The integration ships its tables in migration `079_github_integration`. If you're upgrading an older deployment:
|
||||
|
||||
```bash
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
Three tables get created: `github_installation`, `github_pull_request`, `issue_pull_request`. They cascade-delete with their workspace, so removing a workspace cleans them up automatically.
|
||||
|
||||
### 4. Connect from the UI
|
||||
|
||||
In Multica:
|
||||
|
||||
1. Open **Settings → Integrations** as an owner or admin.
|
||||
2. Click **Connect GitHub**. GitHub opens in a new tab.
|
||||
3. Pick the repositories to grant access to and **Install**.
|
||||
4. GitHub redirects back to `<api-host>/api/github/setup`, which records the installation and bounces you to `<FRONTEND_ORIGIN>/settings?github_connected=1`.
|
||||
|
||||
After that, open any PR whose branch / title / body contains an issue identifier — within a few seconds the Pull requests block appears on that issue's detail page.
|
||||
|
||||
### 5. Verify with a curl probe
|
||||
|
||||
If GitHub's **Recent Deliveries** page reports `401 invalid signature` after install, the two sides have different secrets. The fastest way to find out which side is wrong is to bypass GitHub:
|
||||
|
||||
```bash
|
||||
SECRET="<the value you put in GITHUB_WEBHOOK_SECRET>"
|
||||
BODY='{"zen":"test"}'
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
||||
curl -i -X POST https://<api-host>/api/webhooks/github \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-H "X-GitHub-Event: ping" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
| HTTP status | Meaning | Fix |
|
||||
|---|---|---|
|
||||
| `200` `{"ok":"pong"}` | Server's loaded secret matches your `$SECRET`. The mismatch is on GitHub. | Edit the App → Webhook secret → **paste the same value** → **Save changes** (clicking out of the field without Save keeps the old secret). Redeliver. |
|
||||
| `401 invalid signature` | Server's loaded secret is **not** what you think it is. | Confirm the env var landed in the running process (e.g. `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" | wc -c`). Re-deploy. |
|
||||
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` is empty in the process. | Set the env var, restart the API. |
|
||||
|
||||
## Limitations
|
||||
|
||||
A few rough edges to be aware of today:
|
||||
|
||||
- **No manual link UI yet** — the only way to link a PR is to have the identifier in its branch, title, or body.
|
||||
- **No CI / check state** — only the PR itself is mirrored. Build status, review comments, and reviewers are not surfaced in Multica.
|
||||
- **No workspace-level config** for the merge → Done rule — it's a fixed default (`merged → done`, unless `cancelled`). Workspace-customizable mappings are a future addition.
|
||||
- **Multi-PR-to-one-issue is conservative on merge** — if two PRs both reference `MUL-123` and the first one merges, the issue is moved to `Done` immediately. A follow-up change to wait for all linked PRs to resolve before advancing is in progress.
|
||||
|
||||
## Next
|
||||
|
||||
- [Issues](/issues) — the issue identifiers (`MUL-123`) referenced from PRs
|
||||
- [Workspaces](/workspaces) — where the workspace-specific issue prefix is set
|
||||
- [Environment variables](/environment-variables) — full env reference, including the GitHub variables above
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
title: GitHub 集成
|
||||
description: 一次性连接 GitHub App,之后 PR 的分支名、标题或正文里写了 issue 编号(例如 MUL-123),就会自动挂到那个 issue 上——PR 合并时 issue 自动转 Done。
|
||||
---
|
||||
|
||||
import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
在 **Settings → Integrations** 里一次性连一个 GitHub 账号或组织。之后任何 PR 只要分支名、标题或正文里出现 issue 编号(例如 `MUL-123`),就会**自动关联**到那个 [issue](/issues),出现在 issue 详情页右侧的 **Pull requests** 区块里——PR 合并时,issue 自动转 **Done**。
|
||||
|
||||
没有 per-issue 的配置,整个流程是「编号驱动」的。
|
||||
|
||||
## 集成做了什么
|
||||
|
||||
| 出现位置 | 行为 |
|
||||
|---|---|
|
||||
| **Settings → Integrations** | 工作区 owner / admin 看到一个 GitHub 卡片,里面有 **Connect GitHub** 按钮。点击会打开 GitHub 的 App 安装页;装好后跳回 Settings。 |
|
||||
| **Issue 详情侧栏 → Pull requests** | 列出所有自动关联到该 issue 的 PR,含标题、仓库、状态(`Open` / `Draft` / `Merged` / `Closed`)和作者。点一行跳到 GitHub。 |
|
||||
| **Webhook(后台)** | 每次 `pull_request` 事件触发:upsert PR 行 → 扫描里面的 issue 编号 →(重新)建立 link。幂等——重投 delivery 不会产生重复记录。 |
|
||||
| **Merge 自动改 status** | PR 转 `merged` 时,所有已关联且状态不是 `Done` / `Cancelled` 的 issue 会被推到 `Done`。时间线里以 source 为 `github_pr_merged` 记录。 |
|
||||
|
||||
只镜像 PR 本身。Commit、没开 PR 的分支、CI 检查状态都**不**入库——集成有意保持窄边界。
|
||||
|
||||
## 编号是怎么匹配的
|
||||
|
||||
Webhook 从三个字段抽取编号,顺序是:**PR head 分支** → **PR 标题** → **PR 正文**。匹配规则:
|
||||
|
||||
- 大小写不敏感——`mul-123`、`MUL-123`、`Mul-123` 都能匹配
|
||||
- 有边界——左侧 `\b`、右侧只接数字,避免误抓 `v1.2-3`、email 地址等
|
||||
- 限定到本工作区——只匹配本工作区的 [issue prefix](/workspaces)。前缀是 `MUL` 的工作区里,PR 出现 `FOO-1` 不会匹配,即使数字撞另一个 issue 也不会
|
||||
- 自动去重——`Closes MUL-1, MUL-1` 只关联一次
|
||||
|
||||
一个 PR 里**可以同时引用多个 issue**。比如 `Closes MUL-1, MUL-2`:PR 同时关联两个 issue,合并时两个 issue 都会转 `Done`。
|
||||
|
||||
## Merge 自动转 Done 的规则
|
||||
|
||||
PR 的 `merged` 字段翻成 `true` 时,逐个评估关联的 issue:
|
||||
|
||||
| Issue 当前状态 | 结果 |
|
||||
|---|---|
|
||||
| `done` | 不变(已经是终态)|
|
||||
| `cancelled` | **不变**——cancelled 是用户明确放弃工作的信号,集成不覆盖 |
|
||||
| 其他(`todo` / `in_progress` / `in_review` / `blocked` / `backlog`)| 转成 `done` |
|
||||
|
||||
PR **关闭但没合并**——只更新 PR 卡片的状态为 `Closed`,issue 状态不变。"关闭但不合并"语义因团队而异,Multica 不替用户做决定。
|
||||
|
||||
<Callout type="info">
|
||||
状态变更的 actor 是 `system`。订阅了该 issue 的成员会收到 inbox 通知,和成员手动改状态时一致。
|
||||
</Callout>
|
||||
|
||||
## 哪些情况不会自动关联
|
||||
|
||||
- **Commit message 里的编号**——只扫 PR 的分支 / 标题 / 正文。一个 commit message 写 `MUL-123: fix login` 不会触发关联,除非同样的字符串也出现在 PR 标题或正文里
|
||||
- **PR 评论里的编号**——只扫 PR 自己的元数据,后续的 GitHub comment 不读
|
||||
- **App 没安装的仓库里的 PR**——没 App,Multica 收不到 webhook
|
||||
- **手动把 PR 关联到 issue**——暂时没有这个 UI。如果你们的约定把编号放到 Multica 不扫的地方,请改放到 PR 标题或正文里
|
||||
|
||||
## 断开连接
|
||||
|
||||
**Settings → Integrations** 里没有 installation 列表——现有 installation 直接到 GitHub 上管理:
|
||||
|
||||
- **从 GitHub 卸载** —— 个人在 `https://github.com/settings/installations`、组织在 `https://github.com/organizations/<org>/settings/installations` 卸载 Multica App。Multica 收到 `installation.deleted` webhook 后立刻删行;任何已打开的 Settings tab 实时更新,不用刷新
|
||||
- **Multica 这边的断开是 admin only** —— 卡片对非 admin 不显示连接操作
|
||||
|
||||
断开之后,已经镜像的 PR 行保留在数据库里——历史 issue 侧栏仍能显示当时关联的 PR,但来自这个 installation 的新 webhook 事件不再被接受。
|
||||
|
||||
## 权限和可见性
|
||||
|
||||
- **Connect / Disconnect** 需要工作区 **owner 或 admin**。普通成员能看到卡片描述但看不到 Connect 按钮
|
||||
- **Pull requests** 侧栏对所有能看到该 issue 的成员可见——和 issue 详情页其他部分权限一致
|
||||
- GitHub App 申请的是 PR 和 Metadata 的 **只读** 权限。Multica 从不向 GitHub 推 commit、评论或 status check
|
||||
|
||||
## Self-Host 配置
|
||||
|
||||
如果你在 Multica Cloud 上,集成已经配好——跳过本节。
|
||||
|
||||
Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境变量。完整流程如下。
|
||||
|
||||
### 1. 创建一个 GitHub App
|
||||
|
||||
到下面其中一个页面:
|
||||
|
||||
- 个人账号 → `https://github.com/settings/apps/new`
|
||||
- 组织 → `https://github.com/organizations/<org>/settings/apps/new`
|
||||
|
||||
按下表填写:
|
||||
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| **GitHub App name** | 任何能辨识的名字,例如 `Multica` 或 `Multica (staging)` |
|
||||
| **Homepage URL** | 你的 Multica 前端,例如 `https://multica.example.com` |
|
||||
| **Callback URL** | 留空——本集成不使用 OAuth 用户身份 |
|
||||
| **Setup URL** | `https://<api-host>/api/github/setup`。**勾选 "Redirect on update"** |
|
||||
| **Webhook → Active** | 启用 |
|
||||
| **Webhook URL** | `https://<api-host>/api/webhooks/github` |
|
||||
| **Webhook secret** | 生成一个长随机字符串(例如 `openssl rand -hex 32`)。这个值会同样填到 step 2 的 env 里 |
|
||||
| **Permissions → Repository → Pull requests** | **Read-only** |
|
||||
| **Permissions → Repository → Metadata** | Read-only(必填)|
|
||||
| **Subscribe to events** | 勾选 **Pull request** |
|
||||
| **Where can this GitHub App be installed?** | 自选。单组织部署建议选 `Only on this account` |
|
||||
|
||||
点 **Create GitHub App** 之后,从详情页记下两件事:
|
||||
|
||||
- 顶部 **public link** 的尾部即 slug。`https://github.com/apps/multica-acme` → slug = `multica-acme`
|
||||
- 你刚生成的 **webhook secret**(GitHub 之后不会再让你读取这个值——现在就保存好)
|
||||
|
||||
<Callout type="warning">
|
||||
**Webhook secret ≠ Client secret。** App 设置页里两个字段紧挨着。**Webhook secret** 用于签 `pull_request` payload,这才是 Multica 需要的那个;**Client secret** 是 OAuth 用的,和本集成无关。混淆这两个会得到「每条 webhook 都 `401 invalid signature`」的诡异症状。
|
||||
</Callout>
|
||||
|
||||
### 2. 配置环境变量
|
||||
|
||||
API server 上:
|
||||
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
```
|
||||
|
||||
两个都必填。任何一个缺失:
|
||||
|
||||
- Settings 里 `Connect GitHub` 按钮会被 **disable**,并显示「not configured」提示
|
||||
- `/api/webhooks/github` 直接返回 **`503 github webhooks not configured`**——Multica 在 secret 没配置时拒绝处理事件,不会出现「没 secret 也接受 webhook」的安全坑
|
||||
|
||||
`FRONTEND_ORIGIN` 也必须设置(任何生产 self-host 都已经设了)——setup 回调结束后用它把用户跳回 `<FRONTEND_ORIGIN>/settings`。
|
||||
|
||||
设完 env 重启 API。
|
||||
|
||||
### 3. 执行 migration
|
||||
|
||||
集成的表在 migration `079_github_integration` 里。如果是升级既有部署:
|
||||
|
||||
```bash
|
||||
make migrate-up
|
||||
```
|
||||
|
||||
会创建三张表:`github_installation`、`github_pull_request`、`issue_pull_request`。三张表都 cascade 跟随 workspace——删工作区会自动清理。
|
||||
|
||||
### 4. 在 UI 里连接
|
||||
|
||||
到 Multica:
|
||||
|
||||
1. 以 owner 或 admin 身份打开 **Settings → Integrations**
|
||||
2. 点 **Connect GitHub**,GitHub 在新 tab 打开
|
||||
3. 选择要授权的仓库,点 **Install**
|
||||
4. GitHub 跳回 `<api-host>/api/github/setup`,落库后再跳到 `<FRONTEND_ORIGIN>/settings?github_connected=1`
|
||||
|
||||
之后在任意一个仓库开一个分支 / 标题 / 正文带本工作区 issue 编号的 PR——几秒内对应 issue 的详情页上就能看到 Pull requests 区块。
|
||||
|
||||
### 5. 用 curl 自检
|
||||
|
||||
如果 GitHub 的 **Recent Deliveries** 里第一次 PR 事件就报 `401 invalid signature`,说明两边的 secret 不一致。绕过 GitHub 直接测 server 是最快的定位方法:
|
||||
|
||||
```bash
|
||||
SECRET="<你填给 GITHUB_WEBHOOK_SECRET 的值>"
|
||||
BODY='{"zen":"test"}'
|
||||
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')
|
||||
curl -i -X POST https://<api-host>/api/webhooks/github \
|
||||
-H "X-Hub-Signature-256: sha256=$SIG" \
|
||||
-H "X-GitHub-Event: ping" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
| HTTP 状态 | 含义 | 修法 |
|
||||
|---|---|---|
|
||||
| `200` `{"ok":"pong"}` | server 加载的 secret 和你 `$SECRET` 一致——GitHub 那边的 secret 才是错的 | 编辑 App → Webhook secret 字段**粘相同的值** → **必须点 Save changes**(不点 Save 等于没改)→ Redeliver |
|
||||
| `401 invalid signature` | server 加载的 secret **不是**你以为的那个 | 进容器确认 env 实际生效(例如 `kubectl exec` → `echo -n "$GITHUB_WEBHOOK_SECRET" \| wc -c`),重新部署 |
|
||||
| `503 github webhooks not configured` | `GITHUB_WEBHOOK_SECRET` 在进程里是空的 | 配上 env,重启 API |
|
||||
|
||||
## 已知限制
|
||||
|
||||
目前还没做的几个边界:
|
||||
|
||||
- **手动 link UI 暂未提供**——关联 PR 的唯一方法是把 issue 编号写到 PR 分支 / 标题 / 正文
|
||||
- **不读 CI / check 状态**——只镜像 PR 本身,构建状态、reviewer 评论、reviewer 列表都没接进 Multica
|
||||
- **没有工作区级别的 merge → status 映射配置**——默认固定是 `merged → done`(cancelled 除外)。可配置映射是后续迭代
|
||||
- **同 issue 多 PR 时,merge 行为偏激进**——两个 PR 都引用 `MUL-123` 时,第一个 merge 就把 issue 转 Done。"等所有关联 PR 都解决再推进 issue 状态"的优化已经在做了
|
||||
|
||||
## 下一步
|
||||
|
||||
- [Issues](/issues) —— PR 引用的 issue 编号(`MUL-123`)的来源
|
||||
- [工作区](/workspaces) —— 工作区 issue prefix 的设置位置
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单,包含上面提到的 GitHub 变量
|
||||
@@ -27,8 +27,6 @@
|
||||
"autopilots",
|
||||
"---Inbox---",
|
||||
"inbox",
|
||||
"---Integrations---",
|
||||
"github-integration",
|
||||
"---Self-hosting & ops---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -26,8 +26,6 @@
|
||||
"autopilots",
|
||||
"---收件箱---",
|
||||
"inbox",
|
||||
"---集成---",
|
||||
"github-integration",
|
||||
"---自部署运维---",
|
||||
"environment-variables",
|
||||
"auth-setup",
|
||||
|
||||
@@ -115,6 +115,5 @@ Same flow as Cloud — see [Cloud quickstart → Steps 5-6](/cloud-quickstart#5-
|
||||
|
||||
- [Environment variables](/environment-variables) — full env reference
|
||||
- [Auth setup](/auth-setup) — Resend / OAuth / signup allowlist in detail
|
||||
- [GitHub integration](/github-integration) — connect a GitHub App so PRs auto-link to issues and merging closes them
|
||||
- [Troubleshooting](/troubleshooting) — start here when things go wrong
|
||||
- [Desktop app](/desktop-app) — optional Desktop setup via `~/.multica/desktop.json`; the web frontend + CLI remains the quickest self-host path
|
||||
|
||||
@@ -114,6 +114,5 @@ multica setup self-host
|
||||
|
||||
- [环境变量](/environment-variables) —— 完整 env 清单
|
||||
- [登录与注册配置](/auth-setup) —— Resend / OAuth / 注册白名单详细配置
|
||||
- [GitHub 集成](/github-integration) —— 连一个 GitHub App,让 PR 自动关联 issue、merge 时自动转 Done
|
||||
- [故障排查](/troubleshooting) —— 遇到问题先来这里
|
||||
- [桌面应用](/desktop-app) —— 可以通过 `~/.multica/desktop.json` 连接 Desktop;Web 前端 + CLI 仍然是最快的自部署路径
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { DashboardPage as default } from "@multica/views/dashboard";
|
||||
@@ -39,6 +39,7 @@
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.75 0.16 85);
|
||||
--info: oklch(0.55 0.18 250);
|
||||
--priority: oklch(0.65 0.18 50);
|
||||
--scrollbar-thumb: oklch(0 0 0 / 10%);
|
||||
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
|
||||
--scrollbar-track: transparent;
|
||||
|
||||
@@ -44,15 +44,6 @@ export function LandingHeader({
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className={cn(
|
||||
headerButtonClassName("ghost", variant),
|
||||
"hidden sm:inline-flex",
|
||||
)}
|
||||
>
|
||||
{t.header.changelog}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -7,7 +7,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
dashboard: "Dashboard",
|
||||
changelog: "Changelog",
|
||||
},
|
||||
|
||||
hero: {
|
||||
@@ -284,103 +283,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
title: "GitHub Integration, Chat Attachments & Safer Issue Navigation",
|
||||
changes: [],
|
||||
features: [
|
||||
"Connect GitHub so linked pull requests appear on Multica issues, sync their status, and close the Multica issue automatically when the PR closes",
|
||||
"Chat messages can include file attachments and image previews",
|
||||
"Agents and runtimes can now be kept public or private for clearer team access",
|
||||
"Stopping a single agent task now asks for confirmation before it is terminated",
|
||||
"New GitHub integration docs cover both hosted and self-hosted setup",
|
||||
],
|
||||
improvements: [
|
||||
"Issue links land more reliably on the exact comment or activity you opened",
|
||||
"Long issue timelines scroll more smoothly",
|
||||
"The feedback dialog now points contributors toward GitHub discussions and issues",
|
||||
"Self-hosted Caddy guidance now calls out real-time connection requirements",
|
||||
"Linux desktop packages show the Multica app icon again",
|
||||
],
|
||||
fixes: [
|
||||
"Downloaded attachments keep their original filenames",
|
||||
"Local attachments are served more reliably, and upload controls stay disabled until files are ready",
|
||||
"Issue creation dialogs keep their text fields at the correct height",
|
||||
"Runtime documentation links point to the correct page",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.30",
|
||||
date: "2026-05-11",
|
||||
title: "Mermaid in Issues, Per-Runtime Timezone & Workspace-Leave Runtime Revocation",
|
||||
changes: [],
|
||||
features: [
|
||||
"Mermaid diagrams render inline in issue descriptions",
|
||||
"Sub-issue rows gain inline status and assignee pickers, with batch select across rows",
|
||||
"Per-runtime timezone for token-usage aggregation, so daily rollups respect your local day",
|
||||
"Private agents are gated by an `allowed_principals` predicate, with fine-grained visibility",
|
||||
"A member leaving or being removed from a workspace now revokes their runtimes automatically",
|
||||
"Set custom per-token prices for unmaintained models so usage reflects real cost",
|
||||
"Landing page header gains a Changelog link",
|
||||
],
|
||||
improvements: [
|
||||
"Daemon self-heals when a runtime is deleted server-side — no more zombie local entries",
|
||||
"Chat and comment composer share the same `Mod+Enter` send shortcut",
|
||||
"Copilot CLI model catalog expanded with correct dotted IDs",
|
||||
"Copilot failure details now surface in the UI instead of a generic error",
|
||||
"Daemon brief is inlined into the system prompt for providers that need it",
|
||||
"Realtime WebSocket accepts same-origin upgrades from mobile and CLI",
|
||||
],
|
||||
fixes: [
|
||||
"Recent-issues list no longer leaks across workspaces",
|
||||
"CloudFront attachment download URLs are re-signed at click time, fixing expired previews",
|
||||
"Windows reply templates use `--content-file` across every provider so non-ASCII bodies survive",
|
||||
"Daemon suppresses extra git console pop-ups on Windows",
|
||||
"Pi extension tools are no longer filtered by a hardcoded `--tools` allowlist",
|
||||
"Inbox scrolls to the target comment once the issue finishes loading",
|
||||
"`autopilot create/update` accepts `--mode run_only`",
|
||||
"Changelog header link styled to match the GitHub ghost button",
|
||||
"OpenAI Codex / GPT model pricing populated — cost no longer shows $0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.29",
|
||||
date: "2026-05-09",
|
||||
title: "Project Picker in Quick Create, Resolvable Comments & Timeline Performance",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Create lets you pick a project, and remembers your last choice",
|
||||
"Comment threads can be resolved and collapsed, keeping long discussions tidy",
|
||||
"Issue live banner now shows agent tasks waiting in queue",
|
||||
"Failed or cancelled tasks can be rerun in one click from the Execution Log",
|
||||
"Agent Create modal gains an expand button for editing long descriptions",
|
||||
],
|
||||
improvements: [
|
||||
"Issue timeline no longer fully re-renders on every WebSocket event — long issues scroll smoothly",
|
||||
"Editor skips parsing very large or JSON pastes, eliminating freezes",
|
||||
"Autopilot skips dispatch when the assignee runtime is offline, avoiding empty runs",
|
||||
"Inbox auto-archives `task_failed` rows once they reach a terminal state",
|
||||
"Hermes sends agent instructions inline with each request",
|
||||
"Timeline and Comment switched to client-side virtualization, dropping server-side pagination",
|
||||
"Reserved slugs share a single JSON between front and back end, with CI guarding drift",
|
||||
"ACP error messages include the JSON-RPC `error.data` field for clearer debugging",
|
||||
],
|
||||
fixes: [
|
||||
"429 / insufficient-balance agent runs are now marked `failed` instead of `completed`",
|
||||
"Agent sessions stuck on poisoned images can recover, so the issue resumes",
|
||||
"`pi --list-models` table format parses correctly, restoring model discovery",
|
||||
"`pi` colon-to-slash normalization only applies to the legacy format",
|
||||
"`kiro` and `kimi` added to the inline-system-prompt provider allowlist",
|
||||
"Priority dropdown badge colors aligned with PriorityIcon semantic tokens",
|
||||
"Long single-line agent messages now expand correctly",
|
||||
"Desktop \"copy issue link\" uses the current connection URL instead of localhost",
|
||||
"Mobile WebSocket handshake succeeds without cookies",
|
||||
"Workspace slug creation validates reserved words; slug error messages are translated",
|
||||
"Timeline correctly syncs `around` state when props flip to falsy",
|
||||
"DropdownMenu popovers size to their content",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
|
||||
@@ -20,7 +20,7 @@ type FooterGroup = {
|
||||
};
|
||||
|
||||
export type LandingDict = {
|
||||
header: { github: string; login: string; dashboard: string; changelog: string };
|
||||
header: { github: string; login: string; dashboard: string };
|
||||
hero: {
|
||||
headlineLine1: string;
|
||||
headlineLine2: string;
|
||||
|
||||
@@ -7,7 +7,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
github: "GitHub",
|
||||
login: "\u767b\u5f55",
|
||||
dashboard: "\u8fdb\u5165\u5de5\u4f5c\u53f0",
|
||||
changelog: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
},
|
||||
|
||||
hero: {
|
||||
@@ -284,103 +283,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
title: "GitHub 集成、聊天附件与 Issue 定位优化",
|
||||
changes: [],
|
||||
features: [
|
||||
"接入 GitHub 后,关联的 Pull Request 会显示在 Multica Issue 中,状态会同步到 Multica,关闭 PR 后会自动关闭对应 Issue",
|
||||
"聊天消息支持添加文件附件和图片预览",
|
||||
"Agent 和 runtime 可以设置公开或私有,方便控制团队可见范围",
|
||||
"停止单个 agent 任务前会先弹出确认,避免误操作",
|
||||
"新增 GitHub 集成文档,覆盖托管版和自托管配置",
|
||||
],
|
||||
improvements: [
|
||||
"打开 Issue 链接时,会更稳定地定位到指定评论或动态",
|
||||
"很长的 Issue 时间线滚动更顺畅",
|
||||
"反馈入口更明确地引导用户到 GitHub 参与讨论和反馈",
|
||||
"自托管 Caddy 配置文档补充实时连接要求",
|
||||
"Linux 桌面端安装包恢复显示 Multica 应用图标",
|
||||
],
|
||||
fixes: [
|
||||
"下载附件时保留原始文件名",
|
||||
"本地附件访问更稳定,上传按钮会等文件准备好后再可用",
|
||||
"创建 Issue 弹窗里的文本框高度显示正确",
|
||||
"Runtime 文档入口跳转到正确页面",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.30",
|
||||
date: "2026-05-11",
|
||||
title: "Issue 内 Mermaid、Runtime 时区聚合与离开 Workspace 自动吊销",
|
||||
changes: [],
|
||||
features: [
|
||||
"Issue 描述内联渲染 Mermaid 图表",
|
||||
"Sub-issue 行支持就地切换状态与 assignee,并支持跨行批量选中",
|
||||
"Token 用量按每个 runtime 自己的时区聚合,每日 rollup 与本地日期对齐",
|
||||
"私有 Agent 通过 `allowed_principals` 判定可见性,权限粒度更细",
|
||||
"成员离开或被移出 workspace 时,自动吊销其名下的 runtime",
|
||||
"对未维护的模型支持自定义 token 价格,使用量真实反映成本",
|
||||
"Landing 页面 header 加入 Changelog 入口",
|
||||
],
|
||||
improvements: [
|
||||
"服务端删除 runtime 时,daemon 端自我修复,不再留下僵尸条目",
|
||||
"Chat 与评论输入框统一使用 `Mod+Enter` 发送",
|
||||
"Copilot CLI 模型目录补齐正确的 dotted ID",
|
||||
"Copilot 失败详情直接在 UI 中透出,不再只是一个通用错误",
|
||||
"Daemon brief 直接内联进 system prompt,针对需要的 provider 生效",
|
||||
"Realtime WebSocket 放行同源升级,移动端与 CLI 可正常握手",
|
||||
],
|
||||
fixes: [
|
||||
"Recent issues 列表不再跨 workspace 串扰",
|
||||
"CloudFront 附件下载链接在点击时重新签名,过期预览的问题修复",
|
||||
"所有 provider 的 Windows reply 模板改用 `--content-file`,非 ASCII 内容不再丢失",
|
||||
"Daemon 抑制 Windows 上多余的 git 控制台弹窗",
|
||||
"Pi 插件工具不再被硬编码的 `--tools` allowlist 过滤掉",
|
||||
"Inbox 在 issue 加载完成后再滚动到目标评论",
|
||||
"`autopilot create/update` 允许 `--mode run_only`",
|
||||
"Changelog header 链接样式对齐 GitHub ghost button",
|
||||
"OpenAI Codex / GPT 模型价格补齐,使用成本不再显示为 $0",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.29",
|
||||
date: "2026-05-09",
|
||||
title: "Quick Create 项目选择器、评论可折叠与 Timeline 性能优化",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Create 支持选择 project,并记住上一次的选项",
|
||||
"评论 thread 支持解决并折叠,长讨论看起来更清爽",
|
||||
"Issue Live Banner 显示 agent 队列中等待执行的任务",
|
||||
"失败 / 取消的任务可以在 Execution Log 一键重跑",
|
||||
"Agent Create 弹窗新增放大按钮,长描述编辑更舒服",
|
||||
],
|
||||
improvements: [
|
||||
"Issue Timeline 不再因每个 WS 事件做完整 re-render,长 Issue 滚动更顺",
|
||||
"Editor 跳过对超大文本 / JSON 粘贴的解析,避免卡顿",
|
||||
"Autopilot 在 assignee runtime 离线时跳过 dispatch,避免空跑",
|
||||
"Inbox 自动归档处于终态的 `task_failed` 行",
|
||||
"Hermes 把 agent instructions 直接随请求内联传入",
|
||||
"Timeline / Comment 改为纯客户端虚拟化,去掉服务端分页",
|
||||
"Reserved slugs 前后端共享同一份 JSON,CI 守住漂移",
|
||||
"ACP 错误消息现在带上 JSON-RPC 的 `error.data` 字段,排错更友好",
|
||||
],
|
||||
fixes: [
|
||||
"429 / 余额不足的 agent run 现在被标记为 `failed` 而不是 `completed`",
|
||||
"因 poisoned image 卡死的 agent session 可以恢复,issue 不再卡住",
|
||||
"`pi --list-models` 表格格式可被正确解析,模型发现恢复",
|
||||
"`pi` colon-to-slash 归一化只作用于 legacy 格式,避免误伤新格式",
|
||||
"`kiro` 与 `kimi` 加入 inline-system-prompt provider 白名单",
|
||||
"Priority Dropdown 徽章颜色对齐 PriorityIcon 的 semantic token",
|
||||
"Agent 单行长消息可正常展开",
|
||||
"桌面端复制 issue link 使用当前连接环境,不再硬编码 localhost",
|
||||
"移动端 WebSocket 在没有 cookie 的情况下也能握手",
|
||||
"创建 workspace 时校验保留字,slug 错误提示已 i18n",
|
||||
"Timeline 在 falsy prop 切换时正确同步 around 状态",
|
||||
"DropdownMenu 弹层尺寸跟随内容",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
|
||||
@@ -58,11 +58,9 @@ services:
|
||||
APP_ENV: ${APP_ENV:-production}
|
||||
MULTICA_DEV_VERIFICATION_CODE: ${MULTICA_DEV_VERIFICATION_CODE:-}
|
||||
MULTICA_APP_URL: ${MULTICA_APP_URL:-http://localhost:3000}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-}
|
||||
GITHUB_WEBHOOK_SECRET: ${GITHUB_WEBHOOK_SECRET:-}
|
||||
ALLOW_SIGNUP: ${ALLOW_SIGNUP:-true}
|
||||
ALLOWED_EMAILS: ${ALLOWED_EMAILS:-}
|
||||
ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-}
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
|
||||
@@ -1,555 +0,0 @@
|
||||
# Agent 快速创建 — 三阶段实施计划
|
||||
|
||||
> Status: Draft (设计阶段,未动工)
|
||||
> Owner: TBD
|
||||
> Last updated: 2026-05-13
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **目标**:降低用户创建 Agent 的门槛,从「手工填表 + 一个个挑 skill」演进到「一键模板」「AI 推荐 skill」「AI 直接创建 agent」三档
|
||||
- **三阶段**:Template(必做、独立)→ Skill Finder(AI 推荐 skill)→ AI Create Agent(AI 直接创建)
|
||||
- **架构关键**:Phase 2/3 复用现有 Quick-create Issue 基础设施(派任务给 agent + tool calling + inbox 通知),不引入新 LLM 调用路径
|
||||
- **不需要新基础设施**:无 SSE、无 server-side LLM、无新 WS channel
|
||||
- **soft blocker**:两处 routine 重构(`createSkillWithFiles` TX 拆分、skill 同名 find-or-create)
|
||||
- **不做**:接入 Anthropic 官方 marketplace(plugin 体系跟单体 skill 形态不匹配)、接入 ClawHub(战略对位错误 + 实际使用率低,见 §5)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 当前现状
|
||||
|
||||
当前用户创建一个 Agent 需要走的步骤:
|
||||
|
||||
1. 进 `/agents` 页面 → 点 "Create Agent"
|
||||
2. 手工填 name / description / runtime / model
|
||||
3. 手工写 instructions(空白文本框,用户自己思考措辞)
|
||||
4. 创建完后进 Agent 详情页 → 点 "Add Skill" → 一个一个挑 skill 关联
|
||||
5. 如果 workspace 还没有需要的 skill,得先去别处建/导入 skill(`POST /api/skills/import` 支持 skills.sh / GitHub / ClawHub 三种 URL)
|
||||
|
||||
**痛点**:
|
||||
- 用户得**预先知道**自己需要哪些 skill,这要求他对 skill 生态熟悉
|
||||
- 写 instructions 是空白文本编辑,大多数用户不知道写什么
|
||||
- 跨多页操作,体感上"创建一个能用的 Agent"是个项目,不是个动作
|
||||
|
||||
### 1.2 三阶段方案
|
||||
|
||||
| Phase | 提供给用户的能力 | 是否需要 AI | 独立可发布 |
|
||||
|---|---|---|---|
|
||||
| **1. Template** | 选模板 → 自动 import 模板带的 skill + 预填 instructions | 否 | ✅ |
|
||||
| **2. Skill Finder** | 描述需求 → AI 推荐 skill 列表 → 一键导入到 workspace | ✅ | ✅(独立功能,任何场景都能用) |
|
||||
| **3. AI Create Agent** | 描述需求 → AI 自己 find skill + 写 instructions + 创建 agent | ✅ | 依赖 Phase 2 |
|
||||
|
||||
每个 phase **本身有用户价值**,不需要等下一个 phase 才能用:
|
||||
- Phase 1 用户能用模板创建 agent,即使后两阶段没做
|
||||
- Phase 2 用户能在任何地方"用 AI 找 skill"(创建 agent 时、给现有 agent 加 skill 时、单纯逛 skill 时)
|
||||
- Phase 3 是 1+2 的组合
|
||||
|
||||
### 1.3 不在范围内
|
||||
|
||||
明确不做的事(及理由,见 §5):
|
||||
- 接入 Anthropic 官方 plugin marketplace(`anthropics/claude-plugins-official`)
|
||||
- 接入 ClawHub 的"发现/搜索"层(import 路径已经存在,但是死代码,建议下线)
|
||||
- 让 AI 直接装 skill 到用户本地 `~/.claude/skills/`(npx skills CLI 行为)
|
||||
- Server-side LLM 调用(后端目前没有 LLM SDK,这条路引入新基础设施,而 Quick-create 模式可以避开)
|
||||
|
||||
---
|
||||
|
||||
## 2. 关键概念回顾
|
||||
|
||||
> 这一节给没参与前期讨论的同事看。已经熟悉 skill 系统的可跳到 §3。
|
||||
|
||||
### 2.1 Skill 是什么
|
||||
|
||||
Skill 是一个**按需加载的能力包**,本质是 SKILL.md 文件 + 可选附件。Anthropic 2025-12 把它发布为开放标准(agentskills.io),Cursor / OpenAI / GitHub Copilot 等都已采纳——同一份 SKILL.md 跨多个 agent 工具都能用。
|
||||
|
||||
每个 runtime(Claude Code / Cursor / Codex 等)启动时**自动扫**自己约定的目录(`~/.claude/skills/`、`.cursor/skills/` 等),读 SKILL.md 的 frontmatter 形成"我手上有这些 skill"的清单注入 system prompt。具体 skill 正文只在被触发时才进 context。
|
||||
|
||||
### 2.2 Multica 的 Skill 数据模型
|
||||
|
||||
3 张表(migration `008_structured_skills.up.sql`):
|
||||
|
||||
| 表 | 关键字段 |
|
||||
|---|---|
|
||||
| `skill` | `id, workspace_id, name, description, content (=SKILL.md 正文), config (含 origin 元数据)` |
|
||||
| `skill_file` | `skill_id, path, content`(SKILL.md 的附件,如 examples/*.md、scripts/*.py) |
|
||||
| `agent_skill` | `agent_id, skill_id`(M:N 关联) |
|
||||
|
||||
**关键约束**:`UNIQUE(workspace_id, name)` — 同 workspace 内 skill 名字必须唯一。
|
||||
|
||||
### 2.3 Skill 流转链路(数据库 → runtime)
|
||||
|
||||
任务运行时,skill 从 PG 到 runtime 的完整路径:
|
||||
|
||||
```
|
||||
1. 数据库:skill + skill_file + agent_skill 三张表的行
|
||||
|
||||
2. Daemon claim 任务:
|
||||
POST /api/runtimes/{runtimeId}/tasks/claim
|
||||
handler/daemon.go:1018-1098 (ClaimTaskByRuntime)
|
||||
→ service/task.go:1447-1463 (LoadAgentSkills)
|
||||
→ 把 agent 关联的所有 skill 全文塞进 HTTP 响应
|
||||
|
||||
3. Daemon 算工作目录:
|
||||
server/internal/daemon/execenv/execenv.go:114, 124
|
||||
workDir = {WorkspacesRoot}/{wsID}/{shortTaskID}/workdir
|
||||
|
||||
4. Daemon 按 runtime 算 skill 目录:
|
||||
server/internal/daemon/execenv/context.go:121-158 (resolveSkillsDir)
|
||||
claude → {workDir}/.claude/skills
|
||||
cursor → {workDir}/.cursor/skills
|
||||
codex → 特殊:{codexHome}/skills
|
||||
|
||||
5. Daemon 把字符串写成磁盘文件:
|
||||
context.go:175-204 (writeSkillFiles)
|
||||
核心就两行 os.WriteFile
|
||||
|
||||
6. Daemon 启动 runtime,cwd = workDir
|
||||
runtime 自己扫 .claude/skills/(等)→ 加载 frontmatter
|
||||
|
||||
7. 任务结束:os.RemoveAll(workDir)
|
||||
PG 是真相源,workDir 是每次任务临时复印件
|
||||
```
|
||||
|
||||
**核心 invariant**:Multica 不教 runtime 怎么用 skill,只把文件摆到 runtime 已经会扫的位置。
|
||||
|
||||
### 2.4 Template = Instructions + Skill 引用
|
||||
|
||||
Template 是个**静态 JSON 定义**,包含:
|
||||
- 预写好的 instructions
|
||||
- 一组 skill 引用(用 URL 指向 skills.sh / GitHub)
|
||||
|
||||
用户选模板时,后端:
|
||||
1. 对每个 skill 引用,**复用现有 `/api/skills/import` 的 fetcher**(`fetchFromSkillsSh` / `fetchFromGitHub`)拉内容
|
||||
2. 物化到 workspace(同名复用 / 新建)
|
||||
3. CreateAgent + setAgentSkills
|
||||
4. 整个流程一个事务
|
||||
|
||||
skill 引用为什么用 URL 而不是内联 SKILL.md 内容:
|
||||
- 复用现有 import 基础设施,零新代码
|
||||
- skill 内容跟 GitHub 同步,不需要 vendoring 进 multica 仓库
|
||||
- 模板 JSON 体积小,git review 友好
|
||||
|
||||
### 2.5 Quick-create Issue 模式(Phase 2/3 复用的基础设施)
|
||||
|
||||
当前 `POST /api/issues/quick-create`(handler/issue.go:877-982)的流程:
|
||||
|
||||
```
|
||||
1. 后端 enqueue 任务:
|
||||
- agent_task_queue 加一行,issue_id = NULL,context JSONB = {type: "quick-create", prompt: ...}
|
||||
- 立即返回 202 Accepted + task_id
|
||||
|
||||
2. Daemon claim 任务时识别 quick-create:
|
||||
- 检查 task.Context != nil AND !task.IssueID.Valid
|
||||
- 解析为 QuickCreateContext (service/task.go:1810-1811)
|
||||
|
||||
3. Daemon 构造 prompt:
|
||||
- daemon/prompt.go:45-106 (buildQuickCreatePrompt)
|
||||
- 把用户的自然语言 prompt 作为语义核心
|
||||
- 加上"调用 multica issue create CLI 命令"的指令
|
||||
|
||||
4. Agent 跑 LLM + tool calling:
|
||||
- LLM 输出形如 `multica issue create --title="..." --description="..."` 的命令
|
||||
- daemon 执行 CLI 命令,CLI 调 POST /api/issues 创建 issue
|
||||
- CLI 自动在请求里带上 MULTICA_QUICK_CREATE_TASK_ID env(daemon/daemon.go:2081)
|
||||
→ 让创建出来的 issue 带 origin_type='quick_create' + origin_id=<task_id>
|
||||
|
||||
5. 后端 link + 通知:
|
||||
- 完成检测:GetIssueByOrigin(workspace_id, "quick_create", task_id)
|
||||
- LinkTaskToIssue(task_id, issue_id) 把任务行的 issue_id 补上
|
||||
- 写 inbox_item 通知用户(notifyQuickCreateCompleted, service/task.go:1908-1920)
|
||||
```
|
||||
|
||||
**关键洞察**:这个模式**完全通用化**了。复用它只需要:
|
||||
1. 新的 context JSONB type(比如 `"skill-find"`、`"agent-create"`)
|
||||
2. 新的 prompt builder
|
||||
3. 新的"完成检测 + inbox 通知"
|
||||
|
||||
不需要任何 daemon / 任务队列层面的改动。
|
||||
|
||||
---
|
||||
|
||||
## 3. 三阶段详细设计
|
||||
|
||||
### Phase 1:Agent Template
|
||||
|
||||
**目标**:用户选模板 → 一键得到一个可用的 agent(自带 skill + instructions),不需要 AI 参与。
|
||||
|
||||
#### 设计
|
||||
|
||||
- **Template 定义存放**:静态 JSON,commit 在 `server/internal/agenttmpl/templates/*.json`
|
||||
- **Template JSON 形态**:
|
||||
```json
|
||||
{
|
||||
"slug": "code-reviewer",
|
||||
"name": "Code Reviewer",
|
||||
"description": "审代码用的 agent",
|
||||
"instructions": "你审代码,关注 N+1 查询、错误处理、类型安全...",
|
||||
"skills": [
|
||||
{ "source_url": "https://skills.sh/obra/superpowers/tdd" },
|
||||
{ "source_url": "https://github.com/foo/bar/tree/main/skills/code-style" }
|
||||
]
|
||||
}
|
||||
```
|
||||
- **新 endpoint**:`POST /api/agents/from-template`
|
||||
- 请求:`{template_slug, name, runtime_id, ...overrides}`
|
||||
- 后端流程(**全部在一个事务里**):
|
||||
1. 加载 template JSON
|
||||
2. 对每个 skill source_url:
|
||||
- 调用 `detectImportSource(url)`(skill.go:586-617)分发到对应 fetcher
|
||||
- 通过 GetSkillByWorkspaceAndName 检查 workspace 是否已有同名 skill
|
||||
- 有 → 复用现有 skill_id
|
||||
- 无 → 调 `createSkillWithFilesInTx`(待重构,见 §4)物化
|
||||
3. `CreateAgent`(复用 agent.go:CreateAgent 的内部逻辑)
|
||||
4. 批量 `AddAgentSkill` 关联
|
||||
- 响应:`{agent: {...}, imported_skill_ids: [...], reused_skill_ids: [...]}`
|
||||
- **前端**:`CreateAgentDialog`(packages/views/agents/components/create-agent-dialog.tsx)加 "From template" 模式,跟现有 manual / duplicate 模式并列
|
||||
- 模板选择器 → 预览(instructions + skill 列表)→ 提交调新 endpoint
|
||||
- 响应里的 `reused_skill_ids` 用 toast 提示"以下 skill 已存在,沿用了 workspace 现有版本"
|
||||
|
||||
#### 起步模板清单(初版,可调)
|
||||
|
||||
- `code-reviewer` — 代码审查
|
||||
- `tdd-pair` — TDD 配对编程
|
||||
- `db-reviewer` — 数据库 / SQL 审查
|
||||
- `pr-summarizer` — PR 摘要
|
||||
- `docs-writer` — 文档撰写
|
||||
|
||||
具体每个模板选哪些 skill URL,在 Phase 1 启动时单独决定(需要逛 skills.sh 选高质量 skill)。
|
||||
|
||||
#### Phase 1 改动清单
|
||||
|
||||
| 文件 / 位置 | 改动 |
|
||||
|---|---|
|
||||
| `server/internal/agenttmpl/`(新包) | 加载 JSON 模板的代码 |
|
||||
| `server/internal/agenttmpl/templates/*.json`(新文件) | 5 个起步模板 |
|
||||
| `server/internal/handler/agent.go` | 新 handler `CreateAgentFromTemplate` |
|
||||
| `server/internal/handler/skill_create.go` | **重构**:拆出 `createSkillWithFilesInTx` 变体(见 §4) |
|
||||
| `server/pkg/db/queries/skill.sql` | 加 `GetSkillByWorkspaceAndName`(见 §4) |
|
||||
| `server/cmd/server/router.go` | 注册新 endpoint |
|
||||
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 template 模式 |
|
||||
| `packages/core/api/agent.ts` | 加 `createAgentFromTemplate` API 调用 |
|
||||
| `packages/views/agents/components/template-picker.tsx`(新文件) | 模板选择器组件 |
|
||||
|
||||
### Phase 2:Skill Finder
|
||||
|
||||
**目标**:用户用自然语言描述需求(如"我想审 SQL"),AI 推荐一组 skill,用户勾选一键导入到 workspace。
|
||||
|
||||
#### 设计
|
||||
|
||||
- **架构选型**:走 quick-create 模式,**不是后端直接调 LLM**
|
||||
- **新 endpoint**:`POST /api/skills/find`
|
||||
- 请求:`{prompt, agent_id}`(agent_id 是用来跑这个 LLM 任务的 agent,跟 Quick-create Issue 一样要求预先有 agent)
|
||||
- 后端流程:
|
||||
1. enqueue 任务:`agent_task_queue` 加一行,context JSONB = `{type: "skill-find", prompt}`
|
||||
2. 返回 202 + task_id
|
||||
- **Daemon prompt builder**:`daemon/prompt.go` 加 `buildSkillFindPrompt`(类比 buildQuickCreatePrompt)
|
||||
- 喂给 agent 的 prompt 大致:
|
||||
```
|
||||
用户需求:{user_prompt}
|
||||
|
||||
你的任务:从以下 curated skill 清单里选 3-5 个最相关的推荐给用户。
|
||||
|
||||
可选 skill 清单(JSON):
|
||||
{curated_skill_index}
|
||||
|
||||
输出:调用 `multica skill find --output-results '<JSON>'` 命令,
|
||||
JSON 形态为 [{name, description, source_url, reason}, ...]
|
||||
```
|
||||
- **CLI 命令**(新):`multica skill find --output-results <JSON>`
|
||||
- 不发起 HTTP 请求,只把 JSON 写到 daemon 通过 env 指定的临时文件
|
||||
- daemon 读这个文件,把内容塞进 inbox notification 的 payload
|
||||
- **Curated skill 索引**:`server/internal/agenttmpl/skill_index.json`(新文件)
|
||||
- 几十到上百条精选 skill,每条:`{name, description, source_url, tags, install_count}`
|
||||
- 维护方式:工程师/产品手工维护,代码 review 卡内容质量
|
||||
- MVP **不做**实时 GitHub Code Search 或 skills.sh 爬虫
|
||||
- **完成通知**:写 inbox_item,type = `skill_find_done`,payload 含推荐结果数组
|
||||
- **前端**:
|
||||
- 独立"Find Skill"页面(`/skills/find` 或 `/skills?ai=true`)
|
||||
- skill list page 上"用 AI 找 skill"按钮入口
|
||||
- 用户输入 prompt → 提交 → 等通知 → inbox item 里展示 skill 卡片(name + description + source_url + reason)
|
||||
- 用户勾选 → 一键批量调现有 `POST /api/skills/import`(每个 skill 一次,可考虑加 batch endpoint 但 MVP 不必要)
|
||||
|
||||
#### Phase 2 改动清单
|
||||
|
||||
| 文件 / 位置 | 改动 |
|
||||
|---|---|
|
||||
| `server/internal/handler/skill.go` | 新 handler `FindSkill`(enqueue task) |
|
||||
| `server/internal/service/task.go` | 加 `EnqueueSkillFindTask` + 完成检测 + inbox 通知 |
|
||||
| `server/internal/daemon/prompt.go` | 加 `buildSkillFindPrompt` |
|
||||
| `server/internal/daemon/daemon.go` | 加 `SkillFindContext` 识别 + env 注入 |
|
||||
| `server/cmd/multica/cmd_skill.go` | 加 `find --output-results` 子命令 |
|
||||
| `server/internal/agenttmpl/skill_index.json`(新文件) | curated 清单 |
|
||||
| `packages/views/skills/components/find-skills-dialog.tsx`(新文件) | UI |
|
||||
| `packages/core/api/skill.ts` | 加 `findSkills` API |
|
||||
| `packages/views/inbox/items/skill-find-result.tsx`(新文件) | inbox item 渲染 |
|
||||
|
||||
### Phase 3:AI Create Agent
|
||||
|
||||
**目标**:用户描述需求,AI 自己 find skill + 写 instructions + 创建 agent。
|
||||
|
||||
#### 设计
|
||||
|
||||
- **架构选型**:走 quick-create 模式,**组合 Phase 2 的 find 能力 + 新的 agent create CLI**
|
||||
- **新 endpoint**:`POST /api/agents/ai-draft`
|
||||
- 请求:`{prompt, host_agent_id}`(host_agent_id 是跑这个元任务的 agent)
|
||||
- 后端:enqueue 任务,context = `{type: "agent-create", prompt}`,返回 202 + task_id
|
||||
- **Daemon prompt builder**:`buildAgentCreatePrompt` 指挥 agent 三步走:
|
||||
```
|
||||
1. 调用 `multica skill find --output-results ...` 选 skill
|
||||
(或直接看 curated 清单选)
|
||||
2. 基于选定 skill 写 instructions
|
||||
3. 调用 `multica agent create --name ... --instructions ... --skill-ids ...`
|
||||
创建 agent 并关联 skill
|
||||
```
|
||||
- **CLI 命令**(新):`multica agent create`
|
||||
- 后端 handler 已存在(handler/agent.go:CreateAgent),只需要绑 CLI(~50 行)
|
||||
- 创建时带 `MULTICA_AI_DRAFT_TASK_ID` env,服务端用它做 origin 标记 + LinkTaskToAgent
|
||||
- **完成通知**:inbox_item type = `agent_draft_done`,payload 含 agent_id + 摘要
|
||||
- **前端**:`CreateAgentDialog` 加 "AI" 模式
|
||||
- 输入需求 → 提交 → 等通知 → inbox 通知里点击 → 跳新 agent 详情页(用户在那儿编辑/调整)
|
||||
|
||||
#### Phase 3 改动清单
|
||||
|
||||
| 文件 / 位置 | 改动 |
|
||||
|---|---|
|
||||
| `server/internal/handler/agent.go` | 新 handler `AIDraftAgent`(enqueue task) |
|
||||
| `server/internal/service/task.go` | 加 `EnqueueAgentDraftTask` + 完成检测 + inbox 通知 |
|
||||
| `server/internal/daemon/prompt.go` | 加 `buildAgentCreatePrompt` |
|
||||
| `server/cmd/multica/cmd_agent.go` | 加 `create` 子命令(handler 已有) |
|
||||
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 "AI" 模式 |
|
||||
| `packages/core/api/agent.ts` | 加 `aiDraftAgent` API |
|
||||
| `packages/views/inbox/items/agent-draft-result.tsx`(新文件) | inbox item 渲染 |
|
||||
|
||||
---
|
||||
|
||||
## 4. Blocker 清单与修复方案
|
||||
|
||||
### 4.1 [SOFT] `createSkillWithFiles` 不可组合事务
|
||||
|
||||
**问题**:`server/internal/handler/skill_create.go:21-71` 这个函数自己 `Begin()` 一个事务,执行完 `Commit()`。Phase 1 需要在外层事务里**多次**调用它(import N 个 skill + createAgent + setAgentSkills 都在一个 TX),但现在没法这么用。
|
||||
|
||||
**影响范围**:Phase 1
|
||||
|
||||
**修复方案**:
|
||||
|
||||
```go
|
||||
// 拆成两个函数(保持原 API 向后兼容):
|
||||
|
||||
// 新增:接受外部 qtx,不管事务
|
||||
func createSkillWithFilesInTx(
|
||||
ctx context.Context,
|
||||
qtx *db.Queries,
|
||||
input skillCreateInput,
|
||||
) (*SkillWithFilesResponse, error) {
|
||||
// 不 Begin/Commit,只调 qtx.CreateSkill + qtx.UpsertSkillFile loop
|
||||
}
|
||||
|
||||
// 改造:原函数变成包装层,内部调 InTx 版
|
||||
func (h *Handler) createSkillWithFiles(
|
||||
ctx context.Context,
|
||||
input skillCreateInput,
|
||||
) (*SkillWithFilesResponse, error) {
|
||||
tx, _ := h.TxStarter.Begin(ctx)
|
||||
defer tx.Rollback()
|
||||
qtx := h.Queries.WithTx(tx)
|
||||
result, err := createSkillWithFilesInTx(ctx, qtx, input)
|
||||
if err != nil { return nil, err }
|
||||
tx.Commit()
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
旧调用方完全不变。Phase 1 新 endpoint 自己 Begin,然后多次调 `*InTx` 变体,最后统一 Commit。
|
||||
|
||||
**工作量**:小(< 100 行重构)
|
||||
|
||||
### 4.2 [SOFT] Skill 同名冲突
|
||||
|
||||
**问题**:`skill` 表有 `UNIQUE(workspace_id, name)` 约束。Phase 1 模板导入时,如果模板里的 skill 跟 workspace 已有 skill 同名,INSERT 会报 PG 错误 23505,整个 from-template 流程挂掉。
|
||||
|
||||
**影响范围**:Phase 1
|
||||
|
||||
**修复方案**:加 find-or-create 模式:
|
||||
|
||||
1. 新 query `GetSkillByWorkspaceAndName`(`server/pkg/db/queries/skill.sql`)
|
||||
2. Phase 1 流程改成:
|
||||
- 对每个模板 skill,先查 workspace 是否已有同名
|
||||
- 有 → 复用现有 skill_id,跳过 import
|
||||
- 无 → 调 `createSkillWithFilesInTx` 物化
|
||||
3. 响应里返回 `reused_skill_ids: [...]`,前端 toast "以下 skill 已存在,沿用现有版本"
|
||||
|
||||
**不选择"覆盖"或"加后缀"的原因**:用户可能已经改过本地版本,覆盖会丢用户修改;加后缀污染 skill 列表。
|
||||
|
||||
**工作量**:小(< 50 行 + 1 条 sqlc query)
|
||||
|
||||
### 4.3 [SOFT] 缺 `multica skill find` CLI
|
||||
|
||||
**影响范围**:Phase 2
|
||||
|
||||
**方案**:加一个 CLI 子命令,模仿 `multica skill import` 的实现(`server/cmd/multica/cmd_skill.go:55-60, 323-357`)。**注意**:这个命令不发 HTTP 请求,只是 LLM agent 用来"输出推荐结果"的 channel——它把 LLM 推荐的 JSON 写到 daemon 指定的临时文件,daemon 读完塞进 inbox notification。
|
||||
|
||||
**工作量**:小(~80 行)
|
||||
|
||||
### 4.4 [SOFT] 缺 `multica agent create` CLI
|
||||
|
||||
**影响范围**:Phase 3
|
||||
|
||||
**方案**:后端 handler 已有(`handler/agent.go:CreateAgent`),只需在 `server/cmd/multica/cmd_agent.go` 加 `create` 子命令。
|
||||
|
||||
**工作量**:小(~50 行)
|
||||
|
||||
### 4.5 [非 blocker] System Agent 问题
|
||||
|
||||
**之前误判为 hard blocker,实际不是**:
|
||||
|
||||
Quick-create Issue 当前的设计就要求用户**预先有一个 agent** 才能用——AI 路径不为"零 agent 起步"服务。Phase 2/3 沿用这个前提,所以**新 workspace 没 agent 时 AI 功能不可用**是符合现有产品模型的,不需要 bootstrap 一个 system agent。
|
||||
|
||||
产品自然解锁路径:
|
||||
1. 新用户进 workspace
|
||||
2. 用 **Phase 1 Template**(无需 AI、无需现有 agent)创建第一个 agent
|
||||
3. 之后 Phase 2/3 即可用,host_agent 就用刚创建的那个
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键设计决策(及理由)
|
||||
|
||||
### 5.1 为什么不接 Anthropic 官方 marketplace?
|
||||
|
||||
**结构错配**。Anthropic 官方 marketplace(`anthropics/claude-plugins-official`)是 **plugin 体系**:每个 plugin 是个 bundle,包含 `.claude-plugin/plugin.json` + `skills/` + `agents/` + `hooks/` + `.mcp.json`。
|
||||
|
||||
Multica 只有**单体 skill**(SKILL.md + skill_file),没有 plugin / bundle 概念。要接入得新写 plugin parser + 拆分逻辑,工作量大,而 skills.sh 已经覆盖了同一批高质量内容(skills.sh 后端就是 GitHub raw,绝大多数 skill 作者就在 GitHub 上,Anthropic plugin 体系里的 skill 通常也在作者的 GitHub repo 里有单体副本)。
|
||||
|
||||
### 5.2 为什么走 quick-create 模式而不是后端直接调 LLM?
|
||||
|
||||
代码事实:`server/` 目前**完全没有任何 LLM SDK**(grep `anthropic-sdk-go` / `openai-go` / 任何 LLM provider 都是 0 命中)。所有 LLM 调用都通过 daemon → runtime → CLI 这条路。
|
||||
|
||||
走 quick-create 模式的优势:
|
||||
- **不引入新基础设施**(SSE / LLM client / API key 管理)
|
||||
- **复用 agent 的 instructions / model / runtime 配置**(用户已经在某个 agent 里配置过的偏好自动生效)
|
||||
- **统一计费 / 用量监控**(LLM 调用都计在用户 agent 的 quota 里)
|
||||
|
||||
代价:
|
||||
- 用户得**预先有一个 agent**(参见 §4.5,这跟 Quick-create Issue 现状一致)
|
||||
- LLM 调用通过 daemon 多一跳,延迟略增(但不阻塞 202 响应)
|
||||
|
||||
### 5.3 为什么 Skill Finder 是 endpoint 不是 SKILL.md?
|
||||
|
||||
**Skill Finder 名字里的 "Skill" 是它的产物(找的是 skill),不是它自己实现成 SKILL.md**。
|
||||
|
||||
如果做成 SKILL.md 文件:
|
||||
- 它得装进某个 agent 里才能用 → 单点功能变得需要前置配置
|
||||
- skill 教 agent 调什么?调 `npx skills`(装到本地,目标错)?调 Multica API(那要写 tool channel,绕一大圈)
|
||||
- AI 创建 Agent(Phase 3)那条路要"启动 agent → agent 调 skill → skill 调 tool",链路复杂三倍
|
||||
|
||||
做成 endpoint:
|
||||
- 用户独立可用(独立 UI 入口)
|
||||
- AI 创建 Agent 后端直接调 endpoint,两个功能共用一段逻辑
|
||||
- 简单
|
||||
|
||||
### 5.4 Curated Skill 索引 vs 实时搜索
|
||||
|
||||
**MVP 用 curated 清单**(几十条精选 URL + 摘要 commit 在 repo 里)。理由:
|
||||
- 质量可控
|
||||
- 不踩 GitHub Code Search rate limit
|
||||
- 不被 LLM 编 URL(LLM 知识 cutoff + hallucinate URL 是真问题)
|
||||
- 维护成本低
|
||||
|
||||
进阶可加 `search_skills(query)` tool 实时打 GitHub Code Search,等用户反馈"清单太窄"再做。
|
||||
|
||||
### 5.5 不做 ClawHub(顺手清理建议)
|
||||
|
||||
**现状**:`POST /api/skills/import` 当前支持 3 个 source(`fetchFromClawHub` skill.go:642-744、`fetchFromSkillsSh` skill.go:757-879、`fetchFromGitHub` skill.go:1363-1463)。ClawHub 是个独立 HTTP 客户端,不复用 GitHub 基础设施。
|
||||
|
||||
**判断**(详见之前讨论):
|
||||
- ClawHub 服务的是 OpenClaw 平台(Multica 同生态位竞品的内容生态)
|
||||
- UI 没有发现/搜索层,用户只能粘 URL,而 ClawHub 装机量远低于 skills.sh,用户主动逛的概率极低
|
||||
- 独立代码路径,API 演进时单独跟进
|
||||
|
||||
**建议**(独立于本计划,可以一起做也可以延后):
|
||||
- 跑 `SELECT count(*) FROM skill WHERE config->'origin'->>'type' = 'clawhub'` 看实际使用量
|
||||
- 接近 0 → 渐进下线(先去 UI SourceCard,后续 release 删 fetcher)
|
||||
- 有量 → 留着,但仍不为它做新功能
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施依赖与排期
|
||||
|
||||
```
|
||||
[Phase 1] Template
|
||||
└── 独立,无依赖
|
||||
└── 包含 2 个 soft blocker 的修复(§4.1 §4.2)
|
||||
↓
|
||||
[Phase 2] Skill Finder
|
||||
└── 依赖 Phase 1 中的 skill import 路径(已存在,沿用)
|
||||
└── 含 1 个 soft blocker(§4.3)
|
||||
↓
|
||||
[Phase 3] AI Create Agent
|
||||
└── 依赖 Phase 2(复用 find skill 能力)
|
||||
└── 含 1 个 soft blocker(§4.4)
|
||||
```
|
||||
|
||||
**真实排期建议**:
|
||||
- Phase 1 可单独发版,有独立价值
|
||||
- Phase 2 独立可发版(找 skill 是高频独立场景)
|
||||
- Phase 3 等 Phase 2 ready 后开始
|
||||
|
||||
每个 phase 启动时单独开 PR 设计 doc,本文档只是路线图。
|
||||
|
||||
---
|
||||
|
||||
## 7. 风险与缓解
|
||||
|
||||
| 风险 | 缓解 |
|
||||
|---|---|
|
||||
| GitHub rate limit(模板 import 多个 skill 时) | 已有 `GITHUB_TOKEN` env 支持(skill.go:1163-1166),5000/h 配额够用。生产环境确保配置 |
|
||||
| 模板里引用的 skill repo 被作者删除 | from-template handler 容错:某个 skill fetch 失败 → 整个事务回滚,前端展示具体哪个 URL 挂了。模板自己也定期 review |
|
||||
| LLM 推荐编造 URL(Phase 2) | 用 curated 清单作为 context,**不让 LLM 自由发挥 URL**,推荐范围限定在清单内 |
|
||||
| Phase 3 LLM 写出离谱 instructions | 用户在 inbox 通知里点击 → 跳新 agent 详情页**编辑模式**,不直接进入"已就绪"状态。用户必须确认 |
|
||||
| 模板格式后续要演进(加字段) | Template JSON 加 `version` 字段,后端按 version 兼容老格式 |
|
||||
| Curated skill 清单过时(作者改 repo / 删 skill) | 加 CI 任务定期跑一遍清单 URL,挂掉的报警通知维护者 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 不在本文档范围(已识别的下一步话题)
|
||||
|
||||
- 跨 workspace 模板共享 / marketplace 化(用户能把自己的 agent 存成模板分享)
|
||||
- 实时 GitHub Code Search tool(Phase 2 进阶)
|
||||
- Server-side LLM 调用基础设施(如果未来需要 streaming 等场景)
|
||||
- ClawHub 下线决策(独立讨论,见 §5.5)
|
||||
- Skill 版本管理(workspace skill 版本号 / 升级提示)
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:代码索引
|
||||
|
||||
> 给接手开发的同事的快速参考。每条 file:line 都在本计划里被引用过,记录在这里方便跳转。
|
||||
|
||||
| 主题 | 位置 |
|
||||
|---|---|
|
||||
| Skill DB 模型 | `server/migrations/008_structured_skills.up.sql:4-32` |
|
||||
| Skill 创建 handler + 事务 | `server/internal/handler/skill.go:143-162` + `skill_create.go:21-71` |
|
||||
| Skill import 入口(支持 3 个 source) | `server/internal/handler/skill.go:1538` |
|
||||
| Skill import source 分发 | `server/internal/handler/skill.go:586-617` (`detectImportSource`) |
|
||||
| Skills.sh fetcher | `server/internal/handler/skill.go:757-879` (`fetchFromSkillsSh`) |
|
||||
| GitHub fetcher | `server/internal/handler/skill.go:1363-1463` (`fetchFromGitHub`) |
|
||||
| ClawHub fetcher | `server/internal/handler/skill.go:642-744` (`fetchFromClawHub`) |
|
||||
| Agent 创建 handler | `server/internal/handler/agent.go:380-399` (request) + `:422-564` (CreateAgent) |
|
||||
| Agent 创建 sqlc | `server/pkg/db/queries/agent.sql:19-25` |
|
||||
| Agent-Skill 关联 sqlc | `server/pkg/db/queries/agent.sql:86-103` |
|
||||
| 当前 Agent Duplication(前端模式) | `packages/views/agents/components/agents-page.tsx:286-301`(post-create skill copy) |
|
||||
| Agent 创建 dialog | `packages/views/agents/components/create-agent-dialog.tsx` |
|
||||
| Skill add dialog | `packages/views/agents/components/skill-add-dialog.tsx` |
|
||||
| Quick-create Issue handler | `server/internal/handler/issue.go:877-982` (`QuickCreateIssue`) |
|
||||
| Quick-create task enqueue | `server/internal/service/task.go:488+` (`EnqueueQuickCreateTask`) |
|
||||
| Daemon claim + load skills | `server/internal/handler/daemon.go:1018-1098` + `service/task.go:1447-1463` |
|
||||
| Daemon prompt build | `server/internal/daemon/prompt.go:17-36` (dispatch) + `:45-106` (`buildQuickCreatePrompt`) |
|
||||
| Daemon execenv prepare | `server/internal/daemon/execenv/execenv.go:103-176` |
|
||||
| Skill 目录约定(runtime mapping) | `server/internal/daemon/execenv/context.go:121-158` (`resolveSkillsDir`) |
|
||||
| Skill 文件落盘 | `server/internal/daemon/execenv/context.go:175-204` (`writeSkillFiles`) |
|
||||
| Quick-create 完成检测 + inbox | `server/internal/service/task.go:1810-1949` |
|
||||
| LinkTaskToIssue | `server/internal/handler/agent.go:97-105` |
|
||||
| Quick-create Issue 前端 modal | `packages/views/modals/quick-create-issue.tsx:48-570+` |
|
||||
| Multica CLI 入口 | `server/cmd/multica/main.go:62-79` |
|
||||
| Skill CLI 命令 | `server/cmd/multica/cmd_skill.go:17-96`(已有 import,无 find) |
|
||||
| Agent CLI 命令 | `server/cmd/multica/cmd_agent.go:101-112`(已有 list/get,无 create) |
|
||||
130
docs/rfcs/0001-mention-dedup-policy.md
Normal file
130
docs/rfcs/0001-mention-dedup-policy.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# RFC: Per-mention agent task enqueue (drop @mention coalescing dedup)
|
||||
|
||||
- Issue: [MUL-1913](mention://issue/9f54962b-e055-43eb-a649-1b16db52fea2)
|
||||
- Status: Accepted
|
||||
- Date: 2026-05-09
|
||||
|
||||
## Background
|
||||
|
||||
When a member @mentions an agent on an issue (or a member comments on an
|
||||
issue assigned to an agent), the trigger path enqueues an `agent_task_queue`
|
||||
row. Today both paths short-circuit when the same agent already has a
|
||||
`queued` or `dispatched` task on the same issue:
|
||||
|
||||
- `server/internal/handler/comment.go` `enqueueMentionedAgentTasks` — @mention
|
||||
trigger
|
||||
- `server/internal/handler/issue.go` `shouldEnqueueOnComment` — assignee-on-
|
||||
comment trigger
|
||||
|
||||
Both call `Queries.HasPendingTaskForIssueAndAgent` and skip enqueue when it
|
||||
returns true. The intent was a coalescing queue: rapid-fire comments fold
|
||||
into a single pending task, and when that task picks up it reads all the
|
||||
latest comments anyway.
|
||||
|
||||
## Problem
|
||||
|
||||
The coalescing model has three user-visible costs:
|
||||
|
||||
1. **No UI feedback for the merged comment.** A second @mention does not
|
||||
create a task, so no queued banner appears and there is no toast saying
|
||||
"merged into pending task". Users perceive the @mention as lost.
|
||||
2. **Trigger comment provenance is lost.** Only the first trigger comment
|
||||
is recorded on the task; subsequent triggers are not referenced by any
|
||||
task. Auditing "what made this run happen" fails.
|
||||
3. **Distinct intents collapse.** When two @mentions live in different
|
||||
threads with different requests ("add a test" vs. "fix copy"), folding
|
||||
them into one task forces the agent to disambiguate, and the user cannot
|
||||
cancel one without cancelling both.
|
||||
|
||||
Different threads and different mention text are strong signals that the
|
||||
two triggers are distinct intents — coalescing throws that signal away.
|
||||
|
||||
## Decision
|
||||
|
||||
**Adopt option C: every @mention or assignee-comment trigger creates its
|
||||
own task.** No `(issue, agent)` dedup at enqueue time.
|
||||
|
||||
Per-(issue, agent) execution stays serial because `ClaimAgentTask`
|
||||
(`server/pkg/db/queries/agent.sql`) refuses to dispatch a queued row when
|
||||
the same agent has another `dispatched` or `running` row on the same
|
||||
issue. Multiple queued rows pile up safely and drain in
|
||||
`(priority DESC, created_at ASC)` order. This is a coordination-side
|
||||
property — `FOR UPDATE SKIP LOCKED` locks the row being claimed, not the
|
||||
`(issue, agent)` key — and relies on the daemon today never invoking
|
||||
`ClaimAgentTask` concurrently for the same agent. Tightening that into a
|
||||
real DB-level guarantee (e.g. an advisory lock keyed on `(issue, agent)`)
|
||||
is out of scope for this RFC.
|
||||
|
||||
### Mutual exclusion between on_comment and @mention paths
|
||||
|
||||
Without `(issue, agent)` dedup at enqueue time, a single member comment
|
||||
that @mentions the assignee would otherwise enqueue twice with identical
|
||||
`trigger_comment_id`: once via the on_comment path
|
||||
(`shouldEnqueueOnComment` → `EnqueueTaskForIssue`) and once via the
|
||||
@mention path (`enqueueMentionedAgentTasks` → `EnqueueTaskForMention`).
|
||||
Same trick applies to a plain reply that inherits the assignee mention
|
||||
from the thread root.
|
||||
|
||||
The on_comment gate gains a `commentMentionsAssignee` clause that uses the
|
||||
same effective-mention computation as the @mention path
|
||||
(`shouldInheritParentMentions` for inheritance). When the @mention path
|
||||
will enqueue for the assignee, on_comment skips. The two paths become
|
||||
mutually exclusive on a `(comment, assignee)` pair.
|
||||
|
||||
### Considered alternatives
|
||||
|
||||
- **A. Keep coalescing, add a UI hint** ("merged into pending task"). Fixes
|
||||
visibility but not provenance and not the distinct-intent case.
|
||||
- **B. Allow up to N queued tasks per (issue, agent), coalesce above N.**
|
||||
Combines the worst of both — still loses the Nth+1 trigger comment, and
|
||||
introduces a magic number.
|
||||
- **C. No dedup, every trigger creates a task. (Chosen.)**
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **True rapid-fire duplicate suppression.** A user double-clicks @ within
|
||||
a second; both create tasks and the agent runs twice on identical
|
||||
context. Acceptable cost — the agent reads its own previous comment in
|
||||
the second run and can early-exit. We may revisit by having the
|
||||
scheduler skip a queued task when "no relevant comments since the
|
||||
previous task for this (issue, agent) completed", but that is a
|
||||
follow-up, not a blocker for this RFC.
|
||||
- **Cross-agent dedup.** Different agents on the same issue continue to
|
||||
run in parallel; nothing changes there.
|
||||
- **Queued banner UI.** Already shipped in
|
||||
[MUL-1897](mention://issue/14fdefb4-3a36-4406-a840-1f6700ac95b5).
|
||||
|
||||
## Implementation
|
||||
|
||||
1. Remove the `HasPendingTaskForIssueAndAgent` short-circuit in
|
||||
`enqueueMentionedAgentTasks`.
|
||||
2. Remove the `HasPendingTaskForIssueAndAgent` short-circuit in
|
||||
`shouldEnqueueOnComment`. The function reduces to the
|
||||
assignee-readiness check (`isAgentAssigneeReady` + non-backlog status).
|
||||
3. Add the `commentMentionsAssignee` clause to the on_comment gate so
|
||||
the on_comment and @mention paths are mutually exclusive on a
|
||||
`(comment, assignee)` pair (see "Mutual exclusion" above).
|
||||
4. Drop `HasPendingTaskForIssueAndAgent` and the unused
|
||||
`HasPendingTaskForIssue` from `server/pkg/db/queries/agent.sql`. Re-run
|
||||
`make sqlc`.
|
||||
5. Tests:
|
||||
- `TestRepeatedMentionsEnqueueSeparateTasks` — two @mentions on an
|
||||
unassigned issue produce two `queued` rows with distinct
|
||||
`trigger_comment_id` values.
|
||||
- `TestAssigneeMentionDoesNotDoubleEnqueue` — a member comment that
|
||||
@mentions the assignee on an assigned issue produces exactly one
|
||||
`queued` row (the mention path), not two.
|
||||
6. No migration needed. No frontend changes needed: the queued banner
|
||||
already aggregates over `ListActiveTasksByIssue`, so multiple queued
|
||||
rows render correctly.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Cost:** users who comment frequently on agent-assigned issues will
|
||||
trigger more runs than today. Mitigated by per-(issue, agent) serial
|
||||
execution — no extra concurrency, just more sequential work — and by
|
||||
the future "skip if no relevant comments" optimization noted above.
|
||||
- **Replay:** the second queued task reads issue state that the first
|
||||
task may have already addressed. Agents already need to read recent
|
||||
comments and judge whether work is still required; this RFC does not
|
||||
change that contract.
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* E2E: chat attachment upload + send back-fills the message link.
|
||||
*
|
||||
* Stays at the HTTP layer (auth → upload-file → send-chat-message → DB
|
||||
* check) so the test doesn't depend on a real agent runtime being online.
|
||||
* The UI wiring is covered by `chat-input.test.tsx` in @multica/views; this
|
||||
* spec is the end-to-end contract proof: the backend really does persist
|
||||
* chat_session_id at upload and back-fill chat_message_id at send.
|
||||
*/
|
||||
import "./env";
|
||||
import { test, expect } from "@playwright/test";
|
||||
import pg from "pg";
|
||||
import { createTestApi } from "./helpers";
|
||||
import type { TestApiClient } from "./fixtures";
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL || `http://localhost:${process.env.PORT || "8080"}`;
|
||||
const DATABASE_URL =
|
||||
process.env.DATABASE_URL ?? "postgres://multica:multica@localhost:5432/multica?sslmode=disable";
|
||||
|
||||
interface UploadRow {
|
||||
id: string;
|
||||
url: string;
|
||||
chat_session_id: string | null;
|
||||
chat_message_id: string | null;
|
||||
}
|
||||
|
||||
async function authedFetch(api: TestApiClient, path: string, init?: RequestInit) {
|
||||
const token = api.getToken();
|
||||
if (!token) throw new Error("test api client not logged in");
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
return fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
}
|
||||
|
||||
test.describe("Chat attachments", () => {
|
||||
let api: TestApiClient;
|
||||
let pgClient: pg.Client | null = null;
|
||||
let createdSessionId: string | null = null;
|
||||
let createdAgentId: string | null = null;
|
||||
let createdRuntimeId: string | null = null;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
api = await createTestApi();
|
||||
pgClient = new pg.Client(DATABASE_URL);
|
||||
await pgClient.connect();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
try {
|
||||
if (pgClient) {
|
||||
if (createdSessionId) {
|
||||
await pgClient.query(`DELETE FROM chat_session WHERE id = $1`, [createdSessionId]);
|
||||
}
|
||||
if (createdAgentId) {
|
||||
await pgClient.query(`DELETE FROM agent WHERE id = $1`, [createdAgentId]);
|
||||
}
|
||||
if (createdRuntimeId) {
|
||||
await pgClient.query(`DELETE FROM agent_runtime WHERE id = $1`, [createdRuntimeId]);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pgClient) await pgClient.end();
|
||||
pgClient = null;
|
||||
createdSessionId = null;
|
||||
createdAgentId = null;
|
||||
createdRuntimeId = null;
|
||||
await api.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("upload-file binds attachment to the chat_session; send back-fills chat_message_id", async () => {
|
||||
expect(pgClient).not.toBeNull();
|
||||
const pgc = pgClient!;
|
||||
|
||||
// Resolve the workspace + caller so we can seed an agent/runtime/session
|
||||
// directly via SQL. Going through the HTTP API would require modelling
|
||||
// local-daemon ownership which isn't needed for this contract test.
|
||||
const workspaces = await api.getWorkspaces();
|
||||
const ws = workspaces[0]!;
|
||||
api.setWorkspaceSlug(ws.slug);
|
||||
api.setWorkspaceId(ws.id);
|
||||
|
||||
const userRow = await pgc.query(
|
||||
`SELECT id FROM "user" WHERE email = $1 LIMIT 1`,
|
||||
["e2e@multica.ai"],
|
||||
);
|
||||
if (userRow.rows.length === 0) throw new Error("e2e user missing");
|
||||
const userId = userRow.rows[0].id as string;
|
||||
|
||||
// Seed runtime + agent + chat_session.
|
||||
const runtimeIns = await pgc.query(
|
||||
`INSERT INTO agent_runtime (
|
||||
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
||||
device_info, metadata, last_seen_at
|
||||
)
|
||||
VALUES ($1, NULL, $2, 'cloud', $3, 'online', $4, '{}'::jsonb, now())
|
||||
RETURNING id`,
|
||||
[ws.id, `e2e chat runtime ${Date.now()}`, "e2e_chat_runtime", "E2E chat runtime"],
|
||||
);
|
||||
createdRuntimeId = runtimeIns.rows[0].id as string;
|
||||
|
||||
const agentIns = await pgc.query(
|
||||
`INSERT INTO agent (
|
||||
workspace_id, name, description, runtime_mode, runtime_config,
|
||||
runtime_id, visibility, max_concurrent_tasks, owner_id
|
||||
)
|
||||
VALUES ($1, $2, '', 'cloud', '{}'::jsonb, $3, 'workspace', 1, $4)
|
||||
RETURNING id`,
|
||||
[ws.id, `E2E Chat Agent ${Date.now()}`, createdRuntimeId, userId],
|
||||
);
|
||||
createdAgentId = agentIns.rows[0].id as string;
|
||||
|
||||
const sessionIns = await pgc.query(
|
||||
`INSERT INTO chat_session (workspace_id, agent_id, creator_id, title, status)
|
||||
VALUES ($1, $2, $3, 'E2E Chat Attachment Session', 'active')
|
||||
RETURNING id`,
|
||||
[ws.id, createdAgentId, userId],
|
||||
);
|
||||
createdSessionId = sessionIns.rows[0].id as string;
|
||||
|
||||
// 1. Upload a small PNG against the chat session.
|
||||
const pngBytes = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
|
||||
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR
|
||||
]);
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([new Uint8Array(pngBytes)], { type: "image/png" }), "e2e.png");
|
||||
form.append("chat_session_id", createdSessionId);
|
||||
const uploadRes = await authedFetch(api, "/api/upload-file", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
headers: { "X-Workspace-Slug": ws.slug },
|
||||
});
|
||||
expect(uploadRes.status).toBe(200);
|
||||
const uploaded = (await uploadRes.json()) as UploadRow;
|
||||
expect(uploaded.chat_session_id).toBe(createdSessionId);
|
||||
expect(uploaded.chat_message_id).toBeNull();
|
||||
expect(uploaded.url).toBeTruthy();
|
||||
|
||||
// 2. Send a chat message that references the attachment.
|
||||
const sendRes = await authedFetch(api, `/api/chat/sessions/${createdSessionId}/messages`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Workspace-Slug": ws.slug,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: `look at this `,
|
||||
attachment_ids: [uploaded.id],
|
||||
}),
|
||||
});
|
||||
expect(sendRes.status).toBe(201);
|
||||
const sendBody = (await sendRes.json()) as { message_id: string; task_id: string };
|
||||
expect(sendBody.message_id).toBeTruthy();
|
||||
|
||||
// 3. DB check: the attachment row's chat_message_id matches the new message.
|
||||
const after = await pgc.query<{ chat_message_id: string | null }>(
|
||||
`SELECT chat_message_id::text FROM attachment WHERE id = $1`,
|
||||
[uploaded.id],
|
||||
);
|
||||
expect(after.rows[0]?.chat_message_id).toBe(sendBody.message_id);
|
||||
|
||||
// 4. Clean up the attachment we created (chat_session cascade handles the
|
||||
// rest in afterEach via chat_session row deletion).
|
||||
await pgc.query(`DELETE FROM attachment WHERE id = $1`, [uploaded.id]);
|
||||
});
|
||||
});
|
||||
@@ -49,8 +49,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -82,30 +82,3 @@ export function agentTasksOptions(wsId: string, agentId: string) {
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Agent templates are workspace-independent: a static catalog served from
|
||||
// the server's embedded JSON. Cache effectively forever — the only way the
|
||||
// list / detail change is a server deploy, and a hard reload picks that up.
|
||||
export const agentTemplateKeys = {
|
||||
all: () => ["agent-templates"] as const,
|
||||
list: () => [...agentTemplateKeys.all(), "list"] as const,
|
||||
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
|
||||
};
|
||||
|
||||
export function agentTemplateListOptions() {
|
||||
return queryOptions({
|
||||
queryKey: agentTemplateKeys.list(),
|
||||
queryFn: () => api.listAgentTemplates(),
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function agentTemplateDetailOptions(slug: string) {
|
||||
return queryOptions({
|
||||
queryKey: agentTemplateKeys.detail(slug),
|
||||
queryFn: () => api.getAgentTemplate(slug),
|
||||
staleTime: Infinity,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -144,123 +144,4 @@ describe("ApiClient", () => {
|
||||
expect(headers["X-Client-Version"]).toBeUndefined();
|
||||
expect(headers["X-Client-OS"]).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("getAttachment", () => {
|
||||
it("returns the parsed attachment for a well-formed response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
id: "att-1",
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "u-1",
|
||||
filename: "report.md",
|
||||
url: "https://static.example.test/ws/att-1.md",
|
||||
download_url:
|
||||
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
|
||||
content_type: "text/markdown",
|
||||
size_bytes: 123,
|
||||
created_at: "2026-05-11T00:00:00Z",
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const att = await client.getAttachment("att-1");
|
||||
|
||||
expect(att.id).toBe("att-1");
|
||||
expect(att.download_url).toContain("Policy=");
|
||||
});
|
||||
|
||||
it("falls back to an empty attachment when the response is missing download_url", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "att-1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const att = await client.getAttachment("att-1");
|
||||
|
||||
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
|
||||
// safely read `download_url` without crashing — they'll see "" and
|
||||
// surface a user-facing error instead of opening `undefined`.
|
||||
expect(att.id).toBe("");
|
||||
expect(att.download_url).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat attachment wiring", () => {
|
||||
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const file = new File(["hi"], "hi.png", { type: "image/png" });
|
||||
await client.uploadFile(file, { chatSessionId: "session-123" });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = fetchMock.mock.calls[0]!;
|
||||
expect(url).toBe("https://api.example.test/api/upload-file");
|
||||
expect(init?.method).toBe("POST");
|
||||
const body = init?.body as FormData;
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
expect(body.get("chat_session_id")).toBe("session-123");
|
||||
expect(body.get("issue_id")).toBeNull();
|
||||
expect(body.get("comment_id")).toBeNull();
|
||||
});
|
||||
|
||||
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
content: "hello",
|
||||
attachment_ids: ["att-1", "att-2"],
|
||||
});
|
||||
});
|
||||
|
||||
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation(() =>
|
||||
Promise.resolve(
|
||||
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await client.sendChatMessage("session-1", "hello");
|
||||
await client.sendChatMessage("session-1", "again", []);
|
||||
|
||||
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
|
||||
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,6 @@ import type {
|
||||
ListIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
CreateAgentFromTemplateRequest,
|
||||
CreateAgentFromTemplateResponse,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
@@ -42,15 +38,13 @@ import type {
|
||||
RuntimeHourlyActivity,
|
||||
RuntimeUsageByAgent,
|
||||
RuntimeUsageByHour,
|
||||
DashboardUsageDaily,
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
RuntimeUpdate,
|
||||
RuntimeModelListRequest,
|
||||
RuntimeLocalSkillListRequest,
|
||||
CreateRuntimeLocalSkillImportRequest,
|
||||
RuntimeLocalSkillImportRequest,
|
||||
TimelineEntry,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
AssigneeFrequencyEntry,
|
||||
TaskMessagePayload,
|
||||
Attachment,
|
||||
@@ -88,9 +82,6 @@ import type {
|
||||
ListAutopilotRunsResponse,
|
||||
NotificationPreferenceResponse,
|
||||
NotificationPreferences,
|
||||
GitHubPullRequest,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -98,24 +89,13 @@ import { createRequestId } from "../utils";
|
||||
import { getCurrentSlug } from "../platform/workspace-storage";
|
||||
import { parseWithFallback } from "./schema";
|
||||
import {
|
||||
AgentTemplateSchema,
|
||||
AgentTemplateSummaryListSchema,
|
||||
AttachmentResponseSchema,
|
||||
ChildIssuesResponseSchema,
|
||||
CommentsListSchema,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
DashboardUsageByAgentListSchema,
|
||||
DashboardUsageDailyListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_DETAIL,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
EMPTY_ATTACHMENT,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
EMPTY_LIST_ISSUES_RESPONSE,
|
||||
EMPTY_TIMELINE_ENTRIES,
|
||||
EMPTY_TIMELINE_PAGE,
|
||||
ListIssuesResponseSchema,
|
||||
SubscribersListSchema,
|
||||
TimelineEntriesSchema,
|
||||
TimelinePageSchema,
|
||||
} from "./schemas";
|
||||
|
||||
/** Identifies the calling client to the server.
|
||||
@@ -463,7 +443,7 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
@@ -537,11 +517,20 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listTimeline(issueId: string): Promise<TimelineEntry[]> {
|
||||
async listTimeline(
|
||||
issueId: string,
|
||||
pageParam: TimelinePageParam = { mode: "latest" },
|
||||
limit = 50,
|
||||
): Promise<TimelinePage> {
|
||||
const params = new URLSearchParams();
|
||||
params.set("limit", String(limit));
|
||||
if (pageParam.mode === "before") params.set("before", pageParam.cursor);
|
||||
else if (pageParam.mode === "after") params.set("after", pageParam.cursor);
|
||||
else if (pageParam.mode === "around") params.set("around", pageParam.id);
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/issues/${issueId}/timeline`,
|
||||
`/api/issues/${issueId}/timeline?${params.toString()}`,
|
||||
);
|
||||
return parseWithFallback(raw, TimelineEntriesSchema, EMPTY_TIMELINE_ENTRIES, {
|
||||
return parseWithFallback(raw, TimelinePageSchema, EMPTY_TIMELINE_PAGE, {
|
||||
endpoint: "GET /api/issues/:id/timeline",
|
||||
});
|
||||
}
|
||||
@@ -644,51 +633,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
|
||||
const raw = await this.fetch<unknown>("/api/agent-templates");
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
AgentTemplateSummaryListSchema,
|
||||
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
|
||||
{ endpoint: "GET /api/agent-templates" },
|
||||
);
|
||||
}
|
||||
|
||||
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
|
||||
const raw = await this.fetch<unknown>(
|
||||
`/api/agent-templates/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
// Round-trip the requested slug into the fallback so a malformed
|
||||
// detail response still produces a navigable record matching the URL
|
||||
// the user clicked.
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
AgentTemplateSchema,
|
||||
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
|
||||
{ endpoint: "GET /api/agent-templates/:slug" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Creates an agent from a curated template. The server fetches every
|
||||
* referenced skill URL in parallel, materializes them into the workspace
|
||||
* (find-or-create by name), and writes the agent + skill bindings in a
|
||||
* single transaction. On any upstream fetch failure, the entire write is
|
||||
* rolled back and the API returns 422 with `failed_urls`. */
|
||||
async createAgentFromTemplate(
|
||||
data: CreateAgentFromTemplateRequest,
|
||||
): Promise<CreateAgentFromTemplateResponse> {
|
||||
const raw = await this.fetch<unknown>("/api/agents/from-template", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
return parseWithFallback(
|
||||
raw,
|
||||
CreateAgentFromTemplateResponseSchema,
|
||||
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
|
||||
{ endpoint: "POST /api/agents/from-template" },
|
||||
);
|
||||
}
|
||||
|
||||
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
|
||||
return this.fetch(`/api/agents/${id}`, {
|
||||
method: "PUT",
|
||||
@@ -723,16 +667,6 @@ export class ApiClient {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateRuntime(
|
||||
runtimeId: string,
|
||||
patch: { timezone?: string; visibility?: "private" | "public" },
|
||||
): Promise<AgentRuntime> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeUsage(runtimeId: string, params?: { days?: number }): Promise<RuntimeUsage[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.days) search.set("days", String(params.days));
|
||||
@@ -761,58 +695,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/usage/by-hour?${search}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard — three independent rollups for `/{slug}/dashboard`.
|
||||
// Each accepts an optional `project_id` to narrow the scope to one project.
|
||||
// Cost is computed client-side from the model pricing table (same contract
|
||||
// as the per-runtime endpoints above).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getDashboardUsageDaily(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageDaily[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/daily?${search}`);
|
||||
return parseWithFallback<DashboardUsageDaily[]>(
|
||||
raw,
|
||||
DashboardUsageDailyListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/dashboard/usage/daily" },
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardUsageByAgent(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardUsageByAgent[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/usage/by-agent?${search}`);
|
||||
return parseWithFallback<DashboardUsageByAgent[]>(
|
||||
raw,
|
||||
DashboardUsageByAgentListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/dashboard/usage/by-agent" },
|
||||
);
|
||||
}
|
||||
|
||||
async getDashboardAgentRunTime(
|
||||
params: { days?: number; project_id?: string | null },
|
||||
): Promise<DashboardAgentRunTime[]> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.days) search.set("days", String(params.days));
|
||||
if (params.project_id) search.set("project_id", params.project_id);
|
||||
const raw = await this.fetch<unknown>(`/api/dashboard/agent-runtime?${search}`);
|
||||
return parseWithFallback<DashboardAgentRunTime[]>(
|
||||
raw,
|
||||
DashboardAgentRunTimeListSchema,
|
||||
[],
|
||||
{ endpoint: "GET /api/dashboard/agent-runtime" },
|
||||
);
|
||||
}
|
||||
|
||||
async initiateUpdate(
|
||||
runtimeId: string,
|
||||
targetVersion: string,
|
||||
@@ -921,12 +803,6 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async rerunIssue(issueId: string): Promise<AgentTask> {
|
||||
return this.fetch(`/api/issues/${issueId}/rerun`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
// Inbox
|
||||
async listInbox(): Promise<InboxItem[]> {
|
||||
return this.fetch("/api/inbox");
|
||||
@@ -1137,15 +1013,11 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
// File Upload & Attachments
|
||||
async uploadFile(
|
||||
file: File,
|
||||
opts?: { issueId?: string; commentId?: string; chatSessionId?: string },
|
||||
): Promise<Attachment> {
|
||||
async uploadFile(file: File, opts?: { issueId?: string; commentId?: string }): Promise<Attachment> {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (opts?.issueId) formData.append("issue_id", opts.issueId);
|
||||
if (opts?.commentId) formData.append("comment_id", opts.commentId);
|
||||
if (opts?.chatSessionId) formData.append("chat_session_id", opts.chatSessionId);
|
||||
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
@@ -1166,10 +1038,7 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} /api/upload-file`, { rid, duration: `${Date.now() - start}ms` });
|
||||
const raw = (await res.json()) as unknown;
|
||||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||||
endpoint: "POST /api/upload-file",
|
||||
});
|
||||
return res.json() as Promise<Attachment>;
|
||||
}
|
||||
|
||||
// Chat Sessions
|
||||
@@ -1197,18 +1066,10 @@ export class ApiClient {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||||
}
|
||||
|
||||
async sendChatMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachmentIds?: string[],
|
||||
): Promise<SendChatMessageResponse> {
|
||||
const body: { content: string; attachment_ids?: string[] } = { content };
|
||||
if (attachmentIds && attachmentIds.length > 0) {
|
||||
body.attachment_ids = attachmentIds;
|
||||
}
|
||||
async sendChatMessage(sessionId: string, content: string): Promise<SendChatMessageResponse> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1232,17 +1093,6 @@ export class ApiClient {
|
||||
return this.fetch(`/api/issues/${issueId}/attachments`);
|
||||
}
|
||||
|
||||
// Fetches a fresh attachment metadata record. The server re-signs
|
||||
// `download_url` on every call (30 min expiry), so the click-time
|
||||
// download flow uses this endpoint to avoid handing the user a stale
|
||||
// signed URL cached in TanStack Query.
|
||||
async getAttachment(id: string): Promise<Attachment> {
|
||||
const raw = await this.fetch<unknown>(`/api/attachments/${id}`);
|
||||
return parseWithFallback(raw, AttachmentResponseSchema, EMPTY_ATTACHMENT, {
|
||||
endpoint: "GET /api/attachments/{id}",
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAttachment(id: string): Promise<void> {
|
||||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
@@ -1426,23 +1276,4 @@ export class ApiClient {
|
||||
async deleteAutopilotTrigger(autopilotId: string, triggerId: string): Promise<void> {
|
||||
await this.fetch(`/api/autopilots/${autopilotId}/triggers/${triggerId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// GitHub integration
|
||||
async getGitHubConnectURL(workspaceId: string): Promise<GitHubConnectResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/connect`);
|
||||
}
|
||||
|
||||
async listGitHubInstallations(workspaceId: string): Promise<ListGitHubInstallationsResponse> {
|
||||
return this.fetch(`/api/workspaces/${workspaceId}/github/installations`);
|
||||
}
|
||||
|
||||
async deleteGitHubInstallation(workspaceId: string, installationId: string): Promise<void> {
|
||||
await this.fetch(`/api/workspaces/${workspaceId}/github/installations/${installationId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
async listIssuePullRequests(issueId: string): Promise<{ pull_requests: GitHubPullRequest[] }> {
|
||||
return this.fetch(`/api/issues/${issueId}/pull-requests`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,34 +25,53 @@ afterEach(() => {
|
||||
// an empty/safe shape, never throws into React.
|
||||
describe("ApiClient schema fallback", () => {
|
||||
describe("listTimeline", () => {
|
||||
it("falls back to an empty array when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
it("falls back to an empty page when required fields are missing", async () => {
|
||||
stubFetchJson({});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const entries = await client.listTimeline("issue-1");
|
||||
expect(entries).toEqual([]);
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page).toEqual({
|
||||
entries: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when the body is not an array", async () => {
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
it("falls back when a field has the wrong type", async () => {
|
||||
stubFetchJson({
|
||||
entries: "not-an-array",
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const entries = await client.listTimeline("issue-1");
|
||||
expect(entries).toEqual([]);
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toEqual([]);
|
||||
expect(page.has_more_after).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a new entry type rather than crashing on enum drift", async () => {
|
||||
stubFetchJson([
|
||||
{
|
||||
type: "future_kind", // not in TS union
|
||||
id: "e-1",
|
||||
actor_type: "member",
|
||||
actor_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
stubFetchJson({
|
||||
entries: [
|
||||
{
|
||||
type: "future_kind", // not in TS union
|
||||
id: "e-1",
|
||||
actor_type: "member",
|
||||
actor_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const entries = await client.listTimeline("issue-1");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.type).toBe("future_kind");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toHaveLength(1);
|
||||
expect(page.entries[0]?.type).toBe("future_kind");
|
||||
});
|
||||
|
||||
// Forward-compat: when the server adds a new field to an existing
|
||||
@@ -60,21 +79,51 @@ describe("ApiClient schema fallback", () => {
|
||||
// zod 4 strips it, which would silently break a future TS type that
|
||||
// adopts the field — see schemas.ts header comment.
|
||||
it("preserves unknown fields the schema didn't list", async () => {
|
||||
stubFetchJson([
|
||||
{
|
||||
type: "comment",
|
||||
id: "e-1",
|
||||
actor_type: "member",
|
||||
actor_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
// New server-side field not present in TimelineEntrySchema:
|
||||
future_field: { nested: "value" },
|
||||
},
|
||||
]);
|
||||
stubFetchJson({
|
||||
entries: [
|
||||
{
|
||||
type: "comment",
|
||||
id: "e-1",
|
||||
actor_type: "member",
|
||||
actor_id: "u-1",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
// New server-side field not present in TimelineEntrySchema:
|
||||
future_field: { nested: "value" },
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
// New top-level field not present in TimelinePageSchema:
|
||||
page_metadata: { took_ms: 42 },
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const entries = await client.listTimeline("issue-1");
|
||||
const entry = entries[0] as unknown as Record<string, unknown>;
|
||||
const page = await client.listTimeline("issue-1");
|
||||
const raw = page as unknown as Record<string, unknown>;
|
||||
const entry = page.entries[0] as unknown as Record<string, unknown>;
|
||||
expect(entry.future_field).toEqual({ nested: "value" });
|
||||
expect(raw.page_metadata).toEqual({ took_ms: 42 });
|
||||
});
|
||||
|
||||
it("returns an empty page when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats null arrays as empty arrays", async () => {
|
||||
stubFetchJson({
|
||||
entries: null,
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const page = await client.listTimeline("issue-1");
|
||||
expect(page.entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,108 +166,6 @@ describe("ApiClient schema fallback", () => {
|
||||
expect(res).toEqual({ issues: [] });
|
||||
});
|
||||
});
|
||||
|
||||
// Agent template catalog is hit by the desktop create-agent picker.
|
||||
// Installed desktop builds outlive any given server, so the shape MUST
|
||||
// survive future field renames / wrapping without crashing. Each test
|
||||
// here mirrors a concrete future drift we want to absorb.
|
||||
describe("listAgentTemplates", () => {
|
||||
it("falls back to [] when the body is null", async () => {
|
||||
stubFetchJson(null);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults skills to [] when the field is missing from a template", async () => {
|
||||
// Future server: drops `skills` because the picker no longer reads
|
||||
// them. Picker code calls `template.skills.length` — must not throw.
|
||||
stubFetchJson([{ slug: "x", name: "X" }]);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls).toHaveLength(1);
|
||||
expect(tmpls[0]?.skills).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts the bare-array shape (current contract)", async () => {
|
||||
stubFetchJson([
|
||||
{ slug: "a", name: "A", description: "", skills: [] },
|
||||
{ slug: "b", name: "B", description: "", skills: [] },
|
||||
]);
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("accepts a future {templates: [...]} envelope without breaking", async () => {
|
||||
// Server migrates to a paginated envelope. We unwrap so the picker
|
||||
// keeps working on the older bare-array consumer.
|
||||
stubFetchJson({
|
||||
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
|
||||
total: 1,
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const tmpls = await client.listAgentTemplates();
|
||||
expect(tmpls).toHaveLength(1);
|
||||
expect(tmpls[0]?.slug).toBe("a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAgentTemplate", () => {
|
||||
it("falls back to a minimal record carrying the requested slug", async () => {
|
||||
// Slug is part of the URL the user clicked — the fallback round-
|
||||
// trips it so the page header still makes sense after a parse miss.
|
||||
stubFetchJson({ wrong: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAgentTemplate("code-reviewer");
|
||||
expect(detail.slug).toBe("code-reviewer");
|
||||
expect(detail.skills).toEqual([]);
|
||||
expect(detail.instructions).toBe("");
|
||||
});
|
||||
|
||||
it("defaults instructions to '' when the field is missing", async () => {
|
||||
stubFetchJson({
|
||||
slug: "code-reviewer",
|
||||
name: "Code Reviewer",
|
||||
description: "",
|
||||
skills: [],
|
||||
});
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const detail = await client.getAgentTemplate("code-reviewer");
|
||||
expect(detail.instructions).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createAgentFromTemplate", () => {
|
||||
it("falls back to an empty agent when the response is malformed", async () => {
|
||||
// The agent was created server-side even though the client can't
|
||||
// parse the response — UI code reads `agent.id === ""` and skips
|
||||
// the navigation step rather than landing on `/agents/`.
|
||||
stubFetchJson({ unexpected: "shape" });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const resp = await client.createAgentFromTemplate({
|
||||
template_slug: "x",
|
||||
name: "X",
|
||||
runtime_id: "rt-1",
|
||||
});
|
||||
expect(resp.agent.id).toBe("");
|
||||
expect(resp.imported_skill_ids).toEqual([]);
|
||||
expect(resp.reused_skill_ids).toEqual([]);
|
||||
});
|
||||
|
||||
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
|
||||
stubFetchJson({ agent: { id: "agent-1" } });
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const resp = await client.createAgentFromTemplate({
|
||||
template_slug: "x",
|
||||
name: "X",
|
||||
runtime_id: "rt-1",
|
||||
});
|
||||
expect(resp.agent.id).toBe("agent-1");
|
||||
expect(resp.imported_skill_ids).toEqual([]);
|
||||
expect(resp.reused_skill_ids).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Direct tests for the helper, decoupled from any specific endpoint —
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
Agent,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
CreateAgentFromTemplateResponse,
|
||||
ListIssuesResponse,
|
||||
TimelineEntry,
|
||||
} from "../types";
|
||||
import type { ListIssuesResponse, TimelinePage } from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
@@ -46,44 +38,10 @@ const ReactionSchema = z.object({
|
||||
created_at: z.string(),
|
||||
});
|
||||
|
||||
// Nested attachments embedded in timeline/comment responses stay lenient on
|
||||
// purpose: a single malformed attachment must not knock the whole timeline
|
||||
// into the fallback `[]`.
|
||||
const AttachmentSchema = z.object({
|
||||
id: z.string(),
|
||||
}).loose();
|
||||
|
||||
// Standalone attachment lookup (`GET /api/attachments/{id}`) is the source of
|
||||
// truth for click-time download URLs. The two fields the download flow opens
|
||||
// in a new tab — `download_url` and `url` — must be strings, otherwise we'd
|
||||
// happily `window.open(undefined)`. `filename` gates the toast/title and is
|
||||
// also enforced so a missing value falls back to the empty record below.
|
||||
export const AttachmentResponseSchema = z.object({
|
||||
id: z.string(),
|
||||
url: z.string(),
|
||||
download_url: z.string(),
|
||||
filename: z.string(),
|
||||
chat_session_id: z.string().nullable().optional(),
|
||||
chat_message_id: z.string().nullable().optional(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_ATTACHMENT: Attachment = {
|
||||
id: "",
|
||||
workspace_id: "",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "",
|
||||
uploader_id: "",
|
||||
filename: "",
|
||||
url: "",
|
||||
download_url: "",
|
||||
content_type: "",
|
||||
size_bytes: 0,
|
||||
created_at: "",
|
||||
};
|
||||
|
||||
// All object schemas use `.loose()` so unknown server-side fields pass
|
||||
// through unchanged. zod 4's `.object()` defaults to STRIP, which would
|
||||
// silently drop new fields and surface as a "field neither showed up in
|
||||
@@ -108,12 +66,22 @@ const TimelineEntrySchema = z.object({
|
||||
coalesced_count: z.number().optional(),
|
||||
}).loose();
|
||||
|
||||
// /timeline returns a flat array of TimelineEntry, oldest first. The
|
||||
// previously cursor-paginated wrapper was removed (#1929) — at observed data
|
||||
// sizes (p99 ~30 entries per issue) paged delivery only created bugs.
|
||||
export const TimelineEntriesSchema = z.array(TimelineEntrySchema);
|
||||
export const TimelinePageSchema = z.object({
|
||||
entries: z.array(TimelineEntrySchema).default([]),
|
||||
next_cursor: z.string().nullable().default(null),
|
||||
prev_cursor: z.string().nullable().default(null),
|
||||
has_more_before: z.boolean().default(false),
|
||||
has_more_after: z.boolean().default(false),
|
||||
target_index: z.number().optional(),
|
||||
}).loose();
|
||||
|
||||
export const EMPTY_TIMELINE_ENTRIES: TimelineEntry[] = [];
|
||||
export const EMPTY_TIMELINE_PAGE: TimelinePage = {
|
||||
entries: [],
|
||||
next_cursor: null,
|
||||
prev_cursor: null,
|
||||
has_more_before: false,
|
||||
has_more_after: false,
|
||||
};
|
||||
|
||||
export const CommentSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -177,132 +145,3 @@ export const SubscribersListSchema = z.array(SubscriberSchema);
|
||||
export const ChildIssuesResponseSchema = z.object({
|
||||
issues: z.array(IssueSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workspace dashboard schemas
|
||||
//
|
||||
// The dashboard hits three independent rollup endpoints. Each returns a flat
|
||||
// array, and every field is consumed by chart / KPI math — a missing number
|
||||
// silently degrades to NaN downstream, so we coerce missing numbers to 0.
|
||||
// String fields stay lenient (no enum narrowing) to survive future model /
|
||||
// agent ID drift.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DashboardUsageDailySchema = z.object({
|
||||
date: z.string(),
|
||||
model: z.string(),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const DashboardUsageDailyListSchema = z.array(DashboardUsageDailySchema);
|
||||
|
||||
const DashboardUsageByAgentSchema = z.object({
|
||||
agent_id: z.string(),
|
||||
model: z.string(),
|
||||
input_tokens: z.number().default(0),
|
||||
output_tokens: z.number().default(0),
|
||||
cache_read_tokens: z.number().default(0),
|
||||
cache_write_tokens: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const DashboardUsageByAgentListSchema = z.array(DashboardUsageByAgentSchema);
|
||||
|
||||
const DashboardAgentRunTimeSchema = z.object({
|
||||
agent_id: z.string(),
|
||||
total_seconds: z.number().default(0),
|
||||
task_count: z.number().default(0),
|
||||
failed_count: z.number().default(0),
|
||||
}).loose();
|
||||
|
||||
export const DashboardAgentRunTimeListSchema = z.array(DashboardAgentRunTimeSchema);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent template catalog — `/api/agent-templates*` and the
|
||||
// create-from-template response. The desktop app's create-agent picker
|
||||
// reaches these endpoints, and a future server change to the template shape
|
||||
// would white-screen older installed builds (#2192 pattern) without these
|
||||
// parsers. Lenient by the same rules as IssueSchema above: arrays default to
|
||||
// `[]`, optional fields stay optional, `.loose()` lets unknown fields pass
|
||||
// through unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const AgentTemplateSkillRefSchema = z.object({
|
||||
source_url: z.string(),
|
||||
cached_name: z.string().default(""),
|
||||
cached_description: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
const AgentTemplateSummarySchemaBase = z.object({
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().default(""),
|
||||
category: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
accent: z.string().optional(),
|
||||
// skills MUST default to [] — picker code reads `template.skills.length`
|
||||
// and `.map(...)`, both of which crash on `undefined`. The most common
|
||||
// future drift (field renamed / wrapped) lands here.
|
||||
skills: z.array(AgentTemplateSkillRefSchema).default([]),
|
||||
}).loose();
|
||||
|
||||
export const AgentTemplateSummarySchema = AgentTemplateSummarySchemaBase;
|
||||
|
||||
// List endpoint historically returns a bare array. Server could legitimately
|
||||
// migrate to `{templates: [...]}` later — we accept either shape so an old
|
||||
// desktop survives the upgrade.
|
||||
export const AgentTemplateSummaryListSchema = z.union([
|
||||
z.array(AgentTemplateSummarySchemaBase),
|
||||
z.object({ templates: z.array(AgentTemplateSummarySchemaBase).default([]) })
|
||||
.loose()
|
||||
.transform((v) => v.templates),
|
||||
]);
|
||||
|
||||
export const EMPTY_AGENT_TEMPLATE_SUMMARY_LIST: AgentTemplateSummary[] = [];
|
||||
|
||||
export const AgentTemplateSchema = AgentTemplateSummarySchemaBase.extend({
|
||||
// Detail-only field. Default "" so a malformed detail still renders the
|
||||
// header + skill list; the user just sees an empty Instructions block.
|
||||
instructions: z.string().default(""),
|
||||
}).loose();
|
||||
|
||||
// Used as the parse fallback for `GET /api/agent-templates/:slug`. Slug comes
|
||||
// from the URL, so we round-trip the requested one back into the fallback
|
||||
// at the call site (see `getAgentTemplate` in client.ts).
|
||||
export const EMPTY_AGENT_TEMPLATE_DETAIL: AgentTemplate = {
|
||||
slug: "",
|
||||
name: "",
|
||||
description: "",
|
||||
skills: [],
|
||||
instructions: "",
|
||||
};
|
||||
|
||||
// `agent` is a full Agent record — schematising every field would duplicate
|
||||
// a 50-field interface and bit-rot fast. We keep it loose and require only
|
||||
// `id`, the one field the create-from-template flow consumes (used to
|
||||
// navigate to the new agent's detail page). Downstream code already
|
||||
// optional-chains the rest.
|
||||
const MinimalAgentSchema = z.object({
|
||||
id: z.string(),
|
||||
}).loose();
|
||||
|
||||
export const CreateAgentFromTemplateResponseSchema = z.object({
|
||||
agent: MinimalAgentSchema,
|
||||
imported_skill_ids: z.array(z.string()).default([]),
|
||||
reused_skill_ids: z.array(z.string()).default([]),
|
||||
}).loose();
|
||||
|
||||
// Fallback when the success response fails to parse. The agent server-side
|
||||
// has likely been created already, so we can't pretend nothing happened —
|
||||
// the caller (`create-agent-dialog.tsx`) is responsible for noticing
|
||||
// `agent.id === ""` and skipping navigation while keeping the list
|
||||
// invalidation, so the user finds their new agent in the list.
|
||||
export const EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE: CreateAgentFromTemplateResponse = {
|
||||
agent: { id: "" } as Agent,
|
||||
imported_skill_ids: [],
|
||||
reused_skill_ids: [],
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./queries";
|
||||
@@ -1,72 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
// Workspace dashboard query options. All three endpoints share the same
|
||||
// (wsId, days, projectId) key shape so workspace switching, time-range
|
||||
// changes, and the project filter each invalidate the cache cleanly.
|
||||
//
|
||||
// The cache key includes `wsId` explicitly: TanStack Query already isolates
|
||||
// per workspace via the key, but threading wsId into the queryFn lets
|
||||
// callers fail fast (return [] on empty wsId) instead of issuing a request
|
||||
// the server would reject.
|
||||
//
|
||||
// `projectId` is normalised to `null` (not undefined / "all") so the
|
||||
// queryKey shape is stable across renders even when the dropdown sits on
|
||||
// "all projects".
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: (wsId: string) => ["dashboard", wsId] as const,
|
||||
daily: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "daily", days, projectId] as const,
|
||||
byAgent: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "by-agent", days, projectId] as const,
|
||||
agentRuntime: (wsId: string, days: number, projectId: string | null) =>
|
||||
[...dashboardKeys.all(wsId), "agent-runtime", days, projectId] as const,
|
||||
};
|
||||
|
||||
// 60s staleTime matches the per-runtime usage queries — the data is rollup-
|
||||
// driven on the server (5-min rollup cadence) and the dashboard isn't a
|
||||
// real-time view, so background refetches every minute are plenty.
|
||||
const STALE_TIME = 60 * 1000;
|
||||
|
||||
export function dashboardUsageDailyOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.daily(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageDaily({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboardUsageByAgentOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.byAgent(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardUsageByAgent({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
|
||||
export function dashboardAgentRunTimeOptions(
|
||||
wsId: string,
|
||||
days: number,
|
||||
projectId: string | null,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: dashboardKeys.agentRuntime(wsId, days, projectId),
|
||||
queryFn: () =>
|
||||
api.getDashboardAgentRunTime({ days, project_id: projectId ?? undefined }),
|
||||
enabled: !!wsId,
|
||||
staleTime: STALE_TIME,
|
||||
});
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./queries";
|
||||
@@ -1,22 +0,0 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
|
||||
export const githubKeys = {
|
||||
all: (wsId: string) => ["github", wsId] as const,
|
||||
installations: (wsId: string) => [...githubKeys.all(wsId), "installations"] as const,
|
||||
pullRequests: (issueId: string) => ["github", "pull-requests", issueId] as const,
|
||||
};
|
||||
|
||||
export const githubInstallationsOptions = (wsId: string) =>
|
||||
queryOptions({
|
||||
queryKey: githubKeys.installations(wsId),
|
||||
queryFn: () => api.listGitHubInstallations(wsId),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
|
||||
export const issuePullRequestsOptions = (issueId: string) =>
|
||||
queryOptions({
|
||||
queryKey: githubKeys.pullRequests(issueId),
|
||||
queryFn: () => api.listIssuePullRequests(issueId),
|
||||
enabled: !!issueId,
|
||||
});
|
||||
@@ -14,7 +14,6 @@ export interface UploadResult {
|
||||
export interface UploadContext {
|
||||
issueId?: string;
|
||||
commentId?: string;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
export function useFileUpload(
|
||||
@@ -34,7 +33,6 @@ export function useFileUpload(
|
||||
const att: Attachment = await api.uploadFile(file, {
|
||||
issueId: ctx?.issueId,
|
||||
commentId: ctx?.commentId,
|
||||
chatSessionId: ctx?.chatSessionId,
|
||||
});
|
||||
return { id: att.id, filename: att.filename, link: att.url };
|
||||
} finally {
|
||||
|
||||
@@ -12,9 +12,9 @@ export const PRIORITY_CONFIG: Record<
|
||||
IssuePriority,
|
||||
{ label: string; bars: number; color: string; badgeBg: string; badgeText: string }
|
||||
> = {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-destructive/10", badgeText: "text-destructive" },
|
||||
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-warning/10", badgeText: "text-warning" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-warning/10", badgeText: "text-warning" },
|
||||
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-info/10", badgeText: "text-info" },
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-priority", badgeText: "text-white" },
|
||||
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-priority/80", badgeText: "text-white" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-priority/15", badgeText: "text-priority" },
|
||||
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-priority/10", badgeText: "text-priority" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
@@ -23,6 +23,12 @@ import type {
|
||||
ListIssuesCache,
|
||||
} from "../types";
|
||||
import type { TimelineEntry, IssueSubscriber, Reaction } from "../types";
|
||||
import {
|
||||
mapAllEntries,
|
||||
filterAllEntries,
|
||||
prependToLatestPage,
|
||||
type TimelineCacheData,
|
||||
} from "./timeline-cache";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mutation variable types — used by both mutation hooks and
|
||||
@@ -109,7 +115,7 @@ export function useCreateIssue() {
|
||||
);
|
||||
// Surface the just-created issue in cmd+k's Recent list without
|
||||
// requiring the user to open it first.
|
||||
useRecentIssuesStore.getState().recordVisit(wsId, newIssue.id);
|
||||
useRecentIssuesStore.getState().recordVisit(newIssue.id);
|
||||
// Invalidate parent's children query so sub-issues list updates immediately
|
||||
if (newIssue.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newIssue.parent_issue_id) });
|
||||
@@ -139,26 +145,11 @@ export function useUpdateIssue() {
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
// parent's children cache in sync (used by the parent issue's
|
||||
// sub-issues list). Falls back to scanning loaded children caches —
|
||||
// when the user navigates straight to a parent's detail page, the
|
||||
// child may live only there, not in detail/list.
|
||||
let parentId: string | null =
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
prevDetail?.parent_issue_id ??
|
||||
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
if (!parentId) {
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => c.id === id)) continue;
|
||||
const candidate = key[key.length - 1];
|
||||
if (typeof candidate === "string") {
|
||||
parentId = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
@@ -259,46 +250,13 @@ export function useBatchUpdateIssues() {
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Mirror the optimistic patch into any loaded children cache so
|
||||
// sub-issue rows on a parent's detail page reflect the change too.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
const affectedParentIds = new Set<string>();
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
affectedParentIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (idSet.has(c.id) ? { ...c, ...updates } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
return { prevList, prevChildren, affectedParentIds };
|
||||
return { prevList };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, parentId),
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -318,40 +276,16 @@ export function useBatchDeleteIssues() {
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
// Children cache may be the only place sub-issues live when the user
|
||||
// operates from a parent's detail page. Collect affected parents and
|
||||
// optimistically filter the deleted ids out of each children cache so
|
||||
// the row disappears immediately, mirroring the list-cache behaviour.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
parentIssueIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((c) => !idSet.has(c.id)),
|
||||
);
|
||||
}
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
});
|
||||
return { prevList, prevChildren, parentIssueIds };
|
||||
return { prevList, parentIssueIds };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
@@ -369,8 +303,6 @@ export function useBatchDeleteIssues() {
|
||||
// Comments / Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineCache = TimelineEntry[];
|
||||
|
||||
export function useCreateComment(issueId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -386,6 +318,11 @@ export function useCreateComment(issueId: string) {
|
||||
attachmentIds?: string[];
|
||||
}) => api.createComment(issueId, content, type, parentId, attachmentIds),
|
||||
onSuccess: (comment) => {
|
||||
// Write into every paginated timeline cache that's currently at-latest
|
||||
// (around-mode caches viewing older windows skip silently inside
|
||||
// prependToLatestPage). Both the latest cache and any open around-mode
|
||||
// window that has been scrolled all the way to the live tail get the
|
||||
// optimistic entry; everything else falls back to invalidation.
|
||||
const entry: TimelineEntry = {
|
||||
type: "comment",
|
||||
id: comment.id,
|
||||
@@ -399,22 +336,14 @@ export function useCreateComment(issueId: string) {
|
||||
created_at: comment.created_at,
|
||||
updated_at: comment.updated_at,
|
||||
};
|
||||
// Dedupe by id: the `comment:created` WS event may have already added
|
||||
// this entry from the broadcast path before this onSuccess fires. Skip
|
||||
// the append if the entry is already in the cache.
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) => {
|
||||
if (!old) return [entry];
|
||||
if (old.some((e) => e.id === entry.id)) return old;
|
||||
return [...old, entry];
|
||||
});
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) => prependToLatestPage(old, entry),
|
||||
);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
},
|
||||
// No onSettled invalidate. The `comment:created` WS broadcast keeps
|
||||
// the timeline cache fresh after a successful create, and reconnect
|
||||
// recovery in useIssueTimeline already invalidates if the connection
|
||||
// dropped. Re-fetching on every submit replaces every entry's
|
||||
// reference, which forces every memoized CommentCard subtree to
|
||||
// re-render (visible as a flash across sibling threads during AI
|
||||
// streaming).
|
||||
});
|
||||
}
|
||||
|
||||
@@ -424,16 +353,26 @@ export function useUpdateComment(issueId: string) {
|
||||
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
|
||||
api.updateComment(commentId, content),
|
||||
onMutate: async ({ commentId, content }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.map((e) => (e.id === commentId ? { ...e, content } : e)),
|
||||
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
|
||||
// Snapshot every open timeline cache (latest + any around windows) so
|
||||
// an error rollback restores them all atomically.
|
||||
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
|
||||
queryKey: ["issues", "timeline", issueId],
|
||||
});
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) =>
|
||||
mapAllEntries(old, (e) =>
|
||||
e.id === commentId ? { ...e, content } : e,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
return { prevSnapshots };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev !== undefined) {
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
if (ctx?.prevSnapshots) {
|
||||
for (const [key, prev] of ctx.prevSnapshots) {
|
||||
qc.setQueryData(key, prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -447,36 +386,44 @@ export function useDeleteComment(issueId: string) {
|
||||
return useMutation({
|
||||
mutationFn: (commentId: string) => api.deleteComment(commentId),
|
||||
onMutate: async (commentId) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
|
||||
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
|
||||
queryKey: ["issues", "timeline", issueId],
|
||||
});
|
||||
|
||||
// Cascade: collect all descendants of the deleted comment.
|
||||
// Cascade: collect all child comment IDs across every loaded page.
|
||||
const toRemove = new Set<string>([commentId]);
|
||||
if (prev) {
|
||||
for (const [, data] of prevSnapshots) {
|
||||
if (!data) continue;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const e of prev) {
|
||||
if (
|
||||
e.parent_id &&
|
||||
toRemove.has(e.parent_id) &&
|
||||
!toRemove.has(e.id)
|
||||
) {
|
||||
toRemove.add(e.id);
|
||||
changed = true;
|
||||
for (const page of data.pages) {
|
||||
for (const e of page.entries) {
|
||||
if (
|
||||
e.parent_id &&
|
||||
toRemove.has(e.parent_id) &&
|
||||
!toRemove.has(e.id)
|
||||
) {
|
||||
toRemove.add(e.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.filter((e) => !toRemove.has(e.id)),
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) => filterAllEntries(old, (e) => toRemove.has(e.id)),
|
||||
);
|
||||
return { prev };
|
||||
return { prevSnapshots };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prev !== undefined) {
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
if (ctx?.prevSnapshots) {
|
||||
for (const [key, prev] of ctx.prevSnapshots) {
|
||||
qc.setQueryData(key, prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
@@ -491,25 +438,31 @@ export function useResolveComment(issueId: string) {
|
||||
mutationFn: ({ commentId, resolved }: { commentId: string; resolved: boolean }) =>
|
||||
resolved ? api.resolveComment(commentId) : api.unresolveComment(commentId),
|
||||
onMutate: async ({ commentId, resolved }) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));
|
||||
qc.setQueryData<TimelineCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.map((e) =>
|
||||
e.id === commentId
|
||||
? {
|
||||
...e,
|
||||
resolved_at: resolved ? new Date().toISOString() : null,
|
||||
resolved_by_type: resolved ? e.resolved_by_type ?? null : null,
|
||||
resolved_by_id: resolved ? e.resolved_by_id ?? null : null,
|
||||
}
|
||||
: e,
|
||||
),
|
||||
await qc.cancelQueries({ queryKey: ["issues", "timeline", issueId] });
|
||||
const prevSnapshots = qc.getQueriesData<TimelineCacheData>({
|
||||
queryKey: ["issues", "timeline", issueId],
|
||||
});
|
||||
qc.setQueriesData<TimelineCacheData>(
|
||||
{ queryKey: ["issues", "timeline", issueId] },
|
||||
(old) =>
|
||||
mapAllEntries(old, (e) =>
|
||||
e.id === commentId
|
||||
? {
|
||||
...e,
|
||||
resolved_at: resolved ? new Date().toISOString() : null,
|
||||
resolved_by_type: resolved ? e.resolved_by_type ?? null : null,
|
||||
resolved_by_id: resolved ? e.resolved_by_id ?? null : null,
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
return { prev };
|
||||
return { prevSnapshots };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prev !== undefined) {
|
||||
qc.setQueryData(issueKeys.timeline(issueId), ctx.prev);
|
||||
if (ctx?.prevSnapshots) {
|
||||
for (const [key, prev] of ctx.prevSnapshots) {
|
||||
qc.setQueryData(key, prev);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type {
|
||||
IssueStatus,
|
||||
ListIssuesParams,
|
||||
ListIssuesCache,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
} from "../types";
|
||||
import { BOARD_STATUSES } from "./config";
|
||||
|
||||
@@ -21,17 +23,19 @@ export const issueKeys = {
|
||||
[...issueKeys.all(wsId), "children", id] as const,
|
||||
childProgress: (wsId: string) =>
|
||||
[...issueKeys.all(wsId), "child-progress"] as const,
|
||||
/** Full-issue timeline (single TanStack Query, no cursor). */
|
||||
timeline: (issueId: string) =>
|
||||
["issues", "timeline", issueId] as const,
|
||||
/**
|
||||
* Cursor-paginated timeline cache. Around-mode lookups use a separate cache
|
||||
* (keyed by the anchor id) so an Inbox-jump fetch does not pollute the
|
||||
* default latest-page cache that the regular issue list path consumes.
|
||||
*/
|
||||
timeline: (issueId: string, around?: string | null) =>
|
||||
around
|
||||
? (["issues", "timeline", issueId, "around", around] as const)
|
||||
: (["issues", "timeline", issueId] as const),
|
||||
reactions: (issueId: string) => ["issues", "reactions", issueId] as const,
|
||||
subscribers: (issueId: string) =>
|
||||
["issues", "subscribers", issueId] as const,
|
||||
usage: (issueId: string) => ["issues", "usage", issueId] as const,
|
||||
/** Issue-level attachments — used by the description editor so its
|
||||
* inline file-card / image NodeViews can re-sign download URLs at
|
||||
* click time. */
|
||||
attachments: (issueId: string) => ["issues", "attachments", issueId] as const,
|
||||
/** Per-issue task list (issue-detail Execution log section). */
|
||||
tasks: (issueId: string) => ["issues", "tasks", issueId] as const,
|
||||
/** Prefix-match key for invalidating tasks across all issues — used by
|
||||
@@ -137,16 +141,39 @@ export function childIssuesOptions(wsId: string, id: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-fetch timeline options. The endpoint returns the full ordered set of
|
||||
* comments + activities for an issue (server caps at 2000 as a safety net).
|
||||
* Cursor pagination was removed in #1929 — at observed data sizes (p99 ~30
|
||||
* entries per issue) it added complexity without a UX win and broke reply
|
||||
* threads at page boundaries.
|
||||
* Infinite-query options for the cursor-paginated timeline. The first page is
|
||||
* either the latest 50 entries (no `around`) or a 50-wide window centered on
|
||||
* the given comment/activity id (Inbox jump path). `getNextPageParam` walks
|
||||
* older; `getPreviousPageParam` walks newer.
|
||||
*/
|
||||
export function issueTimelineOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
queryFn: () => api.listTimeline(issueId),
|
||||
export function issueTimelineInfiniteOptions(
|
||||
issueId: string,
|
||||
around?: string | null,
|
||||
) {
|
||||
return infiniteQueryOptions<
|
||||
TimelinePage,
|
||||
Error,
|
||||
{ pages: TimelinePage[]; pageParams: TimelinePageParam[] },
|
||||
readonly unknown[],
|
||||
TimelinePageParam
|
||||
>({
|
||||
queryKey: issueKeys.timeline(issueId, around ?? null),
|
||||
initialPageParam: around
|
||||
? ({ mode: "around", id: around } as TimelinePageParam)
|
||||
: ({ mode: "latest" } as TimelinePageParam),
|
||||
queryFn: ({ pageParam }) => api.listTimeline(issueId, pageParam),
|
||||
// Walk older: append a page below the current oldest (last entry of the
|
||||
// last loaded page). undefined = no more older entries.
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.has_more_before && lastPage.next_cursor
|
||||
? ({ mode: "before", cursor: lastPage.next_cursor } as TimelinePageParam)
|
||||
: undefined,
|
||||
// Walk newer: prepend a page above the current newest (first entry of the
|
||||
// first loaded page). undefined = at the latest, no newer to fetch.
|
||||
getPreviousPageParam: (firstPage) =>
|
||||
firstPage.has_more_after && firstPage.prev_cursor
|
||||
? ({ mode: "after", cursor: firstPage.prev_cursor } as TimelinePageParam)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -173,14 +200,3 @@ export function issueUsageOptions(issueId: string) {
|
||||
queryFn: () => api.getIssueUsage(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
// Backs the description editor's fresh-sign download flow: NodeViews resolve
|
||||
// an attachment id by matching the markdown URL against this list. The list
|
||||
// is workspace-private metadata and lives on the same cache lifetime as the
|
||||
// rest of the issue detail surface.
|
||||
export function issueAttachmentsOptions(issueId: string) {
|
||||
return queryOptions({
|
||||
queryKey: issueKeys.attachments(issueId),
|
||||
queryFn: () => api.listAttachments(issueId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
/**
|
||||
* Per-comment draft persistence — survives:
|
||||
* - virtualization unmount (the reason this exists: when a TipTap editor
|
||||
* scrolls out of the Virtuoso viewport, its in-memory state is lost)
|
||||
* - tab close / accidental Cmd-W
|
||||
* - reload
|
||||
*
|
||||
* Keys are issue-scoped because createWorkspaceAwareStorage only partitions
|
||||
* by workspace, not by issue. Without issueId in the key, two issues with
|
||||
* thread replies open in adjacent desktop tabs would collide.
|
||||
*/
|
||||
|
||||
export type CommentDraftKey =
|
||||
| `new:${string}` // top-level CommentInput, key = `new:${issueId}`
|
||||
| `reply:${string}:${string}` // ReplyInput inside a thread, key = `reply:${issueId}:${rootCommentId}`
|
||||
| `edit:${string}:${string}`; // inline edit on existing comment, key = `edit:${issueId}:${commentId}`
|
||||
|
||||
interface CommentDraft {
|
||||
content: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface CommentDraftStore {
|
||||
drafts: Record<string, CommentDraft>;
|
||||
getDraft: (key: CommentDraftKey) => string | undefined;
|
||||
setDraft: (key: CommentDraftKey, content: string) => void;
|
||||
clearDraft: (key: CommentDraftKey) => void;
|
||||
}
|
||||
|
||||
// Drafts older than 30 days are dropped on store init. Without TTL the store
|
||||
// would accumulate every edit attempt across every issue indefinitely and
|
||||
// slowly leak localStorage quota.
|
||||
const TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function pruneStaleDrafts(drafts: Record<string, CommentDraft>): Record<string, CommentDraft> {
|
||||
const cutoff = Date.now() - TTL_MS;
|
||||
const out: Record<string, CommentDraft> = {};
|
||||
for (const [k, v] of Object.entries(drafts)) {
|
||||
if (v.updatedAt >= cutoff && v.content.trim().length > 0) {
|
||||
out[k] = v;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const useCommentDraftStore = create<CommentDraftStore>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
drafts: {},
|
||||
getDraft: (key) => get().drafts[key]?.content,
|
||||
setDraft: (key, content) =>
|
||||
set((s) => ({
|
||||
drafts: { ...s.drafts, [key]: { content, updatedAt: Date.now() } },
|
||||
})),
|
||||
clearDraft: (key) =>
|
||||
set((s) => {
|
||||
if (!(key in s.drafts)) return s;
|
||||
const next = { ...s.drafts };
|
||||
delete next[key];
|
||||
return { drafts: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_comment_drafts",
|
||||
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) {
|
||||
state.drafts = pruneStaleDrafts(state.drafts);
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
registerForWorkspaceRehydration(() => useCommentDraftStore.persist.rehydrate());
|
||||
@@ -1,11 +1,7 @@
|
||||
export { useIssueSelectionStore } from "./selection-store";
|
||||
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
|
||||
export { useIssueDraftStore } from "./draft-store";
|
||||
export {
|
||||
useRecentIssuesStore,
|
||||
selectRecentIssues,
|
||||
type RecentIssueEntry,
|
||||
} from "./recent-issues-store";
|
||||
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
|
||||
export {
|
||||
ViewStoreProvider,
|
||||
useViewStore,
|
||||
@@ -13,7 +9,6 @@ export {
|
||||
} from "./view-store-context";
|
||||
export { useIssuesScopeStore, type IssuesScope } from "./issues-scope-store";
|
||||
export { useCommentCollapseStore } from "./comment-collapse-store";
|
||||
export { useCommentDraftStore, type CommentDraftKey } from "./comment-draft-store";
|
||||
export {
|
||||
myIssuesViewStore,
|
||||
type MyIssuesViewState,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useQuickCreateStore } from "./quick-create-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
lastAgentId: null,
|
||||
lastProjectId: null,
|
||||
prompt: "",
|
||||
keepOpen: false,
|
||||
};
|
||||
@@ -24,14 +23,4 @@ describe("quick create store", () => {
|
||||
clearPrompt();
|
||||
expect(useQuickCreateStore.getState().prompt).toBe("");
|
||||
});
|
||||
|
||||
it("remembers the last project picked so frequent users skip the picker", () => {
|
||||
const { setLastProjectId } = useQuickCreateStore.getState();
|
||||
|
||||
setLastProjectId("proj-1");
|
||||
expect(useQuickCreateStore.getState().lastProjectId).toBe("proj-1");
|
||||
|
||||
setLastProjectId(null);
|
||||
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,19 +5,16 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Per-workspace memory of the last agent and project the user picked in the
|
||||
// Quick Create modal. Defaulted to those values on next open so frequent
|
||||
// users skip the pickers entirely — without this, anyone targeting a single
|
||||
// project ends up retyping "in project A" on every prompt. Persisted with
|
||||
// the workspace-aware StateStorage so switching workspaces shows the right
|
||||
// default automatically. Per-user scoping comes for free from localStorage
|
||||
// being browser-profile-local — matches how draft-store /
|
||||
// issues-scope-store / comment-collapse-store already namespace themselves.
|
||||
// Per-workspace memory of the last agent the user picked in the Quick Create
|
||||
// modal. Defaulted to that agent on next open so frequent users skip the
|
||||
// picker entirely. Persisted with the workspace-aware StateStorage so
|
||||
// switching workspaces shows the right default automatically. Per-user
|
||||
// scoping comes for free from localStorage being browser-profile-local —
|
||||
// matches how draft-store / issues-scope-store / comment-collapse-store
|
||||
// already namespace themselves.
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
lastProjectId: string | null;
|
||||
setLastProjectId: (id: string | null) => void;
|
||||
prompt: string;
|
||||
setPrompt: (prompt: string) => void;
|
||||
clearPrompt: () => void;
|
||||
@@ -30,8 +27,6 @@ export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
lastProjectId: null,
|
||||
setLastProjectId: (id) => set({ lastProjectId: id }),
|
||||
prompt: "",
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
clearPrompt: () => set({ prompt: "" }),
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useRecentIssuesStore, selectRecentIssues } from "./recent-issues-store";
|
||||
|
||||
beforeEach(() => {
|
||||
useRecentIssuesStore.setState({ byWorkspace: {} });
|
||||
});
|
||||
|
||||
describe("useRecentIssuesStore.recordVisit", () => {
|
||||
it("keeps visits namespaced by workspace id", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-b", "issue-2");
|
||||
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(state["ws-a"]?.map((e) => e.id)).toEqual(["issue-1"]);
|
||||
expect(state["ws-b"]?.map((e) => e.id)).toEqual(["issue-2"]);
|
||||
});
|
||||
|
||||
it("moves the most recent visit to the front and dedupes", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-a", "issue-2");
|
||||
recordVisit("ws-a", "issue-1");
|
||||
|
||||
const ids = useRecentIssuesStore
|
||||
.getState()
|
||||
.byWorkspace["ws-a"]?.map((e) => e.id);
|
||||
expect(ids).toEqual(["issue-1", "issue-2"]);
|
||||
});
|
||||
|
||||
it("caps each workspace's bucket at 20 entries", () => {
|
||||
const { recordVisit } = useRecentIssuesStore.getState();
|
||||
for (let i = 0; i < 25; i++) recordVisit("ws-a", `issue-${i}`);
|
||||
expect(useRecentIssuesStore.getState().byWorkspace["ws-a"]).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useRecentIssuesStore.pruneWorkspaces", () => {
|
||||
it("drops buckets for workspaces not in the active set", () => {
|
||||
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
recordVisit("ws-b", "issue-2");
|
||||
recordVisit("ws-c", "issue-3");
|
||||
|
||||
pruneWorkspaces(["ws-a", "ws-c"]);
|
||||
const state = useRecentIssuesStore.getState().byWorkspace;
|
||||
expect(Object.keys(state).sort()).toEqual(["ws-a", "ws-c"]);
|
||||
});
|
||||
|
||||
it("is a no-op when every bucket is still active", () => {
|
||||
const { recordVisit, pruneWorkspaces } = useRecentIssuesStore.getState();
|
||||
recordVisit("ws-a", "issue-1");
|
||||
const before = useRecentIssuesStore.getState().byWorkspace;
|
||||
pruneWorkspaces(["ws-a"]);
|
||||
expect(useRecentIssuesStore.getState().byWorkspace).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectRecentIssues", () => {
|
||||
it("returns the bucket for the given workspace", () => {
|
||||
useRecentIssuesStore.getState().recordVisit("ws-a", "issue-1");
|
||||
const items = selectRecentIssues("ws-a")(useRecentIssuesStore.getState());
|
||||
expect(items.map((e) => e.id)).toEqual(["issue-1"]);
|
||||
});
|
||||
|
||||
it("returns a stable empty array when wsId is null or unknown", () => {
|
||||
const a = selectRecentIssues(null)(useRecentIssuesStore.getState());
|
||||
const b = selectRecentIssues(null)(useRecentIssuesStore.getState());
|
||||
const c = selectRecentIssues("missing")(useRecentIssuesStore.getState());
|
||||
expect(a).toBe(b);
|
||||
expect(a).toBe(c);
|
||||
expect(a).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
createWorkspaceAwareStorage,
|
||||
registerForWorkspaceRehydration,
|
||||
} from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
const MAX_RECENT_ISSUES = 20;
|
||||
const MAX_WORKSPACES = 50;
|
||||
const EMPTY: RecentIssueEntry[] = [];
|
||||
|
||||
export interface RecentIssueEntry {
|
||||
id: string;
|
||||
@@ -14,82 +16,33 @@ export interface RecentIssueEntry {
|
||||
}
|
||||
|
||||
interface RecentIssuesState {
|
||||
byWorkspace: Record<string, RecentIssueEntry[]>;
|
||||
recordVisit: (wsId: string, id: string) => void;
|
||||
pruneWorkspaces: (activeWsIds: string[]) => void;
|
||||
items: RecentIssueEntry[];
|
||||
recordVisit: (id: string) => void;
|
||||
}
|
||||
|
||||
// Namespace by workspace id (UUID) instead of namespacing the storage key by
|
||||
// slug. The storage-key approach (see createWorkspaceAwareStorage) breaks when
|
||||
// a setter fires from a child component's mount-effect before
|
||||
// WorkspaceRouteLayout's effect has set the current slug — child effects run
|
||||
// before parent effects, so writes land in the un-namespaced bare key and
|
||||
// leak across workspaces (bug surfaced by /<slug>/issues firing per-id GETs
|
||||
// for recents from other workspaces, most returning 404).
|
||||
//
|
||||
// Keying on wsId (rather than slug) means the data survives workspace renames
|
||||
// and matches the wsId that callers already have via useWorkspaceId().
|
||||
export const useRecentIssuesStore = create<RecentIssuesState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
byWorkspace: {},
|
||||
recordVisit: (wsId, id) =>
|
||||
items: [],
|
||||
recordVisit: (id) =>
|
||||
set((state) => {
|
||||
const bucket = state.byWorkspace[wsId] ?? EMPTY;
|
||||
const filtered = bucket.filter((i) => i.id !== id);
|
||||
const filtered = state.items.filter((i) => i.id !== id);
|
||||
const updated: RecentIssueEntry = { id, visitedAt: Date.now() };
|
||||
const nextBucket = [updated, ...filtered].slice(0, MAX_RECENT_ISSUES);
|
||||
|
||||
let nextByWorkspace = {
|
||||
...state.byWorkspace,
|
||||
[wsId]: nextBucket,
|
||||
return {
|
||||
items: [updated, ...filtered].slice(0, MAX_RECENT_ISSUES),
|
||||
};
|
||||
|
||||
// LRU defense: if pruneWorkspaces never gets a chance to run (offline,
|
||||
// failed list query) and the user touches lots of workspaces, cap the
|
||||
// total to avoid unbounded growth. Evict the workspace whose most
|
||||
// recent visit is the oldest.
|
||||
const ids = Object.keys(nextByWorkspace);
|
||||
if (ids.length > MAX_WORKSPACES) {
|
||||
const oldest = ids.reduce((oldestId, candidateId) => {
|
||||
const a = nextByWorkspace[oldestId]?.[0]?.visitedAt ?? 0;
|
||||
const b = nextByWorkspace[candidateId]?.[0]?.visitedAt ?? 0;
|
||||
return b < a ? candidateId : oldestId;
|
||||
});
|
||||
const { [oldest]: _, ...rest } = nextByWorkspace;
|
||||
nextByWorkspace = rest;
|
||||
}
|
||||
|
||||
return { byWorkspace: nextByWorkspace };
|
||||
}),
|
||||
pruneWorkspaces: (activeWsIds) =>
|
||||
set((state) => {
|
||||
const allow = new Set(activeWsIds);
|
||||
let changed = false;
|
||||
const next: Record<string, RecentIssueEntry[]> = {};
|
||||
for (const [wsId, items] of Object.entries(state.byWorkspace)) {
|
||||
if (allow.has(wsId)) next[wsId] = items;
|
||||
else changed = true;
|
||||
}
|
||||
return changed ? { byWorkspace: next } : state;
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_recent_issues",
|
||||
storage: createJSONStorage(() => defaultStorage),
|
||||
partialize: (state) => ({ byWorkspace: state.byWorkspace }),
|
||||
// v0 stored a flat `items` array under the bare key (or, when the
|
||||
// workspace slug happened to be set at write time, under
|
||||
// `multica_recent_issues:<slug>`). Both shapes are unsafe to surface
|
||||
// because v0 entries don't know which workspace they belonged to —
|
||||
// drop them and let the cache repopulate as the user visits issues.
|
||||
version: 1,
|
||||
migrate: () => ({ byWorkspace: {} }),
|
||||
storage: createJSONStorage(() =>
|
||||
createWorkspaceAwareStorage(defaultStorage),
|
||||
),
|
||||
partialize: (state) => ({ items: state.items }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function selectRecentIssues(wsId: string | null) {
|
||||
return (state: RecentIssuesState) =>
|
||||
wsId ? (state.byWorkspace[wsId] ?? EMPTY) : EMPTY;
|
||||
}
|
||||
registerForWorkspaceRehydration(() =>
|
||||
useRecentIssuesStore.persist.rehydrate(),
|
||||
);
|
||||
|
||||
73
packages/core/issues/timeline-cache.ts
Normal file
73
packages/core/issues/timeline-cache.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { InfiniteData } from "@tanstack/react-query";
|
||||
import type {
|
||||
TimelineEntry,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
} from "../types";
|
||||
|
||||
/** Shape of the cursor-paginated timeline cache. Exported so consumers (the
|
||||
* hook, mutations, tests) all reference the same type. */
|
||||
export type TimelineCacheData = InfiniteData<TimelinePage, TimelinePageParam>;
|
||||
|
||||
/** Map fn over every entry across every page, preserving page identity for
|
||||
* any page whose entries don't change so React.memo on CommentCard isn't
|
||||
* defeated by gratuitous reference churn. */
|
||||
export function mapAllEntries(
|
||||
data: TimelineCacheData | undefined,
|
||||
fn: (e: TimelineEntry) => TimelineEntry,
|
||||
): TimelineCacheData | undefined {
|
||||
if (!data) return data;
|
||||
let pagesChanged = false;
|
||||
const pages = data.pages.map((page) => {
|
||||
let entriesChanged = false;
|
||||
const entries = page.entries.map((e) => {
|
||||
const next = fn(e);
|
||||
if (next !== e) entriesChanged = true;
|
||||
return next;
|
||||
});
|
||||
if (!entriesChanged) return page;
|
||||
pagesChanged = true;
|
||||
return { ...page, entries };
|
||||
});
|
||||
if (!pagesChanged) return data;
|
||||
return { ...data, pages };
|
||||
}
|
||||
|
||||
/** Filter out entries matching the predicate from every page. */
|
||||
export function filterAllEntries(
|
||||
data: TimelineCacheData | undefined,
|
||||
predicate: (e: TimelineEntry) => boolean,
|
||||
): TimelineCacheData | undefined {
|
||||
if (!data) return data;
|
||||
let pagesChanged = false;
|
||||
const pages = data.pages.map((page) => {
|
||||
const entries = page.entries.filter((e) => !predicate(e));
|
||||
if (entries.length === page.entries.length) return page;
|
||||
pagesChanged = true;
|
||||
return { ...page, entries };
|
||||
});
|
||||
if (!pagesChanged) return data;
|
||||
return { ...data, pages };
|
||||
}
|
||||
|
||||
/** Prepend a new entry to the latest page (pages[0]). Caller must verify
|
||||
* the cache is at-latest before calling — otherwise the entry is hidden
|
||||
* behind a "show newer" gap and shouldn't be injected. Returns the data
|
||||
* unchanged if the cache is not at-latest or the entry already exists. */
|
||||
export function prependToLatestPage(
|
||||
data: TimelineCacheData | undefined,
|
||||
entry: TimelineEntry,
|
||||
): TimelineCacheData | undefined {
|
||||
if (!data || data.pages.length === 0) return data;
|
||||
const first = data.pages[0];
|
||||
if (!first) return data;
|
||||
if (first.has_more_after) return data; // not at latest; skip silently
|
||||
if (first.entries.some((e) => e.id === entry.id)) return data;
|
||||
return {
|
||||
...data,
|
||||
pages: [
|
||||
{ ...first, entries: [entry, ...first.entries] },
|
||||
...data.pages.slice(1),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,7 @@
|
||||
"./issues": "./issues/index.ts",
|
||||
"./issues/queries": "./issues/queries.ts",
|
||||
"./issues/mutations": "./issues/mutations.ts",
|
||||
"./issues/timeline-cache": "./issues/timeline-cache.ts",
|
||||
"./issues/ws-updaters": "./issues/ws-updaters.ts",
|
||||
"./issues/config": "./issues/config/index.ts",
|
||||
"./issues/config/status": "./issues/config/status.ts",
|
||||
@@ -46,9 +47,6 @@
|
||||
"./runtimes/queries": "./runtimes/queries.ts",
|
||||
"./runtimes/mutations": "./runtimes/mutations.ts",
|
||||
"./runtimes/hooks": "./runtimes/hooks.ts",
|
||||
"./runtimes/custom-pricing-store": "./runtimes/custom-pricing-store.ts",
|
||||
"./dashboard": "./dashboard/index.ts",
|
||||
"./dashboard/queries": "./dashboard/queries.ts",
|
||||
"./agents": "./agents/index.ts",
|
||||
"./agents/queries": "./agents/queries.ts",
|
||||
"./agents/derive-presence": "./agents/derive-presence.ts",
|
||||
@@ -68,8 +66,6 @@
|
||||
"./pins": "./pins/index.ts",
|
||||
"./pins/queries": "./pins/queries.ts",
|
||||
"./pins/mutations": "./pins/mutations.ts",
|
||||
"./github": "./github/index.ts",
|
||||
"./github/queries": "./github/queries.ts",
|
||||
"./feedback": "./feedback/index.ts",
|
||||
"./feedback/mutations": "./feedback/mutations.ts",
|
||||
"./realtime": "./realtime/index.ts",
|
||||
|
||||
@@ -17,7 +17,6 @@ describe("paths.workspace() shape", () => {
|
||||
expect(new Set(parameterlessRoutes)).toEqual(
|
||||
new Set([
|
||||
"root",
|
||||
"usage",
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
@@ -36,7 +35,6 @@ describe("paths.workspace() shape", () => {
|
||||
// Check that none of the parameterless paths embed a leaked literal
|
||||
// and that their second URL segment matches the method name's kebab-case.
|
||||
const expectedSegments: Array<[string, string]> = [
|
||||
["usage", "usage"],
|
||||
["issues", "issues"],
|
||||
["projects", "projects"],
|
||||
["autopilots", "autopilots"],
|
||||
|
||||
@@ -4,8 +4,7 @@ import { paths, isGlobalPath } from "./paths";
|
||||
describe("paths.workspace(slug)", () => {
|
||||
const ws = paths.workspace("acme");
|
||||
|
||||
it("builds workspace paths with slug prefix", () => {
|
||||
expect(ws.usage()).toBe("/acme/usage");
|
||||
it("builds dashboard paths with slug prefix", () => {
|
||||
expect(ws.issues()).toBe("/acme/issues");
|
||||
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
|
||||
expect(ws.projects()).toBe("/acme/projects");
|
||||
|
||||
@@ -18,7 +18,6 @@ function workspaceScoped(slug: string) {
|
||||
const ws = `/${encode(slug)}`;
|
||||
return {
|
||||
root: () => `${ws}/issues`,
|
||||
usage: () => `${ws}/usage`,
|
||||
issues: () => `${ws}/issues`,
|
||||
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
|
||||
projects: () => `${ws}/projects`,
|
||||
|
||||
@@ -70,7 +70,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"search",
|
||||
"members",
|
||||
|
||||
// Workspace route segments
|
||||
// Dashboard / workspace route segments
|
||||
// Reserving each segment name prevents `/{slug}/{view}` from being visually
|
||||
// ambiguous (e.g. a workspace named `issues` would make `/issues/abc` mean two
|
||||
// things). `workspaces` covers the global `/workspaces/new` workspace-creation
|
||||
@@ -81,7 +81,6 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"agents",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"usage",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
|
||||
@@ -93,8 +93,6 @@ function makeRuntime(ownerId: string | null): RuntimeDevice {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: ownerId,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: null,
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -20,19 +20,13 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("workspace-aware storage", () => {
|
||||
it("drops writes and returns null for reads when no workspace is set", () => {
|
||||
it("uses plain key when no workspace is set", () => {
|
||||
const adapter = mockAdapter();
|
||||
setCurrentWorkspace(null, null);
|
||||
const storage = createWorkspaceAwareStorage(adapter);
|
||||
|
||||
storage.setItem("draft", "data");
|
||||
expect(adapter.setItem).not.toHaveBeenCalled();
|
||||
|
||||
expect(storage.getItem("draft")).toBeNull();
|
||||
expect(adapter.getItem).not.toHaveBeenCalled();
|
||||
|
||||
storage.removeItem("draft");
|
||||
expect(adapter.removeItem).not.toHaveBeenCalled();
|
||||
expect(adapter.setItem).toHaveBeenCalledWith("draft", "data");
|
||||
});
|
||||
|
||||
it("namespaces key with slug when workspace is set", () => {
|
||||
|
||||
@@ -94,24 +94,14 @@ export function registerForWorkspaceRehydration(fn: () => void) {
|
||||
/**
|
||||
* Storage that automatically namespaces keys with the current workspace slug.
|
||||
* Reads _currentSlug at call time, so it follows workspace switches dynamically.
|
||||
*
|
||||
* When no workspace is active (e.g. zustand persist's initial hydration before
|
||||
* WorkspaceRouteLayout has called setCurrentWorkspace, or a setter firing from
|
||||
* a child component's mount-effect before the parent layout's effect has run),
|
||||
* reads return null and writes are dropped — explicitly NOT a fallback to the
|
||||
* un-namespaced bare key, which used to leak workspace-scoped data across
|
||||
* workspaces. Persisted stores get a real read once setCurrentWorkspace
|
||||
* triggers their registered rehydrate fn.
|
||||
*/
|
||||
export function createWorkspaceAwareStorage(adapter: StorageAdapter): StateStorage {
|
||||
const resolve = (key: string) =>
|
||||
_currentSlug ? `${key}:${_currentSlug}` : key;
|
||||
|
||||
return {
|
||||
getItem: (key) =>
|
||||
_currentSlug ? adapter.getItem(`${key}:${_currentSlug}`) : null,
|
||||
setItem: (key, value) => {
|
||||
if (_currentSlug) adapter.setItem(`${key}:${_currentSlug}`, value);
|
||||
},
|
||||
removeItem: (key) => {
|
||||
if (_currentSlug) adapter.removeItem(`${key}:${_currentSlug}`);
|
||||
},
|
||||
getItem: (key) => adapter.getItem(resolve(key)),
|
||||
setItem: (key, value) => adapter.setItem(resolve(key), value),
|
||||
removeItem: (key) => adapter.removeItem(resolve(key)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,9 +31,9 @@ export const PROJECT_PRIORITY_CONFIG: Record<
|
||||
ProjectPriority,
|
||||
{ label: string; bars: number; color: string; badgeBg: string; badgeText: string }
|
||||
> = {
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-destructive/10", badgeText: "text-destructive" },
|
||||
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-warning/10", badgeText: "text-warning" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-warning/10", badgeText: "text-warning" },
|
||||
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-info/10", badgeText: "text-info" },
|
||||
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-priority", badgeText: "text-white" },
|
||||
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-priority/80", badgeText: "text-white" },
|
||||
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-priority/15", badgeText: "text-priority" },
|
||||
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-priority/10", badgeText: "text-priority" },
|
||||
none: { label: "No priority", bars: 0, color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
|
||||
};
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
|
||||
import { applyChatDoneToCache } from "./use-realtime-sync";
|
||||
|
||||
const sessionId = "session-1";
|
||||
const taskId = "task-1";
|
||||
const messagesKey = chatKeys.messages(sessionId);
|
||||
const pendingKey = chatKeys.pendingTask(sessionId);
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function userMessage(): ChatMessage {
|
||||
return {
|
||||
id: "msg-user",
|
||||
chat_session_id: sessionId,
|
||||
role: "user",
|
||||
content: "hello",
|
||||
task_id: null,
|
||||
created_at: "2026-05-13T05:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
|
||||
return {
|
||||
chat_session_id: sessionId,
|
||||
task_id: taskId,
|
||||
message_id: "msg-assistant",
|
||||
content: "done",
|
||||
elapsed_ms: 1234,
|
||||
created_at: "2026-05-13T05:00:02Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyChatDoneToCache", () => {
|
||||
it("writes the assistant message before clearing pending task", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
|
||||
qc.setQueryData<ChatPendingTask>(pendingKey, {
|
||||
task_id: taskId,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
const setQueryData = vi.spyOn(qc, "setQueryData");
|
||||
|
||||
applyChatDoneToCache(qc, donePayload());
|
||||
|
||||
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
|
||||
expect(setQueryData.mock.calls[1]?.[0]).toEqual(pendingKey);
|
||||
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
||||
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
|
||||
userMessage(),
|
||||
{
|
||||
id: "msg-assistant",
|
||||
chat_session_id: sessionId,
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
task_id: taskId,
|
||||
created_at: "2026-05-13T05:00:02Z",
|
||||
elapsed_ms: 1234,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate a replayed chat done event", () => {
|
||||
const qc = createQueryClient();
|
||||
const assistant: ChatMessage = {
|
||||
id: "msg-assistant",
|
||||
chat_session_id: sessionId,
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
task_id: taskId,
|
||||
created_at: "2026-05-13T05:00:02Z",
|
||||
elapsed_ms: 1234,
|
||||
};
|
||||
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
|
||||
qc.setQueryData<ChatPendingTask>(pendingKey, {
|
||||
task_id: taskId,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
applyChatDoneToCache(qc, donePayload());
|
||||
|
||||
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
|
||||
userMessage(),
|
||||
assistant,
|
||||
]);
|
||||
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back to invalidation-only when older servers omit message fields", () => {
|
||||
const qc = createQueryClient();
|
||||
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
|
||||
qc.setQueryData<ChatPendingTask>(pendingKey, {
|
||||
task_id: taskId,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
applyChatDoneToCache(
|
||||
qc,
|
||||
donePayload({ message_id: undefined, content: undefined }),
|
||||
);
|
||||
|
||||
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
|
||||
userMessage(),
|
||||
]);
|
||||
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { WSClient } from "../api/ws-client";
|
||||
import type { StoreApi, UseBoundStore } from "zustand";
|
||||
import type { AuthState } from "../auth/store";
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
agentRunCountsKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { githubKeys } from "../github/queries";
|
||||
import {
|
||||
onIssueCreated,
|
||||
onIssueUpdated,
|
||||
@@ -62,7 +61,6 @@ import type {
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
ChatDonePayload,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
@@ -71,42 +69,6 @@ const chatWsLogger = createLogger("chat.ws");
|
||||
|
||||
const logger = createLogger("realtime-sync");
|
||||
|
||||
export function applyChatDoneToCache(
|
||||
qc: QueryClient,
|
||||
payload: ChatDonePayload,
|
||||
) {
|
||||
const sessionId = payload.chat_session_id;
|
||||
const taskId = payload.task_id;
|
||||
const messageId = payload.message_id;
|
||||
const content = payload.content;
|
||||
if (messageId && content !== undefined) {
|
||||
qc.setQueryData<ChatMessage[] | undefined>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => {
|
||||
if (!old) return old; // first fetch will pick it up
|
||||
// Idempotent against reconnect replay.
|
||||
if (old.some((m) => m.id === messageId)) return old;
|
||||
const assistant: ChatMessage = {
|
||||
id: messageId,
|
||||
chat_session_id: sessionId,
|
||||
role: "assistant",
|
||||
content,
|
||||
task_id: taskId,
|
||||
created_at: payload.created_at ?? new Date().toISOString(),
|
||||
elapsed_ms: payload.elapsed_ms ?? null,
|
||||
};
|
||||
return [...old, assistant];
|
||||
},
|
||||
);
|
||||
}
|
||||
// Replacement is in the messages list now; safe to drop pending.
|
||||
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
|
||||
// Authoritative refetch reconciles redaction / migrations / clients
|
||||
// that took the fallback branch above.
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
|
||||
}
|
||||
|
||||
export interface RealtimeSyncStores {
|
||||
authStore: UseBoundStore<StoreApi<AuthState>>;
|
||||
}
|
||||
@@ -195,15 +157,6 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: autopilotKeys.all(wsId) });
|
||||
},
|
||||
github_installation: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: githubKeys.installations(wsId) });
|
||||
},
|
||||
pull_request: () => {
|
||||
// PR list is keyed by issue id, not workspace, so we invalidate all
|
||||
// PR queries — the open issue detail page will refetch its own list.
|
||||
qc.invalidateQueries({ queryKey: ["github", "pull-requests"] });
|
||||
},
|
||||
// Powers the agent presence cache: any task lifecycle change
|
||||
// (dispatch / completed / failed / cancelled) refreshes the
|
||||
// workspace-wide agent-task-snapshot query so per-agent presence
|
||||
@@ -379,25 +332,12 @@ export function useRealtimeSync(
|
||||
|
||||
// --- Timeline event handlers (global fallback) ---
|
||||
// These events are also handled granularly by useIssueTimeline when
|
||||
// IssueDetail is mounted. This global handler exists to mark the
|
||||
// timeline cache stale for issues whose IssueDetail is *not* mounted,
|
||||
// so stale data isn't served on next mount (staleTime: Infinity, set on
|
||||
// the QueryClient default, relies on this).
|
||||
//
|
||||
// `refetchType: "none"` is the load-bearing detail: without it, an
|
||||
// active IssueDetail observer would refetch the entire timeline on
|
||||
// every comment / activity / reaction event. The refetch replaces
|
||||
// every entry's reference and busts React.memo on every CommentCard
|
||||
// subtree (visible during AI streaming as a flash across all sibling
|
||||
// threads, MUL-1941). Inactive observers don't refetch either way;
|
||||
// when IssueDetail mounts later, the stale flag triggers the refetch
|
||||
// through `refetchOnMount`. Active observers stay fresh via the
|
||||
// granular setQueryData handlers in `useIssueTimeline`.
|
||||
// IssueDetail is mounted. This global handler ensures the timeline cache
|
||||
// is invalidated even when IssueDetail is unmounted, so stale data
|
||||
// isn't served on next mount (staleTime: Infinity relies on this).
|
||||
|
||||
const invalidateTimeline = (issueId: string) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
refetchType: "none",
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
};
|
||||
|
||||
const unsubCommentCreated = ws.on("comment:created", (p) => {
|
||||
@@ -605,21 +545,13 @@ export function useRealtimeSync(
|
||||
chatWsLogger.info("chat:done (global)", {
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
has_message: !!payload.message_id,
|
||||
});
|
||||
// Inline-insert the assistant message into the messages cache BEFORE
|
||||
// clearing pending-task. Both writes land in the same React render
|
||||
// tick, so ChatMessageList sees `pendingAlreadyPersisted === true`
|
||||
// and the live TimelineView unmounts only after AssistantMessage has
|
||||
// mounted — no flicker window. This applies TkDodo's "combine
|
||||
// setQueryData (active query) + invalidateQueries (others)" pattern
|
||||
// (https://tkdodo.eu/blog/using-web-sockets-with-react-query).
|
||||
//
|
||||
// Falls back to invalidate-only when the server omits the message
|
||||
// payload (older builds). Older clients hitting a newer server also
|
||||
// work: they ignore the extra fields and rely on the invalidate
|
||||
// below, which keeps the old behavior alive.
|
||||
applyChatDoneToCache(qc, payload);
|
||||
// Assistant message was just written and task flipped out of 'running'.
|
||||
// Clear pending-task cache immediately so the live-timeline-vs-assistant
|
||||
// race window collapses to zero — the subsequent refetch will confirm.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
// Assistant message just landed → has_unread may have flipped to true.
|
||||
invalidateSessionLists();
|
||||
@@ -690,12 +622,9 @@ export function useRealtimeSync(
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
// `chat:done` (broadcast immediately before this event in CompleteTask)
|
||||
// already wrote the assistant message into the messages cache and
|
||||
// cleared `chatKeys.pendingTask`. This event is now only responsible
|
||||
// for refreshing the per-user cross-session aggregate that drives the
|
||||
// FAB indicator — `chat:done` is per-session and doesn't carry that
|
||||
// information.
|
||||
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
|
||||
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
|
||||
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist, type StateStorage } from "zustand/middleware";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
// User-supplied pricing for models we don't ship a maintained rate for.
|
||||
// We can't track every model OpenRouter / Codex / Hermes / Kimi etc. release,
|
||||
// so the empty-state diagnostic lets users fill in their own rates. Stored
|
||||
// globally (not workspace-scoped) because the rate of `gpt-5.5-mini` is the
|
||||
// same regardless of which workspace you're viewing.
|
||||
export interface CustomModelPricing {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
}
|
||||
|
||||
export interface CustomPricingState {
|
||||
pricings: Record<string, CustomModelPricing>;
|
||||
setCustomPricing: (model: string, pricing: CustomModelPricing) => void;
|
||||
removeCustomPricing: (model: string) => void;
|
||||
}
|
||||
|
||||
// StorageAdapter (sync getItem returning string | null) is a structural subset
|
||||
// of zustand's StateStorage, so it can be handed in directly via cast.
|
||||
const stateStorage = defaultStorage as unknown as StateStorage;
|
||||
|
||||
export const useCustomPricingStore = create<CustomPricingState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
pricings: {},
|
||||
setCustomPricing: (model, pricing) =>
|
||||
set((state) => ({
|
||||
pricings: { ...state.pricings, [model]: pricing },
|
||||
})),
|
||||
removeCustomPricing: (model) =>
|
||||
set((state) => {
|
||||
if (!(model in state.pricings)) return state;
|
||||
const next = { ...state.pricings };
|
||||
delete next[model];
|
||||
return { pricings: next };
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "multica_runtime_custom_pricing",
|
||||
storage: createJSONStorage(() => stateStorage),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// Vanilla accessor for non-React callers (the `resolvePricing` helper in
|
||||
// packages/views/runtimes/utils.ts reads from here during cost estimation).
|
||||
export function getCustomPricing(model: string): CustomModelPricing | undefined {
|
||||
return useCustomPricingStore.getState().pricings[model];
|
||||
}
|
||||
@@ -17,8 +17,6 @@ function makeRuntime(overrides: Partial<AgentRuntime> = {}): AgentRuntime {
|
||||
device_info: "",
|
||||
metadata: {},
|
||||
owner_id: null,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: new Date(FIXED_NOW - 10_000).toISOString(),
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
|
||||
@@ -7,4 +7,3 @@ export * from "./types";
|
||||
export * from "./derive-health";
|
||||
export * from "./use-runtime-health";
|
||||
export * from "./cli-version";
|
||||
export * from "./custom-pricing-store";
|
||||
|
||||
@@ -11,38 +11,3 @@ export function useDeleteRuntime(wsId: string) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// useUpdateRuntime patches editable fields on a runtime (timezone, visibility).
|
||||
// Invalidates the runtime list AND any keys downstream of the updated runtime
|
||||
// — usage queries are bucketed by tz on the server, so a tz change must blow
|
||||
// away cached usage rows or the chart would lie for one polling cycle. A
|
||||
// visibility change only needs the runtime list to refetch so the picker
|
||||
// disabled-state recomputes.
|
||||
export function useUpdateRuntime(wsId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
runtimeId,
|
||||
patch,
|
||||
}: {
|
||||
runtimeId: string;
|
||||
patch: { timezone?: string; visibility?: "private" | "public" };
|
||||
}) => api.updateRuntime(runtimeId, patch),
|
||||
onSettled: (_data, _err, vars) => {
|
||||
qc.invalidateQueries({ queryKey: runtimeKeys.all(wsId) });
|
||||
if (vars && vars.patch.timezone !== undefined) {
|
||||
// Usage query keys are not workspace-scoped; invalidate only this
|
||||
// runtime's daily/by-agent/by-hour usage rows under the new tz buckets.
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["runtimes", "usage", vars.runtimeId],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["runtimes", "usage", "by-agent", vars.runtimeId],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["runtimes", "usage", "by-hour", vars.runtimeId],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,3 +30,23 @@ export interface TimelineEntry {
|
||||
coalesced_count?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-paginated timeline page. Entries are newest-first
|
||||
* (created_at DESC, id DESC). Cursors are opaque base64 strings — pass them
|
||||
* back unchanged via TimelinePageParam.
|
||||
*/
|
||||
export interface TimelinePage {
|
||||
entries: TimelineEntry[];
|
||||
next_cursor: string | null;
|
||||
prev_cursor: string | null;
|
||||
has_more_before: boolean;
|
||||
has_more_after: boolean;
|
||||
/** Set only in around-id mode; index of the anchor entry within `entries`. */
|
||||
target_index?: number;
|
||||
}
|
||||
|
||||
export type TimelinePageParam =
|
||||
| { mode: "latest" }
|
||||
| { mode: "before"; cursor: string }
|
||||
| { mode: "after"; cursor: string }
|
||||
| { mode: "around"; id: string };
|
||||
|
||||
@@ -4,14 +4,6 @@ export type AgentRuntimeMode = "local" | "cloud";
|
||||
|
||||
export type AgentVisibility = "workspace" | "private";
|
||||
|
||||
// Runtime visibility is a separate axis from agent visibility — different
|
||||
// vocabulary because it gates a different action. "private" (default) means
|
||||
// only the runtime owner and workspace admins can bind agents to it;
|
||||
// "public" opens binding to any workspace member. Older backends that
|
||||
// haven't shipped MUL-2062 omit the field; the consumer must default to
|
||||
// "private" so the strictest behavior is the fallback.
|
||||
export type RuntimeVisibility = "private" | "public";
|
||||
|
||||
export interface RuntimeDevice {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -24,9 +16,6 @@ export interface RuntimeDevice {
|
||||
device_info: string;
|
||||
metadata: Record<string, unknown>;
|
||||
owner_id: string | null;
|
||||
/** Defaults to "private" when the backend predates the visibility flag. */
|
||||
visibility: RuntimeVisibility;
|
||||
timezone: string;
|
||||
last_seen_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -168,76 +157,6 @@ export interface CreateAgentRequest {
|
||||
template?: string;
|
||||
}
|
||||
|
||||
/** Agent template summary — fields needed by the picker grid. Does NOT
|
||||
* include `instructions` to keep the list payload small; the detail
|
||||
* endpoint or the create flow returns the full template body. */
|
||||
export interface AgentTemplateSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** Optional grouping for the picker UI ("Engineering" / "Writing" / …). */
|
||||
category?: string;
|
||||
/** Optional lucide-react icon name (e.g. "Search"). Frontend falls back
|
||||
* to a generic icon when empty. */
|
||||
icon?: string;
|
||||
/** Optional semantic color token for the icon badge — one of "info" /
|
||||
* "success" / "warning" / "primary" / "secondary". Frontend has a
|
||||
* static class map so Tailwind can JIT-scan all variants. */
|
||||
accent?: string;
|
||||
skills: AgentTemplateSkillRef[];
|
||||
}
|
||||
|
||||
/** Full agent template — same as `AgentTemplateSummary` plus the
|
||||
* instructions block. Returned by `GET /api/agent-templates/:slug`. */
|
||||
export interface AgentTemplate extends AgentTemplateSummary {
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
/** Skill reference inside an agent template. `source_url` is the upstream
|
||||
* GitHub / skills.sh URL fetched on create; `cached_*` mirror the upstream
|
||||
* frontmatter at template-author time and let the picker render without
|
||||
* HTTP fetches. */
|
||||
export interface AgentTemplateSkillRef {
|
||||
source_url: string;
|
||||
cached_name: string;
|
||||
cached_description: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentFromTemplateRequest {
|
||||
template_slug: string;
|
||||
name: string;
|
||||
runtime_id: string;
|
||||
model?: string;
|
||||
visibility?: AgentVisibility;
|
||||
max_concurrent_tasks?: number;
|
||||
/** Optional overrides applied to the template before creation. nil/omit
|
||||
* uses the template's own value. */
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
avatar_url?: string;
|
||||
/** Workspace skill IDs attached **in addition to** the template's
|
||||
* skills. Server dedupes against template skills automatically. */
|
||||
extra_skill_ids?: string[];
|
||||
}
|
||||
|
||||
export interface CreateAgentFromTemplateResponse {
|
||||
agent: Agent;
|
||||
/** Skill IDs that were newly created in the workspace from upstream URLs. */
|
||||
imported_skill_ids: string[];
|
||||
/** Skill IDs that already existed in the workspace (same name) and were
|
||||
* reused rather than re-imported. The UI can surface this as a toast so
|
||||
* the user knows their pre-existing skill wasn't overwritten. */
|
||||
reused_skill_ids: string[];
|
||||
}
|
||||
|
||||
/** 422 body returned by `POST /api/agents/from-template` when one or more
|
||||
* template skill URLs cannot be reached. The transaction is rolled back —
|
||||
* no partial workspace state. */
|
||||
export interface CreateAgentFromTemplateFailure {
|
||||
error: string;
|
||||
failed_urls: string[];
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
@@ -359,44 +278,6 @@ export interface RuntimeUsageByHour {
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
// One (date, model) bucket of token usage for the workspace dashboard.
|
||||
// Same shape as RuntimeUsage but workspace-scoped (no runtime_id, no
|
||||
// provider field on the wire) and optionally narrowed to a single project
|
||||
// on the server side. Cost stays client-side via the model pricing table.
|
||||
export interface DashboardUsageDaily {
|
||||
date: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
// Per-(agent, model) token totals for the workspace dashboard. Identical
|
||||
// wire shape to RuntimeUsageByAgent — the client folds by agent_id and
|
||||
// sums cost.
|
||||
export interface DashboardUsageByAgent {
|
||||
agent_id: string;
|
||||
model: string;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
task_count: number;
|
||||
}
|
||||
|
||||
// Per-agent total terminal-task run-time + counts. Powers the workspace
|
||||
// dashboard's "time by agent" list. failed_count is a subset of
|
||||
// task_count (failed tasks still contribute to total_seconds because
|
||||
// they consumed runtime to fail).
|
||||
export interface DashboardAgentRunTime {
|
||||
agent_id: string;
|
||||
total_seconds: number;
|
||||
task_count: number;
|
||||
failed_count: number;
|
||||
}
|
||||
|
||||
export type RuntimeUpdateStatus =
|
||||
| "pending"
|
||||
| "running"
|
||||
|
||||
@@ -3,8 +3,6 @@ export interface Attachment {
|
||||
workspace_id: string;
|
||||
issue_id: string | null;
|
||||
comment_id: string | null;
|
||||
chat_session_id: string | null;
|
||||
chat_message_id: string | null;
|
||||
uploader_type: string;
|
||||
uploader_id: string;
|
||||
filename: string;
|
||||
|
||||
@@ -28,14 +28,6 @@ export interface ChatMessage {
|
||||
content: string;
|
||||
task_id: string | null;
|
||||
created_at: string;
|
||||
/**
|
||||
* Attachments linked to this message via the attachment table's
|
||||
* chat_message_id FK. Populated by ListChatMessages. UI renders these
|
||||
* as file/image cards inside the bubble; the markdown URL inline in
|
||||
* `content` may have an expiring signature, while attachment metadata
|
||||
* here is stable and the source of truth for click-time download.
|
||||
*/
|
||||
attachments?: import("./attachment").Attachment[];
|
||||
/**
|
||||
* When set, this is an assistant message synthesized by the server's
|
||||
* FailTask fallback (mirrors the issue path's failure system comment).
|
||||
|
||||
@@ -67,12 +67,7 @@ export type WSEventType =
|
||||
| "invitation:created"
|
||||
| "invitation:accepted"
|
||||
| "invitation:declined"
|
||||
| "invitation:revoked"
|
||||
| "github_installation:created"
|
||||
| "github_installation:deleted"
|
||||
| "pull_request:linked"
|
||||
| "pull_request:updated"
|
||||
| "pull_request:unlinked";
|
||||
| "invitation:revoked";
|
||||
|
||||
export interface WSMessage<T = unknown> {
|
||||
type: WSEventType;
|
||||
@@ -289,16 +284,7 @@ export interface ChatMessageEventPayload {
|
||||
export interface ChatDonePayload {
|
||||
chat_session_id: string;
|
||||
task_id: string;
|
||||
/**
|
||||
* Server populates these from the freshly-persisted assistant ChatMessage
|
||||
* row so the WS handler can write it into the messages cache inline. Older
|
||||
* servers (pre-#2123) only sent chat_session_id + task_id; treat every field
|
||||
* below as optional and fall back to a refetch when absent.
|
||||
*/
|
||||
message_id?: string;
|
||||
content?: string;
|
||||
elapsed_ms?: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface ChatSessionReadPayload {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
export type GitHubPullRequestState = "open" | "closed" | "merged" | "draft";
|
||||
|
||||
export interface GitHubInstallation {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
installation_id: number;
|
||||
account_login: string;
|
||||
account_type: "User" | "Organization";
|
||||
account_avatar_url: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface GitHubPullRequest {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
repo_owner: string;
|
||||
repo_name: string;
|
||||
number: number;
|
||||
title: string;
|
||||
state: GitHubPullRequestState;
|
||||
html_url: string;
|
||||
branch: string | null;
|
||||
author_login: string | null;
|
||||
author_avatar_url: string | null;
|
||||
merged_at: string | null;
|
||||
closed_at: string | null;
|
||||
pr_created_at: string;
|
||||
pr_updated_at: string;
|
||||
}
|
||||
|
||||
export interface ListGitHubInstallationsResponse {
|
||||
installations: GitHubInstallation[];
|
||||
/** Whether the deployment has GitHub App credentials configured. When false, the Connect button is hidden / disabled. */
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface GitHubConnectResponse {
|
||||
/** The GitHub App install URL the browser should open. Empty when `configured` is false. */
|
||||
url?: string;
|
||||
configured: boolean;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export interface InboxItem {
|
||||
workspace_id: string;
|
||||
recipient_type: "member" | "agent";
|
||||
recipient_id: string;
|
||||
actor_type: "member" | "agent" | "system" | null;
|
||||
actor_type: "member" | "agent" | null;
|
||||
actor_id: string | null;
|
||||
type: InboxItemType;
|
||||
severity: InboxSeverity;
|
||||
|
||||
@@ -11,12 +11,6 @@ export type {
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
AgentTemplateSkillRef,
|
||||
CreateAgentFromTemplateRequest,
|
||||
CreateAgentFromTemplateResponse,
|
||||
CreateAgentFromTemplateFailure,
|
||||
UpdateAgentRequest,
|
||||
Skill,
|
||||
SkillSummary,
|
||||
@@ -29,9 +23,6 @@ export type {
|
||||
RuntimeHourlyActivity,
|
||||
RuntimeUsageByAgent,
|
||||
RuntimeUsageByHour,
|
||||
DashboardUsageDaily,
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
RuntimeUpdate,
|
||||
RuntimeUpdateStatus,
|
||||
RuntimeModel,
|
||||
@@ -54,6 +45,8 @@ export type { Comment, CommentType, CommentAuthorType, Reaction } from "./commen
|
||||
export type { Label, CreateLabelRequest, UpdateLabelRequest, ListLabelsResponse, IssueLabelsResponse } from "./label";
|
||||
export type {
|
||||
TimelineEntry,
|
||||
TimelinePage,
|
||||
TimelinePageParam,
|
||||
AssigneeFrequencyEntry,
|
||||
} from "./activity";
|
||||
export type { IssueSubscriber } from "./subscriber";
|
||||
@@ -76,13 +69,6 @@ export type {
|
||||
ListProjectResourcesResponse,
|
||||
} from "./project";
|
||||
export type { PinnedItem, PinnedItemType, CreatePinRequest, ReorderPinsRequest } from "./pin";
|
||||
export type {
|
||||
GitHubInstallation,
|
||||
GitHubPullRequest,
|
||||
GitHubPullRequestState,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
} from "./github";
|
||||
export type {
|
||||
Autopilot,
|
||||
AutopilotStatus,
|
||||
|
||||
@@ -22,7 +22,6 @@ export function useActorName() {
|
||||
const getActorName = (type: string, id: string) => {
|
||||
if (type === "member") return getMemberName(id);
|
||||
if (type === "agent") return getAgentName(id);
|
||||
if (type === "system") return "Multica";
|
||||
return "System";
|
||||
};
|
||||
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
interface ActorAvatarProps {
|
||||
name: string;
|
||||
initials: string;
|
||||
avatarUrl?: string | null;
|
||||
isAgent?: boolean;
|
||||
isSystem?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
@@ -20,7 +18,6 @@ function ActorAvatar({
|
||||
initials,
|
||||
avatarUrl,
|
||||
isAgent,
|
||||
isSystem,
|
||||
size = 20,
|
||||
className,
|
||||
}: ActorAvatarProps) {
|
||||
@@ -49,8 +46,6 @@ function ActorAvatar({
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setImgError(true)}
|
||||
/>
|
||||
) : isSystem ? (
|
||||
<MulticaIcon noSpin style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : isAgent ? (
|
||||
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : (
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUp, Loader2, Square } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
|
||||
interface SubmitButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -15,52 +9,25 @@ interface SubmitButtonProps {
|
||||
loading?: boolean;
|
||||
running?: boolean;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
* Tooltip shown over the send button when idle. Pass a string or a node
|
||||
* (e.g. `Send · ⌘↵`). Omit to render no tooltip.
|
||||
* Callers compose the shortcut hint themselves to keep this component
|
||||
* free of `@multica/core` (platform-detection) and i18n imports.
|
||||
*/
|
||||
tooltip?: ReactNode;
|
||||
/** Tooltip shown over the stop button while a run is in progress. */
|
||||
stopTooltip?: ReactNode;
|
||||
}
|
||||
|
||||
function SubmitButton({
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
running,
|
||||
onStop,
|
||||
tooltip,
|
||||
stopTooltip,
|
||||
}: SubmitButtonProps) {
|
||||
function SubmitButton({ onClick, disabled, loading, running, onStop }: SubmitButtonProps) {
|
||||
if (running) {
|
||||
const stopButton = (
|
||||
return (
|
||||
<Button size="icon-sm" onClick={onStop}>
|
||||
<Square className="fill-current" />
|
||||
</Button>
|
||||
);
|
||||
if (!stopTooltip) return stopButton;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={stopButton} />
|
||||
<TooltipContent side="top">{stopTooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const submitButton = (
|
||||
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
|
||||
{loading ? <Loader2 className="animate-spin" /> : <ArrowUp />}
|
||||
</Button>
|
||||
);
|
||||
if (!tooltip) return submitButton;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={submitButton} />
|
||||
<TooltipContent side="top">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button size="icon-sm" disabled={disabled || loading} onClick={onClick}>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<ArrowUp />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
--color-info: var(--info);
|
||||
--color-brand: var(--brand);
|
||||
--color-brand-foreground: var(--brand-foreground);
|
||||
--color-priority: var(--priority);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
@@ -93,6 +94,7 @@
|
||||
--success: oklch(0.55 0.16 145);
|
||||
--warning: oklch(0.75 0.16 85);
|
||||
--info: oklch(0.55 0.18 250);
|
||||
--priority: oklch(0.65 0.18 50);
|
||||
--scrollbar-thumb: oklch(0 0 0 / 10%);
|
||||
--scrollbar-thumb-hover: oklch(0 0 0 / 18%);
|
||||
--scrollbar-track: transparent;
|
||||
@@ -139,6 +141,7 @@
|
||||
--success: oklch(0.65 0.15 145);
|
||||
--warning: oklch(0.70 0.16 85);
|
||||
--info: oklch(0.65 0.18 250);
|
||||
--priority: oklch(0.70 0.18 50);
|
||||
--scrollbar-thumb: oklch(1 0 0 / 8%);
|
||||
--scrollbar-thumb-hover: oklch(1 0 0 / 18%);
|
||||
--scrollbar-track: transparent;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Lock,
|
||||
MoreHorizontal,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
type AgentPresenceDetail,
|
||||
useWorkspacePresenceMap,
|
||||
} from "@multica/core/agents";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useWorkspacePaths } from "@multica/core/paths";
|
||||
@@ -79,19 +78,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
const presence: AgentPresenceDetail | null =
|
||||
agent ? presenceMap.get(agent.id) ?? null : null;
|
||||
|
||||
// Fallback fetch: when the agent is missing from the workspace list, hit
|
||||
// GET /api/agents/{id} directly to disambiguate "doesn't exist" (404) from
|
||||
// "you can't see this private agent" (403). Only fires after the list has
|
||||
// settled, so the common path makes zero extra requests.
|
||||
const { error: detailError } = useQuery({
|
||||
queryKey: ["agent-detail-probe", wsId, agentId],
|
||||
queryFn: () => api.getAgent(agentId),
|
||||
enabled: !agentsLoading && !agent && !!agentId,
|
||||
retry: false,
|
||||
});
|
||||
const isForbidden =
|
||||
detailError instanceof ApiError && detailError.status === 403;
|
||||
|
||||
// Permission hook MUST be called unconditionally — its `agent | null`
|
||||
// signature handles the not-found / loading case internally so the early
|
||||
// returns below don't violate the rules of hooks. Backend gates archive
|
||||
@@ -136,31 +122,6 @@ export function AgentDetailPage({ agentId }: AgentDetailPageProps) {
|
||||
return <DetailLoadingSkeleton />;
|
||||
}
|
||||
|
||||
// --- No permission (private agent the caller is not in allowed_principals for) ---
|
||||
if (!agent && isForbidden) {
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<BackHeader paths={paths.agents()} title={t(($) => $.detail.back_to_agents)} />
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 py-16 text-center">
|
||||
<Lock className="h-8 w-8 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t(($) => $.detail.no_access_title)}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.detail.no_access_hint)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => navigation.push(paths.agents())}
|
||||
>
|
||||
{t(($) => $.detail.back_to_agents_full)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Not found / error ---
|
||||
if (!agent) {
|
||||
return (
|
||||
|
||||
@@ -208,7 +208,7 @@ export function AgentOverviewPane({
|
||||
);
|
||||
}
|
||||
|
||||
// Padded, full-width container shared by every config tab. `h-full flex
|
||||
// Centred, max-width container shared by every config tab. `h-full flex
|
||||
// flex-col` lets a tab opt into "fill the viewport" by giving its root
|
||||
// element `flex-1 min-h-0` (Instructions does this so the editor expands
|
||||
// instead of pushing the Save row off-screen). Tabs that don't opt in
|
||||
@@ -216,6 +216,6 @@ export function AgentOverviewPane({
|
||||
// list) still scrolls via the parent's overflow-y-auto.
|
||||
function TabContent({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col p-4 md:p-6">{children}</div>
|
||||
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,24 +280,35 @@ export function AgentsPage() {
|
||||
if (view === "archived" && archivedCount === 0) setView("active");
|
||||
}, [view, archivedCount]);
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest): Promise<Agent> => {
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const agent = await api.createAgent(data);
|
||||
// Skill follow-up is now owned by the dialog (it reads the user's
|
||||
// form selection, which already includes the duplicate source's
|
||||
// skills as a default when applicable). The dialog will call
|
||||
// setAgentSkills after we return; we just have to surface the
|
||||
// created agent so it can.
|
||||
let cachedAgent = agent;
|
||||
// When duplicating, carry the source agent's skill assignments over.
|
||||
// Skills aren't part of CreateAgentRequest (they're managed via
|
||||
// setAgentSkills) so the create endpoint can't take them inline; we
|
||||
// do a follow-up call. Failure here doesn't abort the duplicate —
|
||||
// the agent already exists and the user can re-attach skills from
|
||||
// the detail page.
|
||||
if (duplicateTemplate?.skills.length) {
|
||||
try {
|
||||
await api.setAgentSkills(agent.id, {
|
||||
skill_ids: duplicateTemplate.skills.map((s) => s.id),
|
||||
});
|
||||
cachedAgent = { ...agent, skills: duplicateTemplate.skills };
|
||||
} catch {
|
||||
// Surfaced softly; the agent itself is fine.
|
||||
}
|
||||
}
|
||||
qc.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
|
||||
const exists = current.some((a) => a.id === agent.id);
|
||||
const exists = current.some((a) => a.id === cachedAgent.id);
|
||||
return exists
|
||||
? current.map((a) => (a.id === agent.id ? agent : a))
|
||||
: [...current, agent];
|
||||
? current.map((a) => (a.id === cachedAgent.id ? cachedAgent : a))
|
||||
: [...current, cachedAgent];
|
||||
});
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
navigation.push(paths.agentDetail(agent.id));
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
return agent;
|
||||
};
|
||||
|
||||
const handleDuplicate = useCallback((agent: Agent) => {
|
||||
@@ -456,7 +467,6 @@ export function AgentsPage() {
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
template={duplicateTemplate}
|
||||
existingAgentNames={agents.map((a) => a.name)}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Camera, ImagePlus, Loader2, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface AvatarPickerProps {
|
||||
/** Current avatar URL. null when nothing chosen yet. */
|
||||
value: string | null;
|
||||
/** Fires after a successful upload — the parent stashes the URL for the
|
||||
* create call. Re-fires with null when the user clears the choice. */
|
||||
onChange: (url: string | null) => void;
|
||||
/** Pixel size of the square. Defaults to 56 (h-14 / w-14), which lines
|
||||
* up vertically with the Name + Description stack in the create-agent
|
||||
* form so the two read as a single visual row. */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact avatar picker — a single square that lives next to the Name
|
||||
* input in the create-agent form. Mirrors the visual language of
|
||||
* agent-detail-inspector.tsx (Camera overlay on hover, file input behind
|
||||
* the scenes), so users who've configured an avatar elsewhere in the app
|
||||
* recognise the affordance immediately.
|
||||
*
|
||||
* No avatar yet → dashed placeholder with an ImagePlus icon.
|
||||
* Has avatar → image fills the square, hover dims it with a Camera
|
||||
* overlay for "click to change". A small × in the corner
|
||||
* clears the choice.
|
||||
*/
|
||||
export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps) {
|
||||
const { t } = useT("agents");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { upload, uploading } = useFileUpload(api);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
|
||||
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = ""; // allow re-selecting the same file
|
||||
if (!file.type.startsWith("image/")) {
|
||||
toast.error(t(($) => $.create_dialog.avatar.select_image_toast));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await upload(file);
|
||||
if (!result) return;
|
||||
setPreviewError(false);
|
||||
onChange(result.link);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t(($) => $.create_dialog.avatar.upload_failed_toast),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const hasValue = !!value && !previewError;
|
||||
const dimensionStyle = { width: size, height: size };
|
||||
|
||||
return (
|
||||
<div className="relative shrink-0" style={dimensionStyle}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className={cn(
|
||||
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ring",
|
||||
hasValue
|
||||
? "border bg-muted"
|
||||
: "border border-dashed bg-muted/40 hover:bg-muted",
|
||||
)}
|
||||
aria-label={
|
||||
hasValue
|
||||
? t(($) => $.create_dialog.avatar.change_aria)
|
||||
: t(($) => $.create_dialog.avatar.upload_aria)
|
||||
}
|
||||
style={dimensionStyle}
|
||||
>
|
||||
{hasValue ? (
|
||||
<img
|
||||
src={value ?? undefined}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setPreviewError(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay only when there's already an image — otherwise the
|
||||
placeholder icon already invites the click. */}
|
||||
{hasValue && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-white" />
|
||||
) : (
|
||||
<Camera className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Tiny X to clear, only shown when there's a value. Positioned just
|
||||
outside the avatar's top-right corner so it doesn't cover the
|
||||
image. */}
|
||||
{hasValue && !uploading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(null);
|
||||
setPreviewError(false);
|
||||
}}
|
||||
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label={t(($) => $.create_dialog.avatar.remove_aria)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import type { Agent, MemberWithUser, RuntimeDevice } from "@multica/core/types";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import { WorkspaceSlugProvider } from "@multica/core/paths";
|
||||
import { NavigationProvider, type NavigationAdapter } from "../../navigation";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enAgents from "../../locales/en/agents.json";
|
||||
|
||||
const navigationStub: NavigationAdapter = {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
pathname: "/",
|
||||
searchParams: new URLSearchParams(),
|
||||
getShareableUrl: (path: string) => path,
|
||||
};
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
|
||||
|
||||
vi.mock("@multica/core/hooks", () => ({
|
||||
useWorkspaceId: () => "ws-1",
|
||||
}));
|
||||
|
||||
// ModelDropdown talks to the api; the create dialog only needs it as a
|
||||
// stand-in here, so swap it out.
|
||||
vi.mock("./model-dropdown", () => ({
|
||||
ModelDropdown: () => null,
|
||||
}));
|
||||
|
||||
// Provider logos don't matter for these assertions but they pull in SVGs.
|
||||
vi.mock("../../runtimes/components/provider-logo", () => ({
|
||||
ProviderLogo: () => null,
|
||||
}));
|
||||
|
||||
// Avatars hit the api for member metadata.
|
||||
vi.mock("../../common/actor-avatar", () => ({
|
||||
ActorAvatar: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
import { CreateAgentDialog } from "./create-agent-dialog";
|
||||
|
||||
const ME = "user-me";
|
||||
const OTHER = "user-other";
|
||||
|
||||
const members: MemberWithUser[] = [
|
||||
{
|
||||
id: "m-me",
|
||||
user_id: ME,
|
||||
workspace_id: "ws-1",
|
||||
role: "member",
|
||||
name: "Me",
|
||||
email: "me@example.com",
|
||||
avatar_url: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "m-other",
|
||||
user_id: OTHER,
|
||||
workspace_id: "ws-1",
|
||||
role: "member",
|
||||
name: "Other",
|
||||
email: "other@example.com",
|
||||
avatar_url: null,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
function makeRuntime(overrides: Partial<RuntimeDevice>): RuntimeDevice {
|
||||
return {
|
||||
id: "rt",
|
||||
workspace_id: "ws-1",
|
||||
daemon_id: null,
|
||||
name: "Test Runtime",
|
||||
runtime_mode: "local",
|
||||
provider: "claude",
|
||||
launch_header: "",
|
||||
status: "online",
|
||||
device_info: "host.local",
|
||||
metadata: {},
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
timezone: "UTC",
|
||||
last_seen_at: "2026-04-27T11:59:50Z",
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTemplate(runtimeId: string): Agent {
|
||||
return {
|
||||
id: "agent-template",
|
||||
workspace_id: "ws-1",
|
||||
runtime_id: runtimeId,
|
||||
name: "Template Agent",
|
||||
description: "",
|
||||
instructions: "",
|
||||
avatar_url: null,
|
||||
runtime_mode: "local",
|
||||
runtime_config: {},
|
||||
custom_env: {},
|
||||
custom_args: [],
|
||||
custom_env_redacted: false,
|
||||
visibility: "private",
|
||||
status: "idle",
|
||||
max_concurrent_tasks: 1,
|
||||
model: "",
|
||||
owner_id: ME,
|
||||
skills: [],
|
||||
created_at: "2026-04-01T00:00:00Z",
|
||||
updated_at: "2026-04-01T00:00:00Z",
|
||||
archived_at: null,
|
||||
archived_by: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const onCreate = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkspaceSlugProvider slug="test-ws">
|
||||
<NavigationProvider value={navigationStub}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
</NavigationProvider>
|
||||
</WorkspaceSlugProvider>
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
// Without a `template`, the dialog opens on the blank-vs-template
|
||||
// chooser. These tests target the manual form's runtime picker, so
|
||||
// advance through the chooser to the form. Duplicate mode jumps
|
||||
// straight to the form and doesn't render the chooser.
|
||||
if (!template) {
|
||||
fireEvent.click(screen.getByText(enAgents.create_dialog.chooser.blank_title));
|
||||
}
|
||||
return { onCreate, onClose };
|
||||
}
|
||||
|
||||
describe("CreateAgentDialog runtime visibility gate", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
// Base UI Dialog renders into a portal on document.body and leaves
|
||||
// focus-guard / inert wrapper divs around after the React tree unmounts.
|
||||
// The auto-cleanup from @testing-library/react drops the container but
|
||||
// not the portal residue, so two-tests-in-a-row queries see double
|
||||
// matches ("All", "My Runtime"). Force cleanup + wipe body between tests.
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("disables another member's private runtime in the picker", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([mine, othersPrivate]);
|
||||
|
||||
// Flip to "All" so other-owned runtimes show.
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
// Open the picker.
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const disabledRow = screen
|
||||
.getByText("Others Private")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(disabledRow).not.toBeNull();
|
||||
expect(disabledRow.disabled).toBe(true);
|
||||
expect(disabledRow.title).toMatch(/Private runtime/i);
|
||||
});
|
||||
|
||||
it("lets a plain member pick another member's public runtime", () => {
|
||||
const mine = makeRuntime({ id: "rt-mine", name: "My Runtime", owner_id: ME, visibility: "private" });
|
||||
const othersPublic = makeRuntime({
|
||||
id: "rt-others-public",
|
||||
name: "Others Public",
|
||||
owner_id: OTHER,
|
||||
visibility: "public",
|
||||
});
|
||||
renderDialog([mine, othersPublic]);
|
||||
|
||||
fireEvent.click(screen.getByText("All"));
|
||||
fireEvent.click(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
);
|
||||
|
||||
const publicRow = screen
|
||||
.getByText("Others Public")
|
||||
.closest("button") as HTMLButtonElement;
|
||||
expect(publicRow).not.toBeNull();
|
||||
expect(publicRow.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults the selected runtime to a usable one, not a locked private", () => {
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
renderDialog([othersPrivate, mine]);
|
||||
|
||||
// The trigger label shows the selected runtime name. The picker must
|
||||
// not seed with the other-owned private runtime even if it sorted
|
||||
// first in the input list.
|
||||
expect(screen.queryByText("Others Private", { selector: "span.truncate" })).toBeNull();
|
||||
expect(screen.getByText("My Runtime", { selector: "span.truncate" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("in duplicate mode, does not pre-fill the template's runtime when it's now locked", async () => {
|
||||
// Template runtime is owned by someone else and now private — the
|
||||
// duplicate flow used to seed with it anyway, leaving the user with
|
||||
// a Create button that 403s server-side. Now we fall back to the
|
||||
// first usable runtime instead.
|
||||
const othersPrivate = makeRuntime({
|
||||
id: "rt-others-private",
|
||||
name: "Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
const mine = makeRuntime({
|
||||
id: "rt-mine",
|
||||
name: "My Runtime",
|
||||
owner_id: ME,
|
||||
visibility: "private",
|
||||
});
|
||||
const template = makeTemplate("rt-others-private");
|
||||
const { onCreate } = renderDialog([othersPrivate, mine], template);
|
||||
|
||||
expect(
|
||||
screen.getByText("My Runtime", { selector: "span.truncate" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Others Private", { selector: "span.truncate" }),
|
||||
).toBeNull();
|
||||
|
||||
// Sanity check: with a usable selection seeded, Create should submit.
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onCreate.mock.calls[0]?.[0].runtime_id).toBe("rt-mine");
|
||||
});
|
||||
|
||||
it("disables Create when the selected runtime is locked (template + no usable fallback)", () => {
|
||||
// Edge case: template points at a locked runtime AND the workspace
|
||||
// has no usable alternatives in scope. The defense-in-depth gate on
|
||||
// the Create button must keep the user from submitting a 403.
|
||||
const onlyOthersPrivate = makeRuntime({
|
||||
id: "rt-only-others-private",
|
||||
name: "Only Others Private",
|
||||
owner_id: OTHER,
|
||||
visibility: "private",
|
||||
});
|
||||
// Flip the picker to "All" so the locked runtime is at least
|
||||
// visible — that's the scope where the selected-but-locked state
|
||||
// can persist after the initial seed search returns nothing.
|
||||
const template = makeTemplate("rt-only-others-private");
|
||||
renderDialog([onlyOthersPrivate], template);
|
||||
|
||||
// The Create button is rendered by lucide-free CTA text "Create".
|
||||
const createBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent === "Create");
|
||||
expect(createBtn).toBeDefined();
|
||||
expect((createBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Cloud, Lock, Monitor } from "lucide-react";
|
||||
import { Cloud, Monitor } from "lucide-react";
|
||||
import type { AgentRuntime, MemberWithUser } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../../common/actor-avatar";
|
||||
import {
|
||||
@@ -45,28 +45,18 @@ export function RuntimePicker({
|
||||
|
||||
// Compute filtered list unconditionally — the early `!canEdit` return
|
||||
// below would otherwise re-order this hook across renders.
|
||||
const isDisabled = (r: AgentRuntime): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
const filtered = useMemo(() => {
|
||||
const list =
|
||||
filter === "mine" && currentUserId
|
||||
? runtimes.filter((r) => r.owner_id === currentUserId)
|
||||
: runtimes;
|
||||
return [...list].sort((a, b) => {
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isDisabled(a);
|
||||
const bDisabled = isDisabled(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
if (a.owner_id === currentUserId && b.owner_id !== currentUserId)
|
||||
return -1;
|
||||
if (a.owner_id !== currentUserId && b.owner_id === currentUserId)
|
||||
return 1;
|
||||
return 0;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, filter, currentUserId]);
|
||||
|
||||
if (!canEdit) {
|
||||
@@ -167,12 +157,10 @@ export function RuntimePicker({
|
||||
filtered.map((rt) => {
|
||||
const owner = getOwner(rt.owner_id);
|
||||
const rtOnline = rt.status === "online";
|
||||
const locked = isDisabled(rt);
|
||||
const tooltip = [
|
||||
rt.name,
|
||||
owner ? t(($) => $.pickers.runtime_owned_by, { name: owner.name }) : null,
|
||||
rtOnline ? t(($) => $.pickers.runtime_online) : t(($) => $.pickers.runtime_offline),
|
||||
locked ? t(($) => $.create_dialog.runtime_private_locked_tooltip) : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
@@ -180,11 +168,7 @@ export function RuntimePicker({
|
||||
<PickerItem
|
||||
key={rt.id}
|
||||
selected={rt.id === value}
|
||||
disabled={locked}
|
||||
onClick={() => {
|
||||
if (locked) return;
|
||||
void select(rt.id);
|
||||
}}
|
||||
onClick={() => void select(rt.id)}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<ProviderLogo
|
||||
@@ -201,12 +185,6 @@ export function RuntimePicker({
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{locked && (
|
||||
<span className="shrink-0 inline-flex items-center gap-0.5 rounded bg-muted px-1 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{owner && (
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { ChevronDown, FileText, X } from "lucide-react";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface InstructionsEditorProps {
|
||||
/** Markdown source. Used both as default value when expanded and as
|
||||
* preview text when collapsed. */
|
||||
value: string;
|
||||
/** Fires on every keystroke (debounced inside ContentEditor). */
|
||||
onChange: (value: string) => void;
|
||||
/** Optional placeholder override. Defaults to the i18n "click to write"
|
||||
* copy; the create dialog passes the duplicate-specific string for
|
||||
* agents being cloned. */
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible Instructions field for the create-agent dialog. Stays compact
|
||||
* until the user wants to write — most agents only need instructions when
|
||||
* they're being authored carefully, not on every quick-create.
|
||||
*
|
||||
* Two states:
|
||||
* collapsed → small clickable card, shows a preview of `value` (or the
|
||||
* placeholder when empty). One click expands.
|
||||
* expanded → full ContentEditor (markdown, bubble menu, mention support,
|
||||
* attachment upload). "Collapse" button on the right of the
|
||||
* header tucks it back; value is preserved.
|
||||
*/
|
||||
export function InstructionsEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: InstructionsEditorProps) {
|
||||
const { t } = useT("agents");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
|
||||
const label = t(($) => $.create_dialog.instructions.label);
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? t(($) => $.create_dialog.instructions.placeholder_blank);
|
||||
|
||||
const expand = () => {
|
||||
setExpanded(true);
|
||||
// Focus on next tick so the editor mounts first.
|
||||
setTimeout(() => editorRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={expand}
|
||||
className="mt-1.5 flex w-full items-start gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{value.trim() ? (
|
||||
// Preview: first 2 lines of markdown, ellipsised.
|
||||
<div className="line-clamp-2 whitespace-pre-wrap text-sm text-foreground/80">
|
||||
{value}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">{resolvedPlaceholder}</div>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.instructions.collapse)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1.5 rounded-lg border bg-card",
|
||||
"focus-within:border-primary/40",
|
||||
)}
|
||||
>
|
||||
<ContentEditor
|
||||
ref={editorRef}
|
||||
defaultValue={value}
|
||||
onUpdate={onChange}
|
||||
placeholder={t(($) => $.create_dialog.instructions.editor_placeholder)}
|
||||
className="min-h-[160px] max-h-[320px] overflow-y-auto px-3 py-2.5 text-sm"
|
||||
showBubbleMenu={true}
|
||||
disableMentions={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent, SkillSummary } from "@multica/core/types";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
@@ -19,19 +20,18 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { useT } from "../../i18n";
|
||||
import { SkillPickerList } from "./skill-picker-list";
|
||||
|
||||
/**
|
||||
* "Attach workspace skills to this agent." Multi-select with explicit
|
||||
* Confirm — earlier iterations attached on a single row click, which
|
||||
* meant the user couldn't tick several skills at once and the dialog
|
||||
* closed before they could review their choice.
|
||||
* Single source of truth for "attach a workspace skill to this agent".
|
||||
* Used by both:
|
||||
* - SkillsTab — full surface, "Add skill" button
|
||||
* - Inspector → SkillAttach — inline dashed `+ Attach` chip
|
||||
*
|
||||
* Already-attached skills are filtered out of the list entirely (vs.
|
||||
* showing them disabled). When there are no remaining workspace skills
|
||||
* to attach, the empty-state copy explains why, and the Confirm button
|
||||
* is naturally disabled because nothing can be selected.
|
||||
* Owns the workspace-skill list query, the "what's still attachable" filter,
|
||||
* the API call, and the optimistic invalidation. Callers only manage the
|
||||
* open/close state — they don't repeat the attach logic.
|
||||
*/
|
||||
export function SkillAddDialog({
|
||||
agent,
|
||||
@@ -45,44 +45,34 @@ export function SkillAddDialog({
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const attachedIds = useMemo(
|
||||
() => new Set(agent.skills.map((s) => s.id)),
|
||||
[agent.skills],
|
||||
);
|
||||
// Hide attached skills outright — the dialog is for adding new ones.
|
||||
// If a user wants to see what's already on the agent, the SkillsTab
|
||||
// list above shows it.
|
||||
const availableSkills = useMemo(
|
||||
() => workspaceSkills.filter((s) => !attachedIds.has(s.id)),
|
||||
[workspaceSkills, attachedIds],
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableSkills = workspaceSkills.filter(
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
);
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const filteredSkills = trimmedQuery
|
||||
? availableSkills.filter((s) => {
|
||||
const name = s.name.toLowerCase();
|
||||
const description = s.description?.toLowerCase() ?? "";
|
||||
return (
|
||||
name.includes(trimmedQuery) || description.includes(trimmedQuery)
|
||||
);
|
||||
})
|
||||
: availableSkills;
|
||||
|
||||
const handleOpenChange = (v: boolean) => {
|
||||
if (!v) setSelectedIds(new Set());
|
||||
if (!v) setQuery("");
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const handleToggle = (skill: SkillSummary) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(skill.id)) next.delete(skill.id);
|
||||
else next.add(skill.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
const handleAdd = async (skillId: string) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const newIds = [
|
||||
...agent.skills.map((s) => s.id),
|
||||
...selectedIds,
|
||||
];
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
handleOpenChange(false);
|
||||
@@ -93,46 +83,65 @@ export function SkillAddDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const count = selectedIds.size;
|
||||
const showSearch = availableSkills.length > 0;
|
||||
const noMatch = showSearch && filteredSkills.length === 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">
|
||||
{t(($) => $.tab_body.skills.add_dialog_title)}
|
||||
</DialogTitle>
|
||||
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
{t(($) => $.tab_body.skills.add_dialog_description)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<SkillPickerList
|
||||
skills={availableSkills}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={handleToggle}
|
||||
loading={isLoading}
|
||||
emptyMessage={
|
||||
workspaceSkills.length === 0
|
||||
? t(($) => $.tab_body.skills.add_dialog_empty)
|
||||
: t(($) => $.tab_body.skills.add_dialog_empty_partial)
|
||||
}
|
||||
/>
|
||||
|
||||
{showSearch && (
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
|
||||
aria-label={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
|
||||
className="pl-7"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
||||
{filteredSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
onClick={() => handleAdd(skill.id)}
|
||||
disabled={saving}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{skill.name}</div>
|
||||
{skill.description && (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{availableSkills.length === 0 && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.skills.add_dialog_empty)}
|
||||
</p>
|
||||
)}
|
||||
{noMatch && (
|
||||
<p className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.skills.add_dialog_no_match)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
|
||||
{t(($) => $.tab_body.skills.add_dialog_cancel)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={count === 0 || saving}
|
||||
>
|
||||
{saving
|
||||
? t(($) => $.tab_body.skills.add_dialog_saving)
|
||||
: count > 0
|
||||
? t(($) => $.tab_body.skills.add_dialog_confirm, { count })
|
||||
: t(($) => $.tab_body.skills.add_dialog_confirm_default)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, Plus, X } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { SkillSummary } from "@multica/core/types";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { skillListOptions } from "@multica/core/workspace/queries";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { useT } from "../../i18n";
|
||||
import { SkillPickerList } from "./skill-picker-list";
|
||||
|
||||
interface SkillMultiSelectProps {
|
||||
/** Currently-selected skill IDs (controlled). */
|
||||
selectedIds: ReadonlySet<string>;
|
||||
/** Replaces the selection on every toggle. */
|
||||
onChange: (next: Set<string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select wrapper for the create-agent form. Collapsed by default;
|
||||
* expands into a SkillPickerList configured for toggle behaviour
|
||||
* (click adds to / removes from the local selection set).
|
||||
*
|
||||
* Shares its visual surface with SkillAddDialog via SkillPickerList —
|
||||
* one component owns search + row rendering + indicators, so a tweak
|
||||
* to either appears identically in both flows.
|
||||
*/
|
||||
export function SkillMultiSelect({
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: SkillMultiSelectProps) {
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
|
||||
const [expanded, setExpanded] = useState(selectedIds.size > 0);
|
||||
|
||||
const label = t(($) => $.create_dialog.skills_section.label);
|
||||
|
||||
const toggle = (skill: SkillSummary) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(skill.id)) next.delete(skill.id);
|
||||
else next.add(skill.id);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(true)}
|
||||
className="mt-1.5 flex w-full items-center gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
||||
{selectedIds.size > 0
|
||||
? t(($) => $.create_dialog.skills_section.selected, {
|
||||
count: selectedIds.size,
|
||||
})
|
||||
: t(($) => $.create_dialog.skills_section.placeholder)}
|
||||
</div>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
{selectedIds.size > 0 ? (
|
||||
<span className="ml-2 text-foreground/60">({selectedIds.size})</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="h-6 gap-1 px-2 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.skills_section.collapse)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5">
|
||||
<SkillPickerList
|
||||
skills={workspaceSkills}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={toggle}
|
||||
loading={isLoading}
|
||||
emptyMessage={t(($) => $.create_dialog.skills_section.list_empty_multi)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import type { SkillSummary } from "@multica/core/types";
|
||||
import { Checkbox } from "@multica/ui/components/ui/checkbox";
|
||||
import { Input } from "@multica/ui/components/ui/input";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface SkillPickerListProps {
|
||||
/** Skills to show. Callers filter (e.g. exclude already-attached
|
||||
* skills in SkillAddDialog) before passing — this component just
|
||||
* renders the rows. */
|
||||
skills: readonly SkillSummary[];
|
||||
|
||||
/** Currently-toggled rows. Selected rows get a checked Checkbox and a
|
||||
* subtle background; click toggles. */
|
||||
selectedIds: ReadonlySet<string>;
|
||||
|
||||
/** Fires on every row click. Caller updates `selectedIds`. */
|
||||
onToggle: (skill: SkillSummary) => void;
|
||||
|
||||
/** Show the search input at the top. Default true. */
|
||||
searchable?: boolean;
|
||||
|
||||
/** Loading state for the skills query. */
|
||||
loading?: boolean;
|
||||
|
||||
/** Caller-supplied empty / no-match copy. Falls back to generic i18n
|
||||
* strings when omitted — the dialog and the create-form pass their
|
||||
* own flavour-specific copy. */
|
||||
emptyMessage?: string;
|
||||
noMatchMessage?: string;
|
||||
|
||||
/** Outer-wrapper className. Defaults to `w-full`; callers pass
|
||||
* e.g. `max-w-md` to constrain width. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless multi-select list of workspace skills. Used by both
|
||||
* SkillAddDialog (filtered to unattached skills) and SkillMultiSelect
|
||||
* (create-form selection). One surface owns row layout, the search
|
||||
* input, empty/loading states, and the shadcn Checkbox indicator, so
|
||||
* tweaks land in one place.
|
||||
*
|
||||
* Rows truncate the name + description columns inside `flex-1 min-w-0`
|
||||
* so long text doesn't push the Checkbox out of view.
|
||||
*/
|
||||
export function SkillPickerList({
|
||||
skills,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
searchable = true,
|
||||
loading = false,
|
||||
emptyMessage,
|
||||
noMatchMessage,
|
||||
className,
|
||||
}: SkillPickerListProps) {
|
||||
const { t } = useT("agents");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const trimmedQuery = query.trim().toLowerCase();
|
||||
const filtered = trimmedQuery
|
||||
? skills.filter((s) => {
|
||||
const name = s.name.toLowerCase();
|
||||
const description = s.description?.toLowerCase() ?? "";
|
||||
return name.includes(trimmedQuery) || description.includes(trimmedQuery);
|
||||
})
|
||||
: skills;
|
||||
|
||||
const resolvedEmpty =
|
||||
emptyMessage ?? t(($) => $.create_dialog.skills_section.list_empty_default);
|
||||
const resolvedNoMatch =
|
||||
noMatchMessage ?? t(($) => $.create_dialog.skills_section.list_no_match);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full overflow-hidden rounded-lg border bg-card", className)}>
|
||||
{searchable && skills.length > 0 && (
|
||||
<div className="border-b p-2">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t(($) => $.create_dialog.skills_section.search_placeholder)}
|
||||
className="h-8 pl-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-64 space-y-0.5 overflow-y-auto p-1.5">
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.skills_section.list_loading)}
|
||||
</div>
|
||||
) : skills.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedEmpty}</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedNoMatch}</div>
|
||||
) : (
|
||||
filtered.map((skill) => {
|
||||
const isSelected = selectedIds.has(skill.id);
|
||||
return (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
onClick={() => onToggle(skill)}
|
||||
aria-pressed={isSelected}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors",
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{/* Indicator only — the wrapping <button> handles clicks,
|
||||
so the Checkbox is non-interactive on its own. We
|
||||
pass `checked` so the visual matches the row state. */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
tabIndex={-1}
|
||||
className="pointer-events-none"
|
||||
/>
|
||||
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{skill.name}</div>
|
||||
{skill.description ? (
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,16 +24,17 @@ export function SkillsTab({
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
// Same query the SkillAddDialog uses (TanStack Query dedupes by key, so
|
||||
// this isn't an extra request) — used here only to grey out the "Add
|
||||
// skill" button when the workspace has zero skills total. When skills
|
||||
// exist but are all already attached, we still open the dialog: it
|
||||
// filters out attached skills and renders a localised "no more skills
|
||||
// to add" empty state, which is more useful than a mysterious
|
||||
// greyed-out button.
|
||||
// this isn't an extra request) — used here only to grey out the "Add skill"
|
||||
// button when there's nothing left to attach.
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const [removing, setRemoving] = useState(false);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableCount = workspaceSkills.filter(
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
).length;
|
||||
|
||||
const handleRemove = async (skillId: string) => {
|
||||
setRemoving(true);
|
||||
try {
|
||||
@@ -59,7 +60,7 @@ export function SkillsTab({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
disabled={workspaceSkills.length === 0}
|
||||
disabled={availableCount === 0}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -83,7 +84,7 @@ export function SkillsTab({
|
||||
<p className="mt-1 max-w-xs text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.tab_body.skills.empty_hint)}
|
||||
</p>
|
||||
{workspaceSkills.length > 0 && (
|
||||
{availableCount > 0 && (
|
||||
<Button
|
||||
onClick={() => setShowAdd(true)}
|
||||
size="sm"
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Cloud,
|
||||
Loader2,
|
||||
Lock,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
|
||||
import { runtimeModelsOptions } from "@multica/core/runtimes";
|
||||
import type {
|
||||
AgentTemplateSummary,
|
||||
MemberWithUser,
|
||||
RuntimeDevice,
|
||||
} from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@multica/ui/components/ui/popover";
|
||||
import { Label } from "@multica/ui/components/ui/label";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import { ProviderLogo } from "../../runtimes/components/provider-logo";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { ModelDropdown } from "./model-dropdown";
|
||||
import { getAccentClass, getTemplateIcon } from "./template-picker";
|
||||
|
||||
export interface TemplateDetailUseOptions {
|
||||
runtimeId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface TemplateDetailProps {
|
||||
template: AgentTemplateSummary;
|
||||
/** Workspace runtimes — used to populate the runtime picker. */
|
||||
runtimes: RuntimeDevice[];
|
||||
runtimesLoading?: boolean;
|
||||
/** Members of the workspace, used to label runtime owners. */
|
||||
members: MemberWithUser[];
|
||||
/** Current user id, used to grey-out private runtimes owned by others. */
|
||||
currentUserId: string | null;
|
||||
/** Fired when the user clicks "Use this template". The dialog calls the
|
||||
* create API with the runtime + model the user picked here. */
|
||||
onUse: (template: AgentTemplateSummary, options: TemplateDetailUseOptions) => void;
|
||||
/** True while the parent's create request is in flight; we disable the
|
||||
* Use button so the user can't double-click. */
|
||||
creating?: boolean;
|
||||
/** Upstream URLs the server reported as unreachable on the most recent
|
||||
* create attempt. Surfaces an inline error banner so the user knows
|
||||
* *why* Create didn't navigate. */
|
||||
failedURLs?: readonly string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the create-agent flow: a read-only preview of the picked
|
||||
* template — runtime + model picker, instructions, skill list, and a
|
||||
* "Use this template" CTA. The CTA stays disabled until the user picks
|
||||
* a runtime *and* a model (or the runtime explicitly doesn't support
|
||||
* per-agent model selection, in which case model is auto-cleared and
|
||||
* not required).
|
||||
*/
|
||||
export function TemplateDetail({
|
||||
template,
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
members,
|
||||
currentUserId,
|
||||
onUse,
|
||||
creating = false,
|
||||
failedURLs,
|
||||
}: TemplateDetailProps) {
|
||||
const { t } = useT("agents");
|
||||
const { data: detail, isLoading, error } = useQuery(
|
||||
agentTemplateDetailOptions(template.slug),
|
||||
);
|
||||
|
||||
const Icon = getTemplateIcon(template.icon);
|
||||
const accentClass = getAccentClass(template.accent);
|
||||
|
||||
// Runtime + model state — local to this step so the form path's own
|
||||
// selection is untouched. User must pick both before Use is enabled.
|
||||
const [selectedRuntimeId, setSelectedRuntimeId] = useState<string>("");
|
||||
const [model, setModel] = useState<string>("");
|
||||
const [runtimeOpen, setRuntimeOpen] = useState(false);
|
||||
|
||||
const isRuntimeDisabledForUser = (r: RuntimeDevice): boolean => {
|
||||
if (!currentUserId) return false;
|
||||
if (r.owner_id === currentUserId) return false;
|
||||
return r.visibility !== "public";
|
||||
};
|
||||
|
||||
const sortedRuntimes = useMemo(() => {
|
||||
return [...runtimes].sort((a, b) => {
|
||||
const aMine = a.owner_id === currentUserId;
|
||||
const bMine = b.owner_id === currentUserId;
|
||||
if (aMine && !bMine) return -1;
|
||||
if (!aMine && bMine) return 1;
|
||||
const aDisabled = isRuntimeDisabledForUser(a);
|
||||
const bDisabled = isRuntimeDisabledForUser(b);
|
||||
if (!aDisabled && bDisabled) return -1;
|
||||
if (aDisabled && !bDisabled) return 1;
|
||||
return 0;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [runtimes, currentUserId]);
|
||||
|
||||
const selectedRuntime =
|
||||
runtimes.find((r) => r.id === selectedRuntimeId) ?? null;
|
||||
const selectedRuntimeLocked =
|
||||
selectedRuntime != null && isRuntimeDisabledForUser(selectedRuntime);
|
||||
|
||||
const getOwnerMember = (ownerId: string | null) => {
|
||||
if (!ownerId) return null;
|
||||
return members.find((m) => m.user_id === ownerId) ?? null;
|
||||
};
|
||||
|
||||
// Query the selected runtime's model catalog so we can tell whether the
|
||||
// runtime supports per-agent model selection at all. Cached by TanStack
|
||||
// Query so ModelDropdown's own subscription reuses the same data.
|
||||
const modelsQuery = useQuery(
|
||||
runtimeModelsOptions(
|
||||
selectedRuntime?.status === "online" ? selectedRuntime.id : null,
|
||||
),
|
||||
);
|
||||
const modelSupported = modelsQuery.data?.supported ?? true;
|
||||
|
||||
// Use CTA is enabled only when:
|
||||
// - a runtime is picked and not locked
|
||||
// - either the runtime doesn't support per-agent model selection
|
||||
// (model is irrelevant), or the user picked a non-empty model.
|
||||
const modelSatisfied = !modelSupported || model.trim() !== "";
|
||||
const canUse =
|
||||
!creating &&
|
||||
!!selectedRuntime &&
|
||||
!selectedRuntimeLocked &&
|
||||
modelSatisfied;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
{/* failedURLs banner — sits above the header so it's the first
|
||||
thing the user sees after the spinner clears on a 422. */}
|
||||
{failedURLs && failedURLs.length > 0 && (
|
||||
<div className="mb-5 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<div className="font-medium text-destructive">
|
||||
{t(($) => $.create_dialog.template_failure.title)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_failure.body)}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-0.5 text-xs">
|
||||
{failedURLs.map((u) => (
|
||||
<li key={u} className="break-all font-mono">
|
||||
{u}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header: icon + name + description. Same rhythm as the picker
|
||||
card so the user reads the transition as "the same item,
|
||||
expanded". */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-lg", accentClass)}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold">{template.name}</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{template.description}</p>
|
||||
{template.category ? (
|
||||
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{template.category}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Runtime + model selectors — required before Use is enabled.
|
||||
Two-column grid so they read as a single configuration row. */}
|
||||
<section className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.runtime_label)}
|
||||
</Label>
|
||||
<Popover open={runtimeOpen} onOpenChange={setRuntimeOpen}>
|
||||
<PopoverTrigger
|
||||
disabled={runtimes.length === 0 && !runtimesLoading}
|
||||
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{runtimesLoading ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : selectedRuntime ? (
|
||||
<ProviderLogo provider={selectedRuntime.provider} className="h-4 w-4 shrink-0" />
|
||||
) : (
|
||||
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{runtimesLoading
|
||||
? t(($) => $.create_dialog.runtime_loading)
|
||||
: selectedRuntime?.name ?? t(($) => $.create_dialog.runtime_none)}
|
||||
</span>
|
||||
{selectedRuntime?.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{selectedRuntime
|
||||
? getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info
|
||||
: t(($) => $.create_dialog.runtime_register_first)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${runtimeOpen ? "rotate-180" : ""}`} />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
|
||||
>
|
||||
{sortedRuntimes.map((device) => {
|
||||
const ownerMember = getOwnerMember(device.owner_id);
|
||||
const disabled = isRuntimeDisabledForUser(device);
|
||||
const disabledTitle = disabled
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined;
|
||||
return (
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
title={disabledTitle}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setSelectedRuntimeId(device.id);
|
||||
// Picking a new runtime clears the model — the
|
||||
// catalog (and "supported" flag) changes per
|
||||
// runtime, so any previously-picked model is
|
||||
// potentially invalid.
|
||||
setModel("");
|
||||
setRuntimeOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: device.id === selectedRuntimeId
|
||||
? "bg-accent"
|
||||
: "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<ProviderLogo provider={device.provider} className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{device.name}</span>
|
||||
{device.runtime_mode === "cloud" && (
|
||||
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
|
||||
{t(($) => $.create_dialog.runtime_cloud_badge)}
|
||||
</span>
|
||||
)}
|
||||
{disabled && (
|
||||
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
<Lock className="h-3 w-3" />
|
||||
{t(($) => $.create_dialog.runtime_private_badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ownerMember ? (
|
||||
<>
|
||||
<ActorAvatar actorType="member" actorId={ownerMember.user_id} size={14} />
|
||||
<span className="truncate">{ownerMember.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate">{device.device_info}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
device.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<ModelDropdown
|
||||
runtimeId={selectedRuntime?.id ?? null}
|
||||
runtimeOnline={selectedRuntime?.status === "online"}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={!selectedRuntime}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Skill list */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_detail.skill_count, {
|
||||
count: template.skills.length,
|
||||
})}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{template.skills.map((s) => (
|
||||
<li
|
||||
key={s.source_url}
|
||||
className="rounded-lg border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
<span className="font-mono text-xs font-medium">{s.cached_name}</span>
|
||||
</div>
|
||||
{s.cached_description ? (
|
||||
<p className="mt-1 ml-6 text-xs text-muted-foreground">
|
||||
{s.cached_description}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Instructions — lazy fetch + loading/error states */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_detail.instructions_label)}
|
||||
</h3>
|
||||
<div className="mt-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t(($) => $.create_dialog.template_detail.instructions_loading)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $.create_dialog.template_detail.load_failed)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-60 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/80">
|
||||
{detail?.instructions ?? ""}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky CTA footer */}
|
||||
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
|
||||
<Button
|
||||
onClick={() =>
|
||||
selectedRuntime &&
|
||||
onUse(template, { runtimeId: selectedRuntime.id, model: model.trim() })
|
||||
}
|
||||
disabled={!canUse}
|
||||
title={
|
||||
selectedRuntimeLocked
|
||||
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
|
||||
: undefined
|
||||
}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t(($) => $.create_dialog.template_detail.creating)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t(($) => $.create_dialog.template_detail.use)}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Brush,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Palette,
|
||||
PenLine,
|
||||
Presentation,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateListOptions } from "@multica/core/agents/queries";
|
||||
import type { AgentTemplateSummary } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface TemplatePickerProps {
|
||||
/** Fired when a template card is clicked. The dialog advances to the
|
||||
* detail step (which shows instructions + skills + Use button). */
|
||||
onSelect: (template: AgentTemplateSummary) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of the create-agent flow: a 2-column grid of template cards,
|
||||
* grouped by `category`. Clicking a card moves to the detail step.
|
||||
*
|
||||
* Templates are a static catalog (workspace-independent, only changes on
|
||||
* server deploy), so the catalog is loaded through TanStack Query with
|
||||
* `staleTime: Infinity` — re-opening the picker hits the cache instantly
|
||||
* and there's no per-mount refetch.
|
||||
*
|
||||
* Icons and accent colors come from the template JSON itself (`icon` is a
|
||||
* lucide-react name, `accent` is a Multica semantic token). Resolved
|
||||
* through static maps (ICONS / ACCENTS) so Tailwind can JIT-scan every
|
||||
* class variant — dynamic `bg-${accent}/10` strings would silently not
|
||||
* generate.
|
||||
*/
|
||||
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
|
||||
const { t } = useT("agents");
|
||||
const { data: templates = [], isLoading, error } = useQuery(
|
||||
agentTemplateListOptions(),
|
||||
);
|
||||
|
||||
// Group by category. Templates without a category fall into the
|
||||
// localised "Other" bucket so they still render. Preserves the load
|
||||
// order within each group for deterministic UI (matches the
|
||||
// alphabetic-by-filename order the loader uses on the server).
|
||||
const otherCategory = t(($) => $.create_dialog.template_picker.other_category);
|
||||
const groups = useMemo(() => {
|
||||
const byCategory = new Map<string, AgentTemplateSummary[]>();
|
||||
for (const tmpl of templates) {
|
||||
const key = tmpl.category?.trim() ? tmpl.category : otherCategory;
|
||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||
byCategory.get(key)!.push(tmpl);
|
||||
}
|
||||
return Array.from(byCategory.entries());
|
||||
}, [templates, otherCategory]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="text-sm text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $.create_dialog.template_picker.load_failed)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_picker.empty)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
{groups.map(([category, tmpls]) => (
|
||||
<section key={category}>
|
||||
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{category}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
|
||||
{tmpls.map((tmpl) => (
|
||||
<TemplateCard
|
||||
key={tmpl.slug}
|
||||
template={tmpl}
|
||||
onClick={() => onSelect(tmpl)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: AgentTemplateSummary;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||
const { t } = useT("agents");
|
||||
const Icon = ICONS[template.icon ?? ""] ?? FileText;
|
||||
const accentClass = ACCENTS[template.accent ?? ""] ?? ACCENTS.muted;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
accentClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-sm font-semibold">{template.name}</span>
|
||||
<ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="mt-2.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_card.skills, {
|
||||
count: template.skills.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Static maps so Tailwind's JIT scanner picks up every variant ---
|
||||
|
||||
/** Lucide icon name → component. Add new entries when shipping templates
|
||||
* that use icons not yet listed here. Unknown names fall back to FileText. */
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
Search,
|
||||
Palette,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ListChecks,
|
||||
Brush,
|
||||
PenLine,
|
||||
Megaphone,
|
||||
Presentation,
|
||||
LayoutDashboard,
|
||||
};
|
||||
|
||||
/** Semantic accent → Tailwind class string. The class strings are written
|
||||
* out verbatim so JIT scans them; dynamic `bg-${name}/10` would not be
|
||||
* generated. Mirrors the conventions in runtime-columns.tsx /
|
||||
* usage-section.tsx (existing uses of these tokens). */
|
||||
const DEFAULT_ACCENT = "bg-muted text-muted-foreground";
|
||||
|
||||
const ACCENTS: Record<string, string> = {
|
||||
info: "bg-info/10 text-info",
|
||||
success: "bg-success/10 text-success",
|
||||
warning: "bg-warning/10 text-warning",
|
||||
primary: "bg-primary/10 text-primary",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
muted: DEFAULT_ACCENT,
|
||||
};
|
||||
|
||||
/** Exposed for the detail / form steps so they can render the same icon
|
||||
* badge as the picker card. Keeps visual continuity across steps. */
|
||||
export function getTemplateIcon(iconName: string | undefined): LucideIcon {
|
||||
return ICONS[iconName ?? ""] ?? FileText;
|
||||
}
|
||||
|
||||
export function getAccentClass(accent: string | undefined): string {
|
||||
return ACCENTS[accent ?? ""] ?? DEFAULT_ACCENT;
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import { forwardRef, useRef, useImperativeHandle } from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { I18nProvider } from "@multica/core/i18n/react";
|
||||
import enCommon from "../../locales/en/common.json";
|
||||
import enChat from "../../locales/en/chat.json";
|
||||
|
||||
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
|
||||
|
||||
// Track drop-zone callbacks so the test can simulate a real drop.
|
||||
const dropHandlers = vi.hoisted(() => ({
|
||||
onDrop: null as null | ((files: File[]) => void),
|
||||
}));
|
||||
|
||||
vi.mock("../../editor", () => ({
|
||||
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
|
||||
dropHandlers.onDrop = onDrop;
|
||||
return { isDragOver: false, dropZoneProps: { "data-testid": "drop-zone" } };
|
||||
},
|
||||
FileDropOverlay: () => null,
|
||||
ContentEditor: forwardRef(function MockContentEditor(
|
||||
{
|
||||
defaultValue,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
onUploadFile,
|
||||
}: {
|
||||
defaultValue?: string;
|
||||
onUpdate?: (md: string) => void;
|
||||
placeholder?: string;
|
||||
onUploadFile?: (file: File) => Promise<{ id: string; link: string; filename: string } | null>;
|
||||
},
|
||||
ref: React.Ref<unknown>,
|
||||
) {
|
||||
const valueRef = useRef<string>(defaultValue ?? "");
|
||||
const uploadingRef = useRef(0);
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => {
|
||||
valueRef.current = "";
|
||||
},
|
||||
blur: () => {},
|
||||
focus: () => {},
|
||||
uploadFile: async (file: File) => {
|
||||
uploadingRef.current += 1;
|
||||
try {
|
||||
const result = await onUploadFile?.(file);
|
||||
if (result) {
|
||||
valueRef.current = `${valueRef.current}`.trim();
|
||||
onUpdate?.(valueRef.current);
|
||||
}
|
||||
} finally {
|
||||
uploadingRef.current = Math.max(0, uploadingRef.current - 1);
|
||||
}
|
||||
},
|
||||
hasActiveUploads: () => uploadingRef.current > 0,
|
||||
}));
|
||||
return (
|
||||
<textarea
|
||||
data-testid="editor"
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => {
|
||||
valueRef.current = e.target.value;
|
||||
onUpdate?.(e.target.value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock chat store with an in-memory implementation that supports both
|
||||
// (selector) calls and getState().
|
||||
vi.mock("@multica/core/chat", () => {
|
||||
const state = {
|
||||
activeSessionId: null as string | null,
|
||||
selectedAgentId: "agent-1",
|
||||
inputDrafts: {} as Record<string, string>,
|
||||
focusMode: false,
|
||||
setInputDraft: vi.fn(),
|
||||
clearInputDraft: vi.fn(),
|
||||
};
|
||||
return {
|
||||
DRAFT_NEW_SESSION: "__draft_new__",
|
||||
useChatStore: Object.assign(
|
||||
(selector?: (s: typeof state) => unknown) =>
|
||||
selector ? selector(state) : state,
|
||||
{ getState: () => state },
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChatInput } from "./chat-input";
|
||||
|
||||
function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}) {
|
||||
const onSend = props.onSend ?? vi.fn();
|
||||
const onUploadFile =
|
||||
props.onUploadFile ??
|
||||
vi.fn(async (_file: File) => ({
|
||||
id: "att-1",
|
||||
link: "https://cdn.example/att-1.png",
|
||||
filename: "img.png",
|
||||
}));
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={onSend} onUploadFile={onUploadFile} agentName="Multica" {...props} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
return { onSend, onUploadFile };
|
||||
}
|
||||
|
||||
describe("ChatInput attachment wiring", () => {
|
||||
it("routes dropped files through the editor's upload handler", async () => {
|
||||
const { onUploadFile } = renderInput();
|
||||
expect(dropHandlers.onDrop).not.toBeNull();
|
||||
const file = new File(["x"], "drop.png", { type: "image/png" });
|
||||
dropHandlers.onDrop?.([file]);
|
||||
// Microtask: the mock editor awaits onUploadFile before mutating its value.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onUploadFile).toHaveBeenCalledWith(file);
|
||||
});
|
||||
|
||||
it("passes attachment_ids to onSend for uploads still referenced in the content", async () => {
|
||||
const onSend = vi.fn();
|
||||
const onUploadFile = vi.fn(async (_file: File) => ({
|
||||
id: "att-42",
|
||||
link: "https://cdn.example/att-42.png",
|
||||
filename: "x.png",
|
||||
}));
|
||||
renderInput({ onSend, onUploadFile });
|
||||
|
||||
// Simulate the drop → editor.uploadFile → onUploadFile happy path. The
|
||||
// mock editor appends the markdown link into its value and calls
|
||||
// onUpdate so the input flips out of the empty state.
|
||||
const file = new File(["x"], "drop.png", { type: "image/png" });
|
||||
dropHandlers.onDrop?.([file]);
|
||||
|
||||
// Wait for the submit button to become enabled (onUpdate has fired and
|
||||
// React has re-rendered). SubmitButton has no aria-label, so we pick
|
||||
// the last action button on the bar (FileUploadButton, SubmitButton).
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(sendButton!);
|
||||
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
const [, ids] = onSend.mock.calls[0]!;
|
||||
expect(ids).toEqual(["att-42"]);
|
||||
});
|
||||
|
||||
it("disables send while an upload is in flight, re-enables after it resolves", async () => {
|
||||
let resolveUpload: (v: { id: string; link: string; filename: string }) => void;
|
||||
const uploadPromise = new Promise<{ id: string; link: string; filename: string }>((res) => {
|
||||
resolveUpload = res;
|
||||
});
|
||||
const onSend = vi.fn();
|
||||
const onUploadFile = vi.fn(() => uploadPromise);
|
||||
renderInput({ onSend, onUploadFile });
|
||||
|
||||
// Give the editor some text so isEmpty=false — this isolates the
|
||||
// disabled state to the pending-upload condition (otherwise both
|
||||
// checks would fire and the test couldn't tell them apart).
|
||||
fireEvent.change(screen.getByTestId("editor"), { target: { value: "preview text" } });
|
||||
|
||||
const file = new File(["x"], "slow.png", { type: "image/png" });
|
||||
dropHandlers.onDrop?.([file]);
|
||||
|
||||
// While the upload is pending the SubmitButton must be disabled.
|
||||
// Bypassing this would send the message with the attachment id
|
||||
// missing from the body.
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
const sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
resolveUpload!({
|
||||
id: "att-slow",
|
||||
link: "https://cdn.example/att-slow.png",
|
||||
filename: "slow.png",
|
||||
});
|
||||
|
||||
let sendButton: HTMLElement;
|
||||
await waitFor(() => {
|
||||
const buttons = screen.getAllByRole("button");
|
||||
sendButton = buttons[buttons.length - 1]!;
|
||||
expect(sendButton).not.toBeDisabled();
|
||||
});
|
||||
fireEvent.click(sendButton!);
|
||||
expect(onSend).toHaveBeenCalledTimes(1);
|
||||
const [, ids] = onSend.mock.calls[0]!;
|
||||
expect(ids).toEqual(["att-slow"]);
|
||||
});
|
||||
|
||||
it("does not render the file upload button when onUploadFile is omitted", () => {
|
||||
renderInput({ onUploadFile: undefined });
|
||||
// FileUploadButton renders an icon button labelled by its tooltip — when
|
||||
// upload wiring is absent the chat input falls back to "submit + extras"
|
||||
// only. Probe by counting buttons: with no upload, only the submit
|
||||
// button is in the action row.
|
||||
const buttons = screen.getAllByRole("button");
|
||||
// The agent picker / context anchor adornments may render zero buttons
|
||||
// in this test (no leftAdornment passed). So a single button = submit.
|
||||
expect(buttons.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,33 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
ContentEditor,
|
||||
type ContentEditorRef,
|
||||
useFileDropZone,
|
||||
FileDropOverlay,
|
||||
} from "../../editor";
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { ContentEditor, type ContentEditorRef } from "../../editor";
|
||||
import { SubmitButton } from "@multica/ui/components/common/submit-button";
|
||||
import { useChatStore, DRAFT_NEW_SESSION } from "@multica/core/chat";
|
||||
import { createLogger } from "@multica/core/logger";
|
||||
import { enterKey, formatShortcut, modKey } from "@multica/core/platform";
|
||||
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
const logger = createLogger("chat.ui");
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string, attachmentIds?: string[]) => void;
|
||||
/** Receives a File and returns the attachment row (with id + CDN link).
|
||||
* The wrapper owner (ChatWindow) lazy-creates a chat_session if needed
|
||||
* and forwards `chatSessionId` to the upload — chat-input only cares
|
||||
* about the upload result so it can map URL → id for back-fill on send.
|
||||
* When unset, paste/drag/button still type into the editor but no upload
|
||||
* fires (the editor's file-upload extension is a no-op without a handler). */
|
||||
onUploadFile?: (file: File) => Promise<UploadResult | null>;
|
||||
onSend: (content: string) => void;
|
||||
onStop?: () => void;
|
||||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -48,7 +33,6 @@ interface ChatInputProps {
|
||||
|
||||
export function ChatInput({
|
||||
onSend,
|
||||
onUploadFile,
|
||||
onStop,
|
||||
isRunning,
|
||||
disabled,
|
||||
@@ -62,70 +46,19 @@ export function ChatInput({
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
const activeSessionId = useChatStore((s) => s.activeSessionId);
|
||||
const selectedAgentId = useChatStore((s) => s.selectedAgentId);
|
||||
// Two keys with deliberately different concerns:
|
||||
//
|
||||
// `draftKey` — zustand storage key. Scopes the in-progress draft per
|
||||
// session so different sessions don't bleed text into each other; for
|
||||
// brand-new chats it falls back to a per-agent slot so switching agents
|
||||
// mid-compose gives each agent its own draft. This is a STORAGE key, not
|
||||
// a React identity.
|
||||
//
|
||||
// `editorKey` — React `key` on the ContentEditor. Used ONLY to force a
|
||||
// remount when the user explicitly switches agent (so Tiptap's
|
||||
// Placeholder, which only reads on mount, refreshes to "Tell {agent}…").
|
||||
// Crucially this does NOT include `activeSessionId`: when the user
|
||||
// uploads a file in a brand-new chat, `handleUploadFile` first awaits
|
||||
// `ensureSession` which lazily creates the session and flips
|
||||
// `activeSessionId` from null → uuid mid-upload. If the editor key
|
||||
// depended on session id, that flip would unmount the editor right as
|
||||
// the blob preview was inserted, dropping the in-progress upload's
|
||||
// image node before file-upload.ts could swap it for the CDN URL — the
|
||||
// user would see the image flash on then disappear. Keeping editor
|
||||
// identity stable across the lazy-create event is what makes
|
||||
// first-upload-creates-session work the same as second-upload.
|
||||
// Scope the new-chat draft by agent:
|
||||
// 1. Switching agents while composing a brand-new chat gives each
|
||||
// agent its own draft (no cross-agent leakage).
|
||||
// 2. Tiptap's Placeholder extension is only applied at mount; this
|
||||
// key changes on agent switch so the editor remounts and the
|
||||
// `Tell {agent} what to do…` placeholder refreshes.
|
||||
const draftKey =
|
||||
activeSessionId ?? `${DRAFT_NEW_SESSION}:${selectedAgentId ?? ""}`;
|
||||
const editorKey = selectedAgentId ?? "no-agent";
|
||||
// Select a primitive — empty-string fallback keeps referential stability.
|
||||
const inputDraft = useChatStore((s) => s.inputDrafts[draftKey] ?? "");
|
||||
const setInputDraft = useChatStore((s) => s.setInputDraft);
|
||||
const clearInputDraft = useChatStore((s) => s.clearInputDraft);
|
||||
const [isEmpty, setIsEmpty] = useState(!inputDraft.trim());
|
||||
// Number of in-flight uploads. We track this explicitly (rather than
|
||||
// peeking at the editor on every render) so the SubmitButton visibly
|
||||
// disables the instant an upload starts and re-enables the instant it
|
||||
// finishes. handleSend ALSO checks `hasActiveUploads()` for paths that
|
||||
// bypass the button (Mod+Enter while paste is mid-stream, drag-drop
|
||||
// racing the keyboard) — defense in depth.
|
||||
const [pendingUploads, setPendingUploads] = useState(0);
|
||||
|
||||
// Maps "CDN URL inserted into the editor" → "attachment row id" so that
|
||||
// on send we can ask the server to bind only the attachments still
|
||||
// referenced in the message body. Cleared after every send. Mirrors the
|
||||
// comment-input flow exactly.
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
const handleUpload = useCallback(
|
||||
async (file: File): Promise<UploadResult | null> => {
|
||||
if (!onUploadFile) return null;
|
||||
setPendingUploads((n) => n + 1);
|
||||
try {
|
||||
const result = await onUploadFile(file);
|
||||
if (result) uploadMapRef.current.set(result.link, result.id);
|
||||
return result;
|
||||
} finally {
|
||||
setPendingUploads((n) => Math.max(0, n - 1));
|
||||
}
|
||||
},
|
||||
[onUploadFile],
|
||||
);
|
||||
|
||||
// Drop zone wraps the rounded card so a drop anywhere on the input
|
||||
// surface routes the file through the editor's upload extension (same
|
||||
// handler as the in-editor paste path).
|
||||
const { isDragOver, dropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
|
||||
});
|
||||
|
||||
const handleSend = () => {
|
||||
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
|
||||
@@ -138,33 +71,12 @@ export function ChatInput({
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Block the send while any file is still uploading. If we let it
|
||||
// through the attachment id is not yet in uploadMapRef (the upload
|
||||
// resolves later) and the attachment would only end up bound to the
|
||||
// session, not the message — the agent then can't `multica attachment
|
||||
// download <id>` the file. The SubmitButton is also disabled in this
|
||||
// state via `uploading`, but Mod+Enter bypasses the button so we
|
||||
// still gate here.
|
||||
if (editorRef.current?.hasActiveUploads()) {
|
||||
logger.debug("input.send skipped: uploads in flight");
|
||||
return;
|
||||
}
|
||||
// Only send attachment IDs for uploads still present in the content.
|
||||
// Edits / deletions that remove the markdown URL also drop the binding.
|
||||
const activeIds: string[] = [];
|
||||
for (const [url, id] of uploadMapRef.current) {
|
||||
if (content.includes(url)) activeIds.push(id);
|
||||
}
|
||||
// Capture draft key BEFORE onSend — creating a new session mutates
|
||||
// activeSessionId synchronously, so reading it after onSend would point
|
||||
// at the new session and leave the old draft orphaned.
|
||||
const keyAtSend = draftKey;
|
||||
logger.info("input.send", {
|
||||
contentLength: content.length,
|
||||
draftKey: keyAtSend,
|
||||
attachmentCount: activeIds.length,
|
||||
});
|
||||
onSend(content, activeIds.length > 0 ? activeIds : undefined);
|
||||
logger.info("input.send", { contentLength: content.length, draftKey: keyAtSend });
|
||||
onSend(content);
|
||||
editorRef.current?.clearContent();
|
||||
// Drop focus so the caret doesn't keep blinking under the StatusPill /
|
||||
// streaming reply that's about to take over the user's attention. The
|
||||
@@ -175,7 +87,6 @@ export function ChatInput({
|
||||
// a fair price for not stealing focus mid-action.
|
||||
editorRef.current?.blur();
|
||||
clearInputDraft(keyAtSend);
|
||||
uploadMapRef.current.clear();
|
||||
setIsEmpty(true);
|
||||
};
|
||||
|
||||
@@ -187,8 +98,6 @@ export function ChatInput({
|
||||
? t(($) => $.input.placeholder_named, { name: agentName })
|
||||
: t(($) => $.input.placeholder_default);
|
||||
|
||||
const uploadEnabled = !!onUploadFile && !disabled && !noAgent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -201,7 +110,6 @@ export function ChatInput({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
{...(uploadEnabled ? dropZoneProps : {})}
|
||||
className={cn(
|
||||
"relative mx-auto flex min-h-16 max-h-40 w-full max-w-4xl flex-col rounded-lg bg-card pb-9 border-1 border-border transition-colors focus-within:border-brand",
|
||||
// Visual + interaction lock when there's no agent. We don't
|
||||
@@ -217,9 +125,9 @@ export function ChatInput({
|
||||
{topSlot}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2">
|
||||
<ContentEditor
|
||||
// See the editorKey / draftKey split note above — editorKey
|
||||
// intentionally does not depend on activeSessionId.
|
||||
key={editorKey}
|
||||
// Remount the editor when the active session changes so its
|
||||
// uncontrolled defaultValue picks up the new session's draft.
|
||||
key={draftKey}
|
||||
ref={editorRef}
|
||||
defaultValue={inputDraft}
|
||||
placeholder={placeholder}
|
||||
@@ -228,15 +136,12 @@ export function ChatInput({
|
||||
setInputDraft(draftKey, md);
|
||||
}}
|
||||
onSubmit={handleSend}
|
||||
onUploadFile={uploadEnabled ? handleUpload : undefined}
|
||||
debounceMs={100}
|
||||
// Chat is short-form — the floating formatting toolbar is
|
||||
// more distraction than feature here.
|
||||
showBubbleMenu={false}
|
||||
// Mod+Enter submits. Bare Enter falls through to Tiptap's
|
||||
// default, which continues lists/quotes and breaks paragraphs.
|
||||
// Without this, Enter-as-send would steal the only key that
|
||||
// continues a bullet list, leaving users stuck after one item.
|
||||
// Enter sends; Shift-Enter inserts a hard break.
|
||||
submitOnEnter
|
||||
/>
|
||||
</div>
|
||||
{leftAdornment && (
|
||||
@@ -244,24 +149,15 @@ export function ChatInput({
|
||||
{leftAdornment}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-1">
|
||||
<div className="absolute bottom-1 right-1.5 flex items-center gap-2">
|
||||
{rightAdornment}
|
||||
{uploadEnabled && (
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
)}
|
||||
<SubmitButton
|
||||
onClick={handleSend}
|
||||
disabled={isEmpty || !!disabled || !!noAgent || pendingUploads > 0}
|
||||
disabled={isEmpty || !!disabled || !!noAgent}
|
||||
running={isRunning}
|
||||
onStop={onStop}
|
||||
tooltip={`${t(($) => $.input.send_tooltip)} · ${formatShortcut(modKey, enterKey)}`}
|
||||
stopTooltip={t(($) => $.input.stop_tooltip)}
|
||||
/>
|
||||
</div>
|
||||
{uploadEnabled && isDragOver && <FileDropOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -31,7 +31,6 @@ import { agentListOptions, memberListOptions } from "@multica/core/workspace/que
|
||||
import { canAssignAgent } from "@multica/views/issues/components";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useAgentPresenceDetail, useWorkspaceAgentAvailability } from "@multica/core/agents";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { NoAgentBanner } from "./no-agent-banner";
|
||||
@@ -190,76 +189,8 @@ export function ChatWindow() {
|
||||
// (focus mode tracks the user's page, not a per-message attachment).
|
||||
const { candidate: anchorCandidate } = useRouteAnchorCandidate(wsId);
|
||||
|
||||
const { uploadWithToast } = useFileUpload(api);
|
||||
|
||||
// Lazy-creates a chat_session the first time the user needs an id —
|
||||
// either to send a message or to attach an uploaded file. Pulled out of
|
||||
// handleSend so the upload path (which fires before any text exists) can
|
||||
// get a session_id to hang the attachment on. Returns null when no agent
|
||||
// is available; callers must early-return in that case.
|
||||
//
|
||||
// Concurrent callers (e.g. user drops a file → handleUploadFile, then
|
||||
// quickly clicks send → handleSend) would each observe activeSessionId
|
||||
// === null and fire a separate createSession.mutateAsync, creating two
|
||||
// sessions and orphaning the attachment on the wrong one. The in-flight
|
||||
// promise ref dedupes those races: the first caller starts the create,
|
||||
// every subsequent caller awaits the same promise until it settles.
|
||||
//
|
||||
// titleSeed is the first 50 chars of the user's message when called from
|
||||
// send; the upload path passes "" and we leave the title empty so the
|
||||
// session-dropdown's existing localized `window.untitled` fallback kicks
|
||||
// in. A follow-up task may back-fill the real title from the first user
|
||||
// message — until then this keeps the session list scannable across locales.
|
||||
//
|
||||
// NOTE: ensureSession does NOT flip `activeSessionId` itself. Callers must
|
||||
// seed `chatKeys.messages(sessionId)` in the Query cache BEFORE calling
|
||||
// `setActiveSession(sessionId)`, otherwise the first useQuery subscription
|
||||
// for the new key reports `isLoading: true` and renders ChatMessageSkeleton
|
||||
// for one frame (the "new-chat first-message" white flash).
|
||||
const sessionPromiseRef = useRef<Promise<string | null> | null>(null);
|
||||
const ensureSession = useCallback(
|
||||
async (titleSeed: string): Promise<string | null> => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
if (!activeAgent) return null;
|
||||
if (sessionPromiseRef.current) return sessionPromiseRef.current;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const session = await createSession.mutateAsync({
|
||||
agent_id: activeAgent.id,
|
||||
title: titleSeed.slice(0, 50),
|
||||
});
|
||||
return session.id;
|
||||
} finally {
|
||||
sessionPromiseRef.current = null;
|
||||
}
|
||||
})();
|
||||
sessionPromiseRef.current = promise;
|
||||
return promise;
|
||||
},
|
||||
[activeSessionId, activeAgent, createSession],
|
||||
);
|
||||
|
||||
const handleUploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
const sessionId = await ensureSession("");
|
||||
if (!sessionId) return null;
|
||||
// Prime the messages cache as empty before flipping activeSessionId so
|
||||
// ChatMessageList mounts directly (no Skeleton frame). Skip the write
|
||||
// when an entry already exists — a concurrent handleSend may have
|
||||
// seeded an optimistic message we must not clobber.
|
||||
qc.setQueryData<ChatMessage[]>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => old ?? [],
|
||||
);
|
||||
setActiveSession(sessionId);
|
||||
return uploadWithToast(file, { chatSessionId: sessionId });
|
||||
},
|
||||
[ensureSession, uploadWithToast, qc, setActiveSession],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (content: string, attachmentIds?: string[]) => {
|
||||
async (content: string) => {
|
||||
if (!activeAgent) {
|
||||
apiLogger.warn("sendChatMessage skipped: no active agent");
|
||||
return;
|
||||
@@ -270,21 +201,24 @@ export function ChatWindow() {
|
||||
? `${buildAnchorMarkdown(anchorCandidate)}\n\n${content}`
|
||||
: content;
|
||||
|
||||
const isNewSession = !activeSessionId;
|
||||
let sessionId = activeSessionId;
|
||||
const isNewSession = !sessionId;
|
||||
|
||||
apiLogger.info("sendChatMessage.start", {
|
||||
sessionId: activeSessionId,
|
||||
sessionId,
|
||||
isNewSession,
|
||||
agentId: activeAgent.id,
|
||||
contentLength: finalContent.length,
|
||||
hasAnchor: focusOn && !!anchorCandidate,
|
||||
attachmentCount: attachmentIds?.length ?? 0,
|
||||
});
|
||||
|
||||
const sessionId = await ensureSession(finalContent);
|
||||
if (!sessionId) {
|
||||
apiLogger.warn("sendChatMessage aborted: ensureSession returned null");
|
||||
return;
|
||||
const session = await createSession.mutateAsync({
|
||||
agent_id: activeAgent.id,
|
||||
title: finalContent.slice(0, 50),
|
||||
});
|
||||
sessionId = session.id;
|
||||
setActiveSession(sessionId);
|
||||
}
|
||||
|
||||
// Optimistic burst — everything that gives the user "I sent a message
|
||||
@@ -301,12 +235,6 @@ export function ChatWindow() {
|
||||
task_id: null,
|
||||
created_at: sentAt,
|
||||
};
|
||||
// Seed cache BEFORE flipping activeSessionId. If we set the active
|
||||
// session first, useQuery's first subscription to the new key sees no
|
||||
// cached data and renders ChatMessageSkeleton for one frame — the
|
||||
// "new-chat first-message" white flash. Priming the cache first means
|
||||
// the very first read after activeSessionId flips hits data
|
||||
// synchronously and ChatMessageList mounts directly.
|
||||
qc.setQueryData<ChatMessage[]>(
|
||||
chatKeys.messages(sessionId),
|
||||
(old) => (old ? [...old, optimistic] : [optimistic]),
|
||||
@@ -321,12 +249,9 @@ export function ChatWindow() {
|
||||
status: "queued",
|
||||
created_at: sentAt,
|
||||
});
|
||||
// Cache primed → safe to publish the new active session. Idempotent
|
||||
// when the session was already active (existing-conversation send).
|
||||
setActiveSession(sessionId);
|
||||
apiLogger.debug("sendChatMessage.optimistic", { sessionId, optimisticId: optimistic.id });
|
||||
|
||||
const result = await api.sendChatMessage(sessionId, finalContent, attachmentIds);
|
||||
const result = await api.sendChatMessage(sessionId, finalContent);
|
||||
apiLogger.info("sendChatMessage.success", {
|
||||
sessionId,
|
||||
messageId: result.message_id,
|
||||
@@ -346,9 +271,9 @@ export function ChatWindow() {
|
||||
activeSessionId,
|
||||
activeAgent,
|
||||
anchorCandidate,
|
||||
ensureSession,
|
||||
qc,
|
||||
createSession,
|
||||
setActiveSession,
|
||||
qc,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -569,7 +494,6 @@ export function ChatWindow() {
|
||||
* when there's no agent (the EmptyState above carries the CTA). */}
|
||||
<ChatInput
|
||||
onSend={handleSend}
|
||||
onUploadFile={handleUploadFile}
|
||||
onStop={handleStop}
|
||||
isRunning={!!pendingTaskId}
|
||||
disabled={isSessionArchived}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user