Compare commits
33 Commits
refactor/u
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d38396f59 | ||
|
|
a033725506 | ||
|
|
efddb2284b | ||
|
|
7e20ca27bb | ||
|
|
4c1bef2e1f | ||
|
|
291c2c7898 | ||
|
|
bdb66c2ce1 | ||
|
|
9ad5eb5ffe | ||
|
|
87464f6c03 | ||
|
|
cde3867d3b | ||
|
|
8f40a61f8b | ||
|
|
c6ccc49650 | ||
|
|
29082f7cfe | ||
|
|
623d29f276 | ||
|
|
19c40c5d68 | ||
|
|
454c8e3d1a | ||
|
|
abfe33f350 | ||
|
|
26924dcc98 | ||
|
|
e2802a5407 | ||
|
|
5db96b4007 | ||
|
|
178cfb5008 | ||
|
|
51aa924124 | ||
|
|
384ddcbe65 | ||
|
|
6a48022123 | ||
|
|
81b62fc8d3 | ||
|
|
e8c2855746 | ||
|
|
157498e9fa | ||
|
|
7fcc8159ba | ||
|
|
b87e54850a | ||
|
|
451c46c43f | ||
|
|
5a9c15bc12 | ||
|
|
06bcc1fab4 | ||
|
|
6e371c2233 |
BIN
apps/desktop/build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/desktop/build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 782 B |
BIN
apps/desktop/build/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
apps/desktop/build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
apps/desktop/build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
apps/desktop/build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
apps/desktop/build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/desktop/build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
@@ -46,20 +46,31 @@ linux:
|
||||
# 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.
|
||||
# Pin StartupWMClass to the WM_CLASS Electron emits on X11. Electron
|
||||
# derives WM_CLASS from `app.getName()`, which reads the *packaged*
|
||||
# ASAR's `package.json` — `productName` if present, otherwise `name`.
|
||||
# PR #2437 assumed electron-builder.yml's productName fed app.getName()
|
||||
# directly; it does not. With our source package.json carrying only
|
||||
# `name: "@multica/desktop"`, packaged Electron emitted
|
||||
# `WM_CLASS=@multica/desktop`, which broke association with this entry
|
||||
# and reproduced #2515 on Ubuntu 0.2.31. The fix lives in two places
|
||||
# outside this file — `productName: "Multica"` on the source
|
||||
# package.json (so the ASAR carries it) and `app.setName("Multica")`
|
||||
# in the production branch of `src/main/index.ts` (belt-and-braces).
|
||||
# Keep `StartupWMClass: Multica` pinned here so any future drift in
|
||||
# those two anchors shows up as a diff against this declaration.
|
||||
# Verification on a real Ubuntu install: `xprop WM_CLASS` on a running
|
||||
# window prints `Multica` for both fields.
|
||||
desktop:
|
||||
entry:
|
||||
StartupWMClass: Multica
|
||||
# Point at pre-rendered hicolor sizes. electron-builder *can* generate
|
||||
# 16/24/32/48/64/128/256/512 from a single build/icon.png, but the
|
||||
# auto-generation silently shipped only the 1024×1024 source in our
|
||||
# v0.2.31 .deb (#2515 reproduces this) — leaving GNOME's hicolor lookup
|
||||
# with no usable size and falling back to the theme default. Shipping
|
||||
# the sizes from source removes the toolchain dependency entirely.
|
||||
icon: build/icons
|
||||
target:
|
||||
- AppImage
|
||||
- deb
|
||||
|
||||
@@ -10,10 +10,11 @@ export default [
|
||||
globals: { ...globals.node },
|
||||
},
|
||||
},
|
||||
// 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.
|
||||
// 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.
|
||||
{
|
||||
files: ["src/main/**/*.ts"],
|
||||
rules: {
|
||||
@@ -25,6 +26,12 @@ 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,5 +1,6 @@
|
||||
{
|
||||
"name": "@multica/desktop",
|
||||
"productName": "Multica",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Multica Desktop — native desktop client for the Multica platform.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { shell } from "electron";
|
||||
import { shell, type BrowserWindow } 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,6 +19,19 @@ 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,7 +5,7 @@ import { electronApp, optimizer, is } from "@electron-toolkit/utils";
|
||||
import fixPath from "fix-path";
|
||||
import { setupAutoUpdater } from "./updater";
|
||||
import { setupDaemonManager } from "./daemon-manager";
|
||||
import { openExternalSafely } from "./external-url";
|
||||
import { openExternalSafely, downloadURLSafely } from "./external-url";
|
||||
import { installContextMenu } from "./context-menu";
|
||||
import { getAppVersion } from "./app-version";
|
||||
import { loadRuntimeConfig } from "./runtime-config-loader";
|
||||
@@ -133,6 +133,27 @@ function createWindow(): void {
|
||||
preload: join(__dirname, "../preload/index.js"),
|
||||
sandbox: false,
|
||||
webSecurity: false,
|
||||
// Required for the Chromium PDF viewer (PDFium) to activate inside
|
||||
// iframes — used by the attachment preview modal for application/pdf
|
||||
// files. Default is false in Electron; without it <iframe src=*.pdf>
|
||||
// renders blank.
|
||||
//
|
||||
// Security trade-off, accepted intentionally:
|
||||
// 1. This window already runs with `webSecurity: false` + `sandbox: false`,
|
||||
// so `plugins: true` does NOT meaningfully widen the renderer's
|
||||
// attack surface beyond what is already accepted.
|
||||
// 2. The only PDFs that reach an iframe here are signed CloudFront URLs
|
||||
// we ourselves issued (see useDownloadAttachment); user-supplied URLs
|
||||
// are routed through `setWindowOpenHandler` → `openExternalSafely` and
|
||||
// cannot land in this renderer.
|
||||
// 3. Chromium's PDFium plugin is itself sandboxed inside its own process
|
||||
// and only handles the `application/pdf` MIME — it does not expose
|
||||
// Flash, Java, or other historical plugin surfaces.
|
||||
//
|
||||
// If we ever tighten `webSecurity` / `sandbox`, revisit this by hosting
|
||||
// the PDF viewer in a dedicated BrowserView with `plugins: true` scoped
|
||||
// to that view, keeping the main renderer plugin-free.
|
||||
plugins: true,
|
||||
additionalArguments: [`--multica-locale=${systemLocale}`],
|
||||
},
|
||||
});
|
||||
@@ -212,6 +233,14 @@ const DEV_APP_NAME = process.env.DESKTOP_APP_SUFFIX
|
||||
if (is.dev) {
|
||||
app.setName(DEV_APP_NAME);
|
||||
app.setPath("userData", join(app.getPath("appData"), DEV_APP_NAME));
|
||||
} else {
|
||||
// Pin the production app name in code. Electron's Linux WM_CLASS is set
|
||||
// from app.getName() when the first BrowserWindow is realized; the
|
||||
// packaged ASAR's package.json `productName` already steers app.getName()
|
||||
// to "Multica", but anchoring it here makes WM_CLASS ↔ StartupWMClass
|
||||
// (declared in electron-builder.yml) survive a regression in
|
||||
// productName / the build pipeline. Must run before requestSingleInstanceLock().
|
||||
app.setName("Multica");
|
||||
}
|
||||
|
||||
// --- Protocol registration -----------------------------------------------
|
||||
@@ -288,6 +317,14 @@ 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
@@ -19,6 +19,9 @@ 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,6 +89,11 @@ 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),
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MyIssuesPage } from "@multica/views/my-issues";
|
||||
import { SkillsPage } from "@multica/views/skills";
|
||||
import { DesktopRuntimesPage } from "./components/desktop-runtimes-page";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { SquadsPage, SquadDetailPage as SquadDetailPageView } from "@multica/views/squads/components";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
|
||||
@@ -146,11 +147,17 @@ export const appRoutes: RouteObject[] = [
|
||||
element: <AgentDetailPage />,
|
||||
handle: { title: "Agent" },
|
||||
},
|
||||
{ path: "squads", element: <SquadsPage />, handle: { title: "Squads" } },
|
||||
{
|
||||
path: "squads/:id",
|
||||
element: <SquadDetailPageView />,
|
||||
handle: { title: "Squad" },
|
||||
},
|
||||
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
|
||||
{
|
||||
path: "dashboard",
|
||||
path: "usage",
|
||||
element: <DashboardPage />,
|
||||
handle: { title: "Dashboard" },
|
||||
handle: { title: "Usage" },
|
||||
},
|
||||
{
|
||||
path: "settings",
|
||||
|
||||
@@ -180,6 +180,61 @@ 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,7 +350,10 @@ export const useTabStore = create<TabStore>()(
|
||||
const { slug, group, index } = hit;
|
||||
|
||||
const closing = group.tabs[index];
|
||||
closing.router.dispose();
|
||||
const disposeClosingRouter = () => {
|
||||
// Let React unmount the tab's RouterProvider before disposing it.
|
||||
window.setTimeout(() => closing.router.dispose(), 0);
|
||||
};
|
||||
|
||||
if (group.tabs.length === 1) {
|
||||
// Last tab in this workspace — reseed a default so the workspace
|
||||
@@ -363,6 +366,7 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: [fresh], activeTabId: fresh.id },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -378,6 +382,7 @@ export const useTabStore = create<TabStore>()(
|
||||
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
|
||||
},
|
||||
});
|
||||
disposeClosingRouter();
|
||||
},
|
||||
|
||||
setActiveTab(tabId) {
|
||||
@@ -402,6 +407,13 @@ 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({
|
||||
@@ -418,6 +430,12 @@ 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;
|
||||
|
||||
@@ -160,6 +160,7 @@ Chinese term reference:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -160,6 +160,7 @@ Multica 的产品名词分两类:
|
||||
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
|
||||
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
|
||||
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
|
||||
| Preview / Download / Upload | 预览 / 下载 / 上传 |
|
||||
| Done / Loading... | 完成 / 加载中... |
|
||||
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
|
||||
| Theme / Language | 主题 / 语言 |
|
||||
|
||||
@@ -111,7 +111,7 @@ After **Create GitHub App**, note two things from the App's detail page:
|
||||
|
||||
On the API server:
|
||||
|
||||
```env
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
|
||||
```
|
||||
|
||||
@@ -111,7 +111,7 @@ Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境
|
||||
|
||||
API server 上:
|
||||
|
||||
```env
|
||||
```dotenv
|
||||
GITHUB_APP_SLUG=multica-acme
|
||||
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
|
||||
```
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { SquadDetailPage as default } from "@multica/views/squads";
|
||||
1
apps/web/app/[workspaceSlug]/(dashboard)/squads/page.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { SquadsPage as default } from "@multica/views/squads";
|
||||
@@ -284,6 +284,30 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.32",
|
||||
date: "2026-05-13",
|
||||
title: "Usage Insights, Chat Renaming & Smoother Desktop Flows",
|
||||
changes: [],
|
||||
features: [
|
||||
"Usage now shows workspace and project token activity, runtime trends, and per-agent rankings in one place",
|
||||
"Chat sessions can be renamed directly from the chat header",
|
||||
"Feedback reports can include screenshots or files so teams have the context they need",
|
||||
],
|
||||
improvements: [
|
||||
"The Usage page has clearer naming and a more dynamic agent leaderboard",
|
||||
"New chats and completed chat responses update more smoothly with fewer loading flashes",
|
||||
"Self-hosted GitHub setup is easier to configure and the setup docs point to the right cloud URL",
|
||||
"User-installed Codex skills are available automatically when new tasks run",
|
||||
],
|
||||
fixes: [
|
||||
"Empty successful agent responses are marked completed instead of blocked",
|
||||
"Pasted mentions in instruction editors keep their mention links",
|
||||
"Desktop attachment downloads use the native Linux flow and tab closing no longer loops",
|
||||
"Gemini and Windows runtime startup checks are more reliable in unattended runs",
|
||||
"Long GitHub repository lists stay usable when adding project resources",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
|
||||
@@ -284,6 +284,30 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.32",
|
||||
date: "2026-05-13",
|
||||
title: "用量洞察、聊天重命名与桌面体验优化",
|
||||
changes: [],
|
||||
features: [
|
||||
"Usage 页面集中展示 workspace 和 project 的 token 使用、runtime 趋势和 agent 排名",
|
||||
"聊天会话可以直接在聊天页顶部重命名",
|
||||
"反馈时可以附带截图或文件,方便团队快速理解问题",
|
||||
],
|
||||
improvements: [
|
||||
"Dashboard 更名为 Usage,并加入更清晰的 agent 排行展示",
|
||||
"新聊天和消息完成状态切换更顺,不再频繁闪加载状态",
|
||||
"自托管 GitHub 配置更完整,文档里的云端链接也已修正",
|
||||
"用户安装的 Codex Skills 会自动带入新的 agent 任务",
|
||||
],
|
||||
fixes: [
|
||||
"没有输出内容但成功完成的 agent 任务会显示为 completed,不再误判为 blocked",
|
||||
"在指令编辑器中粘贴的 mention 会保留可点击链接",
|
||||
"Linux 桌面端下载附件时走系统原生流程,关闭标签页也不再触发循环跳转",
|
||||
"Gemini 和 Windows runtime 的启动检查更稳定,适合无人值守执行",
|
||||
"添加项目资源时,较长的 GitHub 仓库列表可以正常滚动",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.31",
|
||||
date: "2026-05-12",
|
||||
|
||||
555
docs/agent-quick-create-plan.md
Normal file
@@ -0,0 +1,555 @@
|
||||
# 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) |
|
||||
@@ -366,6 +366,28 @@ not have a workspace yet.
|
||||
|---|---|---|
|
||||
| `workspace_id` | string (UUID) | Present only when the user already has a workspace. |
|
||||
| `source` | string | Always `onboarding`. |
|
||||
| `onboarding_session_id` | string (UUID) | Issued on this event and persisted to client storage. Stamped on every onboarding_* event until the funnel terminates. Lets HogQL correlate a full funnel back to a single start, even when `distinct_id` is shared across multiple sessions or skip paths. |
|
||||
|
||||
## `onboarding_session_id`
|
||||
|
||||
All in-product onboarding events carry an `onboarding_session_id` so the funnel
|
||||
can be reconstructed without joining on `distinct_id` alone. The id is generated
|
||||
client-side at `onboarding_started`, persisted across reloads, attached to every
|
||||
subsequent onboarding event, and cleared on `onboarding_completed`.
|
||||
|
||||
Paths that bypass the in-product funnel emit `onboarding_completed` with the
|
||||
property omitted:
|
||||
|
||||
- `skip_existing` from Welcome — the Welcome step clears the session before
|
||||
completing, since the user never entered any real onboarding step. Their
|
||||
earlier `onboarding_started` *does* carry a session id, but their completion
|
||||
does not.
|
||||
- `invite_accept` — server-side completion path that never receives a session
|
||||
id from the client.
|
||||
|
||||
Funnel queries should filter `onboarding_session_id IS NOT NULL` on
|
||||
`onboarding_completed` to isolate real funnel completions from these
|
||||
soft-completions.
|
||||
|
||||
### `onboarding_questionnaire_submitted`
|
||||
|
||||
@@ -382,6 +404,7 @@ re-emit — the funnel counts users, not edits.
|
||||
| `team_size_has_other` | bool | `true` when the user filled the Q1 free-text escape. |
|
||||
| `role_has_other` | bool | Ditto Q2. |
|
||||
| `use_case_has_other` | bool | Ditto Q3. |
|
||||
| `onboarding_session_id` | string (UUID) | Forwarded from the client; lets the questionnaire submission join back to its `onboarding_started`. |
|
||||
|
||||
Person properties set with `$set` (not once — users can go back and
|
||||
change answers before submitting again):
|
||||
@@ -424,6 +447,7 @@ which exit the user took.
|
||||
| `workspace_id` | string (UUID) | Present for workspace-linked onboarding completions. |
|
||||
| `completion_path` | string | One of `full` / `runtime_skipped` / `cloud_waitlist` / `skip_existing` / `invite_accept` / `unknown`. See below. |
|
||||
| `joined_cloud_waitlist` | bool | Derived from `user.cloud_waitlist_email`. Orthogonal to `completion_path` — a user may submit the waitlist form and still pick CLI. |
|
||||
| `onboarding_session_id` | string (UUID) | Present for `full` / `runtime_skipped` / `cloud_waitlist` paths (the in-product funnel). Omitted for `skip_existing` / `invite_accept` because those bypass the funnel and never received a session. |
|
||||
|
||||
Person properties set with `$set_once`:
|
||||
|
||||
|
||||
@@ -82,3 +82,30 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
// backend returns an empty key and this module stays inert.
|
||||
|
||||
import posthog from "posthog-js";
|
||||
import { getOnboardingSessionId } from "../onboarding/session";
|
||||
|
||||
export const EVENT_SCHEMA_VERSION = 2;
|
||||
|
||||
@@ -283,6 +284,14 @@ function withClientEventProperties(
|
||||
if (next.is_demo === undefined) {
|
||||
next.is_demo = false;
|
||||
}
|
||||
// Attach the active onboarding session id when one is in progress.
|
||||
// Stamped on every client event (not just onboarding_*) so a stray
|
||||
// event fired mid-funnel can still be joined back; HogQL filters by
|
||||
// event name when it cares.
|
||||
if (next.onboarding_session_id === undefined) {
|
||||
const sessionId = getOnboardingSessionId();
|
||||
if (sessionId) next.onboarding_session_id = sessionId;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,60 @@ describe("ApiClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAttachmentTextContent", () => {
|
||||
it("returns body text and the original content type from the X-* header", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("# heading\n\nbody\n", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"X-Original-Content-Type": "text/markdown",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
const { text, originalContentType } =
|
||||
await client.getAttachmentTextContent("att-1");
|
||||
|
||||
expect(text).toBe("# heading\n\nbody\n");
|
||||
expect(originalContentType).toBe("text/markdown");
|
||||
});
|
||||
|
||||
it("throws PreviewTooLargeError on 413", async () => {
|
||||
const { PreviewTooLargeError } = await import("./client");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("", { status: 413, statusText: "Payload Too Large" }),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
||||
PreviewTooLargeError,
|
||||
);
|
||||
});
|
||||
|
||||
it("throws PreviewUnsupportedError on 415", async () => {
|
||||
const { PreviewUnsupportedError } = await import("./client");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
|
||||
),
|
||||
);
|
||||
|
||||
const client = new ApiClient("https://api.example.test");
|
||||
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
|
||||
PreviewUnsupportedError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chat attachment wiring", () => {
|
||||
it("uploadFile includes chat_session_id in the FormData body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ListIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
CreateAgentFromTemplateRequest,
|
||||
CreateAgentFromTemplateResponse,
|
||||
UpdateAgentRequest,
|
||||
AgentTask,
|
||||
AgentActivityBucket,
|
||||
@@ -87,6 +91,8 @@ import type {
|
||||
GitHubPullRequest,
|
||||
ListGitHubInstallationsResponse,
|
||||
GitHubConnectResponse,
|
||||
Squad,
|
||||
SquadMember,
|
||||
} from "../types";
|
||||
import type { OnboardingCompletionPath } from "../onboarding/types";
|
||||
import { type Logger, noopLogger } from "../logger";
|
||||
@@ -94,13 +100,19 @@ 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,
|
||||
ListIssuesResponseSchema,
|
||||
@@ -196,6 +208,27 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown by getAttachmentTextContent when the server refuses to inline a
|
||||
// file because it exceeds the 2 MB cap. UI maps to a "too large, please
|
||||
// download" affordance with the Download CTA still available.
|
||||
export class PreviewTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("attachment too large for inline preview");
|
||||
this.name = "PreviewTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
// Thrown by getAttachmentTextContent when the server's text whitelist
|
||||
// rejects the content type. Normally the client's isPreviewable() guard
|
||||
// catches this earlier, but the two whitelists can drift — surfacing the
|
||||
// 415 as a typed error makes the drift visible.
|
||||
export class PreviewUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super("attachment type not supported for inline preview");
|
||||
this.name = "PreviewUnsupportedError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private baseUrl: string;
|
||||
private token: string | null = null;
|
||||
@@ -270,15 +303,23 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
// Sends the request with the standard headers (auth, CSRF, request id,
|
||||
// client identity) and runs the shared error path (401 → handleUnauthorized,
|
||||
// structured ApiError, status-aware log level). Returns the raw Response so
|
||||
// callers can decide how to decode the body — JSON for the typed `fetch<T>`
|
||||
// path, plain text for the attachment-preview proxy, etc.
|
||||
private async fetchRaw(
|
||||
path: string,
|
||||
init?: RequestInit & { extraHeaders?: Record<string, string> },
|
||||
): Promise<Response> {
|
||||
const rid = createRequestId();
|
||||
const start = Date.now();
|
||||
const method = init?.method ?? "GET";
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Request-ID": rid,
|
||||
...this.authHeaders(),
|
||||
...(init?.extraHeaders ?? {}),
|
||||
...((init?.headers as Record<string, string>) ?? {}),
|
||||
};
|
||||
|
||||
@@ -299,12 +340,18 @@ export class ApiClient {
|
||||
}
|
||||
|
||||
this.logger.info(`← ${res.status} ${path}`, { rid, duration: `${Date.now() - start}ms` });
|
||||
return res;
|
||||
}
|
||||
|
||||
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await this.fetchRaw(path, {
|
||||
...init,
|
||||
extraHeaders: { "Content-Type": "application/json" },
|
||||
});
|
||||
// Handle 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
@@ -345,6 +392,7 @@ export class ApiClient {
|
||||
async markOnboardingComplete(payload?: {
|
||||
completion_path?: OnboardingCompletionPath;
|
||||
workspace_id?: string;
|
||||
onboarding_session_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding/complete", {
|
||||
method: "POST",
|
||||
@@ -364,6 +412,7 @@ export class ApiClient {
|
||||
|
||||
async patchOnboarding(payload: {
|
||||
questionnaire?: Record<string, unknown>;
|
||||
onboarding_session_id?: string;
|
||||
}): Promise<User> {
|
||||
return this.fetch("/api/me/onboarding", {
|
||||
method: "PATCH",
|
||||
@@ -634,6 +683,51 @@ 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",
|
||||
@@ -1138,6 +1232,13 @@ export class ApiClient {
|
||||
await this.fetch(`/api/chat/sessions/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async updateChatSession(id: string, data: { title: string }): Promise<ChatSession> {
|
||||
return this.fetch(`/api/chat/sessions/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listChatMessages(sessionId: string): Promise<ChatMessage[]> {
|
||||
return this.fetch(`/api/chat/sessions/${sessionId}/messages`);
|
||||
}
|
||||
@@ -1192,6 +1293,38 @@ export class ApiClient {
|
||||
await this.fetch(`/api/attachments/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Fetches the raw bytes of a text-previewable attachment.
|
||||
//
|
||||
// The endpoint sidesteps CloudFront CORS (not configured on the CDN) and
|
||||
// bypasses Content-Disposition: attachment for the `text/*` family, both
|
||||
// of which would otherwise prevent the renderer from getting the body.
|
||||
// The server always replies with `text/plain; charset=utf-8` for safety;
|
||||
// the original MIME ships back in the `X-Original-Content-Type` header so
|
||||
// the preview dispatcher can choose between markdown / html / plain code.
|
||||
//
|
||||
// Routes through `fetchRaw` so it inherits the standard auth headers,
|
||||
// 401 → handleUnauthorized recovery, request-id logging, and ApiError
|
||||
// shape. 413 / 415 are translated to typed `Preview*Error` instances so
|
||||
// the modal can render specific fallbacks instead of generic failure.
|
||||
async getAttachmentTextContent(
|
||||
id: string,
|
||||
): Promise<{ text: string; originalContentType: string }> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await this.fetchRaw(`/api/attachments/${id}/content`);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
if (err.status === 413) throw new PreviewTooLargeError();
|
||||
if (err.status === 415) throw new PreviewUnsupportedError();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return {
|
||||
text: await res.text(),
|
||||
originalContentType: res.headers.get("X-Original-Content-Type") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
// Projects
|
||||
async listProjects(params?: { status?: string }): Promise<ListProjectsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
@@ -1314,6 +1447,43 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Squads
|
||||
async listSquads(): Promise<Squad[]> {
|
||||
return this.fetch(`/api/squads`);
|
||||
}
|
||||
|
||||
async getSquad(id: string): Promise<Squad> {
|
||||
return this.fetch(`/api/squads/${id}`);
|
||||
}
|
||||
|
||||
async createSquad(data: { name: string; description?: string; leader_id: string }): Promise<Squad> {
|
||||
return this.fetch("/api/squads", { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async updateSquad(id: string, data: { name?: string; description?: string; instructions?: string; leader_id?: string; avatar_url?: string }): Promise<Squad> {
|
||||
return this.fetch(`/api/squads/${id}`, { method: "PUT", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async deleteSquad(id: string): Promise<void> {
|
||||
await this.fetch(`/api/squads/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async listSquadMembers(squadId: string): Promise<SquadMember[]> {
|
||||
return this.fetch(`/api/squads/${squadId}/members`);
|
||||
}
|
||||
|
||||
async addSquadMember(squadId: string, data: { member_type: string; member_id: string; role?: string }): Promise<SquadMember> {
|
||||
return this.fetch(`/api/squads/${squadId}/members`, { method: "POST", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async removeSquadMember(squadId: string, data: { member_type: string; member_id: string }): Promise<void> {
|
||||
await this.fetch(`/api/squads/${squadId}/members`, { method: "DELETE", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
async updateSquadMemberRole(squadId: string, data: { member_type: string; member_id: string; role: string }): Promise<SquadMember> {
|
||||
return this.fetch(`/api/squads/${squadId}/members/role`, { method: "PATCH", body: JSON.stringify(data) });
|
||||
}
|
||||
|
||||
// Autopilots
|
||||
async listAutopilots(params?: { status?: string }): Promise<ListAutopilotsResponse> {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
|
||||
@@ -117,6 +117,108 @@ 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,5 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import type { Attachment, ListIssuesResponse, TimelineEntry } from "../types";
|
||||
import type {
|
||||
Agent,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
Attachment,
|
||||
CreateAgentFromTemplateResponse,
|
||||
ListIssuesResponse,
|
||||
TimelineEntry,
|
||||
} from "../types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Schemas for the highest-risk API endpoints — those whose responses drive
|
||||
@@ -212,3 +220,89 @@ const DashboardAgentRunTimeSchema = z.object({
|
||||
}).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: [],
|
||||
};
|
||||
|
||||
@@ -64,6 +64,45 @@ export function useMarkChatSessionRead() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a chat session. Optimistically swaps the title in the cached
|
||||
* list so the dropdown reflects the new label immediately; rolls back on
|
||||
* error. The matching `chat:session_updated` WS event keeps other
|
||||
* tabs/devices in sync — see use-realtime-sync.ts.
|
||||
*/
|
||||
export function useUpdateChatSession() {
|
||||
const qc = useQueryClient();
|
||||
const wsId = useWorkspaceId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: { sessionId: string; title: string }) => {
|
||||
logger.info("updateChatSession.start", {
|
||||
sessionId: data.sessionId,
|
||||
titleLength: data.title.length,
|
||||
});
|
||||
return api.updateChatSession(data.sessionId, { title: data.title });
|
||||
},
|
||||
onMutate: async ({ sessionId, title }) => {
|
||||
await qc.cancelQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
|
||||
const prevSessions = qc.getQueryData<ChatSession[]>(chatKeys.sessions(wsId));
|
||||
|
||||
const patch = (old?: ChatSession[]) =>
|
||||
old?.map((s) => (s.id === sessionId ? { ...s, title } : s));
|
||||
qc.setQueryData<ChatSession[]>(chatKeys.sessions(wsId), patch);
|
||||
|
||||
return { prevSessions };
|
||||
},
|
||||
onError: (err, vars, ctx) => {
|
||||
logger.error("updateChatSession.error.rollback", { sessionId: vars.sessionId, err });
|
||||
if (ctx?.prevSessions) qc.setQueryData(chatKeys.sessions(wsId), ctx.prevSessions);
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-deletes a chat session. Optimistically removes the row from the
|
||||
* sessions list so the dropdown updates instantly; rolls back on error.
|
||||
|
||||
166
packages/core/issues/delete-cache.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
||||
import {
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type { Issue, ListIssuesCache } from "../types";
|
||||
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
|
||||
import { issueKeys } from "./queries";
|
||||
|
||||
export type DeletedIssueCacheMetadata = {
|
||||
parentIssueIds: string[];
|
||||
};
|
||||
|
||||
function collectParentId(
|
||||
parentIssueIds: Set<string>,
|
||||
parentId: string | null | undefined,
|
||||
) {
|
||||
if (parentId) parentIssueIds.add(parentId);
|
||||
}
|
||||
|
||||
function collectParentFromListCache(
|
||||
parentIssueIds: Set<string>,
|
||||
data: ListIssuesCache | undefined,
|
||||
issueId: string,
|
||||
) {
|
||||
const parentId = data
|
||||
? findIssueLocation(data, issueId)?.issue.parent_issue_id
|
||||
: undefined;
|
||||
collectParentId(parentIssueIds, parentId);
|
||||
}
|
||||
|
||||
function parentIdFromChildrenKey(key: QueryKey) {
|
||||
const parentId = key[key.length - 1];
|
||||
return typeof parentId === "string" ? parentId : null;
|
||||
}
|
||||
|
||||
export function collectDeletedIssueCacheMetadata(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
): DeletedIssueCacheMetadata {
|
||||
const parentIssueIds = new Set<string>();
|
||||
|
||||
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
|
||||
collectParentId(parentIssueIds, detail?.parent_issue_id);
|
||||
|
||||
collectParentFromListCache(
|
||||
parentIssueIds,
|
||||
qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId)),
|
||||
issueId,
|
||||
);
|
||||
|
||||
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
})) {
|
||||
collectParentFromListCache(parentIssueIds, data, issueId);
|
||||
}
|
||||
|
||||
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
})) {
|
||||
const child = data?.find((issue) => issue.id === issueId);
|
||||
if (!child) continue;
|
||||
collectParentId(parentIssueIds, child.parent_issue_id);
|
||||
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
|
||||
}
|
||||
|
||||
return { parentIssueIds: Array.from(parentIssueIds) };
|
||||
}
|
||||
|
||||
export function pruneDeletedIssueFromListCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
|
||||
for (const [key] of qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
})) {
|
||||
qc.setQueryData<ListIssuesCache>(key, (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneDeletedIssueFromParentChildrenCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata: DeletedIssueCacheMetadata,
|
||||
) {
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((issue) => issue.id !== issueId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueParentCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
metadata: DeletedIssueCacheMetadata,
|
||||
) {
|
||||
if (metadata.parentIssueIds.length === 0) return;
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateDeletedIssueDependentCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
|
||||
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
|
||||
}
|
||||
|
||||
export function invalidateIssueScopedCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
}
|
||||
|
||||
export function cleanupDeletedIssueCaches(
|
||||
qc: QueryClient,
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
|
||||
) {
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
|
||||
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
|
||||
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
|
||||
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
}
|
||||
@@ -11,9 +11,17 @@ import {
|
||||
findIssueLocation,
|
||||
getBucket,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
setBucket,
|
||||
} from "./cache-helpers";
|
||||
import {
|
||||
cleanupDeletedIssueCaches,
|
||||
collectDeletedIssueCacheMetadata,
|
||||
invalidateDeletedIssueDependentCaches,
|
||||
invalidateDeletedIssueParentCaches,
|
||||
invalidateIssueScopedCaches,
|
||||
pruneDeletedIssueFromListCaches,
|
||||
pruneDeletedIssueFromParentChildrenCaches,
|
||||
} from "./delete-cache";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { useRecentIssuesStore } from "./stores";
|
||||
import type { Issue, IssueReaction, IssueStatus } from "../types";
|
||||
@@ -217,24 +225,56 @@ export function useDeleteIssue() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => api.deleteIssue(id),
|
||||
onMutate: async (id) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, id) : old,
|
||||
await Promise.all([
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
||||
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
|
||||
]);
|
||||
const metadata = collectDeletedIssueCacheMetadata(qc, wsId, id);
|
||||
await Promise.all(
|
||||
metadata.parentIssueIds.map((parentId) =>
|
||||
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
|
||||
),
|
||||
);
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
});
|
||||
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
prevChildren.set(
|
||||
parentId,
|
||||
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
|
||||
);
|
||||
}
|
||||
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, id);
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
|
||||
return { prevList, parentIssueId: deleted?.parent_issue_id };
|
||||
return { id, metadata, prevList, prevMyLists, prevDetail, prevChildren };
|
||||
},
|
||||
onError: (_err, _id, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevMyLists) {
|
||||
for (const [key, snapshot] of ctx.prevMyLists) {
|
||||
qc.setQueryData(key, snapshot);
|
||||
}
|
||||
}
|
||||
if (ctx?.prevDetail) {
|
||||
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
|
||||
}
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, id, ctx) => {
|
||||
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadata);
|
||||
},
|
||||
onSettled: (_data, _err, _id, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentIssueId) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -309,57 +349,92 @@ export function useBatchDeleteIssues() {
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
|
||||
onMutate: async (ids) => {
|
||||
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
await Promise.all([
|
||||
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
|
||||
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
|
||||
]);
|
||||
const metadataById = new Map(
|
||||
ids.map((id) => [
|
||||
id,
|
||||
collectDeletedIssueCacheMetadata(qc, wsId, id),
|
||||
]),
|
||||
);
|
||||
const parentIssueIds = new Set<string>();
|
||||
if (prevList) {
|
||||
for (const id of ids) {
|
||||
const loc = findIssueLocation(prevList, id);
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
for (const metadata of metadataById.values()) {
|
||||
for (const parentId of metadata.parentIssueIds) {
|
||||
parentIssueIds.add(parentId);
|
||||
}
|
||||
}
|
||||
// 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"],
|
||||
await Promise.all(
|
||||
Array.from(parentIssueIds).map((parentId) =>
|
||||
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
|
||||
),
|
||||
);
|
||||
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
|
||||
queryKey: issueKeys.myAll(wsId),
|
||||
});
|
||||
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)),
|
||||
for (const parentId of parentIssueIds) {
|
||||
prevChildren.set(
|
||||
parentId,
|
||||
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
|
||||
);
|
||||
}
|
||||
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 };
|
||||
|
||||
for (const id of ids) {
|
||||
const metadata = metadataById.get(id);
|
||||
pruneDeletedIssueFromListCaches(qc, wsId, id);
|
||||
if (metadata) {
|
||||
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
|
||||
}
|
||||
}
|
||||
return { prevList, prevMyLists, prevChildren, parentIssueIds, metadataById };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevMyLists) {
|
||||
for (const [key, snapshot] of ctx.prevMyLists) {
|
||||
qc.setQueryData(key, snapshot);
|
||||
}
|
||||
}
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (data, ids, ctx) => {
|
||||
if (data.deleted === ids.length) {
|
||||
for (const id of ids) {
|
||||
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadataById.get(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevMyLists) {
|
||||
for (const [key, snapshot] of ctx.prevMyLists) {
|
||||
qc.setQueryData(key, snapshot);
|
||||
}
|
||||
}
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
for (const id of ids) {
|
||||
invalidateIssueScopedCaches(qc, wsId, id);
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
invalidateDeletedIssueDependentCaches(qc, wsId);
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
|
||||
for (const parentId of ctx.parentIssueIds) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
invalidateDeletedIssueParentCaches(qc, wsId, {
|
||||
parentIssueIds: Array.from(ctx.parentIssueIds),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,17 +1,34 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { onIssueLabelsChanged } from "./ws-updaters";
|
||||
import {
|
||||
agentActivityKeys,
|
||||
agentRunCountsKeys,
|
||||
agentTaskSnapshotKeys,
|
||||
agentTasksKeys,
|
||||
} from "../agents/queries";
|
||||
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
|
||||
import { issueKeys } from "./queries";
|
||||
import { labelKeys } from "../labels/queries";
|
||||
import type {
|
||||
AgentActivityBucket,
|
||||
AgentRunCount,
|
||||
AgentTask,
|
||||
Attachment,
|
||||
Issue,
|
||||
IssueReaction,
|
||||
IssueLabelsResponse,
|
||||
IssueSubscriber,
|
||||
IssueUsageSummary,
|
||||
Label,
|
||||
ListIssuesCache,
|
||||
TimelineEntry,
|
||||
} from "../types";
|
||||
|
||||
const WS_ID = "ws-1";
|
||||
const ISSUE_ID = "issue-1";
|
||||
const OTHER_ISSUE_ID = "issue-2";
|
||||
const PARENT_ISSUE_ID = "parent-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
const labelA: Label = {
|
||||
id: "label-a",
|
||||
@@ -53,6 +70,47 @@ const baseIssue: Issue = {
|
||||
updated_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
const parentedIssue: Issue = {
|
||||
...baseIssue,
|
||||
parent_issue_id: PARENT_ISSUE_ID,
|
||||
};
|
||||
|
||||
const otherIssue: Issue = {
|
||||
...baseIssue,
|
||||
id: OTHER_ISSUE_ID,
|
||||
identifier: "MUL-2",
|
||||
title: "Other",
|
||||
};
|
||||
|
||||
function makeListCache(...issues: Issue[]): ListIssuesCache {
|
||||
return {
|
||||
byStatus: {
|
||||
todo: { issues, total: issues.length },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTask(issueId = ISSUE_ID): AgentTask {
|
||||
return {
|
||||
id: `task-${issueId}`,
|
||||
agent_id: AGENT_ID,
|
||||
runtime_id: "runtime-1",
|
||||
issue_id: issueId,
|
||||
status: "completed",
|
||||
priority: 0,
|
||||
dispatched_at: null,
|
||||
started_at: "2025-01-01T00:00:00Z",
|
||||
completed_at: "2025-01-01T00:01:00Z",
|
||||
result: null,
|
||||
error: null,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function expectInvalidated(qc: QueryClient, queryKey: readonly unknown[]) {
|
||||
expect(qc.getQueryState(queryKey)?.isInvalidated).toBe(true);
|
||||
}
|
||||
|
||||
describe("onIssueLabelsChanged", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
@@ -93,3 +151,243 @@ describe("onIssueLabelsChanged", () => {
|
||||
expect(detail?.labels).toEqual([labelB]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onIssueDeleted", () => {
|
||||
let qc: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
qc = new QueryClient();
|
||||
});
|
||||
|
||||
it("removes every cache entry scoped directly to the deleted issue", () => {
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
|
||||
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(ISSUE_ID), [
|
||||
{
|
||||
type: "activity",
|
||||
id: "activity-1",
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
action: "created",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<IssueReaction[]>(issueKeys.reactions(ISSUE_ID), [
|
||||
{
|
||||
id: "reaction-1",
|
||||
issue_id: ISSUE_ID,
|
||||
actor_type: "member",
|
||||
actor_id: "user-1",
|
||||
emoji: "+1",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<IssueSubscriber[]>(issueKeys.subscribers(ISSUE_ID), [
|
||||
{
|
||||
issue_id: ISSUE_ID,
|
||||
user_type: "member",
|
||||
user_id: "user-1",
|
||||
reason: "manual",
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), {
|
||||
total_input_tokens: 10,
|
||||
total_output_tokens: 20,
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
task_count: 1,
|
||||
});
|
||||
qc.setQueryData<Attachment[]>(issueKeys.attachments(ISSUE_ID), [
|
||||
{
|
||||
id: "attachment-1",
|
||||
workspace_id: WS_ID,
|
||||
issue_id: ISSUE_ID,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "user-1",
|
||||
filename: "evidence.png",
|
||||
url: "s3://bucket/evidence.png",
|
||||
download_url: "https://example.test/evidence.png",
|
||||
content_type: "image/png",
|
||||
size_bytes: 1,
|
||||
created_at: "2025-01-01T00:00:00Z",
|
||||
},
|
||||
]);
|
||||
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [otherIssue]);
|
||||
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
|
||||
labels: [labelA],
|
||||
});
|
||||
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, OTHER_ISSUE_ID), otherIssue);
|
||||
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(OTHER_ISSUE_ID), []);
|
||||
qc.setQueryData<IssueLabelsResponse>(
|
||||
labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID),
|
||||
{ labels: [labelB] },
|
||||
);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.timeline(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.reactions(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.subscribers(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.attachments(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
|
||||
expect(qc.getQueryData(issueKeys.detail(WS_ID, OTHER_ISSUE_ID))).toEqual(
|
||||
otherIssue,
|
||||
);
|
||||
expect(qc.getQueryData(issueKeys.timeline(OTHER_ISSUE_ID))).toEqual([]);
|
||||
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID))).toEqual({
|
||||
labels: [labelB],
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the deleted issue from workspace and my-issues list caches immediately", () => {
|
||||
const myFilter = { assignee_id: AGENT_ID };
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
issueKeys.list(WS_ID),
|
||||
makeListCache(baseIssue, otherIssue),
|
||||
);
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
makeListCache(baseIssue, otherIssue),
|
||||
);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
|
||||
const myList = qc.getQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
);
|
||||
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
OTHER_ISSUE_ID,
|
||||
]);
|
||||
expect(list?.byStatus.todo?.total).toBe(1);
|
||||
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
OTHER_ISSUE_ID,
|
||||
]);
|
||||
expect(myList?.byStatus.todo?.total).toBe(1);
|
||||
expectInvalidated(qc, issueKeys.list(WS_ID));
|
||||
expectInvalidated(qc, issueKeys.myList(WS_ID, "assigned", myFilter));
|
||||
});
|
||||
|
||||
it("invalidates parent progress when the parent id only exists in detail cache", () => {
|
||||
qc.setQueryData<Issue>(
|
||||
issueKeys.detail(WS_ID, ISSUE_ID),
|
||||
parentedIssue,
|
||||
);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
|
||||
parentedIssue,
|
||||
otherIssue,
|
||||
]);
|
||||
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const parentChildren = qc.getQueryData<Issue[]>(
|
||||
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
|
||||
);
|
||||
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
|
||||
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates parent progress when the deleted issue is only present in a children cache", () => {
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
|
||||
parentedIssue,
|
||||
otherIssue,
|
||||
]);
|
||||
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const parentChildren = qc.getQueryData<Issue[]>(
|
||||
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
|
||||
);
|
||||
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
|
||||
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates parent progress when the parent id only exists in a my-issues cache", () => {
|
||||
const myFilter = { assignee_id: AGENT_ID };
|
||||
qc.setQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
makeListCache(parentedIssue, otherIssue),
|
||||
);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
|
||||
otherIssue,
|
||||
]);
|
||||
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
const myList = qc.getQueryData<ListIssuesCache>(
|
||||
issueKeys.myList(WS_ID, "assigned", myFilter),
|
||||
);
|
||||
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
|
||||
OTHER_ISSUE_ID,
|
||||
]);
|
||||
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates child progress when the deleted issue is itself a parent", () => {
|
||||
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [
|
||||
{
|
||||
...otherIssue,
|
||||
parent_issue_id: ISSUE_ID,
|
||||
},
|
||||
]);
|
||||
qc.setQueryData(
|
||||
issueKeys.childProgress(WS_ID),
|
||||
new Map([[ISSUE_ID, { done: 0, total: 1 }]]),
|
||||
);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
|
||||
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
|
||||
});
|
||||
|
||||
it("invalidates agent task and activity caches that can reference the deleted issue", () => {
|
||||
qc.setQueryData<AgentTask[]>(
|
||||
agentTaskSnapshotKeys.list(WS_ID),
|
||||
[makeTask()],
|
||||
);
|
||||
qc.setQueryData<AgentActivityBucket[]>(
|
||||
agentActivityKeys.last30d(WS_ID),
|
||||
[
|
||||
{
|
||||
agent_id: AGENT_ID,
|
||||
bucket_at: "2025-01-01T00:00:00Z",
|
||||
task_count: 1,
|
||||
failed_count: 0,
|
||||
},
|
||||
],
|
||||
);
|
||||
qc.setQueryData<AgentRunCount[]>(agentRunCountsKeys.last30d(WS_ID), [
|
||||
{ agent_id: AGENT_ID, run_count: 1 },
|
||||
]);
|
||||
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
|
||||
makeTask(),
|
||||
]);
|
||||
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
|
||||
|
||||
onIssueDeleted(qc, WS_ID, ISSUE_ID);
|
||||
|
||||
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
|
||||
expectInvalidated(qc, agentActivityKeys.last30d(WS_ID));
|
||||
expectInvalidated(qc, agentRunCountsKeys.last30d(WS_ID));
|
||||
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
|
||||
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
addIssueToBuckets,
|
||||
findIssueLocation,
|
||||
patchIssueInBuckets,
|
||||
removeIssueFromBuckets,
|
||||
} from "./cache-helpers";
|
||||
import { cleanupDeletedIssueCaches } from "./delete-cache";
|
||||
import type { Issue, IssueLabelsResponse, Label } from "../types";
|
||||
import type { ListIssuesCache } from "../types";
|
||||
|
||||
@@ -107,21 +107,5 @@ export function onIssueDeleted(
|
||||
wsId: string,
|
||||
issueId: string,
|
||||
) {
|
||||
// Look up the issue before removing it to check for parent_issue_id
|
||||
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
|
||||
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
|
||||
old ? removeIssueFromBuckets(old, issueId) : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
|
||||
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
|
||||
if (deleted?.parent_issue_id) {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
cleanupDeletedIssueCaches(qc, wsId, issueId);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ type ModalType =
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "create-squad"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
| "issue-add-child"
|
||||
|
||||
@@ -13,3 +13,8 @@ export {
|
||||
} from "./store";
|
||||
export { ONBOARDING_STEP_ORDER } from "./step-order";
|
||||
export { recommendTemplate, type AgentTemplateId } from "./recommend-template";
|
||||
export {
|
||||
startOnboardingSession,
|
||||
getOnboardingSessionId,
|
||||
clearOnboardingSession,
|
||||
} from "./session";
|
||||
|
||||
59
packages/core/onboarding/session.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
async function loadModule() {
|
||||
// Reset module cache so each test starts with a clean in-memory id.
|
||||
const vitest = await import("vitest");
|
||||
vitest.vi.resetModules();
|
||||
return import("./session");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Provide a minimal in-memory localStorage so defaultStorage's
|
||||
// typeof-window check passes and we exercise the persistence path.
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: {
|
||||
getItem: (k: string) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k: string, v: string) => store.set(k, v),
|
||||
removeItem: (k: string) => store.delete(k),
|
||||
},
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error — test-only cleanup
|
||||
delete globalThis.window;
|
||||
// @ts-expect-error — test-only cleanup
|
||||
delete globalThis.localStorage;
|
||||
});
|
||||
|
||||
describe("onboarding session", () => {
|
||||
it("startOnboardingSession returns a stable id within the same funnel", async () => {
|
||||
const session = await loadModule();
|
||||
const id1 = session.startOnboardingSession();
|
||||
const id2 = session.startOnboardingSession();
|
||||
expect(id1).toBe(id2);
|
||||
expect(session.getOnboardingSessionId()).toBe(id1);
|
||||
});
|
||||
|
||||
it("clearOnboardingSession resets so the next start gets a fresh id", async () => {
|
||||
const session = await loadModule();
|
||||
const first = session.startOnboardingSession();
|
||||
session.clearOnboardingSession();
|
||||
expect(session.getOnboardingSessionId()).toBeNull();
|
||||
const second = session.startOnboardingSession();
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it("getOnboardingSessionId returns null when no session has been started", async () => {
|
||||
const session = await loadModule();
|
||||
expect(session.getOnboardingSessionId()).toBeNull();
|
||||
});
|
||||
});
|
||||
63
packages/core/onboarding/session.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Onboarding session identifier — issued once per funnel entry and attached
|
||||
// to every onboarding event so PostHog can correlate the full funnel back to
|
||||
// a single `onboarding_started`. Solves the prior funnel-attribution gap
|
||||
// where `onboarding_completed` events fired from skip/invite paths (no
|
||||
// `onboarding_started` in their lineage) were indistinguishable from real
|
||||
// funnel completions when joining on `distinct_id` alone.
|
||||
//
|
||||
// The id is persisted to client storage because the funnel spans page
|
||||
// reloads (especially on web — desktop bundle install, OAuth redirects).
|
||||
// It's cleared on completion; entering onboarding again starts a fresh
|
||||
// session and a fresh id.
|
||||
|
||||
import { createSafeId } from "../utils";
|
||||
import { defaultStorage } from "../platform/storage";
|
||||
|
||||
const STORAGE_KEY = "multica_onboarding_session_id";
|
||||
|
||||
// In-memory cache so the analytics wrapper doesn't hit storage on every
|
||||
// event. Storage is read once on first access and on every start/clear.
|
||||
let cached: string | null | undefined;
|
||||
|
||||
function read(): string | null {
|
||||
if (cached !== undefined) return cached;
|
||||
cached = defaultStorage.getItem(STORAGE_KEY);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new session id and persist it. Idempotent — calling twice in
|
||||
* the same funnel returns the same id, so a re-mount of the onboarding
|
||||
* shell can't accidentally split one funnel across two sessions.
|
||||
*
|
||||
* The expected fire site is the same place that emits `onboarding_started`.
|
||||
*/
|
||||
export function startOnboardingSession(): string {
|
||||
const existing = read();
|
||||
if (existing) return existing;
|
||||
const id = createSafeId();
|
||||
cached = id;
|
||||
defaultStorage.setItem(STORAGE_KEY, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current session id. Returns null when no onboarding session is
|
||||
* in progress — the analytics wrapper omits the property in that case
|
||||
* rather than emitting an empty string, so HogQL queries can filter
|
||||
* `onboarding_session_id IS NOT NULL` to isolate real funnel events from
|
||||
* skip/invite paths that legitimately have no session.
|
||||
*/
|
||||
export function getOnboardingSessionId(): string | null {
|
||||
return read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the session. Called at the funnel terminus (after a successful
|
||||
* `onboarding_completed`) so a returning user who somehow re-enters
|
||||
* onboarding starts a fresh session.
|
||||
*/
|
||||
export function clearOnboardingSession(): void {
|
||||
cached = null;
|
||||
defaultStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { api } from "../api";
|
||||
import { useAuthStore } from "../auth";
|
||||
import { setPersonProperties } from "../analytics";
|
||||
import {
|
||||
clearOnboardingSession,
|
||||
getOnboardingSessionId,
|
||||
} from "./session";
|
||||
import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
||||
|
||||
/**
|
||||
@@ -16,7 +20,11 @@ import type { OnboardingCompletionPath, QuestionnaireAnswers } from "./types";
|
||||
export async function saveQuestionnaire(
|
||||
answers: Partial<QuestionnaireAnswers>,
|
||||
): Promise<void> {
|
||||
const user = await api.patchOnboarding({ questionnaire: answers });
|
||||
const onboardingSessionId = getOnboardingSessionId() ?? undefined;
|
||||
const user = await api.patchOnboarding({
|
||||
questionnaire: answers,
|
||||
onboarding_session_id: onboardingSessionId,
|
||||
});
|
||||
useAuthStore.getState().setUser(user);
|
||||
// Mirror the three cohort signals into person properties so every
|
||||
// PostHog event on this user can be broken down by role / use_case /
|
||||
@@ -44,11 +52,20 @@ export async function completeOnboarding(
|
||||
completionPath?: OnboardingCompletionPath,
|
||||
workspaceId?: string,
|
||||
): Promise<void> {
|
||||
await api.markOnboardingComplete(
|
||||
completionPath || workspaceId
|
||||
? { completion_path: completionPath, workspace_id: workspaceId }
|
||||
: undefined,
|
||||
);
|
||||
const onboardingSessionId = getOnboardingSessionId() ?? undefined;
|
||||
const payload =
|
||||
completionPath || workspaceId || onboardingSessionId
|
||||
? {
|
||||
completion_path: completionPath,
|
||||
workspace_id: workspaceId,
|
||||
onboarding_session_id: onboardingSessionId,
|
||||
}
|
||||
: undefined;
|
||||
await api.markOnboardingComplete(payload);
|
||||
// Clear the session AFTER the server records it. The funnel terminus
|
||||
// is over — any subsequent re-entry into onboarding starts a fresh
|
||||
// session (and a fresh id), which is what we want.
|
||||
clearOnboardingSession();
|
||||
await useAuthStore.getState().refreshMe();
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,17 @@ describe("paths.workspace() shape", () => {
|
||||
expect(new Set(parameterlessRoutes)).toEqual(
|
||||
new Set([
|
||||
"root",
|
||||
"dashboard",
|
||||
"usage",
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"squads",
|
||||
"inbox",
|
||||
"myIssues",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"squads",
|
||||
"settings",
|
||||
]),
|
||||
);
|
||||
@@ -36,15 +38,17 @@ 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]> = [
|
||||
["dashboard", "dashboard"],
|
||||
["usage", "usage"],
|
||||
["issues", "issues"],
|
||||
["projects", "projects"],
|
||||
["autopilots", "autopilots"],
|
||||
["agents", "agents"],
|
||||
["squads", "squads"],
|
||||
["inbox", "inbox"],
|
||||
["myIssues", "my-issues"],
|
||||
["runtimes", "runtimes"],
|
||||
["skills", "skills"],
|
||||
["squads", "squads"],
|
||||
["settings", "settings"],
|
||||
];
|
||||
const wsAsAny = ws as unknown as Record<string, () => string>;
|
||||
|
||||
@@ -4,8 +4,8 @@ import { paths, isGlobalPath } from "./paths";
|
||||
describe("paths.workspace(slug)", () => {
|
||||
const ws = paths.workspace("acme");
|
||||
|
||||
it("builds dashboard paths with slug prefix", () => {
|
||||
expect(ws.dashboard()).toBe("/acme/dashboard");
|
||||
it("builds workspace paths with slug prefix", () => {
|
||||
expect(ws.usage()).toBe("/acme/usage");
|
||||
expect(ws.issues()).toBe("/acme/issues");
|
||||
expect(ws.issueDetail("abc-123")).toBe("/acme/issues/abc-123");
|
||||
expect(ws.projects()).toBe("/acme/projects");
|
||||
@@ -18,6 +18,8 @@ describe("paths.workspace(slug)", () => {
|
||||
expect(ws.runtimes()).toBe("/acme/runtimes");
|
||||
expect(ws.skills()).toBe("/acme/skills");
|
||||
expect(ws.skillDetail("skl_123")).toBe("/acme/skills/skl_123");
|
||||
expect(ws.squads()).toBe("/acme/squads");
|
||||
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
|
||||
expect(ws.settings()).toBe("/acme/settings");
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function workspaceScoped(slug: string) {
|
||||
const ws = `/${encode(slug)}`;
|
||||
return {
|
||||
root: () => `${ws}/issues`,
|
||||
dashboard: () => `${ws}/dashboard`,
|
||||
usage: () => `${ws}/usage`,
|
||||
issues: () => `${ws}/issues`,
|
||||
issueDetail: (id: string) => `${ws}/issues/${encode(id)}`,
|
||||
projects: () => `${ws}/projects`,
|
||||
@@ -27,6 +27,8 @@ function workspaceScoped(slug: string) {
|
||||
autopilotDetail: (id: string) => `${ws}/autopilots/${encode(id)}`,
|
||||
agents: () => `${ws}/agents`,
|
||||
agentDetail: (id: string) => `${ws}/agents/${encode(id)}`,
|
||||
squads: () => `${ws}/squads`,
|
||||
squadDetail: (id: string) => `${ws}/squads/${encode(id)}`,
|
||||
inbox: () => `${ws}/inbox`,
|
||||
myIssues: () => `${ws}/my-issues`,
|
||||
runtimes: () => `${ws}/runtimes`,
|
||||
|
||||
@@ -70,7 +70,7 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"search",
|
||||
"members",
|
||||
|
||||
// Dashboard / workspace route segments
|
||||
// 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
|
||||
@@ -79,8 +79,10 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"squads",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"usage",
|
||||
"runtimes",
|
||||
"skills",
|
||||
"settings",
|
||||
|
||||
117
packages/core/realtime/use-realtime-sync.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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 } from "@tanstack/react-query";
|
||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import type { WSClient } from "../api/ws-client";
|
||||
import type { StoreApi, UseBoundStore } from "zustand";
|
||||
import type { AuthState } from "../auth/store";
|
||||
@@ -62,6 +62,7 @@ import type {
|
||||
TaskFailedPayload,
|
||||
TaskCancelledPayload,
|
||||
ChatDonePayload,
|
||||
ChatMessage,
|
||||
ChatPendingTask,
|
||||
InvitationCreatedPayload,
|
||||
} from "../types";
|
||||
@@ -70,6 +71,42 @@ 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>>;
|
||||
}
|
||||
@@ -134,6 +171,14 @@ export function useRealtimeSync(
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
||||
},
|
||||
squad: () => {
|
||||
const wsId = getCurrentWsId();
|
||||
if (wsId) {
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.squads(wsId) });
|
||||
// squad:deleted triggers assignee transfer — refresh issues too.
|
||||
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
|
||||
}
|
||||
},
|
||||
label: () => {
|
||||
// label:created/updated/deleted — also refresh issues, since each
|
||||
// issue carries a denormalized snapshot of its labels (rename/recolor
|
||||
@@ -222,6 +267,7 @@ export function useRealtimeSync(
|
||||
"daemon:heartbeat",
|
||||
// Chat events are handled explicitly below; do not double-invalidate.
|
||||
"chat:message", "chat:done", "chat:session_read", "chat:session_deleted",
|
||||
"chat:session_updated",
|
||||
// task:message stays out of the prefix path because it fires per
|
||||
// streamed message during a long run — invalidating the snapshot on
|
||||
// every message would flood the network. Specific chat handlers below
|
||||
@@ -568,13 +614,21 @@ 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,
|
||||
});
|
||||
// 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) });
|
||||
// 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);
|
||||
invalidatePendingAggregate();
|
||||
// Assistant message just landed → has_unread may have flipped to true.
|
||||
invalidateSessionLists();
|
||||
@@ -645,9 +699,12 @@ export function useRealtimeSync(
|
||||
task_id: payload.task_id,
|
||||
chat_session_id: payload.chat_session_id,
|
||||
});
|
||||
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) });
|
||||
// `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.
|
||||
invalidatePendingAggregate();
|
||||
});
|
||||
|
||||
@@ -676,6 +733,33 @@ export function useRealtimeSync(
|
||||
invalidateSessionLists();
|
||||
});
|
||||
|
||||
// chat:session_updated fires after the creator renames a session in
|
||||
// any tab/device. Patch the cached row inline so the dropdown reflects
|
||||
// the new title without a full sessions-list refetch.
|
||||
const unsubChatSessionUpdated = ws.on("chat:session_updated", (p) => {
|
||||
const payload = p as {
|
||||
chat_session_id: string;
|
||||
title?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
chatWsLogger.info("chat:session_updated (global)", payload);
|
||||
const id = getCurrentWsId();
|
||||
if (!id) return;
|
||||
const patch = (
|
||||
old?: { id: string; title: string; updated_at: string }[],
|
||||
) =>
|
||||
old?.map((s) =>
|
||||
s.id === payload.chat_session_id
|
||||
? {
|
||||
...s,
|
||||
title: payload.title ?? s.title,
|
||||
updated_at: payload.updated_at ?? s.updated_at,
|
||||
}
|
||||
: s,
|
||||
);
|
||||
qc.setQueryData(chatKeys.sessions(id), patch);
|
||||
});
|
||||
|
||||
// chat:session_deleted fires after a hard delete. The originating tab has
|
||||
// already optimistically dropped the row via useDeleteChatSession; this
|
||||
// handler keeps OTHER tabs/devices in sync and also clears the active
|
||||
@@ -736,6 +820,7 @@ export function useRealtimeSync(
|
||||
unsubTaskFailed();
|
||||
unsubChatSessionRead();
|
||||
unsubChatSessionDeleted();
|
||||
unsubChatSessionUpdated();
|
||||
timers.forEach(clearTimeout);
|
||||
timers.clear();
|
||||
};
|
||||
|
||||
@@ -168,6 +168,76 @@ 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;
|
||||
|
||||
@@ -54,9 +54,13 @@ export type WSEventType =
|
||||
| "chat:done"
|
||||
| "chat:session_read"
|
||||
| "chat:session_deleted"
|
||||
| "chat:session_updated"
|
||||
| "project:created"
|
||||
| "project:updated"
|
||||
| "project:deleted"
|
||||
| "squad:created"
|
||||
| "squad:updated"
|
||||
| "squad:deleted"
|
||||
| "label:created"
|
||||
| "label:updated"
|
||||
| "label:deleted"
|
||||
@@ -289,7 +293,16 @@ 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 {
|
||||
|
||||
@@ -11,6 +11,12 @@ export type {
|
||||
AgentRuntime,
|
||||
RuntimeDevice,
|
||||
CreateAgentRequest,
|
||||
AgentTemplate,
|
||||
AgentTemplateSummary,
|
||||
AgentTemplateSkillRef,
|
||||
CreateAgentFromTemplateRequest,
|
||||
CreateAgentFromTemplateResponse,
|
||||
CreateAgentFromTemplateFailure,
|
||||
UpdateAgentRequest,
|
||||
Skill,
|
||||
SkillSummary,
|
||||
@@ -94,3 +100,16 @@ export type {
|
||||
GetAutopilotResponse,
|
||||
ListAutopilotRunsResponse,
|
||||
} from "./autopilot";
|
||||
export type {
|
||||
Squad,
|
||||
SquadMember,
|
||||
SquadMemberType,
|
||||
SquadActivityLog,
|
||||
SquadActivityOutcome,
|
||||
CreateSquadRequest,
|
||||
UpdateSquadRequest,
|
||||
AddSquadMemberRequest,
|
||||
RemoveSquadMemberRequest,
|
||||
UpdateSquadMemberRoleRequest,
|
||||
CreateSquadActivityLogRequest,
|
||||
} from "./squad";
|
||||
|
||||
@@ -11,7 +11,7 @@ export type IssueStatus =
|
||||
|
||||
export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none";
|
||||
|
||||
export type IssueAssigneeType = "member" | "agent";
|
||||
export type IssueAssigneeType = "member" | "agent" | "squad";
|
||||
|
||||
export interface IssueReaction {
|
||||
id: string;
|
||||
|
||||
77
packages/core/types/squad.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
export type SquadMemberType = "agent" | "member";
|
||||
|
||||
export type SquadActivityOutcome = "action" | "no_action" | "failed";
|
||||
|
||||
export interface Squad {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
avatar_url: string | null;
|
||||
leader_id: string;
|
||||
creator_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
archived_at: string | null;
|
||||
archived_by: string | null;
|
||||
}
|
||||
|
||||
export interface SquadMember {
|
||||
id: string;
|
||||
squad_id: string;
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SquadActivityLog {
|
||||
id: string;
|
||||
squad_id: string;
|
||||
issue_id: string;
|
||||
trigger_comment_id: string | null;
|
||||
leader_id: string;
|
||||
outcome: SquadActivityOutcome;
|
||||
details: unknown;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateSquadRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
leader_id: string;
|
||||
}
|
||||
|
||||
export interface UpdateSquadRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
leader_id?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface AddSquadMemberRequest {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface RemoveSquadMemberRequest {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
}
|
||||
|
||||
export interface UpdateSquadMemberRoleRequest {
|
||||
member_type: SquadMemberType;
|
||||
member_id: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface CreateSquadActivityLogRequest {
|
||||
squad_id: string;
|
||||
issue_id: string;
|
||||
trigger_comment_id?: string;
|
||||
outcome: SquadActivityOutcome;
|
||||
details?: unknown;
|
||||
}
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "../hooks";
|
||||
import { memberListOptions, agentListOptions } from "./queries";
|
||||
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
|
||||
|
||||
export function useActorName() {
|
||||
const wsId = useWorkspaceId();
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
|
||||
const getMemberName = (userId: string) => {
|
||||
const m = members.find((m) => m.user_id === userId);
|
||||
@@ -19,9 +20,15 @@ export function useActorName() {
|
||||
return a?.name ?? "Unknown Agent";
|
||||
};
|
||||
|
||||
const getSquadName = (squadId: string) => {
|
||||
const s = squads.find((s) => s.id === squadId);
|
||||
return s?.name ?? "Unknown Squad";
|
||||
};
|
||||
|
||||
const getActorName = (type: string, id: string) => {
|
||||
if (type === "member") return getMemberName(id);
|
||||
if (type === "agent") return getAgentName(id);
|
||||
if (type === "squad") return getSquadName(id);
|
||||
if (type === "system") return "Multica";
|
||||
return "System";
|
||||
};
|
||||
@@ -39,8 +46,9 @@ export function useActorName() {
|
||||
const getActorAvatarUrl = (type: string, id: string): string | null => {
|
||||
if (type === "member") return members.find((m) => m.user_id === id)?.avatar_url ?? null;
|
||||
if (type === "agent") return agents.find((a) => a.id === id)?.avatar_url ?? null;
|
||||
if (type === "squad") return squads.find((s) => s.id === id)?.avatar_url ?? null;
|
||||
return null;
|
||||
};
|
||||
|
||||
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||
return { getMemberName, getAgentName, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import { api } from "../api";
|
||||
import type { Agent, Workspace } from "../types";
|
||||
import type { Agent, Squad, Workspace } from "../types";
|
||||
|
||||
export const workspaceKeys = {
|
||||
all: (wsId: string) => ["workspaces", wsId] as const,
|
||||
@@ -9,6 +9,7 @@ export const workspaceKeys = {
|
||||
invitations: (wsId: string) => ["workspaces", wsId, "invitations"] as const,
|
||||
myInvitations: () => ["invitations", "mine"] as const,
|
||||
agents: (wsId: string) => ["workspaces", wsId, "agents"] as const,
|
||||
squads: (wsId: string) => ["workspaces", wsId, "squads"] as const,
|
||||
skills: (wsId: string) => ["workspaces", wsId, "skills"] as const,
|
||||
assigneeFrequency: (wsId: string) => ["workspaces", wsId, "assignee-frequency"] as const,
|
||||
};
|
||||
@@ -43,6 +44,14 @@ export function agentListOptions(wsId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function squadListOptions(wsId: string) {
|
||||
return queryOptions<Squad[]>({
|
||||
queryKey: workspaceKeys.squads(wsId),
|
||||
queryFn: () => api.listSquads(),
|
||||
enabled: !!wsId,
|
||||
});
|
||||
}
|
||||
|
||||
export function skillListOptions(wsId: string) {
|
||||
return queryOptions({
|
||||
queryKey: workspaceKeys.skills(wsId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Bot } from "lucide-react";
|
||||
import { Bot, Users } from "lucide-react";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { MulticaIcon } from "./multica-icon";
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ActorAvatarProps {
|
||||
avatarUrl?: string | null;
|
||||
isAgent?: boolean;
|
||||
isSystem?: boolean;
|
||||
isSquad?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
@@ -21,12 +22,12 @@ function ActorAvatar({
|
||||
avatarUrl,
|
||||
isAgent,
|
||||
isSystem,
|
||||
isSquad,
|
||||
size = 20,
|
||||
className,
|
||||
}: ActorAvatarProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
|
||||
// Reset error state when URL changes (e.g. user uploads new avatar)
|
||||
useEffect(() => {
|
||||
setImgError(false);
|
||||
}, [avatarUrl]);
|
||||
@@ -35,7 +36,10 @@ function ActorAvatar({
|
||||
<div
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
|
||||
"inline-flex shrink-0 items-center justify-center font-medium overflow-hidden",
|
||||
// Squads (a group, non-human) get a square tile so they don't read as
|
||||
// a single person; everyone else stays round.
|
||||
isSquad ? "rounded-md" : "rounded-full",
|
||||
"bg-muted text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
@@ -53,6 +57,8 @@ function ActorAvatar({
|
||||
<MulticaIcon noSpin style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : isAgent ? (
|
||||
<Bot style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : isSquad ? (
|
||||
<Users style={{ width: size * 0.55, height: size * 0.55 }} />
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef } from "react";
|
||||
import { Paperclip } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
interface FileUploadButtonProps {
|
||||
@@ -18,7 +19,9 @@ function FileUploadButton({
|
||||
className,
|
||||
size = "default",
|
||||
}: FileUploadButtonProps) {
|
||||
const { t } = useTranslation("ui");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const attachLabel = t(($) => $.attach_file);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -36,8 +39,8 @@ function FileUploadButton({
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
aria-label={attachLabel}
|
||||
title={attachLabel}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
btnSize,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
@@ -67,9 +68,10 @@ function PaginationPrevious({
|
||||
text = "Previous",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
const { t } = useTranslation("ui")
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
aria-label={t(($) => $.pagination_previous)}
|
||||
size="default"
|
||||
className={cn("pl-1.5!", className)}
|
||||
{...props}
|
||||
@@ -85,9 +87,10 @@ function PaginationNext({
|
||||
text = "Next",
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
|
||||
const { t } = useTranslation("ui")
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
aria-label={t(($) => $.pagination_next)}
|
||||
size="default"
|
||||
className={cn("pr-1.5!", className)}
|
||||
{...props}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react"
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
@@ -265,6 +266,7 @@ function SidebarTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { t } = useTranslation("ui")
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -280,13 +282,15 @@ function SidebarTrigger({
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<span className="sr-only">{t(($) => $.toggle_sidebar)}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar, setWidth, setIsResizing } = useSidebar()
|
||||
const { t } = useTranslation("ui")
|
||||
const toggleLabel = t(($) => $.toggle_sidebar)
|
||||
const didDragRef = React.useRef(false)
|
||||
const dragRef = React.useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
|
||||
@@ -330,11 +334,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
aria-label={toggleLabel}
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
onMouseDown={onMouseDown}
|
||||
title="Toggle Sidebar"
|
||||
title={toggleLabel}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",
|
||||
|
||||
@@ -43,11 +43,19 @@ export function useScrollFade(
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
// ResizeObserver only fires on the container's own box. When children
|
||||
// grow inside a flex/auto-height parent (e.g. async-loaded list items,
|
||||
// collapsibles), scrollHeight changes but clientHeight does not — the
|
||||
// mask would stay "none" until the user scrolls. MutationObserver on
|
||||
// childList catches those content insertions.
|
||||
const mo = new MutationObserver(update);
|
||||
mo.observe(el, { childList: true, subtree: true });
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mo.disconnect();
|
||||
};
|
||||
}, [ref, update]);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from 'react'
|
||||
import { codeToHtml, bundledLanguages, type BundledLanguage } from 'shiki'
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Button } from "@multica/ui/components/ui/button"
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
|
||||
import { cn } from '@multica/ui/lib/utils'
|
||||
@@ -61,6 +62,7 @@ export function CodeBlock({
|
||||
className,
|
||||
mode = 'full'
|
||||
}: CodeBlockProps): React.JSX.Element {
|
||||
const { t } = useTranslation("ui")
|
||||
const [highlighted, setHighlighted] = React.useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = React.useState(true)
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
@@ -178,7 +180,7 @@ export function CodeBlock({
|
||||
{/* Language label + copy button */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-muted/50 border-b text-xs">
|
||||
<span className="text-muted-foreground font-medium uppercase tracking-wide">
|
||||
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
|
||||
{resolvedLang !== 'text' ? resolvedLang : t(($) => $.plain_text)}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
@@ -188,7 +190,7 @@ export function CodeBlock({
|
||||
size="icon-xs"
|
||||
onClick={handleCopy}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-foreground"
|
||||
aria-label="Copy code"
|
||||
aria-label={t(($) => $.copy_code)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-3.5 text-success" />
|
||||
@@ -198,7 +200,7 @@ export function CodeBlock({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>Copy code</TooltipContent>
|
||||
<TooltipContent>{t(($) => $.copy_code)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"./hooks/*": "./hooks/*.ts",
|
||||
"./lib/utils": "./lib/utils.ts",
|
||||
"./lib/data-table": "./lib/data-table.ts",
|
||||
"./i18n-types": "./types/i18next.ts",
|
||||
"./styles/tokens.css": "./styles/tokens.css",
|
||||
"./styles/base.css": "./styles/base.css"
|
||||
},
|
||||
@@ -52,8 +53,10 @@
|
||||
"vaul": "^1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
"react-dom": "catalog:",
|
||||
"react-i18next": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@multica/tsconfig": "workspace:*",
|
||||
|
||||
35
packages/ui/types/i18next.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import "i18next";
|
||||
|
||||
// Local slice of the i18next augmentation that owns the `ui` namespace.
|
||||
// The base augmentation lives in packages/views/i18n/resources-types.ts and
|
||||
// declares everything else; this file contributes only the `ui` entry via
|
||||
// declaration merging on the global `I18nResources` interface so
|
||||
// packages/ui can typecheck the selector form standalone without depending
|
||||
// on @multica/views.
|
||||
//
|
||||
// When both files are loaded together (in a consumer's typecheck program),
|
||||
// the two augmentations compose: views contributes common/auth/... and ui
|
||||
// contributes `ui`. No properties overlap, so the merge is conflict-free.
|
||||
//
|
||||
// The resource shape is mirrored from packages/views/locales/{en,zh-Hans}/ui.json.
|
||||
// Drift between the JSON and these types is not caught by the locale parity
|
||||
// test — if you add a key to ui.json, mirror it here.
|
||||
declare global {
|
||||
interface I18nResources {
|
||||
ui: {
|
||||
attach_file: string;
|
||||
toggle_sidebar: string;
|
||||
pagination_previous: string;
|
||||
pagination_next: string;
|
||||
copy_code: string;
|
||||
plain_text: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
resources: I18nResources;
|
||||
enableSelector: true;
|
||||
}
|
||||
}
|
||||
@@ -208,7 +208,7 @@ export function AgentOverviewPane({
|
||||
);
|
||||
}
|
||||
|
||||
// Centred, max-width container shared by every config tab. `h-full flex
|
||||
// Padded, full-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="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
|
||||
<div className="flex h-full flex-col p-4 md:p-6">{children}</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,35 +280,24 @@ export function AgentsPage() {
|
||||
if (view === "archived" && archivedCount === 0) setView("active");
|
||||
}, [view, archivedCount]);
|
||||
|
||||
const handleCreate = async (data: CreateAgentRequest) => {
|
||||
const handleCreate = async (data: CreateAgentRequest): Promise<Agent> => {
|
||||
const agent = await api.createAgent(data);
|
||||
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.
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
qc.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
|
||||
const exists = current.some((a) => a.id === cachedAgent.id);
|
||||
const exists = current.some((a) => a.id === agent.id);
|
||||
return exists
|
||||
? current.map((a) => (a.id === cachedAgent.id ? cachedAgent : a))
|
||||
: [...current, cachedAgent];
|
||||
? current.map((a) => (a.id === agent.id ? agent : a))
|
||||
: [...current, agent];
|
||||
});
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
navigation.push(paths.agentDetail(agent.id));
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
return agent;
|
||||
};
|
||||
|
||||
const handleDuplicate = useCallback((agent: Agent) => {
|
||||
@@ -467,6 +456,7 @@ export function AgentsPage() {
|
||||
members={members}
|
||||
currentUserId={currentUser?.id ?? null}
|
||||
template={duplicateTemplate}
|
||||
existingAgentNames={agents.map((a) => a.name)}
|
||||
onClose={() => {
|
||||
setShowCreate(false);
|
||||
setDuplicateTemplate(null);
|
||||
|
||||
143
packages/views/agents/components/avatar-picker.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"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,13 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
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", () => ({
|
||||
@@ -120,22 +131,42 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
|
||||
render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateAgentDialog
|
||||
runtimes={runtimes}
|
||||
members={members}
|
||||
currentUserId={ME}
|
||||
template={template}
|
||||
onClose={onClose}
|
||||
onCreate={onCreate}
|
||||
/>
|
||||
<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" });
|
||||
|
||||
116
packages/views/agents/components/instructions-editor.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"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,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { FileText, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import type { Agent, SkillSummary } from "@multica/core/types";
|
||||
import { api } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import {
|
||||
@@ -20,18 +19,19 @@ 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";
|
||||
|
||||
/**
|
||||
* 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
|
||||
* "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.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function SkillAddDialog({
|
||||
agent,
|
||||
@@ -45,34 +45,44 @@ export function SkillAddDialog({
|
||||
const { t } = useT("agents");
|
||||
const wsId = useWorkspaceId();
|
||||
const qc = useQueryClient();
|
||||
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
|
||||
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
|
||||
const availableSkills = workspaceSkills.filter(
|
||||
(s) => !agentSkillIds.has(s.id),
|
||||
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 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) setQuery("");
|
||||
if (!v) setSelectedIds(new Set());
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
const handleAdd = async (skillId: string) => {
|
||||
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;
|
||||
setSaving(true);
|
||||
try {
|
||||
const newIds = [...agent.skills.map((s) => s.id), skillId];
|
||||
const newIds = [
|
||||
...agent.skills.map((s) => s.id),
|
||||
...selectedIds,
|
||||
];
|
||||
await api.setAgentSkills(agent.id, { skill_ids: newIds });
|
||||
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
|
||||
handleOpenChange(false);
|
||||
@@ -83,65 +93,46 @@ export function SkillAddDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const showSearch = availableSkills.length > 0;
|
||||
const noMatch = showSearch && filteredSkills.length === 0;
|
||||
const count = selectedIds.size;
|
||||
|
||||
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>
|
||||
{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>
|
||||
|
||||
<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)
|
||||
}
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
104
packages/views/agents/components/skill-multi-select.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
141
packages/views/agents/components/skill-picker-list.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"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,17 +24,16 @@ 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 there's nothing left to attach.
|
||||
// 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.
|
||||
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 {
|
||||
@@ -60,7 +59,7 @@ export function SkillsTab({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdd(true)}
|
||||
disabled={availableCount === 0}
|
||||
disabled={workspaceSkills.length === 0}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
@@ -84,7 +83,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>
|
||||
{availableCount > 0 && (
|
||||
{workspaceSkills.length > 0 && (
|
||||
<Button
|
||||
onClick={() => setShowAdd(true)}
|
||||
size="sm"
|
||||
|
||||
173
packages/views/agents/components/template-detail.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
|
||||
import type { AgentTemplateSummary } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import { getAccentClass, getTemplateIcon } from "./template-picker";
|
||||
|
||||
interface TemplateDetailProps {
|
||||
template: AgentTemplateSummary;
|
||||
/** Fired when the user clicks "Use this template" — the dialog calls
|
||||
* the create API and navigates to the new agent. */
|
||||
onUse: (template: AgentTemplateSummary) => 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. The detail step is the only place
|
||||
* this banner can render — `quickCreateFromTemplate` fires from here
|
||||
* and never advances to a different step on failure. */
|
||||
failedURLs?: readonly string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the create-agent flow: a read-only preview of the picked
|
||||
* template — instructions, skill list with cached descriptions, and a
|
||||
* "Use this template" CTA at the bottom. Clicking Use kicks off a
|
||||
* one-shot create with default settings (no form step in between).
|
||||
*
|
||||
* Instructions come from the lazy-fetched detail endpoint (the picker
|
||||
* only carries the summary). Cached through TanStack Query keyed by
|
||||
* slug with `staleTime: Infinity`, so navigating back and forth between
|
||||
* picker and detail doesn't re-fetch. Visual rhythm matches the picker
|
||||
* card so the transition feels seamless.
|
||||
*/
|
||||
export function TemplateDetail({
|
||||
template,
|
||||
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);
|
||||
|
||||
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>
|
||||
|
||||
{/* Skill list — always visible (summary has cached descriptions) */}
|
||||
<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 — click Use kicks off the create API call;
|
||||
parent shows a creating spinner and navigates on success. */}
|
||||
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
|
||||
<Button
|
||||
onClick={() => onUse(template)}
|
||||
disabled={creating}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
packages/views/agents/components/template-picker.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"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;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
|
||||
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2, Pencil } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import {
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
useCreateChatSession,
|
||||
useDeleteChatSession,
|
||||
useMarkChatSessionRead,
|
||||
useUpdateChatSession,
|
||||
} from "@multica/core/chat/mutations";
|
||||
import { useChatStore } from "@multica/core/chat";
|
||||
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
|
||||
@@ -210,6 +211,12 @@ export function ChatWindow() {
|
||||
// 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> => {
|
||||
@@ -223,7 +230,6 @@ export function ChatWindow() {
|
||||
agent_id: activeAgent.id,
|
||||
title: titleSeed.slice(0, 50),
|
||||
});
|
||||
setActiveSession(session.id);
|
||||
return session.id;
|
||||
} finally {
|
||||
sessionPromiseRef.current = null;
|
||||
@@ -232,16 +238,25 @@ export function ChatWindow() {
|
||||
sessionPromiseRef.current = promise;
|
||||
return promise;
|
||||
},
|
||||
[activeSessionId, activeAgent, createSession, setActiveSession],
|
||||
[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],
|
||||
[ensureSession, uploadWithToast, qc, setActiveSession],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(
|
||||
@@ -287,6 +302,12 @@ 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]),
|
||||
@@ -301,6 +322,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);
|
||||
@@ -325,6 +349,7 @@ export function ChatWindow() {
|
||||
anchorCandidate,
|
||||
ensureSession,
|
||||
qc,
|
||||
setActiveSession,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -710,7 +735,12 @@ function SessionDropdown({
|
||||
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [pendingDelete, setPendingDelete] = useState<ChatSession | null>(null);
|
||||
// Inline rename: only one row can be in edit mode at a time. We track the
|
||||
// session id (not the full session) so a stale closure can't overwrite a
|
||||
// newer rename pulled in via WS.
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const deleteSession = useDeleteChatSession();
|
||||
const updateSession = useUpdateChatSession();
|
||||
const setActiveSession = useChatStore((s) => s.setActiveSession);
|
||||
const formatTimeAgo = useFormatTimeAgo();
|
||||
|
||||
@@ -749,14 +779,35 @@ function SessionDropdown({
|
||||
});
|
||||
};
|
||||
|
||||
const handleSubmitRename = (sessionId: string, raw: string) => {
|
||||
const trimmed = raw.trim();
|
||||
const current = sessions.find((s) => s.id === sessionId);
|
||||
setRenamingId(null);
|
||||
// No-op submits (unchanged or blank) skip the network round-trip — the
|
||||
// server would reject a blank title anyway, and an unchanged title would
|
||||
// just bump updated_at for no user-visible reason.
|
||||
if (!trimmed || trimmed === current?.title) return;
|
||||
updateSession.mutate({ sessionId, title: trimmed });
|
||||
};
|
||||
|
||||
const renderRow = (session: ChatSession) => {
|
||||
const isCurrent = session.id === activeSessionId;
|
||||
const agent = agentById.get(session.agent_id) ?? null;
|
||||
const isRunning = inFlightSessionIds.has(session.id);
|
||||
const isRenaming = renamingId === session.id;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={session.id}
|
||||
onClick={() => onSelectSession(session)}
|
||||
// While renaming we don't want a row click to select the session
|
||||
// OR close the menu — the user is editing text, not navigating.
|
||||
// closeOnClick=false keeps the dropdown open across input clicks
|
||||
// / button clicks inside the row; the normal "click row → switch
|
||||
// session → close menu" flow is unchanged when isRenaming=false.
|
||||
closeOnClick={!isRenaming}
|
||||
onClick={() => {
|
||||
if (isRenaming) return;
|
||||
onSelectSession(session);
|
||||
}}
|
||||
className="group flex min-w-0 items-center gap-2"
|
||||
>
|
||||
{agent ? (
|
||||
@@ -771,45 +822,84 @@ function SessionDropdown({
|
||||
<span className="size-6 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm">
|
||||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground/70">
|
||||
{formatTimeAgo(session.updated_at)}
|
||||
</div>
|
||||
{isRenaming ? (
|
||||
<SessionRenameInput
|
||||
initialValue={session.title ?? ""}
|
||||
onSubmit={(value) => handleSubmitRename(session.id, value)}
|
||||
onCancel={() => setRenamingId(null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="truncate text-sm">
|
||||
{session.title?.trim() || t(($) => $.window.untitled)}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground/70">
|
||||
{formatTimeAgo(session.updated_at)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Right-edge status pip: in-flight wins over unread because
|
||||
* "still working" is more actionable than "has reply" — and
|
||||
* the two rarely coexist in practice (the unread flag fires
|
||||
* on chat_message write, by which point the task has just
|
||||
* finished). Same pip shape as unread for visual rhythm,
|
||||
* amber + pulse to read as activity. */}
|
||||
{isRunning ? (
|
||||
* amber + pulse to read as activity.
|
||||
*
|
||||
* Hidden while renaming so the inline input has room to
|
||||
* breathe and trailing pips don't visually trail off-screen
|
||||
* next to the editor caret. */}
|
||||
{!isRenaming && isRunning ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.running)}
|
||||
title={t(($) => $.window.running)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
|
||||
/>
|
||||
) : session.has_unread ? (
|
||||
) : !isRenaming && session.has_unread ? (
|
||||
<span
|
||||
aria-label={t(($) => $.window.unread)}
|
||||
title={t(($) => $.window.unread)}
|
||||
className="size-1.5 shrink-0 rounded-full bg-brand"
|
||||
/>
|
||||
) : null}
|
||||
{isCurrent && <Check className="size-3.5 text-muted-foreground shrink-0" />}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPendingDelete(session);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_delete_aria)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
{!isRenaming && isCurrent && (
|
||||
<Check className="size-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{!isRenaming && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
// preventDefault is what tells Base UI's Menu.Item to skip
|
||||
// its close-on-click; stopPropagation prevents the row's
|
||||
// onClick from also firing (which would switch sessions).
|
||||
// onPointerDown is stopped too so the menu's typeahead /
|
||||
// focus tracking doesn't pre-empt the click.
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setRenamingId(session.id);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_rename_aria)}
|
||||
title={t(($) => $.session_history.row_rename_aria)}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPendingDelete(session);
|
||||
}}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
|
||||
aria-label={t(($) => $.session_history.row_delete_aria)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -926,6 +1016,86 @@ function SessionDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline editor for a session title. Mounts focused with the existing
|
||||
* title pre-selected so the user can either replace it outright or arrow
|
||||
* into the existing text. Enter commits, Escape cancels, a real click
|
||||
* outside the input also commits.
|
||||
*
|
||||
* We do NOT commit on the input's `blur` event: Base UI's Menu uses
|
||||
* focus-follows-cursor (hovering a sibling row drags DOM focus there),
|
||||
* so a blur handler would fire on every mouse-move and "save" the user's
|
||||
* half-typed title without them clicking anywhere. Instead a document-
|
||||
* level `pointerdown` listener — registered in capture phase so it runs
|
||||
* before Base UI's outside-click close handler — commits when the user
|
||||
* actually clicks outside the input.
|
||||
*/
|
||||
function SessionRenameInput({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: {
|
||||
initialValue: string;
|
||||
onSubmit: (value: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useT("chat");
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
// Hold the latest value + callback in refs so the mount-only effect's
|
||||
// listener always sees fresh state without re-subscribing on every
|
||||
// keystroke (which would briefly leave a window where pointerdown isn't
|
||||
// observed).
|
||||
const valueRef = useRef(value);
|
||||
valueRef.current = value;
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
onSubmitRef.current = onSubmit;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
if (input.contains(e.target as Node)) return;
|
||||
onSubmitRef.current(valueRef.current);
|
||||
};
|
||||
// Capture phase — Base UI registers its own outside-click handler in
|
||||
// bubble; running first lets us commit before the menu starts to
|
||||
// close (and unmount this component).
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
maxLength={200}
|
||||
aria-label={t(($) => $.session_history.row_rename_aria)}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
// Stop the menu from stealing arrow / typeahead / space input.
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSubmit(value);
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
className="w-full rounded-sm bg-background px-1 py-0.5 text-sm outline-none ring-1 ring-border focus-visible:ring-brand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useFormatTimeAgo(): (dateStr: string) => string {
|
||||
const { t } = useT("chat");
|
||||
return (dateStr: string) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ export function ActorAvatar({
|
||||
avatarUrl={getActorAvatarUrl(actorType, actorId)}
|
||||
isAgent={actorType === "agent"}
|
||||
isSystem={actorType === "system"}
|
||||
isSquad={actorType === "squad"}
|
||||
size={size}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
112
packages/views/common/timezone-select.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@multica/ui/components/ui/select";
|
||||
|
||||
// Common IANA zones surfaced as quick picks. Used as the fallback option set
|
||||
// when Intl.supportedValuesOf is not available, and promoted to the top of
|
||||
// the list when it is.
|
||||
const COMMON_TIMEZONES = [
|
||||
"UTC",
|
||||
"America/Los_Angeles",
|
||||
"America/Denver",
|
||||
"America/Chicago",
|
||||
"America/New_York",
|
||||
"America/Sao_Paulo",
|
||||
"Europe/London",
|
||||
"Europe/Berlin",
|
||||
"Europe/Paris",
|
||||
"Europe/Moscow",
|
||||
"Africa/Cairo",
|
||||
"Asia/Dubai",
|
||||
"Asia/Kolkata",
|
||||
"Asia/Bangkok",
|
||||
"Asia/Shanghai",
|
||||
"Asia/Singapore",
|
||||
"Asia/Tokyo",
|
||||
"Australia/Sydney",
|
||||
"Pacific/Auckland",
|
||||
];
|
||||
|
||||
export function browserTimezone(): string {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return tz || "UTC";
|
||||
} catch {
|
||||
return "UTC";
|
||||
}
|
||||
}
|
||||
|
||||
type IntlWithSupportedValues = typeof Intl & {
|
||||
supportedValuesOf?: (key: "timeZone") => string[];
|
||||
};
|
||||
|
||||
function supportedTimezones(): string[] {
|
||||
try {
|
||||
const supported = (Intl as IntlWithSupportedValues).supportedValuesOf?.(
|
||||
"timeZone",
|
||||
);
|
||||
return supported && supported.length > 0 ? supported : COMMON_TIMEZONES;
|
||||
} catch {
|
||||
return COMMON_TIMEZONES;
|
||||
}
|
||||
}
|
||||
|
||||
export function timezoneOptions(current: string): string[] {
|
||||
const browser = browserTimezone();
|
||||
return Array.from(
|
||||
new Set([current, browser, ...COMMON_TIMEZONES, ...supportedTimezones()]),
|
||||
).filter(Boolean);
|
||||
}
|
||||
|
||||
// Shared single-select timezone picker. Surfaces the browser-resolved zone
|
||||
// with a translated suffix (passed in by the caller — the picker itself stays
|
||||
// i18n-namespace agnostic), followed by a curated set of common IANA zones
|
||||
// and everything Intl.supportedValuesOf exposes.
|
||||
export function TimezoneSelect({
|
||||
value,
|
||||
onValueChange,
|
||||
browserSuffix,
|
||||
disabled,
|
||||
triggerClassName,
|
||||
}: {
|
||||
value: string;
|
||||
onValueChange: (next: string) => void;
|
||||
browserSuffix: string;
|
||||
disabled?: boolean;
|
||||
triggerClassName?: string;
|
||||
}) {
|
||||
const browser = browserTimezone();
|
||||
const options = timezoneOptions(value);
|
||||
const render = (tz: string) =>
|
||||
tz === browser ? `${tz}${browserSuffix}` : tz;
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onValueChange={(next) => {
|
||||
if (next) onValueChange(next);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className={triggerClassName ?? "w-full rounded-md font-mono text-xs"}
|
||||
>
|
||||
<SelectValue>{render(value)}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" className="max-h-72">
|
||||
{options.map((tz) => (
|
||||
<SelectItem key={tz} value={tz} className="font-mono text-xs">
|
||||
{render(tz)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { LayoutDashboard, BarChart3 } from "lucide-react";
|
||||
import { BarChart3, FolderKanban } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Skeleton } from "@multica/ui/components/ui/skeleton";
|
||||
import {
|
||||
@@ -22,13 +22,19 @@ import {
|
||||
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { KpiCard } from "../../runtimes/components/shared";
|
||||
import { DailyCostChart } from "../../runtimes/components/charts";
|
||||
import { DailyCostChart, DailyTokensChart } from "../../runtimes/components/charts";
|
||||
import { ProjectIcon } from "../../projects/components/project-icon";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import {
|
||||
TimezoneSelect,
|
||||
browserTimezone,
|
||||
} from "../../common/timezone-select";
|
||||
import { formatTokens } from "../../runtimes/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import {
|
||||
aggregateAgentTokens,
|
||||
aggregateDailyCost,
|
||||
aggregateDailyTokens,
|
||||
computeDailyTotals,
|
||||
formatDuration,
|
||||
mergeAgentDashboardRows,
|
||||
@@ -104,10 +110,15 @@ function Segmented<T extends string | number>({
|
||||
* and the runtime page using one pricing table.
|
||||
*/
|
||||
export function DashboardPage() {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
const { t: tRuntimes } = useT("runtimes");
|
||||
const wsId = useWorkspaceId();
|
||||
const [days, setDays] = useState<TimeRange>(30);
|
||||
const [projectValue, setProjectValue] = useState<string>(ALL_PROJECTS);
|
||||
// Default to the browser's resolved zone so day-boundary buckets match the
|
||||
// user's local clock on first render. Pure client-state — the rollup queries
|
||||
// are zone-agnostic today; this is the UI affordance the user can pin.
|
||||
const [timezone, setTimezone] = useState<string>(() => browserTimezone());
|
||||
|
||||
// The user can save model prices from the runtimes page; re-render when
|
||||
// they do so the dashboard reflects the new rates.
|
||||
@@ -150,6 +161,7 @@ export function DashboardPage() {
|
||||
// Cost / token math — re-derived when usage, days, or pricings change.
|
||||
const totals = useMemo(() => computeDailyTotals(dailyUsage), [dailyUsage]);
|
||||
const dailyCost = useMemo(() => aggregateDailyCost(dailyUsage), [dailyUsage]);
|
||||
const dailyTokens = useMemo(() => aggregateDailyTokens(dailyUsage), [dailyUsage]);
|
||||
const agentTokenRows = useMemo(
|
||||
() => aggregateAgentTokens(byAgentUsage),
|
||||
[byAgentUsage],
|
||||
@@ -175,12 +187,18 @@ export function DashboardPage() {
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader className="justify-between px-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<LayoutDashboard className="h-4 w-4 text-muted-foreground" />
|
||||
<h1 className="text-sm font-medium">{t(($) => $.title)}</h1>
|
||||
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter, range
|
||||
switch, timezone select) overflows the single h-12 row on narrow
|
||||
and medium widths once the timezone picker is added — letting the
|
||||
right cluster wrap underneath keeps every control reachable
|
||||
without an off-screen bleed. Wider viewports still render the
|
||||
original single row. */}
|
||||
<PageHeader className="h-auto min-h-12 flex-wrap justify-between gap-y-1.5 px-5 py-1.5 sm:py-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<h1 className="truncate text-sm font-medium">{t(($) => $.title)}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ProjectFilter
|
||||
projects={projects}
|
||||
value={projectValue}
|
||||
@@ -191,6 +209,12 @@ export function DashboardPage() {
|
||||
onChange={setDays}
|
||||
options={TIME_RANGES.map((r) => ({ label: r.label, value: r.days }))}
|
||||
/>
|
||||
<TimezoneSelect
|
||||
value={timezone}
|
||||
onValueChange={setTimezone}
|
||||
browserSuffix={tRuntimes(($) => $.detail.timezone_browser_suffix)}
|
||||
triggerClassName="rounded-md font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
</PageHeader>
|
||||
|
||||
@@ -241,11 +265,14 @@ export function DashboardPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Daily cost chart — reuses the runtime DailyCostChart. */}
|
||||
<DailyCostBlock dailyCost={dailyCost} />
|
||||
{/* Daily trend chart — toggle picks Cost vs Tokens axis,
|
||||
mirroring the runtime-detail Usage section so both
|
||||
surfaces share one chart language. */}
|
||||
<DailyTrendBlock dailyCost={dailyCost} dailyTokens={dailyTokens} />
|
||||
|
||||
{/* By-agent combined list. */}
|
||||
<AgentList
|
||||
{/* Per-agent leaderboard — user picks the ranking metric;
|
||||
the progress bar and column emphasis follow the metric. */}
|
||||
<Leaderboard
|
||||
rows={agentRows}
|
||||
agents={agents}
|
||||
lessThanMinuteLabel={t(($) => $.duration.less_than_minute)}
|
||||
@@ -263,16 +290,15 @@ function ProjectFilter({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
projects: { id: string; title: string }[];
|
||||
projects: { id: string; title: string; icon: string | null }[];
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
const allLabel = t(($) => $.filter.all_projects);
|
||||
const selected = projects.find((p) => p.id === value);
|
||||
const selectedTitle =
|
||||
value === ALL_PROJECTS
|
||||
? t(($) => $.filter.all_projects)
|
||||
: projects.find((p) => p.id === value)?.title ??
|
||||
t(($) => $.filter.all_projects);
|
||||
value === ALL_PROJECTS ? allLabel : selected?.title ?? allLabel;
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -280,13 +306,35 @@ function ProjectFilter({
|
||||
onValueChange={(v) => onChange(v ?? ALL_PROJECTS)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-[180px]">
|
||||
<SelectValue>{() => selectedTitle}</SelectValue>
|
||||
<SelectValue>
|
||||
{() => (
|
||||
<>
|
||||
{selected ? (
|
||||
<ProjectIcon project={selected} size="sm" />
|
||||
) : (
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate">{selectedTitle}</span>
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_PROJECTS}>{t(($) => $.filter.all_projects)}</SelectItem>
|
||||
{/* alignItemWithTrigger=false: the default aligns the *selected* item
|
||||
to the trigger, which pushes "All projects" above the trigger and
|
||||
clips it off-screen when the usage header sits at the top of the
|
||||
viewport. Anchor the dropdown to the bottom of the trigger so
|
||||
every entry stays reachable.
|
||||
max-h-72: cap the dropdown so a long project list scrolls instead
|
||||
of stretching to the bottom of the window. */}
|
||||
<SelectContent align="start" alignItemWithTrigger={false} className="max-h-72">
|
||||
<SelectItem value={ALL_PROJECTS}>
|
||||
<FolderKanban className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{allLabel}</span>
|
||||
</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.title}
|
||||
<ProjectIcon project={p} size="sm" />
|
||||
<span className="truncate">{p.title}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -294,35 +342,77 @@ function ProjectFilter({
|
||||
);
|
||||
}
|
||||
|
||||
function DailyCostBlock({
|
||||
type DailyMetric = "cost" | "tokens";
|
||||
|
||||
function DailyTrendBlock({
|
||||
dailyCost,
|
||||
dailyTokens,
|
||||
}: {
|
||||
dailyCost: ReturnType<typeof aggregateDailyCost>;
|
||||
dailyTokens: ReturnType<typeof aggregateDailyTokens>;
|
||||
}) {
|
||||
const { t } = useT("dashboard");
|
||||
const total = dailyCost.reduce((sum, d) => sum + d.total, 0);
|
||||
const { t } = useT("usage");
|
||||
const [metric, setMetric] = useState<DailyMetric>("tokens");
|
||||
|
||||
// Empty-state is per-metric so a workspace that recorded tokens but
|
||||
// has no priced models (unmapped) still gets a real Tokens chart while
|
||||
// its Cost view falls through to the empty-state — same convention as
|
||||
// the runtimes-side DailyTab in usage-section.tsx.
|
||||
const totalCost = dailyCost.reduce((sum, d) => sum + d.total, 0);
|
||||
const totalTokens = dailyTokens.reduce(
|
||||
(sum, d) => sum + d.input + d.output + d.cacheRead + d.cacheWrite,
|
||||
0,
|
||||
);
|
||||
const isEmpty = metric === "cost" ? totalCost === 0 : totalTokens === 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-semibold">{t(($) => $.daily.title)}</h4>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{metric === "cost"
|
||||
? t(($) => $.daily.title_cost)
|
||||
: t(($) => $.daily.title_tokens)}
|
||||
</h4>
|
||||
<Segmented
|
||||
value={metric}
|
||||
onChange={setMetric}
|
||||
options={[
|
||||
{ label: t(($) => $.daily.metric_tokens), value: "tokens" as const },
|
||||
{ label: t(($) => $.daily.metric_cost), value: "cost" as const },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-[240px]">
|
||||
{total === 0 ? (
|
||||
{isEmpty ? (
|
||||
<div className="flex aspect-[3/1] flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-muted/20 p-6 text-center">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/50" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(($) => $.daily.no_data)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
) : metric === "cost" ? (
|
||||
<DailyCostChart data={dailyCost} />
|
||||
) : (
|
||||
<DailyTokensChart data={dailyTokens} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentList({
|
||||
// Which metric ranks the leaderboard. Drives row order, progress bar
|
||||
// width, and which column header is emphasised — keeping the three in
|
||||
// lockstep so the user always sees what the ranking actually measures.
|
||||
type LeaderboardSort = "tokens" | "cost" | "time" | "tasks";
|
||||
|
||||
const SORT_METRIC: Record<LeaderboardSort, (r: AgentDashboardRow) => number> = {
|
||||
tokens: (r) => r.tokens,
|
||||
cost: (r) => r.cost,
|
||||
time: (r) => r.seconds,
|
||||
tasks: (r) => r.taskCount,
|
||||
};
|
||||
|
||||
function Leaderboard({
|
||||
rows,
|
||||
agents,
|
||||
lessThanMinuteLabel,
|
||||
@@ -331,35 +421,67 @@ function AgentList({
|
||||
agents: { id: string; name: string }[];
|
||||
lessThanMinuteLabel: string;
|
||||
}) {
|
||||
const { t } = useT("dashboard");
|
||||
const maxCost = rows.reduce((m, r) => Math.max(m, r.cost), 0);
|
||||
const { t } = useT("usage");
|
||||
const [sortBy, setSortBy] = useState<LeaderboardSort>("tokens");
|
||||
|
||||
const sortOptions = useMemo(
|
||||
() => [
|
||||
{ value: "tokens" as const, label: t(($) => $.leaderboard.header_tokens) },
|
||||
{ value: "cost" as const, label: t(($) => $.leaderboard.header_cost) },
|
||||
{ value: "time" as const, label: t(($) => $.leaderboard.header_time) },
|
||||
{ value: "tasks" as const, label: t(($) => $.leaderboard.header_tasks) },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
// Re-rank when the metric changes; keep the merged input untouched so
|
||||
// upstream `mergeAgentDashboardRows`'s tiebreaker (run time desc) still
|
||||
// applies inside an equal-bucket.
|
||||
const sortedRows = useMemo(() => {
|
||||
const metric = SORT_METRIC[sortBy];
|
||||
return [...rows].sort((a, b) => metric(b) - metric(a));
|
||||
}, [rows, sortBy]);
|
||||
|
||||
const maxValue = useMemo(() => {
|
||||
const metric = SORT_METRIC[sortBy];
|
||||
return sortedRows.reduce((m, r) => Math.max(m, metric(r)), 0);
|
||||
}, [sortedRows, sortBy]);
|
||||
|
||||
// Active column gets foreground text; others stay muted. Helps the user
|
||||
// see "this is what the bar is measuring" at a glance.
|
||||
const colClass = (key: LeaderboardSort) =>
|
||||
`text-right ${sortBy === key ? "text-foreground" : "text-muted-foreground"}`;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 pt-4 pb-3">
|
||||
<h4 className="text-sm font-semibold">{t(($) => $.by_agent.title)}</h4>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.by_agent.caption, { count: rows.length })}
|
||||
</span>
|
||||
<h4 className="text-sm font-semibold">{t(($) => $.leaderboard.title)}</h4>
|
||||
<div className="flex items-center gap-3">
|
||||
<Segmented value={sortBy} onChange={setSortBy} options={sortOptions} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(($) => $.leaderboard.caption, { count: rows.length })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
{sortedRows.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-xs text-muted-foreground">
|
||||
{t(($) => $.by_agent.no_data)}
|
||||
{t(($) => $.leaderboard.no_data)}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)_5rem_5rem_5rem_4rem] items-center gap-3 border-b px-4 py-2 text-xs font-medium text-muted-foreground">
|
||||
<span>{t(($) => $.by_agent.header_agent)}</span>
|
||||
<span>{t(($) => $.leaderboard.header_agent)}</span>
|
||||
<span />
|
||||
<span className="text-right">{t(($) => $.by_agent.header_tokens)}</span>
|
||||
<span className="text-right">{t(($) => $.by_agent.header_cost)}</span>
|
||||
<span className="text-right">{t(($) => $.by_agent.header_time)}</span>
|
||||
<span className="text-right">{t(($) => $.by_agent.header_tasks)}</span>
|
||||
<span className={colClass("tokens")}>{t(($) => $.leaderboard.header_tokens)}</span>
|
||||
<span className={colClass("cost")}>{t(($) => $.leaderboard.header_cost)}</span>
|
||||
<span className={colClass("time")}>{t(($) => $.leaderboard.header_time)}</span>
|
||||
<span className={colClass("tasks")}>{t(($) => $.leaderboard.header_tasks)}</span>
|
||||
</div>
|
||||
<div className="divide-y">
|
||||
{rows.map((row) => {
|
||||
{sortedRows.map((row) => {
|
||||
const agent = agents.find((a) => a.id === row.agentId);
|
||||
const pct = maxCost > 0 ? (row.cost / maxCost) * 100 : 0;
|
||||
const value = SORT_METRIC[sortBy](row);
|
||||
const pct = maxValue > 0 ? (value / maxValue) * 100 : 0;
|
||||
return (
|
||||
<div
|
||||
key={row.agentId}
|
||||
@@ -378,20 +500,28 @@ function AgentList({
|
||||
</div>
|
||||
<div className="relative h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-chart-1"
|
||||
className="h-full rounded-full bg-chart-1 transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tokens" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{formatTokens(row.tokens)}
|
||||
</div>
|
||||
<div className="text-right text-sm font-medium tabular-nums">
|
||||
<div
|
||||
className={`text-right tabular-nums ${sortBy === "cost" ? "text-sm font-medium" : "text-xs text-muted-foreground"}`}
|
||||
>
|
||||
${row.cost.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "time" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{formatDuration(row.seconds, lessThanMinuteLabel)}
|
||||
</div>
|
||||
<div className="text-right text-xs tabular-nums text-muted-foreground">
|
||||
<div
|
||||
className={`text-right text-xs tabular-nums ${sortBy === "tasks" ? "font-medium text-foreground" : "text-muted-foreground"}`}
|
||||
>
|
||||
{row.taskCount}
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,7 +545,7 @@ function DashboardSkeleton() {
|
||||
}
|
||||
|
||||
function DashboardEmpty() {
|
||||
const { t } = useT("dashboard");
|
||||
const { t } = useT("usage");
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border border-dashed py-12 text-center">
|
||||
<BarChart3 className="h-6 w-6 text-muted-foreground/40" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
DashboardUsageByAgent,
|
||||
DashboardAgentRunTime,
|
||||
} from "@multica/core/types";
|
||||
import { estimateCost, estimateCostBreakdown } from "../runtimes/utils";
|
||||
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard data aggregations
|
||||
@@ -66,6 +66,42 @@ export function aggregateDailyCost(usage: DashboardUsageDaily[]): DailyCostStack
|
||||
});
|
||||
}
|
||||
|
||||
// Per-(date, model) rows → 1 row per date with raw token counts split
|
||||
// across the four chart segments. Independent of pricing — unmapped
|
||||
// models still contribute here, even if they're excluded from cost.
|
||||
// Mirrors `aggregateByDate(...).dailyTokens` from the runtimes utils so
|
||||
// the Tokens chart on the Usage page consumes the same shape as the one
|
||||
// on the runtime-detail page.
|
||||
export function aggregateDailyTokens(usage: DashboardUsageDaily[]): DailyTokenData[] {
|
||||
const map = new Map<
|
||||
string,
|
||||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
||||
>();
|
||||
for (const u of usage) {
|
||||
const entry = map.get(u.date) ?? {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
entry.input += u.input_tokens;
|
||||
entry.output += u.output_tokens;
|
||||
entry.cacheRead += u.cache_read_tokens;
|
||||
entry.cacheWrite += u.cache_write_tokens;
|
||||
map.set(u.date, entry);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([date, t]) => ({
|
||||
date,
|
||||
label: formatDateLabel(date),
|
||||
input: t.input,
|
||||
output: t.output,
|
||||
cacheRead: t.cacheRead,
|
||||
cacheWrite: t.cacheWrite,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface DashboardTokenTotals {
|
||||
input: number;
|
||||
output: number;
|
||||
|
||||
@@ -9,6 +9,10 @@ interface ResolvedDownload {
|
||||
// Returns the attachment id for a URL referenced in the markdown, or
|
||||
// `undefined` if it's an external link we don't manage.
|
||||
resolveAttachmentId: (url: string) => string | undefined;
|
||||
// Returns the full Attachment record (content_type, filename, download_url,
|
||||
// ...) for a URL referenced in the markdown. NodeView preview triggers use
|
||||
// this to decide whether the type is previewable and to feed the modal.
|
||||
resolveAttachment: (url: string) => Attachment | undefined;
|
||||
// Called by NodeView click handlers. Re-signs through `getAttachment` when
|
||||
// the URL maps to a known attachment; falls back to `openExternal` for
|
||||
// external URLs so Electron still routes through the IPC bridge instead of
|
||||
@@ -36,12 +40,16 @@ export function AttachmentDownloadProvider({ attachments, children }: ProviderPr
|
||||
if (!url || !attachments?.length) return undefined;
|
||||
return attachments.find((a) => a.url === url)?.id;
|
||||
},
|
||||
resolveAttachment: (url) => {
|
||||
if (!url || !attachments?.length) return undefined;
|
||||
return attachments.find((a) => a.url === url);
|
||||
},
|
||||
openByUrl: (url) => {
|
||||
const id = url && attachments?.length
|
||||
? attachments.find((a) => a.url === url)?.id
|
||||
const att = url && attachments?.length
|
||||
? attachments.find((a) => a.url === url)
|
||||
: undefined;
|
||||
if (id) {
|
||||
download(id);
|
||||
if (att) {
|
||||
download(att.id);
|
||||
return;
|
||||
}
|
||||
if (url) openExternal(url);
|
||||
@@ -70,6 +78,7 @@ export function useAttachmentDownloadResolver(): ResolvedDownload {
|
||||
if (ctx) return ctx;
|
||||
return {
|
||||
resolveAttachmentId: () => undefined,
|
||||
resolveAttachment: () => undefined,
|
||||
openByUrl: (url) => {
|
||||
if (url) openExternal(url);
|
||||
},
|
||||
|
||||
249
packages/views/editor/attachment-preview-modal.test.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
|
||||
// vi.hoisted: factories run before module evaluation, letting us name mocks
|
||||
// referenced from inside vi.mock factories below. The Error classes must be
|
||||
// hoisted too because vi.mock is itself hoisted above the top-level `class`
|
||||
// declarations.
|
||||
const {
|
||||
getAttachmentTextContentMock,
|
||||
downloadMock,
|
||||
FakePreviewTooLargeError,
|
||||
FakePreviewUnsupportedError,
|
||||
} = vi.hoisted(() => {
|
||||
class FakePreviewTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("too large");
|
||||
this.name = "PreviewTooLargeError";
|
||||
}
|
||||
}
|
||||
class FakePreviewUnsupportedError extends Error {
|
||||
constructor() {
|
||||
super("unsupported");
|
||||
this.name = "PreviewUnsupportedError";
|
||||
}
|
||||
}
|
||||
return {
|
||||
getAttachmentTextContentMock: vi.fn(),
|
||||
downloadMock: vi.fn(),
|
||||
FakePreviewTooLargeError,
|
||||
FakePreviewUnsupportedError,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachmentTextContent: getAttachmentTextContentMock },
|
||||
PreviewTooLargeError: FakePreviewTooLargeError,
|
||||
PreviewUnsupportedError: FakePreviewUnsupportedError,
|
||||
}));
|
||||
|
||||
vi.mock("./use-download-attachment", () => ({
|
||||
useDownloadAttachment: () => downloadMock,
|
||||
}));
|
||||
|
||||
// ReadonlyContent has a heavy import surface (lowlight + KaTeX + Mermaid).
|
||||
// Stub it so the markdown dispatch test only verifies wiring.
|
||||
vi.mock("./readonly-content", () => ({
|
||||
ReadonlyContent: ({ content }: { content: string }) => (
|
||||
<div data-testid="readonly-content">{content}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../i18n", () => ({
|
||||
useT: () => ({
|
||||
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
||||
sel({
|
||||
image: { download: "Download" },
|
||||
attachment: {
|
||||
preview: "Preview",
|
||||
preview_loading: "Loading preview…",
|
||||
preview_failed: "Couldn't load preview",
|
||||
preview_too_large: "File is too large to preview. Please download.",
|
||||
preview_unsupported: "This file type can't be previewed.",
|
||||
close: "Close",
|
||||
download_failed: "",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AttachmentPreviewModal } from "./attachment-preview-modal";
|
||||
|
||||
// Fresh QueryClient per render — no retries (preview errors are typed,
|
||||
// not transient) and no caching across tests so each scenario is hermetic.
|
||||
function render(ui: ReactElement) {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
return rtlRender(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
||||
return {
|
||||
id: "att-1",
|
||||
workspace_id: "ws-1",
|
||||
issue_id: null,
|
||||
comment_id: null,
|
||||
chat_session_id: null,
|
||||
chat_message_id: null,
|
||||
uploader_type: "member",
|
||||
uploader_id: "u-1",
|
||||
filename: "test.bin",
|
||||
url: "https://cdn.example.test/att-1.bin",
|
||||
download_url: "https://cdn.example.test/att-1.bin?Signature=s",
|
||||
content_type: "application/octet-stream",
|
||||
size_bytes: 0,
|
||||
created_at: "2026-05-13T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("AttachmentPreviewModal — dispatch", () => {
|
||||
it("renders a PDF iframe pointing at the signed download URL", () => {
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
const iframe = document.querySelector("iframe");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe?.getAttribute("src")).toBe(att.download_url);
|
||||
});
|
||||
|
||||
it("renders a <video> for video/* content types", () => {
|
||||
const att = makeAttachment({ filename: "clip.mp4", content_type: "video/mp4" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
const video = document.querySelector("video");
|
||||
expect(video).toBeTruthy();
|
||||
expect(video?.getAttribute("src")).toBe(att.download_url);
|
||||
});
|
||||
|
||||
it("renders an <audio> for audio/* content types", () => {
|
||||
const att = makeAttachment({ filename: "note.mp3", content_type: "audio/mpeg" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fetches text and hands it to ReadonlyContent for Markdown", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "# heading\n\nbody\n",
|
||||
originalContentType: "text/markdown",
|
||||
});
|
||||
const att = makeAttachment({ filename: "README.md", content_type: "text/markdown" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
|
||||
expect(getAttachmentTextContentMock).toHaveBeenCalledWith("att-1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("readonly-content")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("readonly-content").textContent).toContain("# heading");
|
||||
});
|
||||
|
||||
it("renders an iframe with srcdoc + sandbox='' for HTML", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "<p>hi</p>",
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const att = makeAttachment({ filename: "page.html", content_type: "text/html" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame?.getAttribute("sandbox")).toBe("");
|
||||
expect(frame?.getAttribute("srcdoc")).toBe("<p>hi</p>");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a code block with lowlight for source files", async () => {
|
||||
getAttachmentTextContentMock.mockResolvedValueOnce({
|
||||
text: "package main\n",
|
||||
originalContentType: "text/plain",
|
||||
});
|
||||
const att = makeAttachment({ filename: "main.go", content_type: "text/plain" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const code = document.querySelector("code.hljs");
|
||||
expect(code).toBeTruthy();
|
||||
expect(code?.className).toContain("language-go");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows unsupported fallback when no PreviewKind matches", () => {
|
||||
const att = makeAttachment({ filename: "blob.zip", content_type: "application/zip" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentPreviewModal — error states", () => {
|
||||
it("shows the too-large fallback on PreviewTooLargeError", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewTooLargeError());
|
||||
const att = makeAttachment({ filename: "huge.txt", content_type: "text/plain" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("File is too large to preview. Please download.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the unsupported fallback on PreviewUnsupportedError (server/client drift)", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewUnsupportedError());
|
||||
const att = makeAttachment({ filename: "weird.txt", content_type: "text/plain" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the generic failed fallback on a transport error", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("network down"));
|
||||
const att = makeAttachment({ filename: "x.md", content_type: "text/markdown" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Couldn't load preview")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentPreviewModal — controls", () => {
|
||||
it("ESC closes the modal", () => {
|
||||
const onClose = vi.fn();
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={onClose} />);
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Download button invokes useDownloadAttachment with the attachment id", () => {
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
// Two Download CTAs may exist (header + unsupported fallback). The header
|
||||
// button is always present, look it up by aria-label/title.
|
||||
const buttons = screen.getAllByTitle("Download");
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
fireEvent.click(buttons[0]!);
|
||||
expect(downloadMock).toHaveBeenCalledWith("att-1");
|
||||
});
|
||||
|
||||
it("clicking the backdrop closes the modal", () => {
|
||||
const onClose = vi.fn();
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={onClose} />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(dialog);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
426
packages/views/editor/attachment-preview-modal.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AttachmentPreviewModal — full-screen inline preview for an attachment.
|
||||
*
|
||||
* Sibling to the existing `ImageLightbox` (extensions/image-view.tsx) which
|
||||
* keeps owning images. This modal handles 6 other PreviewKinds:
|
||||
*
|
||||
* - pdf : <iframe src={download_url}> — relies on Chromium's PDFium
|
||||
* plugin. On desktop, requires webPreferences.plugins=true
|
||||
* (see apps/desktop/src/main/index.ts).
|
||||
* - video : <video controls src={download_url}>
|
||||
* - audio : <audio controls src={download_url}>
|
||||
*
|
||||
* - markdown : fetch text via api.getAttachmentTextContent, render via
|
||||
* the existing ReadonlyContent (full mention/mermaid/katex
|
||||
* pipeline included).
|
||||
* - html : fetch text, hand to <iframe srcdoc={text} sandbox="">.
|
||||
* Empty sandbox attribute = max restriction (no scripts,
|
||||
* no forms, no top-nav, no popups, no same-origin) — the
|
||||
* recommended pattern for previewing untrusted HTML.
|
||||
* - text : fetch text, highlight with lowlight if the extension
|
||||
* maps to a known hljs language; otherwise plain <pre>.
|
||||
*
|
||||
* Media types load directly from the CloudFront signed `download_url`.
|
||||
* Text types go through `/api/attachments/{id}/content` to sidestep
|
||||
* CloudFront CORS (not configured) + Content-Disposition: attachment.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, FileText, Loader2, X } from "lucide-react";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import {
|
||||
api,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "@multica/core/api";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { useT } from "../i18n";
|
||||
import { ReadonlyContent } from "./readonly-content";
|
||||
import {
|
||||
extensionToLanguage,
|
||||
getPreviewKind,
|
||||
type PreviewKind,
|
||||
} from "./utils/preview";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AttachmentPreviewModalProps {
|
||||
attachment: Attachment;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook — local state + ready-to-mount modal JSX
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Why no React context / provider: packages/views/ cannot mount a Context.Provider
|
||||
// inside CoreProvider (in packages/core/), and threading a new provider through
|
||||
// every app layout is more friction than it's worth for a feature with at most
|
||||
// one open modal at a time. Instead each entry point gets its own local state
|
||||
// and renders the returned `modal` node. Multiple entry points coexisting just
|
||||
// means each carries its own (collapsed) state — they never collide because
|
||||
// only one preview is open per user click.
|
||||
|
||||
export interface AttachmentPreviewHandle {
|
||||
/** Try to open a preview for the attachment. Returns false when the file
|
||||
* type isn't previewable so the caller can fall back to a download flow. */
|
||||
tryOpen: (attachment: Attachment) => boolean;
|
||||
/** Force-open a preview, skipping the isPreviewable() guard. Use for cases
|
||||
* where the caller has already filtered. */
|
||||
open: (attachment: Attachment) => void;
|
||||
/** Modal node to render somewhere in the caller's tree. Resolves to `null`
|
||||
* when no preview is active. Safe to render inside any container — the
|
||||
* modal portals to document.body. */
|
||||
modal: ReactNode;
|
||||
}
|
||||
|
||||
export function useAttachmentPreview(): AttachmentPreviewHandle {
|
||||
const [current, setCurrent] = useState<Attachment | null>(null);
|
||||
|
||||
const open = useCallback((att: Attachment) => setCurrent(att), []);
|
||||
const tryOpen = useCallback((att: Attachment) => {
|
||||
if (!getPreviewKind(att.content_type, att.filename)) return false;
|
||||
setCurrent(att);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const modal = current ? (
|
||||
<AttachmentPreviewModal
|
||||
attachment={current}
|
||||
open
|
||||
onClose={() => setCurrent(null)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return useMemo(() => ({ open, tryOpen, modal }), [open, tryOpen, modal]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal — frame + dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AttachmentPreviewModal({
|
||||
attachment,
|
||||
open,
|
||||
onClose,
|
||||
}: AttachmentPreviewModalProps) {
|
||||
const { t } = useT("editor");
|
||||
const download = useDownloadAttachment();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const kind = getPreviewKind(attachment.content_type, attachment.filename);
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={attachment.filename}
|
||||
>
|
||||
{/* Larger than the create-issue dialog (max-w-4xl, manualDialogContentClass)
|
||||
because PDF / video previews want more room. Capped to viewport
|
||||
minus the surrounding p-4 (1rem each side) so it never overflows
|
||||
the screen on small displays / split panes. */}
|
||||
<div
|
||||
className="flex h-[min(90vh,calc(100vh-2rem))] w-full max-w-6xl flex-col overflow-hidden rounded-lg bg-background shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-4 py-2">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="truncate text-sm font-medium">{attachment.filename}</p>
|
||||
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
|
||||
{attachment.content_type || "—"}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onClick={() => download(attachment.id)}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.close)}
|
||||
aria-label={t(($) => $.attachment.close)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-background">
|
||||
<PreviewContent
|
||||
kind={kind}
|
||||
attachment={attachment}
|
||||
onDownload={() => download(attachment.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Dispatch on PreviewKind. New cases go here; remember that the modal frame
|
||||
// (header, close, Download CTA, ESC handling) is shared — sub-renderers only
|
||||
// own the content area.
|
||||
function PreviewContent({
|
||||
kind,
|
||||
attachment,
|
||||
onDownload,
|
||||
}: {
|
||||
kind: PreviewKind | null;
|
||||
attachment: Attachment;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
|
||||
if (kind === null) {
|
||||
return (
|
||||
<UnsupportedFallback
|
||||
message={t(($) => $.attachment.preview_unsupported)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "pdf":
|
||||
return (
|
||||
<iframe
|
||||
src={attachment.download_url}
|
||||
className="h-full w-full bg-background"
|
||||
title={attachment.filename}
|
||||
/>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black">
|
||||
<video
|
||||
src={attachment.download_url}
|
||||
controls
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case "audio":
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-8">
|
||||
<audio src={attachment.download_url} controls className="w-full max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
case "markdown":
|
||||
return (
|
||||
<TextBackedPreview
|
||||
attachmentId={attachment.id}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<ReadonlyContent
|
||||
content={text}
|
||||
className="px-6 py-4"
|
||||
attachments={[attachment]}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
case "html":
|
||||
return (
|
||||
<TextBackedPreview
|
||||
attachmentId={attachment.id}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
sandbox=""
|
||||
className="h-full w-full bg-background"
|
||||
title={attachment.filename}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
case "text":
|
||||
return (
|
||||
<TextBackedPreview
|
||||
attachmentId={attachment.id}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<CodeBlock language={extensionToLanguage(attachment.filename)} body={text} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text-backed preview — fetches body once, then hands to the render prop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// React Query owns server state per the project convention; re-opening the
|
||||
// same attachment hits the cache instead of re-fetching. Query is keyed on
|
||||
// the attachment id alone — the 30 min TTL on the server-side signed URL
|
||||
// is much longer than any plausible preview session.
|
||||
function TextBackedPreview({
|
||||
attachmentId,
|
||||
onDownload,
|
||||
render,
|
||||
}: {
|
||||
attachmentId: string;
|
||||
onDownload: () => void;
|
||||
render: (text: string) => ReactNode;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const query = useQuery({
|
||||
queryKey: ["attachment-content", attachmentId] as const,
|
||||
queryFn: () => api.getAttachmentTextContent(attachmentId),
|
||||
// Errors are surfaced as typed fallbacks, not retried — 413 / 415 won't
|
||||
// become 200 on a retry, and a transient failure is easier to recover
|
||||
// from by closing and reopening the modal than waiting on background
|
||||
// retries that have no UI affordance.
|
||||
retry: false,
|
||||
// 413 / 415 bodies are tiny; keep the result around for the session so
|
||||
// the user can flip away and back without refetching.
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 30 * 60_000,
|
||||
});
|
||||
|
||||
if (query.isLoading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t(($) => $.attachment.preview_loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query.error) {
|
||||
if (query.error instanceof PreviewTooLargeError) {
|
||||
return (
|
||||
<UnsupportedFallback
|
||||
message={t(($) => $.attachment.preview_too_large)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (query.error instanceof PreviewUnsupportedError) {
|
||||
return (
|
||||
<UnsupportedFallback
|
||||
message={t(($) => $.attachment.preview_unsupported)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<UnsupportedFallback
|
||||
message={t(($) => $.attachment.preview_failed)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!query.data) return null;
|
||||
return <>{render(query.data.text)}</>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Code block — lowlight, matches readonly-content's hljs CSS
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
function CodeBlock({ language, body }: { language: string | undefined; body: string }) {
|
||||
const html = useMemo(() => {
|
||||
const code = body.replace(/\n$/, "");
|
||||
try {
|
||||
const tree = language
|
||||
? lowlight.highlight(language, code)
|
||||
: lowlight.highlightAuto(code);
|
||||
return toHtml(tree) as string;
|
||||
} catch {
|
||||
// Fallthrough to a plain escaped <pre> when lowlight rejects the
|
||||
// language tag. Avoids crashing the preview on an unknown extension.
|
||||
return escapeHtml(code);
|
||||
}
|
||||
}, [body, language]);
|
||||
|
||||
return (
|
||||
<pre className="rich-text-editor m-0 overflow-auto px-6 py-4 text-sm">
|
||||
<code
|
||||
className={cn("hljs", language && `language-${language}`)}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback — used for 413 / 415 / unknown kinds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UnsupportedFallback({
|
||||
message,
|
||||
onDownload,
|
||||
}: {
|
||||
message: string;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<FileText className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-sm transition-colors hover:bg-muted"
|
||||
onClick={onDownload}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{t(($) => $.image.download)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export the predicate from the dispatch util so entry-point components
|
||||
// only need a single import to gate the Eye button.
|
||||
export { isPreviewable } from "./utils/preview";
|
||||
@@ -89,9 +89,11 @@ interface ContentEditorProps {
|
||||
*/
|
||||
currentIssueId?: string;
|
||||
/**
|
||||
* When true, the @mention extension is not registered. Use for editors
|
||||
* where mentioning members/agents has no business meaning (e.g. agent
|
||||
* system prompts, where the content is fed to an LLM as plain text).
|
||||
* When true, the `@` suggestion picker is disabled but the mention node
|
||||
* type remains in the schema, so existing mentions pasted in from other
|
||||
* Multica editors still render as the normal pill. Use for editors where
|
||||
* *creating* a new mention has no business meaning (e.g. agent system
|
||||
* prompts) but *preserving* an existing one still matters.
|
||||
*/
|
||||
disableMentions?: boolean;
|
||||
/**
|
||||
|
||||
@@ -17,9 +17,11 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { FileText, Loader2, Download } from "lucide-react";
|
||||
import { Eye, FileText, Loader2, Download } from "lucide-react";
|
||||
import { useT } from "../../i18n";
|
||||
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
import { useAttachmentPreview } from "../attachment-preview-modal";
|
||||
import { isPreviewable } from "../utils/preview";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,12 +37,23 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
const href = (node.attrs.href as string) || "";
|
||||
const filename = (node.attrs.filename as string) || "";
|
||||
const uploading = node.attrs.uploading as boolean;
|
||||
const { openByUrl } = useAttachmentDownloadResolver();
|
||||
const { openByUrl, resolveAttachment } = useAttachmentDownloadResolver();
|
||||
const preview = useAttachmentPreview();
|
||||
|
||||
const openFile = () => {
|
||||
openByUrl(href);
|
||||
};
|
||||
|
||||
// The NodeView only holds href + filename. The full Attachment (with
|
||||
// content_type / download_url) lives in the surrounding
|
||||
// AttachmentDownloadProvider — resolve it lazily at click time so the
|
||||
// eye button is only offered when we both know the record and the
|
||||
// dispatcher recognizes the type.
|
||||
const attachment = href ? resolveAttachment(href) : undefined;
|
||||
const previewable = attachment
|
||||
? isPreviewable(attachment.content_type, attachment.filename)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
|
||||
<div
|
||||
@@ -56,10 +69,27 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{uploading ? t(($) => $.file_card.uploading, { filename }) : filename}</p>
|
||||
</div>
|
||||
{!uploading && href && previewable && attachment && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
preview.tryOpen(attachment);
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{!uploading && href && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -70,6 +100,7 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{preview.modal}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,11 +86,12 @@ export interface EditorExtensionsOptions {
|
||||
/** When true, bare Enter also submits (chat-style). Default false. */
|
||||
submitOnEnter?: boolean;
|
||||
/**
|
||||
* When true, the @mention extension is not registered at all. Use for
|
||||
* editors where mentioning members/agents has no business meaning (e.g.
|
||||
* agent system prompts) — typing `@` becomes inert and any pre-existing
|
||||
* `[@user](mention://...)` markdown renders as plain text instead of being
|
||||
* parsed into a mention node.
|
||||
* When true, the `@` suggestion picker is not attached. The mention node
|
||||
* type is still registered in the schema so any mention pasted in from
|
||||
* another Multica editor renders as the normal mention pill instead of
|
||||
* being silently dropped by ProseMirror's schema check. Use for editors
|
||||
* where *creating* a new mention has no business meaning (e.g. agent
|
||||
* system prompts) but *preserving* an existing one still matters.
|
||||
*/
|
||||
disableMentions?: boolean;
|
||||
}
|
||||
@@ -128,16 +129,14 @@ export function createEditorExtensions(
|
||||
// so users can copy rich content out as the original Markdown.
|
||||
createMarkdownCopyExtension(),
|
||||
FileCardExtension,
|
||||
...(options.disableMentions
|
||||
? []
|
||||
: [
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(options.queryClient
|
||||
? { suggestion: createMentionSuggestion(options.queryClient) }
|
||||
: {}),
|
||||
}),
|
||||
]),
|
||||
BaseMentionExtension.configure({
|
||||
HTMLAttributes: { class: "mention" },
|
||||
...(options.disableMentions
|
||||
? { suggestion: { allow: () => false } }
|
||||
: options.queryClient
|
||||
? { suggestion: createMentionSuggestion(options.queryClient) }
|
||||
: {}),
|
||||
}),
|
||||
Typography,
|
||||
Placeholder.configure({ placeholder: placeholderText }),
|
||||
createMarkdownPasteExtension(),
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
ListIssuesCache,
|
||||
MemberWithUser,
|
||||
Agent,
|
||||
Squad,
|
||||
} from "@multica/core/types";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { StatusIcon } from "../../issues/components/status-icon";
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
export interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
type: "member" | "agent" | "issue" | "all";
|
||||
type: "member" | "agent" | "squad" | "issue" | "all";
|
||||
/** Secondary text shown beside the label (e.g. issue title) */
|
||||
description?: string;
|
||||
/** Issue status for StatusIcon rendering */
|
||||
@@ -344,6 +345,11 @@ function MentionRow({
|
||||
// eslint-disable-next-line i18next/no-literal-string
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Agent</Badge>
|
||||
)}
|
||||
{item.type === "squad" && (
|
||||
// "Squad" is a glossary-protected product term — kept un-translated.
|
||||
// eslint-disable-next-line i18next/no-literal-string
|
||||
<Badge variant="outline" className="ml-auto text-[10px] h-4 px-1.5">Squad</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -380,6 +386,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
|
||||
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
|
||||
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
|
||||
const squads: Squad[] = qc.getQueryData(workspaceKeys.squads(wsId)) ?? [];
|
||||
const cachedResponse = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
|
||||
const cachedIssues: Issue[] = cachedResponse ? flattenIssueBuckets(cachedResponse) : [];
|
||||
|
||||
@@ -416,12 +423,16 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
|
||||
)
|
||||
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
|
||||
|
||||
const squadItems: MentionItem[] = squads
|
||||
.filter((s) => !s.archived_at && s.name.toLowerCase().includes(q))
|
||||
.map((s) => ({ id: s.id, label: s.name, type: "squad" as const }));
|
||||
|
||||
// Members and agents share a single ranked list — recently mentioned
|
||||
// targets come first regardless of type, with an alphabetical fallback
|
||||
// for everyone the user hasn't mentioned yet on this device.
|
||||
const recency = getRecencyMap(wsId);
|
||||
const userItems = sortUserItemsByRecency(
|
||||
[...memberItems, ...agentItems],
|
||||
[...memberItems, ...agentItems, ...squadItems],
|
||||
recency,
|
||||
);
|
||||
|
||||
|
||||
@@ -14,3 +14,9 @@ export { useFileDropZone } from "./use-file-drop-zone";
|
||||
export { FileDropOverlay } from "./file-drop-overlay";
|
||||
export { useDownloadAttachment } from "./use-download-attachment";
|
||||
export { AttachmentDownloadProvider } from "./attachment-download-context";
|
||||
export {
|
||||
AttachmentPreviewModal,
|
||||
useAttachmentPreview,
|
||||
isPreviewable,
|
||||
} from "./attachment-preview-modal";
|
||||
export type { AttachmentPreviewHandle } from "./attachment-preview-modal";
|
||||
|
||||
@@ -30,7 +30,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
|
||||
import { toHtml } from "hast-util-to-html";
|
||||
import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
|
||||
import { Maximize2, Download, Eye, Link as LinkIcon, FileText } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
|
||||
@@ -45,6 +45,8 @@ import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { useAttachmentPreview } from "./attachment-preview-modal";
|
||||
import { isPreviewable } from "./utils/preview";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -239,18 +241,24 @@ function ReadonlyImage({
|
||||
function ReadonlyFileCard({
|
||||
href,
|
||||
filename,
|
||||
resolveAttachmentId,
|
||||
resolveAttachment,
|
||||
onDownload,
|
||||
onPreview,
|
||||
}: {
|
||||
href: string;
|
||||
filename: string;
|
||||
resolveAttachmentId: (url: string) => string | undefined;
|
||||
resolveAttachment: (url: string) => Attachment | undefined;
|
||||
onDownload: (attachmentId: string) => void;
|
||||
onPreview: (att: Attachment) => boolean;
|
||||
}) {
|
||||
const handleClick = () => {
|
||||
const id = resolveAttachmentId(href);
|
||||
if (id) {
|
||||
onDownload(id);
|
||||
const { t } = useT("editor");
|
||||
const attachment = href ? resolveAttachment(href) : undefined;
|
||||
const previewable = attachment
|
||||
? isPreviewable(attachment.content_type, attachment.filename)
|
||||
: false;
|
||||
const handleDownloadClick = () => {
|
||||
if (attachment) {
|
||||
onDownload(attachment.id);
|
||||
return;
|
||||
}
|
||||
openExternal(href);
|
||||
@@ -261,11 +269,24 @@ function ReadonlyFileCard({
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{filename}</p>
|
||||
</div>
|
||||
{href && previewable && attachment && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onClick={() => onPreview(attachment)}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{href && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
onClick={handleClick}
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onClick={handleDownloadClick}
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
</button>
|
||||
@@ -276,7 +297,9 @@ function ReadonlyFileCard({
|
||||
|
||||
function buildComponents(
|
||||
resolveAttachmentId: (url: string) => string | undefined,
|
||||
resolveAttachment: (url: string) => Attachment | undefined,
|
||||
onDownload: (attachmentId: string) => void,
|
||||
onPreview: (att: Attachment) => boolean,
|
||||
): Partial<Components> {
|
||||
return {
|
||||
// Links — route mention:// to mention components, others show preview card
|
||||
@@ -304,8 +327,9 @@ function buildComponents(
|
||||
<ReadonlyFileCard
|
||||
href={href}
|
||||
filename={filename}
|
||||
resolveAttachmentId={resolveAttachmentId}
|
||||
resolveAttachment={resolveAttachment}
|
||||
onDownload={onDownload}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -410,9 +434,19 @@ export const ReadonlyContent = memo(function ReadonlyContent({
|
||||
[attachments],
|
||||
);
|
||||
|
||||
const resolveAttachment = useCallback(
|
||||
(url: string): Attachment | undefined => {
|
||||
if (!url || !attachments?.length) return undefined;
|
||||
return attachments.find((a) => a.url === url);
|
||||
},
|
||||
[attachments],
|
||||
);
|
||||
|
||||
const preview = useAttachmentPreview();
|
||||
|
||||
const components = useMemo(
|
||||
() => buildComponents(resolveAttachmentId, download),
|
||||
[resolveAttachmentId, download],
|
||||
() => buildComponents(resolveAttachmentId, resolveAttachment, download, preview.tryOpen),
|
||||
[resolveAttachmentId, resolveAttachment, download, preview.tryOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -426,6 +460,7 @@ export const ReadonlyContent = memo(function ReadonlyContent({
|
||||
{processed}
|
||||
</ReactMarkdown>
|
||||
<LinkHoverCard {...hover} />
|
||||
{preview.modal}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,10 +9,6 @@ vi.mock("@multica/core/api", () => ({
|
||||
api: { getAttachment: getAttachmentMock },
|
||||
}));
|
||||
|
||||
vi.mock("../platform", () => ({
|
||||
openExternal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
@@ -22,7 +18,6 @@ vi.mock("../i18n", () => ({
|
||||
}));
|
||||
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { openExternal } from "../platform";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const SIGNED_URL =
|
||||
@@ -82,9 +77,10 @@ describe("useDownloadAttachment (web)", () => {
|
||||
});
|
||||
|
||||
describe("useDownloadAttachment (desktop)", () => {
|
||||
it("skips the placeholder tab and hands the signed URL to openExternal", async () => {
|
||||
(window as unknown as { desktopAPI: { openExternal: () => void } }).desktopAPI = {
|
||||
openExternal: vi.fn(),
|
||||
it("skips the placeholder tab and hands the signed URL to the desktop download bridge", async () => {
|
||||
const downloadURL = vi.fn();
|
||||
(window as unknown as { desktopAPI: { downloadURL: typeof downloadURL } }).desktopAPI = {
|
||||
downloadURL,
|
||||
};
|
||||
getAttachmentMock.mockResolvedValueOnce({
|
||||
id: "att-1",
|
||||
@@ -103,6 +99,23 @@ describe("useDownloadAttachment (desktop)", () => {
|
||||
// No placeholder — Electron's setWindowOpenHandler would reject
|
||||
// about:blank, so we go straight to the platform's IPC bridge.
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
expect(openExternal).toHaveBeenCalledWith(SIGNED_URL);
|
||||
expect(downloadURL).toHaveBeenCalledWith(SIGNED_URL);
|
||||
});
|
||||
|
||||
it("shows a toast when the API rejects on desktop", async () => {
|
||||
const downloadURL = vi.fn();
|
||||
(window as unknown as { desktopAPI: { downloadURL: typeof downloadURL } }).desktopAPI = {
|
||||
downloadURL,
|
||||
};
|
||||
getAttachmentMock.mockRejectedValueOnce(new Error("network failure"));
|
||||
|
||||
const { result } = renderHook(() => useDownloadAttachment());
|
||||
|
||||
await act(async () => {
|
||||
await result.current("att-1");
|
||||
});
|
||||
|
||||
expect(downloadURL).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,20 +3,19 @@
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@multica/core/api";
|
||||
import { openExternal } from "../platform";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
interface DesktopBridge {
|
||||
openExternal?: (u: string) => Promise<void> | void;
|
||||
downloadURL?: (u: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// Detected at call time, not module load — the bridge is injected by the
|
||||
// Electron preload after `window` exists, and reading it lazily lets the
|
||||
// same hook work in both renderers without a build-time fork.
|
||||
function hasDesktopBridge(): boolean {
|
||||
function hasDesktopDownloadBridge(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const bridge = (window as unknown as { desktopAPI?: DesktopBridge }).desktopAPI;
|
||||
return Boolean(bridge?.openExternal);
|
||||
return Boolean(bridge?.downloadURL);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,11 +34,11 @@ function hasDesktopBridge(): boolean {
|
||||
* spec (`dom-open` step 17) makes that return `null`, which would leave
|
||||
* us nothing to navigate. We disown the opener manually after the fetch.
|
||||
*
|
||||
* - **Desktop**: `window.open` is intercepted by Electron's
|
||||
* `setWindowOpenHandler` and routed through `openExternalSafely`, which
|
||||
* rejects `about:blank`. So on desktop we fetch first, then hand the URL
|
||||
* to `openExternal()` which IPCs into `shell.openExternal` and opens the
|
||||
* system browser.
|
||||
* - **Desktop**: uses `desktopAPI.downloadURL()` which invokes Electron's
|
||||
* native `webContents.downloadURL()`, showing a save dialog and saving
|
||||
* the file directly. This avoids the system browser entirely and fixes
|
||||
* the Linux/Ubuntu issue where HTML files are rendered inline instead
|
||||
* of being downloaded.
|
||||
*/
|
||||
export function useDownloadAttachment(): (attachmentId: string) => Promise<void> {
|
||||
const { t } = useT("editor");
|
||||
@@ -47,14 +46,17 @@ export function useDownloadAttachment(): (attachmentId: string) => Promise<void>
|
||||
async (attachmentId: string) => {
|
||||
const failed = () => toast.error(t(($) => $.attachment.download_failed));
|
||||
|
||||
if (hasDesktopBridge()) {
|
||||
if (hasDesktopDownloadBridge()) {
|
||||
try {
|
||||
const fresh = await api.getAttachment(attachmentId);
|
||||
if (!fresh.download_url) {
|
||||
failed();
|
||||
return;
|
||||
}
|
||||
openExternal(fresh.download_url);
|
||||
const bridge = (
|
||||
window as unknown as { desktopAPI?: DesktopBridge }
|
||||
).desktopAPI;
|
||||
await bridge!.downloadURL!(fresh.download_url);
|
||||
} catch {
|
||||
failed();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { isGlobalPath } from "@multica/core/paths";
|
||||
* as intentional. Only "/issues/..." style paths get auto-prefixed.
|
||||
*/
|
||||
const WORKSPACE_ROUTE_SEGMENTS = new Set([
|
||||
"dashboard",
|
||||
"usage",
|
||||
"issues",
|
||||
"projects",
|
||||
"autopilots",
|
||||
|
||||
90
packages/views/editor/utils/preview.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
extensionToLanguage,
|
||||
getPreviewKind,
|
||||
isPreviewable,
|
||||
type PreviewKind,
|
||||
} from "./preview";
|
||||
|
||||
describe("getPreviewKind", () => {
|
||||
const cases: Array<[string, string, PreviewKind | null]> = [
|
||||
// Media types — typed correctly server-side
|
||||
["application/pdf", "manual.pdf", "pdf"],
|
||||
["video/mp4", "clip.mp4", "video"],
|
||||
["audio/mpeg", "note.mp3", "audio"],
|
||||
|
||||
// Markdown — both well-typed and sniffer-fallback paths
|
||||
["text/markdown", "README", "markdown"],
|
||||
["text/plain", "README.md", "markdown"],
|
||||
["application/octet-stream", "notes.markdown", "markdown"],
|
||||
|
||||
// HTML — both content-type and extension paths
|
||||
["text/html", "page", "html"],
|
||||
["application/octet-stream", "page.html", "html"],
|
||||
|
||||
// Code / config — fallback to text after sniffer guesses "text/plain"
|
||||
["text/plain", "main.go", "text"],
|
||||
["application/octet-stream", "main.go", "text"],
|
||||
["text/plain", "config.yml", "text"],
|
||||
["application/javascript", "bundle.js", "text"],
|
||||
["application/json", "data.json", "text"],
|
||||
|
||||
// Plain text
|
||||
["text/plain", "log.txt", "text"],
|
||||
|
||||
// Build files without extension
|
||||
["application/octet-stream", "Dockerfile", "text"],
|
||||
["application/octet-stream", "Makefile", "text"],
|
||||
|
||||
// Out of scope
|
||||
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "report.docx", null],
|
||||
["application/octet-stream", "blob.bin", null],
|
||||
["application/zip", "archive.zip", null],
|
||||
];
|
||||
|
||||
for (const [ct, filename, want] of cases) {
|
||||
it(`(${ct}, ${filename}) → ${want}`, () => {
|
||||
expect(getPreviewKind(ct, filename)).toBe(want);
|
||||
});
|
||||
}
|
||||
|
||||
// PDF should dispatch from extension alone when content_type is wrong.
|
||||
it("falls through to extension when content_type is mislabeled", () => {
|
||||
expect(getPreviewKind("application/octet-stream", "manual.pdf")).toBe("pdf");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPreviewable", () => {
|
||||
it("is true for any non-null PreviewKind", () => {
|
||||
expect(isPreviewable("application/pdf", "x.pdf")).toBe(true);
|
||||
expect(isPreviewable("text/plain", "x.txt")).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for unsupported types", () => {
|
||||
expect(isPreviewable("application/zip", "x.zip")).toBe(false);
|
||||
expect(isPreviewable("application/octet-stream", "x.bin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extensionToLanguage", () => {
|
||||
it("maps common code extensions to hljs language tokens", () => {
|
||||
expect(extensionToLanguage("index.ts")).toBe("typescript");
|
||||
expect(extensionToLanguage("main.go")).toBe("go");
|
||||
expect(extensionToLanguage("script.py")).toBe("python");
|
||||
expect(extensionToLanguage("style.scss")).toBe("scss");
|
||||
});
|
||||
|
||||
it("falls back to plaintext for non-code text files", () => {
|
||||
expect(extensionToLanguage("log.txt")).toBe("plaintext");
|
||||
});
|
||||
|
||||
it("recognizes extension-less build files", () => {
|
||||
expect(extensionToLanguage("Dockerfile")).toBe("dockerfile");
|
||||
expect(extensionToLanguage("Makefile")).toBe("makefile");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown extensions", () => {
|
||||
expect(extensionToLanguage("blob.bin")).toBeUndefined();
|
||||
expect(extensionToLanguage("noextension")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
185
packages/views/editor/utils/preview.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Preview dispatch table for the AttachmentPreviewModal.
|
||||
*
|
||||
* Add new previewable kinds here. To add a type:
|
||||
* 1. Add a new branch returning a new PreviewKind literal.
|
||||
* 2. Add the corresponding renderer in attachment-preview-modal.tsx's dispatch.
|
||||
* 3. If the renderer needs the file body as text, also extend isTextPreviewable
|
||||
* in server/internal/handler/file.go so the proxy endpoint accepts it.
|
||||
* 4. If the renderer fetches a binary, decide whether to use download_url
|
||||
* (CloudFront, no auth on the client side) or a new authenticated proxy.
|
||||
*/
|
||||
|
||||
export type PreviewKind =
|
||||
| "pdf"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "markdown"
|
||||
| "html"
|
||||
| "text";
|
||||
|
||||
const EXT_LANGUAGE_MAP: Record<string, string> = {
|
||||
// Markdown
|
||||
md: "markdown",
|
||||
markdown: "markdown",
|
||||
// Plain text — left undefined intentionally; lowlight renders the body
|
||||
// unhighlighted when no language is supplied.
|
||||
txt: "plaintext",
|
||||
log: "plaintext",
|
||||
// Web
|
||||
html: "xml",
|
||||
htm: "xml",
|
||||
xml: "xml",
|
||||
svg: "xml",
|
||||
css: "css",
|
||||
scss: "scss",
|
||||
sass: "scss",
|
||||
less: "less",
|
||||
// Config / data
|
||||
json: "json",
|
||||
yml: "yaml",
|
||||
yaml: "yaml",
|
||||
toml: "ini",
|
||||
ini: "ini",
|
||||
conf: "ini",
|
||||
// Shell
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
zsh: "bash",
|
||||
// Languages
|
||||
py: "python",
|
||||
rb: "ruby",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
swift: "swift",
|
||||
c: "c",
|
||||
cc: "cpp",
|
||||
cpp: "cpp",
|
||||
h: "c",
|
||||
hpp: "cpp",
|
||||
cs: "csharp",
|
||||
php: "php",
|
||||
lua: "lua",
|
||||
vim: "vim",
|
||||
sql: "sql",
|
||||
csv: "plaintext",
|
||||
tsv: "plaintext",
|
||||
};
|
||||
|
||||
// Build files that are commonly extension-less.
|
||||
const BASENAME_LANGUAGE_MAP: Record<string, string> = {
|
||||
dockerfile: "dockerfile",
|
||||
makefile: "makefile",
|
||||
};
|
||||
|
||||
// IMPORTANT — KEEP IN SYNC with isTextPreviewable() in
|
||||
// server/internal/handler/file.go. If an extension lands here but the proxy
|
||||
// rejects it, the user sees a 415 fallback in the modal. If the proxy accepts
|
||||
// but this set doesn't, the Eye button doesn't appear at all.
|
||||
//
|
||||
// TODO(follow-up): extract to a JSON single-source-of-truth + generator
|
||||
// (mirror reserved-slugs pattern in server/internal/handler/reserved_slugs.json).
|
||||
const TEXT_EXTENSIONS = new Set<string>([
|
||||
"md", "markdown", "txt", "log", "csv", "tsv",
|
||||
"html", "htm", "json", "xml",
|
||||
"yml", "yaml", "toml", "ini", "conf",
|
||||
"sh", "bash", "zsh",
|
||||
"py", "rb", "go", "rs",
|
||||
"ts", "tsx", "js", "jsx", "mjs", "cjs",
|
||||
"css", "scss", "sass", "less",
|
||||
"sql",
|
||||
"java", "kt", "swift",
|
||||
"c", "cc", "cpp", "h", "hpp",
|
||||
"cs", "php", "lua", "vim",
|
||||
]);
|
||||
|
||||
const TEXT_CONTENT_TYPES = new Set<string>([
|
||||
"application/json",
|
||||
"application/javascript",
|
||||
"application/xml",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"application/toml",
|
||||
"application/x-sh",
|
||||
"application/x-httpd-php",
|
||||
]);
|
||||
|
||||
const TEXT_BASENAMES = new Set<string>(["dockerfile", "makefile"]);
|
||||
|
||||
function extOf(filename: string): string {
|
||||
const base = filename.toLowerCase().split(/[\\/]/).pop() ?? "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
if (dot <= 0) return "";
|
||||
return base.slice(dot + 1);
|
||||
}
|
||||
|
||||
function baseOf(filename: string): string {
|
||||
return (filename.toLowerCase().split(/[\\/]/).pop() ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeContentType(contentType: string): string {
|
||||
const ct = (contentType ?? "").toLowerCase().trim();
|
||||
const semi = ct.indexOf(";");
|
||||
return (semi >= 0 ? ct.slice(0, semi) : ct).trim();
|
||||
}
|
||||
|
||||
function isTextLike(contentType: string, filename: string): boolean {
|
||||
const ct = normalizeContentType(contentType);
|
||||
if (ct.startsWith("text/")) return true;
|
||||
if (TEXT_CONTENT_TYPES.has(ct)) return true;
|
||||
const ext = extOf(filename);
|
||||
if (ext && TEXT_EXTENSIONS.has(ext)) return true;
|
||||
return TEXT_BASENAMES.has(baseOf(filename));
|
||||
}
|
||||
|
||||
// Dispatch on PreviewKind. New cases go in attachment-preview-modal.tsx;
|
||||
// remember that the modal frame (header, close, Download CTA, ESC handling)
|
||||
// is shared — sub-renderers only own the content area.
|
||||
export function getPreviewKind(
|
||||
contentType: string,
|
||||
filename: string,
|
||||
): PreviewKind | null {
|
||||
const ct = normalizeContentType(contentType);
|
||||
|
||||
if (ct === "application/pdf" || extOf(filename) === "pdf") return "pdf";
|
||||
if (ct.startsWith("video/")) return "video";
|
||||
if (ct.startsWith("audio/")) return "audio";
|
||||
|
||||
// Markdown — covers both the well-typed case and the common
|
||||
// server-side sniffer fallback (text/plain for .md).
|
||||
const ext = extOf(filename);
|
||||
if (ct === "text/markdown" || ext === "md" || ext === "markdown") {
|
||||
return "markdown";
|
||||
}
|
||||
if (ct === "text/html" || ext === "html" || ext === "htm") {
|
||||
return "html";
|
||||
}
|
||||
|
||||
if (isTextLike(contentType, filename)) return "text";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPreviewable(contentType: string, filename: string): boolean {
|
||||
return getPreviewKind(contentType, filename) !== null;
|
||||
}
|
||||
|
||||
// Pick the hljs language token for a file. Returns undefined when the file
|
||||
// doesn't have a recognizable extension — callers can fall back to a plain
|
||||
// `<pre>` render. Kept tiny and ext-driven on purpose: lowlight's `common`
|
||||
// pack covers the ~50 languages people upload in practice, anything else
|
||||
// rendered as plain text is preferable to importing the full pack.
|
||||
export function extensionToLanguage(filename: string): string | undefined {
|
||||
const ext = extOf(filename);
|
||||
if (ext && EXT_LANGUAGE_MAP[ext]) return EXT_LANGUAGE_MAP[ext];
|
||||
const base = baseOf(filename);
|
||||
if (BASENAME_LANGUAGE_MAP[base]) return BASENAME_LANGUAGE_MAP[base];
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import "i18next";
|
||||
// Pulls in the `ui` namespace augmentation owned by packages/ui — see
|
||||
// packages/ui/types/i18next.ts. Side-effect import is required for views'
|
||||
// typecheck program to see ui's contribution to `I18nResources`.
|
||||
import "@multica/ui/i18n-types";
|
||||
import type common from "../locales/en/common.json";
|
||||
import type auth from "../locales/en/auth.json";
|
||||
import type settings from "../locales/en/settings.json";
|
||||
@@ -20,7 +24,8 @@ import type chat from "../locales/en/chat.json";
|
||||
import type modals from "../locales/en/modals.json";
|
||||
import type runtimes from "../locales/en/runtimes.json";
|
||||
import type layout from "../locales/en/layout.json";
|
||||
import type dashboard from "../locales/en/dashboard.json";
|
||||
import type usage from "../locales/en/usage.json";
|
||||
import type squads from "../locales/en/squads.json";
|
||||
|
||||
// Module augmentation enables i18next v26 selector API across the monorepo:
|
||||
// `t($ => $.signin.title)` resolves to the value in en/auth.json.
|
||||
@@ -30,33 +35,44 @@ import type dashboard from "../locales/en/dashboard.json";
|
||||
// Adding a namespace: drop a JSON file under en/ and zh-Hans/, then add
|
||||
// the matching `import type` + entry below. Type inference on `t($ => $)`
|
||||
// follows automatically.
|
||||
//
|
||||
// The resource shape lives on a global `I18nResources` interface (not a
|
||||
// type literal inside CustomTypeOptions) so other packages can contribute
|
||||
// namespaces via declaration merging. See packages/ui/types/i18next.d.ts —
|
||||
// it adds the `ui` namespace there, which lets packages/ui typecheck the
|
||||
// selector form standalone without depending on @multica/views.
|
||||
declare global {
|
||||
interface I18nResources {
|
||||
common: typeof common;
|
||||
auth: typeof auth;
|
||||
settings: typeof settings;
|
||||
issues: typeof issues;
|
||||
agents: typeof agents;
|
||||
editor: typeof editor;
|
||||
onboarding: typeof onboarding;
|
||||
invite: typeof invite;
|
||||
labels: typeof labels;
|
||||
members: typeof members;
|
||||
"my-issues": typeof myIssues;
|
||||
search: typeof search;
|
||||
inbox: typeof inbox;
|
||||
workspace: typeof workspace;
|
||||
projects: typeof projects;
|
||||
autopilots: typeof autopilots;
|
||||
skills: typeof skills;
|
||||
chat: typeof chat;
|
||||
modals: typeof modals;
|
||||
runtimes: typeof runtimes;
|
||||
layout: typeof layout;
|
||||
usage: typeof usage;
|
||||
squads: typeof squads;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "i18next" {
|
||||
interface CustomTypeOptions {
|
||||
defaultNS: "common";
|
||||
resources: {
|
||||
common: typeof common;
|
||||
auth: typeof auth;
|
||||
settings: typeof settings;
|
||||
issues: typeof issues;
|
||||
agents: typeof agents;
|
||||
editor: typeof editor;
|
||||
onboarding: typeof onboarding;
|
||||
invite: typeof invite;
|
||||
labels: typeof labels;
|
||||
members: typeof members;
|
||||
"my-issues": typeof myIssues;
|
||||
search: typeof search;
|
||||
inbox: typeof inbox;
|
||||
workspace: typeof workspace;
|
||||
projects: typeof projects;
|
||||
autopilots: typeof autopilots;
|
||||
skills: typeof skills;
|
||||
chat: typeof chat;
|
||||
modals: typeof modals;
|
||||
runtimes: typeof runtimes;
|
||||
layout: typeof layout;
|
||||
dashboard: typeof dashboard;
|
||||
};
|
||||
resources: I18nResources;
|
||||
enableSelector: true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,18 @@ vi.mock("@multica/core/workspace/queries", () => ({
|
||||
queryKey: ["workspaces", "ws-1", "agents"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
squadListOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "squads"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
assigneeFrequencyOptions: () => ({
|
||||
queryKey: ["workspaces", "ws-1", "assignee-frequency"],
|
||||
queryFn: () => Promise.resolve([]),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/workspace/hooks", () => ({
|
||||
useActorName: () => ({ getActorName: (_t: string, _id: string) => "" }),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/pins", () => ({
|
||||
@@ -158,6 +170,29 @@ describe("IssueActionsDropdown", () => {
|
||||
expect(screen.queryByText("Add sub-issue...")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking the Assignee item opens the shared AssigneePicker popover", async () => {
|
||||
render(
|
||||
wrap(
|
||||
<IssueActionsDropdown
|
||||
issue={mockIssue}
|
||||
trigger={<button data-testid="trigger">Menu</button>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("trigger"));
|
||||
fireEvent.click(await screen.findByText("Assignee"));
|
||||
|
||||
// The shared picker exposes a search input and renders the workspace
|
||||
// member under a "Members" group — both come from `AssigneePicker`, not
|
||||
// the legacy submenu (which had neither).
|
||||
expect(
|
||||
await screen.findByPlaceholderText("Assign to..."),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("Members")).toBeInTheDocument();
|
||||
expect(await screen.findByText("Test User")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking Delete issue opens the delete-confirm modal", async () => {
|
||||
render(
|
||||
wrap(
|
||||
|
||||