Compare commits

..

1 Commits

Author SHA1 Message Date
Jiang Bohan
6f20d05bb7 refactor(usage): rename Dashboard → Usage + dynamic per-agent leaderboard
The page added in #2462 lived at `/{slug}/dashboard` and was titled
"Dashboard", which collides with the conventional meaning ("personal
landing surface") and doesn't tell new users what the page is for. Its
actual contents — token spend, cost, run time, task counts — map cleanly
onto the OpenAI / Anthropic / Vercel "Usage" surface, so rename to that.

Renames (user-visible)
- Route: `/{slug}/dashboard` → `/{slug}/usage` (web App Router + desktop
  memory router)
- Sidebar entry: label "Dashboard" / "看板" → "Usage" / "用量", icon
  LayoutDashboard → BarChart3 (page header icon swapped in sync)
- Page title in en/zh-Hans
- Reserved-slugs: add `usage` to workspace route segments group;
  `dashboard` stays reserved in the marketing group (back-compat against
  workspace slug collisions + keeps the name free for a future Home page)
- i18n namespace `dashboard` → `usage` across resources-types.ts,
  locales/index.ts, and the moved JSON files
- WORKSPACE_ROUTE_SEGMENTS in editor link-handler
- paths.workspace(slug).dashboard() → .usage(), with matching test
  expectation updates

Per-agent leaderboard polish (`packages/views/dashboard/components/
dashboard-page.tsx`)
- Card title "Cost & run time by agent" → "Leaderboard" with a 4-way
  Segmented control: Tokens / Cost / Time / Tasks
- Active metric drives row order, progress-bar width, and the
  emphasised column header / cell — keeping ranking, visual quantity,
  and column emphasis in lockstep so users always see what's being
  measured
- Default sort = Tokens (most universally meaningful; Cost still one
  click away)
- Project filter dropdown:
  - Show ProjectIcon next to the selected project + each list item;
    FolderKanban as the "All projects" fallback (matches ProjectPicker
    language)
  - alignItemWithTrigger={false} so "All projects" doesn't get pushed
    above the trigger and clipped when the header sits at the top of
    the viewport (was the root cause of "can't re-select All projects"
    once a project was selected)
  - max-h-72 to cap the dropdown when workspaces accrue many projects;
    matches the runtime-detail Select precedent
- Folder name `packages/views/dashboard/*` and `DashboardPage`
  component name intentionally left in place — user-visible rename
  only, no broad code refactor.

Old `/dashboard` routes are not redirected because the page only landed
in #2462 (a few days ago); no real users, external links, or
desktop-tab persistence have settled on it yet.
2026-05-13 14:02:48 +08:00
194 changed files with 794 additions and 12249 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 782 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -46,31 +46,20 @@ linux:
# Yaru). Forcing `multica` makes every Linux identity slot agree and
# matches `StartupWMClass=Multica` (productName-derived).
executableName: multica
# 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.
# Pin StartupWMClass explicitly to the WM_CLASS that Electron emits on
# X11. Electron derives WM_CLASS from `app.getName()`, which in packaged
# builds resolves to `productName` (`Multica`). Without an explicit
# `StartupWMClass`, electron-builder writes `productName` as the default
# — making this declaration redundant with current settings — but
# pinning the value here turns a silent future drift (e.g. if anyone
# renames productName or sets app.setName at boot) into a visible diff
# against this file. The WM_CLASS ↔ StartupWMClass match is what lets
# GNOME associate the running window with the `.desktop` entry and
# therefore render the right icon. The post-build verification step in
# PR #2437 is `xprop WM_CLASS` on a real Ubuntu install.
desktop:
entry:
StartupWMClass: Multica
# 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

View File

@@ -10,11 +10,10 @@ export default [
globals: { ...globals.node },
},
},
// Security: every renderer-controlled URL that reaches the OS shell or the
// native download system must flow through the safe wrappers in
// src/main/external-url.ts (scheme allowlist). Enforce it statically so
// direct shell.openExternal / webContents.downloadURL calls cannot silently
// regress the protection.
// Security: every renderer-controlled URL that reaches the OS shell must
// flow through openExternalSafely in src/main/external-url.ts (scheme
// allowlist). Enforce it statically so a direct shell.openExternal call
// cannot silently regress the protection.
{
files: ["src/main/**/*.ts"],
rules: {
@@ -26,12 +25,6 @@ export default [
message:
"Do not call shell.openExternal directly. Use openExternalSafely from './external-url' so the http/https allowlist stays enforced.",
},
{
selector:
"CallExpression[callee.object.property.name='webContents'][callee.property.name='downloadURL']",
message:
"Do not call webContents.downloadURL directly. Use downloadURLSafely from './external-url' so the http/https allowlist stays enforced.",
},
],
},
},

View File

@@ -1,6 +1,5 @@
{
"name": "@multica/desktop",
"productName": "Multica",
"version": "0.1.0",
"private": true,
"description": "Multica Desktop — native desktop client for the Multica platform.",

View File

@@ -1,4 +1,4 @@
import { shell, type BrowserWindow } from "electron";
import { shell } from "electron";
// True when the URL parses and uses http/https — the only schemes we let
// reach `shell.openExternal`. Scheme comparison is safe because the WHATWG
@@ -19,19 +19,6 @@ export function openExternalSafely(url: string): Promise<void> | void {
return shell.openExternal(url);
}
// Canonical wrapper around webContents.downloadURL. All renderer-controlled
// URLs that trigger a native download MUST flow through here; direct calls
// to `webContents.downloadURL` elsewhere in the main process are banned by
// the no-restricted-syntax rule in apps/desktop/eslint.config.mjs.
// Reuses the same http/https allowlist as openExternalSafely.
export function downloadURLSafely(win: BrowserWindow, url: string): void {
if (getHttpProtocol(url) === null) {
console.warn(`[security] blocked downloadURL: ${describeScheme(url)}`);
return;
}
win.webContents.downloadURL(url);
}
function getHttpProtocol(url: string): "http:" | "https:" | null {
try {
const { protocol } = new URL(url);

View File

@@ -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, downloadURLSafely } from "./external-url";
import { openExternalSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
@@ -133,27 +133,6 @@ 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}`],
},
});
@@ -233,14 +212,6 @@ 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 -----------------------------------------------
@@ -317,14 +288,6 @@ if (!gotTheLock) {
return openExternalSafely(url);
});
ipcMain.handle("file:download-url", (_event, url: string) => {
if (!mainWindow) {
console.warn("[download] ignored file:download-url — mainWindow torn down");
return;
}
downloadURLSafely(mainWindow, url);
});
// Sync IPC: app version + normalized OS for preload. Sync (not invoke) so
// preload can attach the values to `desktopAPI.appInfo` before any renderer
// code reads them, ensuring the very first HTTP request from the renderer

View File

@@ -19,9 +19,6 @@ interface DesktopAPI {
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
/** Open a URL in the default browser. */
openExternal: (url: string) => Promise<void>;
/** Download a file by URL through Electron's native download system.
* Shows a native save dialog. On non-desktop platforms this is undefined. */
downloadURL: (url: string) => Promise<void>;
/** Hide macOS traffic lights for full-screen modals; restore when false. */
setImmersiveMode: (immersive: boolean) => Promise<void>;
/** Show a native OS notification for a new inbox item. */

View File

@@ -89,11 +89,6 @@ const desktopAPI = {
},
/** Open a URL in the default browser */
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
/** Download a file by URL through Electron's native download system.
* Shows a save dialog and saves to disk. Unlike openExternal, this
* avoids browser rendering of HTML files on Linux.
* On non-desktop platforms this property is undefined. */
downloadURL: (url: string) => ipcRenderer.invoke("file:download-url", url),
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
setImmersiveMode: (immersive: boolean) =>
ipcRenderer.invoke("window:setImmersive", immersive),

View File

@@ -20,7 +20,6 @@ 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";
@@ -147,12 +146,6 @@ 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: "usage",

View File

@@ -180,61 +180,6 @@ describe("useTabStore actions", () => {
expect(s.byWorkspace.acme.tabs[0].id).not.toBe(onlyTabId); // fresh tab
});
it("defers disposing the closed tab router until after the store update", () => {
vi.useFakeTimers();
try {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
const closingTab = useTabStore
.getState()
.byWorkspace.acme.tabs.find((t) => t.id === closedTabId);
const dispose = vi.mocked(closingTab!.router.dispose);
store.closeTab(closedTabId);
expect(dispose).not.toHaveBeenCalled();
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
vi.runAllTimers();
expect(dispose).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("ignores router-sync updates from a tab after it has been closed", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closedTabId = store.addTab("/acme/settings", "Settings", "Settings");
store.closeTab(closedTabId);
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(closedTabId, { path: "/acme/runtimes", icon: "Monitor" });
store.updateTabHistory(closedTabId, 1, 2);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
expect(
useTabStore.getState().byWorkspace.acme.tabs.some((t) => t.id === closedTabId),
).toBe(false);
});
it("does not replace the tab group for no-op router-sync updates", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const tab = useTabStore.getState().byWorkspace.acme.tabs[0];
const before = useTabStore.getState().byWorkspace.acme;
store.updateTab(tab.id, { path: tab.path, icon: tab.icon, title: tab.title });
store.updateTabHistory(tab.id, tab.historyIndex, tab.historyLength);
expect(useTabStore.getState().byWorkspace.acme).toBe(before);
});
it("validateWorkspaceSlugs drops groups for slugs not in the valid set and repoints active", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");

View File

@@ -350,10 +350,7 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const closing = group.tabs[index];
const disposeClosingRouter = () => {
// Let React unmount the tab's RouterProvider before disposing it.
window.setTimeout(() => closing.router.dispose(), 0);
};
closing.router.dispose();
if (group.tabs.length === 1) {
// Last tab in this workspace — reseed a default so the workspace
@@ -366,7 +363,6 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: [fresh], activeTabId: fresh.id },
},
});
disposeClosingRouter();
return;
}
@@ -382,7 +378,6 @@ export const useTabStore = create<TabStore>()(
[slug]: { tabs: nextTabs, activeTabId: nextActiveTabId },
},
});
disposeClosingRouter();
},
setActiveTab(tabId) {
@@ -407,13 +402,6 @@ export const useTabStore = create<TabStore>()(
const { slug, group, index } = hit;
const current = group.tabs[index];
const next: Tab = { ...current, ...patch };
if (
next.path === current.path &&
next.title === current.title &&
next.icon === current.icon
) {
return;
}
const nextTabs = [...group.tabs];
nextTabs[index] = next;
set({
@@ -430,12 +418,6 @@ export const useTabStore = create<TabStore>()(
if (!hit) return;
const { slug, group, index } = hit;
const current = group.tabs[index];
if (
current.historyIndex === historyIndex &&
current.historyLength === historyLength
) {
return;
}
const next: Tab = { ...current, historyIndex, historyLength };
const nextTabs = [...group.tabs];
nextTabs[index] = next;

View File

@@ -160,7 +160,6 @@ Chinese term reference:
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |

View File

@@ -160,7 +160,6 @@ Multica 的产品名词分两类:
| Confirm / Continue / Back | 确认 / 继续 / 返回 |
| Edit / New / Create / Add | 编辑 / 新建 / 创建 / 添加 |
| Remove / Send / Open / Close | 移除 / 发送 / 打开 / 关闭 |
| Preview / Download / Upload | 预览 / 下载 / 上传 |
| Done / Loading... | 完成 / 加载中... |
| Profile / Account / Appearance | 个人资料 / 账号 / 外观 |
| Theme / Language | 主题 / 语言 |

View File

@@ -111,7 +111,7 @@ After **Create GitHub App**, note two things from the App's detail page:
On the API server:
```dotenv
```env
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<the webhook secret you generated>
```

View File

@@ -111,7 +111,7 @@ Self-Host 需要:建一个 GitHub App、指向你的 server、设两个环境
API server 上:
```dotenv
```env
GITHUB_APP_SLUG=multica-acme
GITHUB_WEBHOOK_SECRET=<你刚生成的 webhook secret>
```

View File

@@ -1 +0,0 @@
export { SquadDetailPage as default } from "@multica/views/squads";

View File

@@ -1 +0,0 @@
export { SquadsPage as default } from "@multica/views/squads";

View File

@@ -284,30 +284,6 @@ 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",

View File

@@ -284,30 +284,6 @@ 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",

View File

@@ -1,555 +0,0 @@
# Agent 快速创建 — 三阶段实施计划
> Status: Draft (设计阶段,未动工)
> Owner: TBD
> Last updated: 2026-05-13
## TL;DR
- **目标**:降低用户创建 Agent 的门槛,从「手工填表 + 一个个挑 skill」演进到「一键模板」「AI 推荐 skill」「AI 直接创建 agent」三档
- **三阶段**:Template(必做、独立)→ Skill Finder(AI 推荐 skill)→ AI Create Agent(AI 直接创建)
- **架构关键**:Phase 2/3 复用现有 Quick-create Issue 基础设施(派任务给 agent + tool calling + inbox 通知),不引入新 LLM 调用路径
- **不需要新基础设施**:无 SSE、无 server-side LLM、无新 WS channel
- **soft blocker**:两处 routine 重构(`createSkillWithFiles` TX 拆分、skill 同名 find-or-create)
- **不做**:接入 Anthropic 官方 marketplace(plugin 体系跟单体 skill 形态不匹配)、接入 ClawHub(战略对位错误 + 实际使用率低,见 §5)
---
## 1. 背景与目标
### 1.1 当前现状
当前用户创建一个 Agent 需要走的步骤:
1.`/agents` 页面 → 点 "Create Agent"
2. 手工填 name / description / runtime / model
3. 手工写 instructions(空白文本框,用户自己思考措辞)
4. 创建完后进 Agent 详情页 → 点 "Add Skill" → 一个一个挑 skill 关联
5. 如果 workspace 还没有需要的 skill,得先去别处建/导入 skill(`POST /api/skills/import` 支持 skills.sh / GitHub / ClawHub 三种 URL)
**痛点**:
- 用户得**预先知道**自己需要哪些 skill,这要求他对 skill 生态熟悉
- 写 instructions 是空白文本编辑,大多数用户不知道写什么
- 跨多页操作,体感上"创建一个能用的 Agent"是个项目,不是个动作
### 1.2 三阶段方案
| Phase | 提供给用户的能力 | 是否需要 AI | 独立可发布 |
|---|---|---|---|
| **1. Template** | 选模板 → 自动 import 模板带的 skill + 预填 instructions | 否 | ✅ |
| **2. Skill Finder** | 描述需求 → AI 推荐 skill 列表 → 一键导入到 workspace | ✅ | ✅(独立功能,任何场景都能用) |
| **3. AI Create Agent** | 描述需求 → AI 自己 find skill + 写 instructions + 创建 agent | ✅ | 依赖 Phase 2 |
每个 phase **本身有用户价值**,不需要等下一个 phase 才能用:
- Phase 1 用户能用模板创建 agent,即使后两阶段没做
- Phase 2 用户能在任何地方"用 AI 找 skill"(创建 agent 时、给现有 agent 加 skill 时、单纯逛 skill 时)
- Phase 3 是 1+2 的组合
### 1.3 不在范围内
明确不做的事(及理由,见 §5):
- 接入 Anthropic 官方 plugin marketplace(`anthropics/claude-plugins-official`)
- 接入 ClawHub 的"发现/搜索"层(import 路径已经存在,但是死代码,建议下线)
- 让 AI 直接装 skill 到用户本地 `~/.claude/skills/`(npx skills CLI 行为)
- Server-side LLM 调用(后端目前没有 LLM SDK,这条路引入新基础设施,而 Quick-create 模式可以避开)
---
## 2. 关键概念回顾
> 这一节给没参与前期讨论的同事看。已经熟悉 skill 系统的可跳到 §3。
### 2.1 Skill 是什么
Skill 是一个**按需加载的能力包**,本质是 SKILL.md 文件 + 可选附件。Anthropic 2025-12 把它发布为开放标准(agentskills.io),Cursor / OpenAI / GitHub Copilot 等都已采纳——同一份 SKILL.md 跨多个 agent 工具都能用。
每个 runtime(Claude Code / Cursor / Codex 等)启动时**自动扫**自己约定的目录(`~/.claude/skills/``.cursor/skills/` 等),读 SKILL.md 的 frontmatter 形成"我手上有这些 skill"的清单注入 system prompt。具体 skill 正文只在被触发时才进 context。
### 2.2 Multica 的 Skill 数据模型
3 张表(migration `008_structured_skills.up.sql`):
| 表 | 关键字段 |
|---|---|
| `skill` | `id, workspace_id, name, description, content (=SKILL.md 正文), config (含 origin 元数据)` |
| `skill_file` | `skill_id, path, content`(SKILL.md 的附件,如 examples/*.md、scripts/*.py) |
| `agent_skill` | `agent_id, skill_id`(M:N 关联) |
**关键约束**:`UNIQUE(workspace_id, name)` — 同 workspace 内 skill 名字必须唯一。
### 2.3 Skill 流转链路(数据库 → runtime)
任务运行时,skill 从 PG 到 runtime 的完整路径:
```
1. 数据库:skill + skill_file + agent_skill 三张表的行
2. Daemon claim 任务:
POST /api/runtimes/{runtimeId}/tasks/claim
handler/daemon.go:1018-1098 (ClaimTaskByRuntime)
→ service/task.go:1447-1463 (LoadAgentSkills)
→ 把 agent 关联的所有 skill 全文塞进 HTTP 响应
3. Daemon 算工作目录:
server/internal/daemon/execenv/execenv.go:114, 124
workDir = {WorkspacesRoot}/{wsID}/{shortTaskID}/workdir
4. Daemon 按 runtime 算 skill 目录:
server/internal/daemon/execenv/context.go:121-158 (resolveSkillsDir)
claude → {workDir}/.claude/skills
cursor → {workDir}/.cursor/skills
codex → 特殊:{codexHome}/skills
5. Daemon 把字符串写成磁盘文件:
context.go:175-204 (writeSkillFiles)
核心就两行 os.WriteFile
6. Daemon 启动 runtime,cwd = workDir
runtime 自己扫 .claude/skills/(等)→ 加载 frontmatter
7. 任务结束:os.RemoveAll(workDir)
PG 是真相源,workDir 是每次任务临时复印件
```
**核心 invariant**:Multica 不教 runtime 怎么用 skill,只把文件摆到 runtime 已经会扫的位置。
### 2.4 Template = Instructions + Skill 引用
Template 是个**静态 JSON 定义**,包含:
- 预写好的 instructions
- 一组 skill 引用(用 URL 指向 skills.sh / GitHub)
用户选模板时,后端:
1. 对每个 skill 引用,**复用现有 `/api/skills/import` 的 fetcher**(`fetchFromSkillsSh` / `fetchFromGitHub`)拉内容
2. 物化到 workspace(同名复用 / 新建)
3. CreateAgent + setAgentSkills
4. 整个流程一个事务
skill 引用为什么用 URL 而不是内联 SKILL.md 内容:
- 复用现有 import 基础设施,零新代码
- skill 内容跟 GitHub 同步,不需要 vendoring 进 multica 仓库
- 模板 JSON 体积小,git review 友好
### 2.5 Quick-create Issue 模式(Phase 2/3 复用的基础设施)
当前 `POST /api/issues/quick-create`(handler/issue.go:877-982)的流程:
```
1. 后端 enqueue 任务:
- agent_task_queue 加一行,issue_id = NULL,context JSONB = {type: "quick-create", prompt: ...}
- 立即返回 202 Accepted + task_id
2. Daemon claim 任务时识别 quick-create:
- 检查 task.Context != nil AND !task.IssueID.Valid
- 解析为 QuickCreateContext (service/task.go:1810-1811)
3. Daemon 构造 prompt:
- daemon/prompt.go:45-106 (buildQuickCreatePrompt)
- 把用户的自然语言 prompt 作为语义核心
- 加上"调用 multica issue create CLI 命令"的指令
4. Agent 跑 LLM + tool calling:
- LLM 输出形如 `multica issue create --title="..." --description="..."` 的命令
- daemon 执行 CLI 命令,CLI 调 POST /api/issues 创建 issue
- CLI 自动在请求里带上 MULTICA_QUICK_CREATE_TASK_ID env(daemon/daemon.go:2081)
→ 让创建出来的 issue 带 origin_type='quick_create' + origin_id=<task_id>
5. 后端 link + 通知:
- 完成检测:GetIssueByOrigin(workspace_id, "quick_create", task_id)
- LinkTaskToIssue(task_id, issue_id) 把任务行的 issue_id 补上
- 写 inbox_item 通知用户(notifyQuickCreateCompleted, service/task.go:1908-1920)
```
**关键洞察**:这个模式**完全通用化**了。复用它只需要:
1. 新的 context JSONB type(比如 `"skill-find"``"agent-create"`)
2. 新的 prompt builder
3. 新的"完成检测 + inbox 通知"
不需要任何 daemon / 任务队列层面的改动。
---
## 3. 三阶段详细设计
### Phase 1:Agent Template
**目标**:用户选模板 → 一键得到一个可用的 agent(自带 skill + instructions),不需要 AI 参与。
#### 设计
- **Template 定义存放**:静态 JSON,commit 在 `server/internal/agenttmpl/templates/*.json`
- **Template JSON 形态**:
```json
{
"slug": "code-reviewer",
"name": "Code Reviewer",
"description": "审代码用的 agent",
"instructions": "你审代码,关注 N+1 查询、错误处理、类型安全...",
"skills": [
{ "source_url": "https://skills.sh/obra/superpowers/tdd" },
{ "source_url": "https://github.com/foo/bar/tree/main/skills/code-style" }
]
}
```
- **新 endpoint**:`POST /api/agents/from-template`
- 请求:`{template_slug, name, runtime_id, ...overrides}`
- 后端流程(**全部在一个事务里**):
1. 加载 template JSON
2. 对每个 skill source_url:
- 调用 `detectImportSource(url)`(skill.go:586-617)分发到对应 fetcher
- 通过 GetSkillByWorkspaceAndName 检查 workspace 是否已有同名 skill
- 有 → 复用现有 skill_id
- 无 → 调 `createSkillWithFilesInTx`(待重构,见 §4)物化
3. `CreateAgent`(复用 agent.go:CreateAgent 的内部逻辑)
4. 批量 `AddAgentSkill` 关联
- 响应:`{agent: {...}, imported_skill_ids: [...], reused_skill_ids: [...]}`
- **前端**:`CreateAgentDialog`(packages/views/agents/components/create-agent-dialog.tsx)加 "From template" 模式,跟现有 manual / duplicate 模式并列
- 模板选择器 → 预览(instructions + skill 列表)→ 提交调新 endpoint
- 响应里的 `reused_skill_ids` 用 toast 提示"以下 skill 已存在,沿用了 workspace 现有版本"
#### 起步模板清单(初版,可调)
- `code-reviewer` — 代码审查
- `tdd-pair` — TDD 配对编程
- `db-reviewer` — 数据库 / SQL 审查
- `pr-summarizer` — PR 摘要
- `docs-writer` — 文档撰写
具体每个模板选哪些 skill URL,在 Phase 1 启动时单独决定(需要逛 skills.sh 选高质量 skill)。
#### Phase 1 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/agenttmpl/`(新包) | 加载 JSON 模板的代码 |
| `server/internal/agenttmpl/templates/*.json`(新文件) | 5 个起步模板 |
| `server/internal/handler/agent.go` | 新 handler `CreateAgentFromTemplate` |
| `server/internal/handler/skill_create.go` | **重构**:拆出 `createSkillWithFilesInTx` 变体(见 §4) |
| `server/pkg/db/queries/skill.sql` | 加 `GetSkillByWorkspaceAndName`(见 §4) |
| `server/cmd/server/router.go` | 注册新 endpoint |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 template 模式 |
| `packages/core/api/agent.ts` | 加 `createAgentFromTemplate` API 调用 |
| `packages/views/agents/components/template-picker.tsx`(新文件) | 模板选择器组件 |
### Phase 2:Skill Finder
**目标**:用户用自然语言描述需求(如"我想审 SQL"),AI 推荐一组 skill,用户勾选一键导入到 workspace。
#### 设计
- **架构选型**:走 quick-create 模式,**不是后端直接调 LLM**
- **新 endpoint**:`POST /api/skills/find`
- 请求:`{prompt, agent_id}`(agent_id 是用来跑这个 LLM 任务的 agent,跟 Quick-create Issue 一样要求预先有 agent)
- 后端流程:
1. enqueue 任务:`agent_task_queue` 加一行,context JSONB = `{type: "skill-find", prompt}`
2. 返回 202 + task_id
- **Daemon prompt builder**:`daemon/prompt.go` 加 `buildSkillFindPrompt`(类比 buildQuickCreatePrompt)
- 喂给 agent 的 prompt 大致:
```
用户需求:{user_prompt}
你的任务:从以下 curated skill 清单里选 3-5 个最相关的推荐给用户。
可选 skill 清单(JSON):
{curated_skill_index}
输出:调用 `multica skill find --output-results '<JSON>'` 命令,
JSON 形态为 [{name, description, source_url, reason}, ...]
```
- **CLI 命令**(新):`multica skill find --output-results <JSON>`
- 不发起 HTTP 请求,只把 JSON 写到 daemon 通过 env 指定的临时文件
- daemon 读这个文件,把内容塞进 inbox notification 的 payload
- **Curated skill 索引**:`server/internal/agenttmpl/skill_index.json`(新文件)
- 几十到上百条精选 skill,每条:`{name, description, source_url, tags, install_count}`
- 维护方式:工程师/产品手工维护,代码 review 卡内容质量
- MVP **不做**实时 GitHub Code Search 或 skills.sh 爬虫
- **完成通知**:写 inbox_item,type = `skill_find_done`,payload 含推荐结果数组
- **前端**:
- 独立"Find Skill"页面(`/skills/find` 或 `/skills?ai=true`)
- skill list page 上"用 AI 找 skill"按钮入口
- 用户输入 prompt → 提交 → 等通知 → inbox item 里展示 skill 卡片(name + description + source_url + reason)
- 用户勾选 → 一键批量调现有 `POST /api/skills/import`(每个 skill 一次,可考虑加 batch endpoint 但 MVP 不必要)
#### Phase 2 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/skill.go` | 新 handler `FindSkill`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueSkillFindTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildSkillFindPrompt` |
| `server/internal/daemon/daemon.go` | 加 `SkillFindContext` 识别 + env 注入 |
| `server/cmd/multica/cmd_skill.go` | 加 `find --output-results` 子命令 |
| `server/internal/agenttmpl/skill_index.json`(新文件) | curated 清单 |
| `packages/views/skills/components/find-skills-dialog.tsx`(新文件) | UI |
| `packages/core/api/skill.ts` | 加 `findSkills` API |
| `packages/views/inbox/items/skill-find-result.tsx`(新文件) | inbox item 渲染 |
### Phase 3:AI Create Agent
**目标**:用户描述需求,AI 自己 find skill + 写 instructions + 创建 agent。
#### 设计
- **架构选型**:走 quick-create 模式,**组合 Phase 2 的 find 能力 + 新的 agent create CLI**
- **新 endpoint**:`POST /api/agents/ai-draft`
- 请求:`{prompt, host_agent_id}`(host_agent_id 是跑这个元任务的 agent)
- 后端:enqueue 任务,context = `{type: "agent-create", prompt}`,返回 202 + task_id
- **Daemon prompt builder**:`buildAgentCreatePrompt` 指挥 agent 三步走:
```
1. 调用 `multica skill find --output-results ...` 选 skill
(或直接看 curated 清单选)
2. 基于选定 skill 写 instructions
3. 调用 `multica agent create --name ... --instructions ... --skill-ids ...`
创建 agent 并关联 skill
```
- **CLI 命令**(新):`multica agent create`
- 后端 handler 已存在(handler/agent.go:CreateAgent),只需要绑 CLI(~50 行)
- 创建时带 `MULTICA_AI_DRAFT_TASK_ID` env,服务端用它做 origin 标记 + LinkTaskToAgent
- **完成通知**:inbox_item type = `agent_draft_done`,payload 含 agent_id + 摘要
- **前端**:`CreateAgentDialog` 加 "AI" 模式
- 输入需求 → 提交 → 等通知 → inbox 通知里点击 → 跳新 agent 详情页(用户在那儿编辑/调整)
#### Phase 3 改动清单
| 文件 / 位置 | 改动 |
|---|---|
| `server/internal/handler/agent.go` | 新 handler `AIDraftAgent`(enqueue task) |
| `server/internal/service/task.go` | 加 `EnqueueAgentDraftTask` + 完成检测 + inbox 通知 |
| `server/internal/daemon/prompt.go` | 加 `buildAgentCreatePrompt` |
| `server/cmd/multica/cmd_agent.go` | 加 `create` 子命令(handler 已有) |
| `packages/views/agents/components/create-agent-dialog.tsx` | 加 "AI" 模式 |
| `packages/core/api/agent.ts` | 加 `aiDraftAgent` API |
| `packages/views/inbox/items/agent-draft-result.tsx`(新文件) | inbox item 渲染 |
---
## 4. Blocker 清单与修复方案
### 4.1 [SOFT] `createSkillWithFiles` 不可组合事务
**问题**:`server/internal/handler/skill_create.go:21-71` 这个函数自己 `Begin()` 一个事务,执行完 `Commit()`。Phase 1 需要在外层事务里**多次**调用它(import N 个 skill + createAgent + setAgentSkills 都在一个 TX),但现在没法这么用。
**影响范围**:Phase 1
**修复方案**:
```go
// 拆成两个函数(保持原 API 向后兼容):
// 新增:接受外部 qtx,不管事务
func createSkillWithFilesInTx(
ctx context.Context,
qtx *db.Queries,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
// 不 Begin/Commit,只调 qtx.CreateSkill + qtx.UpsertSkillFile loop
}
// 改造:原函数变成包装层,内部调 InTx 版
func (h *Handler) createSkillWithFiles(
ctx context.Context,
input skillCreateInput,
) (*SkillWithFilesResponse, error) {
tx, _ := h.TxStarter.Begin(ctx)
defer tx.Rollback()
qtx := h.Queries.WithTx(tx)
result, err := createSkillWithFilesInTx(ctx, qtx, input)
if err != nil { return nil, err }
tx.Commit()
return result, nil
}
```
旧调用方完全不变。Phase 1 新 endpoint 自己 Begin,然后多次调 `*InTx` 变体,最后统一 Commit。
**工作量**:小(< 100 行重构)
### 4.2 [SOFT] Skill 同名冲突
**问题**:`skill` 表有 `UNIQUE(workspace_id, name)` 约束。Phase 1 模板导入时,如果模板里的 skill 跟 workspace 已有 skill 同名,INSERT 会报 PG 错误 23505,整个 from-template 流程挂掉。
**影响范围**:Phase 1
**修复方案**:加 find-or-create 模式:
1. 新 query `GetSkillByWorkspaceAndName`(`server/pkg/db/queries/skill.sql`)
2. Phase 1 流程改成:
- 对每个模板 skill,先查 workspace 是否已有同名
- 有 → 复用现有 skill_id,跳过 import
- 无 → 调 `createSkillWithFilesInTx` 物化
3. 响应里返回 `reused_skill_ids: [...]`,前端 toast "以下 skill 已存在,沿用现有版本"
**不选择"覆盖"或"加后缀"的原因**:用户可能已经改过本地版本,覆盖会丢用户修改;加后缀污染 skill 列表。
**工作量**:小(< 50 行 + 1 条 sqlc query)
### 4.3 [SOFT] 缺 `multica skill find` CLI
**影响范围**:Phase 2
**方案**:加一个 CLI 子命令,模仿 `multica skill import` 的实现(`server/cmd/multica/cmd_skill.go:55-60, 323-357`)。**注意**:这个命令不发 HTTP 请求,只是 LLM agent 用来"输出推荐结果"的 channel——它把 LLM 推荐的 JSON 写到 daemon 指定的临时文件,daemon 读完塞进 inbox notification。
**工作量**:小(~80 行)
### 4.4 [SOFT] 缺 `multica agent create` CLI
**影响范围**:Phase 3
**方案**:后端 handler 已有(`handler/agent.go:CreateAgent`),只需在 `server/cmd/multica/cmd_agent.go` 加 `create` 子命令。
**工作量**:小(~50 行)
### 4.5 [非 blocker] System Agent 问题
**之前误判为 hard blocker,实际不是**:
Quick-create Issue 当前的设计就要求用户**预先有一个 agent** 才能用——AI 路径不为"零 agent 起步"服务。Phase 2/3 沿用这个前提,所以**新 workspace 没 agent 时 AI 功能不可用**是符合现有产品模型的,不需要 bootstrap 一个 system agent。
产品自然解锁路径:
1. 新用户进 workspace
2. 用 **Phase 1 Template**(无需 AI、无需现有 agent)创建第一个 agent
3. 之后 Phase 2/3 即可用,host_agent 就用刚创建的那个
---
## 5. 关键设计决策(及理由)
### 5.1 为什么不接 Anthropic 官方 marketplace?
**结构错配**。Anthropic 官方 marketplace(`anthropics/claude-plugins-official`)是 **plugin 体系**:每个 plugin 是个 bundle,包含 `.claude-plugin/plugin.json` + `skills/` + `agents/` + `hooks/` + `.mcp.json`。
Multica 只有**单体 skill**(SKILL.md + skill_file),没有 plugin / bundle 概念。要接入得新写 plugin parser + 拆分逻辑,工作量大,而 skills.sh 已经覆盖了同一批高质量内容(skills.sh 后端就是 GitHub raw,绝大多数 skill 作者就在 GitHub 上,Anthropic plugin 体系里的 skill 通常也在作者的 GitHub repo 里有单体副本)。
### 5.2 为什么走 quick-create 模式而不是后端直接调 LLM?
代码事实:`server/` 目前**完全没有任何 LLM SDK**(grep `anthropic-sdk-go` / `openai-go` / 任何 LLM provider 都是 0 命中)。所有 LLM 调用都通过 daemon → runtime → CLI 这条路。
走 quick-create 模式的优势:
- **不引入新基础设施**(SSE / LLM client / API key 管理)
- **复用 agent 的 instructions / model / runtime 配置**(用户已经在某个 agent 里配置过的偏好自动生效)
- **统一计费 / 用量监控**(LLM 调用都计在用户 agent 的 quota 里)
代价:
- 用户得**预先有一个 agent**(参见 §4.5,这跟 Quick-create Issue 现状一致)
- LLM 调用通过 daemon 多一跳,延迟略增(但不阻塞 202 响应)
### 5.3 为什么 Skill Finder 是 endpoint 不是 SKILL.md?
**Skill Finder 名字里的 "Skill" 是它的产物(找的是 skill),不是它自己实现成 SKILL.md**。
如果做成 SKILL.md 文件:
- 它得装进某个 agent 里才能用 → 单点功能变得需要前置配置
- skill 教 agent 调什么?调 `npx skills`(装到本地,目标错)?调 Multica API(那要写 tool channel,绕一大圈)
- AI 创建 Agent(Phase 3)那条路要"启动 agent → agent 调 skill → skill 调 tool",链路复杂三倍
做成 endpoint:
- 用户独立可用(独立 UI 入口)
- AI 创建 Agent 后端直接调 endpoint,两个功能共用一段逻辑
- 简单
### 5.4 Curated Skill 索引 vs 实时搜索
**MVP 用 curated 清单**(几十条精选 URL + 摘要 commit 在 repo 里)。理由:
- 质量可控
- 不踩 GitHub Code Search rate limit
- 不被 LLM 编 URL(LLM 知识 cutoff + hallucinate URL 是真问题)
- 维护成本低
进阶可加 `search_skills(query)` tool 实时打 GitHub Code Search,等用户反馈"清单太窄"再做。
### 5.5 不做 ClawHub(顺手清理建议)
**现状**:`POST /api/skills/import` 当前支持 3 个 source(`fetchFromClawHub` skill.go:642-744、`fetchFromSkillsSh` skill.go:757-879、`fetchFromGitHub` skill.go:1363-1463)。ClawHub 是个独立 HTTP 客户端,不复用 GitHub 基础设施。
**判断**(详见之前讨论):
- ClawHub 服务的是 OpenClaw 平台(Multica 同生态位竞品的内容生态)
- UI 没有发现/搜索层,用户只能粘 URL,而 ClawHub 装机量远低于 skills.sh,用户主动逛的概率极低
- 独立代码路径,API 演进时单独跟进
**建议**(独立于本计划,可以一起做也可以延后):
- 跑 `SELECT count(*) FROM skill WHERE config->'origin'->>'type' = 'clawhub'` 看实际使用量
- 接近 0 → 渐进下线(先去 UI SourceCard,后续 release 删 fetcher)
- 有量 → 留着,但仍不为它做新功能
---
## 6. 实施依赖与排期
```
[Phase 1] Template
└── 独立,无依赖
└── 包含 2 个 soft blocker 的修复(§4.1 §4.2)
[Phase 2] Skill Finder
└── 依赖 Phase 1 中的 skill import 路径(已存在,沿用)
└── 含 1 个 soft blocker(§4.3)
[Phase 3] AI Create Agent
└── 依赖 Phase 2(复用 find skill 能力)
└── 含 1 个 soft blocker(§4.4)
```
**真实排期建议**:
- Phase 1 可单独发版,有独立价值
- Phase 2 独立可发版(找 skill 是高频独立场景)
- Phase 3 等 Phase 2 ready 后开始
每个 phase 启动时单独开 PR 设计 doc,本文档只是路线图。
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| GitHub rate limit(模板 import 多个 skill 时) | 已有 `GITHUB_TOKEN` env 支持(skill.go:1163-1166),5000/h 配额够用。生产环境确保配置 |
| 模板里引用的 skill repo 被作者删除 | from-template handler 容错:某个 skill fetch 失败 → 整个事务回滚,前端展示具体哪个 URL 挂了。模板自己也定期 review |
| LLM 推荐编造 URL(Phase 2) | 用 curated 清单作为 context,**不让 LLM 自由发挥 URL**,推荐范围限定在清单内 |
| Phase 3 LLM 写出离谱 instructions | 用户在 inbox 通知里点击 → 跳新 agent 详情页**编辑模式**,不直接进入"已就绪"状态。用户必须确认 |
| 模板格式后续要演进(加字段) | Template JSON 加 `version` 字段,后端按 version 兼容老格式 |
| Curated skill 清单过时(作者改 repo / 删 skill) | 加 CI 任务定期跑一遍清单 URL,挂掉的报警通知维护者 |
---
## 8. 不在本文档范围(已识别的下一步话题)
- 跨 workspace 模板共享 / marketplace 化(用户能把自己的 agent 存成模板分享)
- 实时 GitHub Code Search tool(Phase 2 进阶)
- Server-side LLM 调用基础设施(如果未来需要 streaming 等场景)
- ClawHub 下线决策(独立讨论,见 §5.5)
- Skill 版本管理(workspace skill 版本号 / 升级提示)
---
## 附录 A:代码索引
> 给接手开发的同事的快速参考。每条 file:line 都在本计划里被引用过,记录在这里方便跳转。
| 主题 | 位置 |
|---|---|
| Skill DB 模型 | `server/migrations/008_structured_skills.up.sql:4-32` |
| Skill 创建 handler + 事务 | `server/internal/handler/skill.go:143-162` + `skill_create.go:21-71` |
| Skill import 入口(支持 3 个 source) | `server/internal/handler/skill.go:1538` |
| Skill import source 分发 | `server/internal/handler/skill.go:586-617` (`detectImportSource`) |
| Skills.sh fetcher | `server/internal/handler/skill.go:757-879` (`fetchFromSkillsSh`) |
| GitHub fetcher | `server/internal/handler/skill.go:1363-1463` (`fetchFromGitHub`) |
| ClawHub fetcher | `server/internal/handler/skill.go:642-744` (`fetchFromClawHub`) |
| Agent 创建 handler | `server/internal/handler/agent.go:380-399` (request) + `:422-564` (CreateAgent) |
| Agent 创建 sqlc | `server/pkg/db/queries/agent.sql:19-25` |
| Agent-Skill 关联 sqlc | `server/pkg/db/queries/agent.sql:86-103` |
| 当前 Agent Duplication(前端模式) | `packages/views/agents/components/agents-page.tsx:286-301`(post-create skill copy) |
| Agent 创建 dialog | `packages/views/agents/components/create-agent-dialog.tsx` |
| Skill add dialog | `packages/views/agents/components/skill-add-dialog.tsx` |
| Quick-create Issue handler | `server/internal/handler/issue.go:877-982` (`QuickCreateIssue`) |
| Quick-create task enqueue | `server/internal/service/task.go:488+` (`EnqueueQuickCreateTask`) |
| Daemon claim + load skills | `server/internal/handler/daemon.go:1018-1098` + `service/task.go:1447-1463` |
| Daemon prompt build | `server/internal/daemon/prompt.go:17-36` (dispatch) + `:45-106` (`buildQuickCreatePrompt`) |
| Daemon execenv prepare | `server/internal/daemon/execenv/execenv.go:103-176` |
| Skill 目录约定(runtime mapping) | `server/internal/daemon/execenv/context.go:121-158` (`resolveSkillsDir`) |
| Skill 文件落盘 | `server/internal/daemon/execenv/context.go:175-204` (`writeSkillFiles`) |
| Quick-create 完成检测 + inbox | `server/internal/service/task.go:1810-1949` |
| LinkTaskToIssue | `server/internal/handler/agent.go:97-105` |
| Quick-create Issue 前端 modal | `packages/views/modals/quick-create-issue.tsx:48-570+` |
| Multica CLI 入口 | `server/cmd/multica/main.go:62-79` |
| Skill CLI 命令 | `server/cmd/multica/cmd_skill.go:17-96`(已有 import,无 find) |
| Agent CLI 命令 | `server/cmd/multica/cmd_agent.go:101-112`(已有 list/get,无 create) |

View File

@@ -82,30 +82,3 @@ export function agentTasksOptions(wsId: string, agentId: string) {
refetchOnWindowFocus: true,
});
}
// Agent templates are workspace-independent: a static catalog served from
// the server's embedded JSON. Cache effectively forever — the only way the
// list / detail change is a server deploy, and a hard reload picks that up.
export const agentTemplateKeys = {
all: () => ["agent-templates"] as const,
list: () => [...agentTemplateKeys.all(), "list"] as const,
detail: (slug: string) => [...agentTemplateKeys.all(), "detail", slug] as const,
};
export function agentTemplateListOptions() {
return queryOptions({
queryKey: agentTemplateKeys.list(),
queryFn: () => api.listAgentTemplates(),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}
export function agentTemplateDetailOptions(slug: string) {
return queryOptions({
queryKey: agentTemplateKeys.detail(slug),
queryFn: () => api.getAgentTemplate(slug),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
});
}

View File

@@ -200,60 +200,6 @@ 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(

View File

@@ -11,10 +11,6 @@ import type {
ListIssuesParams,
Agent,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
UpdateAgentRequest,
AgentTask,
AgentActivityBucket,
@@ -91,8 +87,6 @@ import type {
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
Squad,
SquadMember,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -100,19 +94,13 @@ import { createRequestId } from "../utils";
import { getCurrentSlug } from "../platform/workspace-storage";
import { parseWithFallback } from "./schema";
import {
AgentTemplateSchema,
AgentTemplateSummaryListSchema,
AttachmentResponseSchema,
ChildIssuesResponseSchema,
CommentsListSchema,
CreateAgentFromTemplateResponseSchema,
DashboardAgentRunTimeListSchema,
DashboardUsageByAgentListSchema,
DashboardUsageDailyListSchema,
EMPTY_AGENT_TEMPLATE_DETAIL,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
EMPTY_ATTACHMENT,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
EMPTY_LIST_ISSUES_RESPONSE,
EMPTY_TIMELINE_ENTRIES,
ListIssuesResponseSchema,
@@ -208,27 +196,6 @@ 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;
@@ -303,23 +270,15 @@ export class ApiClient {
}
}
// 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> {
private async fetch<T>(path: string, init?: RequestInit): Promise<T> {
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>) ?? {}),
};
@@ -340,18 +299,12 @@ 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>;
}
@@ -681,51 +634,6 @@ export class ApiClient {
});
}
async listAgentTemplates(): Promise<AgentTemplateSummary[]> {
const raw = await this.fetch<unknown>("/api/agent-templates");
return parseWithFallback(
raw,
AgentTemplateSummaryListSchema,
EMPTY_AGENT_TEMPLATE_SUMMARY_LIST,
{ endpoint: "GET /api/agent-templates" },
);
}
async getAgentTemplate(slug: string): Promise<AgentTemplate> {
const raw = await this.fetch<unknown>(
`/api/agent-templates/${encodeURIComponent(slug)}`,
);
// Round-trip the requested slug into the fallback so a malformed
// detail response still produces a navigable record matching the URL
// the user clicked.
return parseWithFallback(
raw,
AgentTemplateSchema,
{ ...EMPTY_AGENT_TEMPLATE_DETAIL, slug },
{ endpoint: "GET /api/agent-templates/:slug" },
);
}
/** Creates an agent from a curated template. The server fetches every
* referenced skill URL in parallel, materializes them into the workspace
* (find-or-create by name), and writes the agent + skill bindings in a
* single transaction. On any upstream fetch failure, the entire write is
* rolled back and the API returns 422 with `failed_urls`. */
async createAgentFromTemplate(
data: CreateAgentFromTemplateRequest,
): Promise<CreateAgentFromTemplateResponse> {
const raw = await this.fetch<unknown>("/api/agents/from-template", {
method: "POST",
body: JSON.stringify(data),
});
return parseWithFallback(
raw,
CreateAgentFromTemplateResponseSchema,
EMPTY_CREATE_AGENT_FROM_TEMPLATE_RESPONSE,
{ endpoint: "POST /api/agents/from-template" },
);
}
async updateAgent(id: string, data: UpdateAgentRequest): Promise<Agent> {
return this.fetch(`/api/agents/${id}`, {
method: "PUT",
@@ -1230,13 +1138,6 @@ 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`);
}
@@ -1291,38 +1192,6 @@ 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();
@@ -1445,43 +1314,6 @@ 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();

View File

@@ -1,9 +1,4 @@
export {
ApiClient,
ApiError,
PreviewTooLargeError,
PreviewUnsupportedError,
} from "./client";
export { ApiClient, ApiError } from "./client";
export type {
ApiClientOptions,
ImportStarterContentPayload,

View File

@@ -117,108 +117,6 @@ describe("ApiClient schema fallback", () => {
expect(res).toEqual({ issues: [] });
});
});
// Agent template catalog is hit by the desktop create-agent picker.
// Installed desktop builds outlive any given server, so the shape MUST
// survive future field renames / wrapping without crashing. Each test
// here mirrors a concrete future drift we want to absorb.
describe("listAgentTemplates", () => {
it("falls back to [] when the body is null", async () => {
stubFetchJson(null);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toEqual([]);
});
it("defaults skills to [] when the field is missing from a template", async () => {
// Future server: drops `skills` because the picker no longer reads
// them. Picker code calls `template.skills.length` — must not throw.
stubFetchJson([{ slug: "x", name: "X" }]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.skills).toEqual([]);
});
it("accepts the bare-array shape (current contract)", async () => {
stubFetchJson([
{ slug: "a", name: "A", description: "", skills: [] },
{ slug: "b", name: "B", description: "", skills: [] },
]);
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls.map((t) => t.slug)).toEqual(["a", "b"]);
});
it("accepts a future {templates: [...]} envelope without breaking", async () => {
// Server migrates to a paginated envelope. We unwrap so the picker
// keeps working on the older bare-array consumer.
stubFetchJson({
templates: [{ slug: "a", name: "A", description: "", skills: [] }],
total: 1,
});
const client = new ApiClient("https://api.example.test");
const tmpls = await client.listAgentTemplates();
expect(tmpls).toHaveLength(1);
expect(tmpls[0]?.slug).toBe("a");
});
});
describe("getAgentTemplate", () => {
it("falls back to a minimal record carrying the requested slug", async () => {
// Slug is part of the URL the user clicked — the fallback round-
// trips it so the page header still makes sense after a parse miss.
stubFetchJson({ wrong: "shape" });
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.slug).toBe("code-reviewer");
expect(detail.skills).toEqual([]);
expect(detail.instructions).toBe("");
});
it("defaults instructions to '' when the field is missing", async () => {
stubFetchJson({
slug: "code-reviewer",
name: "Code Reviewer",
description: "",
skills: [],
});
const client = new ApiClient("https://api.example.test");
const detail = await client.getAgentTemplate("code-reviewer");
expect(detail.instructions).toBe("");
});
});
describe("createAgentFromTemplate", () => {
it("falls back to an empty agent when the response is malformed", async () => {
// The agent was created server-side even though the client can't
// parse the response — UI code reads `agent.id === ""` and skips
// the navigation step rather than landing on `/agents/`.
stubFetchJson({ unexpected: "shape" });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
it("defaults imported_skill_ids / reused_skill_ids to [] when missing", async () => {
stubFetchJson({ agent: { id: "agent-1" } });
const client = new ApiClient("https://api.example.test");
const resp = await client.createAgentFromTemplate({
template_slug: "x",
name: "X",
runtime_id: "rt-1",
});
expect(resp.agent.id).toBe("agent-1");
expect(resp.imported_skill_ids).toEqual([]);
expect(resp.reused_skill_ids).toEqual([]);
});
});
});
// Direct tests for the helper, decoupled from any specific endpoint —

View File

@@ -1,13 +1,5 @@
import { z } from "zod";
import type {
Agent,
AgentTemplate,
AgentTemplateSummary,
Attachment,
CreateAgentFromTemplateResponse,
ListIssuesResponse,
TimelineEntry,
} from "../types";
import type { Attachment, ListIssuesResponse, TimelineEntry } from "../types";
// ---------------------------------------------------------------------------
// Schemas for the highest-risk API endpoints — those whose responses drive
@@ -220,89 +212,3 @@ 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: [],
};

View File

@@ -64,45 +64,6 @@ 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.

View File

@@ -7,7 +7,6 @@ type ModalType =
| "create-issue"
| "quick-create-issue"
| "create-project"
| "create-squad"
| "feedback"
| "issue-set-parent"
| "issue-add-child"

View File

@@ -22,12 +22,10 @@ describe("paths.workspace() shape", () => {
"projects",
"autopilots",
"agents",
"squads",
"inbox",
"myIssues",
"runtimes",
"skills",
"squads",
"settings",
]),
);
@@ -43,12 +41,10 @@ describe("paths.workspace() shape", () => {
["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>;

View File

@@ -18,8 +18,6 @@ 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");
});

View File

@@ -27,8 +27,6 @@ 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`,

View File

@@ -79,7 +79,6 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
"projects",
"autopilots",
"agents",
"squads",
"inbox",
"my-issues",
"usage",

View File

@@ -1,117 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { chatKeys } from "../chat/queries";
import type { ChatDonePayload, ChatMessage, ChatPendingTask } from "../types";
import { applyChatDoneToCache } from "./use-realtime-sync";
const sessionId = "session-1";
const taskId = "task-1";
const messagesKey = chatKeys.messages(sessionId);
const pendingKey = chatKeys.pendingTask(sessionId);
function createQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
}
function userMessage(): ChatMessage {
return {
id: "msg-user",
chat_session_id: sessionId,
role: "user",
content: "hello",
task_id: null,
created_at: "2026-05-13T05:00:00Z",
};
}
function donePayload(overrides: Partial<ChatDonePayload> = {}): ChatDonePayload {
return {
chat_session_id: sessionId,
task_id: taskId,
message_id: "msg-assistant",
content: "done",
elapsed_ms: 1234,
created_at: "2026-05-13T05:00:02Z",
...overrides,
};
}
describe("applyChatDoneToCache", () => {
it("writes the assistant message before clearing pending task", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
const setQueryData = vi.spyOn(qc, "setQueryData");
applyChatDoneToCache(qc, donePayload());
expect(setQueryData.mock.calls[0]?.[0]).toEqual(messagesKey);
expect(setQueryData.mock.calls[1]?.[0]).toEqual(pendingKey);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
{
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
},
]);
});
it("does not duplicate a replayed chat done event", () => {
const qc = createQueryClient();
const assistant: ChatMessage = {
id: "msg-assistant",
chat_session_id: sessionId,
role: "assistant",
content: "done",
task_id: taskId,
created_at: "2026-05-13T05:00:02Z",
elapsed_ms: 1234,
};
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage(), assistant]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(qc, donePayload());
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
assistant,
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
it("falls back to invalidation-only when older servers omit message fields", () => {
const qc = createQueryClient();
qc.setQueryData<ChatMessage[]>(messagesKey, [userMessage()]);
qc.setQueryData<ChatPendingTask>(pendingKey, {
task_id: taskId,
status: "running",
});
applyChatDoneToCache(
qc,
donePayload({ message_id: undefined, content: undefined }),
);
expect(qc.getQueryData<ChatMessage[]>(messagesKey)).toEqual([
userMessage(),
]);
expect(qc.getQueryData<ChatPendingTask>(pendingKey)).toEqual({});
});
});

View File

@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import type { WSClient } from "../api/ws-client";
import type { StoreApi, UseBoundStore } from "zustand";
import type { AuthState } from "../auth/store";
@@ -62,7 +62,6 @@ import type {
TaskFailedPayload,
TaskCancelledPayload,
ChatDonePayload,
ChatMessage,
ChatPendingTask,
InvitationCreatedPayload,
} from "../types";
@@ -71,42 +70,6 @@ const chatWsLogger = createLogger("chat.ws");
const logger = createLogger("realtime-sync");
export function applyChatDoneToCache(
qc: QueryClient,
payload: ChatDonePayload,
) {
const sessionId = payload.chat_session_id;
const taskId = payload.task_id;
const messageId = payload.message_id;
const content = payload.content;
if (messageId && content !== undefined) {
qc.setQueryData<ChatMessage[] | undefined>(
chatKeys.messages(sessionId),
(old) => {
if (!old) return old; // first fetch will pick it up
// Idempotent against reconnect replay.
if (old.some((m) => m.id === messageId)) return old;
const assistant: ChatMessage = {
id: messageId,
chat_session_id: sessionId,
role: "assistant",
content,
task_id: taskId,
created_at: payload.created_at ?? new Date().toISOString(),
elapsed_ms: payload.elapsed_ms ?? null,
};
return [...old, assistant];
},
);
}
// Replacement is in the messages list now; safe to drop pending.
qc.setQueryData(chatKeys.pendingTask(sessionId), {});
// Authoritative refetch reconciles redaction / migrations / clients
// that took the fallback branch above.
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
}
export interface RealtimeSyncStores {
authStore: UseBoundStore<StoreApi<AuthState>>;
}
@@ -171,14 +134,6 @@ 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
@@ -267,7 +222,6 @@ 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
@@ -614,21 +568,13 @@ export function useRealtimeSync(
chatWsLogger.info("chat:done (global)", {
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
has_message: !!payload.message_id,
});
// Inline-insert the assistant message into the messages cache BEFORE
// clearing pending-task. Both writes land in the same React render
// tick, so ChatMessageList sees `pendingAlreadyPersisted === true`
// and the live TimelineView unmounts only after AssistantMessage has
// mounted — no flicker window. This applies TkDodo's "combine
// setQueryData (active query) + invalidateQueries (others)" pattern
// (https://tkdodo.eu/blog/using-web-sockets-with-react-query).
//
// Falls back to invalidate-only when the server omits the message
// payload (older builds). Older clients hitting a newer server also
// work: they ignore the extra fields and rely on the invalidate
// below, which keeps the old behavior alive.
applyChatDoneToCache(qc, payload);
// Assistant message was just written and task flipped out of 'running'.
// Clear pending-task cache immediately so the live-timeline-vs-assistant
// race window collapses to zero — the subsequent refetch will confirm.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
// Assistant message just landed → has_unread may have flipped to true.
invalidateSessionLists();
@@ -699,12 +645,9 @@ export function useRealtimeSync(
task_id: payload.task_id,
chat_session_id: payload.chat_session_id,
});
// `chat:done` (broadcast immediately before this event in CompleteTask)
// already wrote the assistant message into the messages cache and
// cleared `chatKeys.pendingTask`. This event is now only responsible
// for refreshing the per-user cross-session aggregate that drives the
// FAB indicator — `chat:done` is per-session and doesn't carry that
// information.
qc.setQueryData(chatKeys.pendingTask(payload.chat_session_id), {});
qc.invalidateQueries({ queryKey: chatKeys.messages(payload.chat_session_id) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(payload.chat_session_id) });
invalidatePendingAggregate();
});
@@ -733,33 +676,6 @@ 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
@@ -820,7 +736,6 @@ export function useRealtimeSync(
unsubTaskFailed();
unsubChatSessionRead();
unsubChatSessionDeleted();
unsubChatSessionUpdated();
timers.forEach(clearTimeout);
timers.clear();
};

View File

@@ -168,76 +168,6 @@ export interface CreateAgentRequest {
template?: string;
}
/** Agent template summary — fields needed by the picker grid. Does NOT
* include `instructions` to keep the list payload small; the detail
* endpoint or the create flow returns the full template body. */
export interface AgentTemplateSummary {
slug: string;
name: string;
description: string;
/** Optional grouping for the picker UI ("Engineering" / "Writing" / …). */
category?: string;
/** Optional lucide-react icon name (e.g. "Search"). Frontend falls back
* to a generic icon when empty. */
icon?: string;
/** Optional semantic color token for the icon badge — one of "info" /
* "success" / "warning" / "primary" / "secondary". Frontend has a
* static class map so Tailwind can JIT-scan all variants. */
accent?: string;
skills: AgentTemplateSkillRef[];
}
/** Full agent template — same as `AgentTemplateSummary` plus the
* instructions block. Returned by `GET /api/agent-templates/:slug`. */
export interface AgentTemplate extends AgentTemplateSummary {
instructions: string;
}
/** Skill reference inside an agent template. `source_url` is the upstream
* GitHub / skills.sh URL fetched on create; `cached_*` mirror the upstream
* frontmatter at template-author time and let the picker render without
* HTTP fetches. */
export interface AgentTemplateSkillRef {
source_url: string;
cached_name: string;
cached_description: string;
}
export interface CreateAgentFromTemplateRequest {
template_slug: string;
name: string;
runtime_id: string;
model?: string;
visibility?: AgentVisibility;
max_concurrent_tasks?: number;
/** Optional overrides applied to the template before creation. nil/omit
* uses the template's own value. */
description?: string;
instructions?: string;
avatar_url?: string;
/** Workspace skill IDs attached **in addition to** the template's
* skills. Server dedupes against template skills automatically. */
extra_skill_ids?: string[];
}
export interface CreateAgentFromTemplateResponse {
agent: Agent;
/** Skill IDs that were newly created in the workspace from upstream URLs. */
imported_skill_ids: string[];
/** Skill IDs that already existed in the workspace (same name) and were
* reused rather than re-imported. The UI can surface this as a toast so
* the user knows their pre-existing skill wasn't overwritten. */
reused_skill_ids: string[];
}
/** 422 body returned by `POST /api/agents/from-template` when one or more
* template skill URLs cannot be reached. The transaction is rolled back —
* no partial workspace state. */
export interface CreateAgentFromTemplateFailure {
error: string;
failed_urls: string[];
}
export interface UpdateAgentRequest {
name?: string;
description?: string;

View File

@@ -54,13 +54,9 @@ 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"
@@ -293,16 +289,7 @@ export interface ChatMessageEventPayload {
export interface ChatDonePayload {
chat_session_id: string;
task_id: string;
/**
* Server populates these from the freshly-persisted assistant ChatMessage
* row so the WS handler can write it into the messages cache inline. Older
* servers (pre-#2123) only sent chat_session_id + task_id; treat every field
* below as optional and fall back to a refetch when absent.
*/
message_id?: string;
content?: string;
elapsed_ms?: number;
created_at?: string;
}
export interface ChatSessionReadPayload {

View File

@@ -11,12 +11,6 @@ export type {
AgentRuntime,
RuntimeDevice,
CreateAgentRequest,
AgentTemplate,
AgentTemplateSummary,
AgentTemplateSkillRef,
CreateAgentFromTemplateRequest,
CreateAgentFromTemplateResponse,
CreateAgentFromTemplateFailure,
UpdateAgentRequest,
Skill,
SkillSummary,
@@ -100,16 +94,3 @@ export type {
GetAutopilotResponse,
ListAutopilotRunsResponse,
} from "./autopilot";
export type {
Squad,
SquadMember,
SquadMemberType,
SquadActivityLog,
SquadActivityOutcome,
CreateSquadRequest,
UpdateSquadRequest,
AddSquadMemberRequest,
RemoveSquadMemberRequest,
UpdateSquadMemberRoleRequest,
CreateSquadActivityLogRequest,
} from "./squad";

View File

@@ -11,7 +11,7 @@ export type IssueStatus =
export type IssuePriority = "urgent" | "high" | "medium" | "low" | "none";
export type IssueAssigneeType = "member" | "agent" | "squad";
export type IssueAssigneeType = "member" | "agent";
export interface IssueReaction {
id: string;

View File

@@ -1,77 +0,0 @@
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;
}

View File

@@ -2,13 +2,12 @@
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "../hooks";
import { memberListOptions, agentListOptions, squadListOptions } from "./queries";
import { memberListOptions, agentListOptions } 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);
@@ -20,15 +19,9 @@ 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";
};
@@ -46,9 +39,8 @@ 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, getSquadName, getActorName, getActorInitials, getActorAvatarUrl };
return { getMemberName, getAgentName, getActorName, getActorInitials, getActorAvatarUrl };
}

View File

@@ -1,6 +1,6 @@
import { queryOptions } from "@tanstack/react-query";
import { api } from "../api";
import type { Agent, Squad, Workspace } from "../types";
import type { Agent, Workspace } from "../types";
export const workspaceKeys = {
all: (wsId: string) => ["workspaces", wsId] as const,
@@ -9,7 +9,6 @@ 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,
};
@@ -44,14 +43,6 @@ 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),

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { Bot, Users } from "lucide-react";
import { Bot } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { MulticaIcon } from "./multica-icon";
@@ -11,7 +11,6 @@ interface ActorAvatarProps {
avatarUrl?: string | null;
isAgent?: boolean;
isSystem?: boolean;
isSquad?: boolean;
size?: number;
className?: string;
}
@@ -22,12 +21,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]);
@@ -36,10 +35,7 @@ function ActorAvatar({
<div
data-slot="avatar"
className={cn(
"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",
"inline-flex shrink-0 items-center justify-center rounded-full font-medium overflow-hidden",
"bg-muted text-muted-foreground",
className
)}
@@ -57,8 +53,6 @@ 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
)}

View File

@@ -2,7 +2,6 @@
import { useRef } from "react";
import { Paperclip } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@multica/ui/lib/utils";
interface FileUploadButtonProps {
@@ -19,9 +18,7 @@ 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];
@@ -39,8 +36,8 @@ function FileUploadButton({
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled}
aria-label={attachLabel}
title={attachLabel}
aria-label="Attach file"
title="Attach file"
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,

View File

@@ -1,5 +1,4 @@
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"
@@ -68,10 +67,9 @@ function PaginationPrevious({
text = "Previous",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label={t(($) => $.pagination_previous)}
aria-label="Go to previous page"
size="default"
className={cn("pl-1.5!", className)}
{...props}
@@ -87,10 +85,9 @@ function PaginationNext({
text = "Next",
...props
}: React.ComponentProps<typeof PaginationLink> & { text?: string }) {
const { t } = useTranslation("ui")
return (
<PaginationLink
aria-label={t(($) => $.pagination_next)}
aria-label="Go to next page"
size="default"
className={cn("pr-1.5!", className)}
{...props}

View File

@@ -4,7 +4,6 @@ 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"
@@ -266,7 +265,6 @@ function SidebarTrigger({
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { t } = useTranslation("ui")
return (
<Button
@@ -282,15 +280,13 @@ function SidebarTrigger({
{...props}
>
<PanelLeftIcon />
<span className="sr-only">{t(($) => $.toggle_sidebar)}</span>
<span className="sr-only">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)
@@ -334,11 +330,11 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label={toggleLabel}
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={handleClick}
onMouseDown={onMouseDown}
title={toggleLabel}
title="Toggle Sidebar"
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",

View File

@@ -43,19 +43,11 @@ 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]);

View File

@@ -1,7 +1,6 @@
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'
@@ -62,7 +61,6 @@ 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)
@@ -180,7 +178,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 : t(($) => $.plain_text)}
{resolvedLang !== 'text' ? resolvedLang : 'plain text'}
</span>
<Tooltip>
<TooltipTrigger
@@ -190,7 +188,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={t(($) => $.copy_code)}
aria-label="Copy code"
>
{copied ? (
<Check className="size-3.5 text-success" />
@@ -200,7 +198,7 @@ export function CodeBlock({
</Button>
}
/>
<TooltipContent>{t(($) => $.copy_code)}</TooltipContent>
<TooltipContent>Copy code</TooltipContent>
</Tooltip>
</div>

View File

@@ -17,7 +17,6 @@
"./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"
},
@@ -53,10 +52,8 @@
"vaul": "^1.1.2"
},
"peerDependencies": {
"i18next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-i18next": "catalog:"
"react-dom": "catalog:"
},
"devDependencies": {
"@multica/tsconfig": "workspace:*",

View File

@@ -1,35 +0,0 @@
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;
}
}

View File

@@ -208,7 +208,7 @@ export function AgentOverviewPane({
);
}
// Padded, full-width container shared by every config tab. `h-full flex
// Centred, max-width container shared by every config tab. `h-full flex
// flex-col` lets a tab opt into "fill the viewport" by giving its root
// element `flex-1 min-h-0` (Instructions does this so the editor expands
// instead of pushing the Save row off-screen). Tabs that don't opt in
@@ -216,6 +216,6 @@ export function AgentOverviewPane({
// list) still scrolls via the parent's overflow-y-auto.
function TabContent({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-full flex-col p-4 md:p-6">{children}</div>
<div className="mx-auto flex h-full max-w-2xl flex-col p-4 md:p-6">{children}</div>
);
}

View File

@@ -280,24 +280,35 @@ export function AgentsPage() {
if (view === "archived" && archivedCount === 0) setView("active");
}, [view, archivedCount]);
const handleCreate = async (data: CreateAgentRequest): Promise<Agent> => {
const handleCreate = async (data: CreateAgentRequest) => {
const agent = await api.createAgent(data);
// Skill follow-up is now owned by the dialog (it reads the user's
// form selection, which already includes the duplicate source's
// skills as a default when applicable). The dialog will call
// setAgentSkills after we return; we just have to surface the
// created agent so it can.
let cachedAgent = agent;
// When duplicating, carry the source agent's skill assignments over.
// Skills aren't part of CreateAgentRequest (they're managed via
// setAgentSkills) so the create endpoint can't take them inline; we
// do a follow-up call. Failure here doesn't abort the duplicate —
// the agent already exists and the user can re-attach skills from
// the detail page.
if (duplicateTemplate?.skills.length) {
try {
await api.setAgentSkills(agent.id, {
skill_ids: duplicateTemplate.skills.map((s) => s.id),
});
cachedAgent = { ...agent, skills: duplicateTemplate.skills };
} catch {
// Surfaced softly; the agent itself is fine.
}
}
qc.setQueryData<Agent[]>(workspaceKeys.agents(wsId), (current = []) => {
const exists = current.some((a) => a.id === agent.id);
const exists = current.some((a) => a.id === cachedAgent.id);
return exists
? current.map((a) => (a.id === agent.id ? agent : a))
: [...current, agent];
? current.map((a) => (a.id === cachedAgent.id ? cachedAgent : a))
: [...current, cachedAgent];
});
setShowCreate(false);
setDuplicateTemplate(null);
navigation.push(paths.agentDetail(agent.id));
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
return agent;
};
const handleDuplicate = useCallback((agent: Agent) => {
@@ -456,7 +467,6 @@ export function AgentsPage() {
members={members}
currentUserId={currentUser?.id ?? null}
template={duplicateTemplate}
existingAgentNames={agents.map((a) => a.name)}
onClose={() => {
setShowCreate(false);
setDuplicateTemplate(null);

View File

@@ -1,143 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { Camera, ImagePlus, Loader2, X } from "lucide-react";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface AvatarPickerProps {
/** Current avatar URL. null when nothing chosen yet. */
value: string | null;
/** Fires after a successful upload — the parent stashes the URL for the
* create call. Re-fires with null when the user clears the choice. */
onChange: (url: string | null) => void;
/** Pixel size of the square. Defaults to 56 (h-14 / w-14), which lines
* up vertically with the Name + Description stack in the create-agent
* form so the two read as a single visual row. */
size?: number;
}
/**
* Compact avatar picker — a single square that lives next to the Name
* input in the create-agent form. Mirrors the visual language of
* agent-detail-inspector.tsx (Camera overlay on hover, file input behind
* the scenes), so users who've configured an avatar elsewhere in the app
* recognise the affordance immediately.
*
* No avatar yet → dashed placeholder with an ImagePlus icon.
* Has avatar → image fills the square, hover dims it with a Camera
* overlay for "click to change". A small × in the corner
* clears the choice.
*/
export function AvatarPicker({ value, onChange, size = 56 }: AvatarPickerProps) {
const { t } = useT("agents");
const fileInputRef = useRef<HTMLInputElement>(null);
const { upload, uploading } = useFileUpload(api);
const [previewError, setPreviewError] = useState(false);
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
e.target.value = ""; // allow re-selecting the same file
if (!file.type.startsWith("image/")) {
toast.error(t(($) => $.create_dialog.avatar.select_image_toast));
return;
}
try {
const result = await upload(file);
if (!result) return;
setPreviewError(false);
onChange(result.link);
} catch (err) {
toast.error(
err instanceof Error
? err.message
: t(($) => $.create_dialog.avatar.upload_failed_toast),
);
}
};
const hasValue = !!value && !previewError;
const dimensionStyle = { width: size, height: size };
return (
<div className="relative shrink-0" style={dimensionStyle}>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className={cn(
"group relative h-full w-full overflow-hidden rounded-lg outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-ring",
hasValue
? "border bg-muted"
: "border border-dashed bg-muted/40 hover:bg-muted",
)}
aria-label={
hasValue
? t(($) => $.create_dialog.avatar.change_aria)
: t(($) => $.create_dialog.avatar.upload_aria)
}
style={dimensionStyle}
>
{hasValue ? (
<img
src={value ?? undefined}
alt=""
className="h-full w-full object-cover"
onError={() => setPreviewError(true)}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-muted-foreground">
{uploading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<ImagePlus className="h-5 w-5" />
)}
</div>
)}
{/* Hover overlay only when there's already an image — otherwise the
placeholder icon already invites the click. */}
{hasValue && (
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin text-white" />
) : (
<Camera className="h-4 w-4 text-white" />
)}
</div>
)}
</button>
{/* Tiny X to clear, only shown when there's a value. Positioned just
outside the avatar's top-right corner so it doesn't cover the
image. */}
{hasValue && !uploading && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onChange(null);
setPreviewError(false);
}}
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-sm transition-colors hover:bg-muted hover:text-foreground"
aria-label={t(($) => $.create_dialog.avatar.remove_aria)}
>
<X className="h-3 w-3" />
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFile}
/>
</div>
);
}

View File

@@ -1,24 +1,13 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { render, screen, fireEvent } 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", () => ({
@@ -131,42 +120,22 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<QueryClientProvider client={queryClient}>
<WorkspaceSlugProvider slug="test-ws">
<NavigationProvider value={navigationStub}>
<CreateAgentDialog
runtimes={runtimes}
members={members}
currentUserId={ME}
template={template}
onClose={onClose}
onCreate={onCreate}
/>
</NavigationProvider>
</WorkspaceSlugProvider>
<CreateAgentDialog
runtimes={runtimes}
members={members}
currentUserId={ME}
template={template}
onClose={onClose}
onCreate={onCreate}
/>
</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" });

File diff suppressed because it is too large Load Diff

View File

@@ -1,116 +0,0 @@
"use client";
import { useRef, useState } from "react";
import { ChevronDown, FileText, X } from "lucide-react";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface InstructionsEditorProps {
/** Markdown source. Used both as default value when expanded and as
* preview text when collapsed. */
value: string;
/** Fires on every keystroke (debounced inside ContentEditor). */
onChange: (value: string) => void;
/** Optional placeholder override. Defaults to the i18n "click to write"
* copy; the create dialog passes the duplicate-specific string for
* agents being cloned. */
placeholder?: string;
}
/**
* Collapsible Instructions field for the create-agent dialog. Stays compact
* until the user wants to write — most agents only need instructions when
* they're being authored carefully, not on every quick-create.
*
* Two states:
* collapsed → small clickable card, shows a preview of `value` (or the
* placeholder when empty). One click expands.
* expanded → full ContentEditor (markdown, bubble menu, mention support,
* attachment upload). "Collapse" button on the right of the
* header tucks it back; value is preserved.
*/
export function InstructionsEditor({
value,
onChange,
placeholder,
}: InstructionsEditorProps) {
const { t } = useT("agents");
const [expanded, setExpanded] = useState(false);
const editorRef = useRef<ContentEditorRef>(null);
const label = t(($) => $.create_dialog.instructions.label);
const resolvedPlaceholder =
placeholder ?? t(($) => $.create_dialog.instructions.placeholder_blank);
const expand = () => {
setExpanded(true);
// Focus on next tick so the editor mounts first.
setTimeout(() => editorRef.current?.focus(), 0);
};
if (!expanded) {
return (
<div>
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<button
type="button"
onClick={expand}
className="mt-1.5 flex w-full items-start gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<FileText className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
{value.trim() ? (
// Preview: first 2 lines of markdown, ellipsised.
<div className="line-clamp-2 whitespace-pre-wrap text-sm text-foreground/80">
{value}
</div>
) : (
<div className="text-sm text-muted-foreground">{resolvedPlaceholder}</div>
)}
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
</button>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setExpanded(false)}
className="h-6 gap-1 px-2 text-xs"
>
<X className="h-3 w-3" />
{t(($) => $.create_dialog.instructions.collapse)}
</Button>
</div>
<div
className={cn(
"mt-1.5 rounded-lg border bg-card",
"focus-within:border-primary/40",
)}
>
<ContentEditor
ref={editorRef}
defaultValue={value}
onUpdate={onChange}
placeholder={t(($) => $.create_dialog.instructions.editor_placeholder)}
className="min-h-[160px] max-h-[320px] overflow-y-auto px-3 py-2.5 text-sm"
showBubbleMenu={true}
disableMentions={true}
/>
</div>
</div>
);
}

View File

@@ -1,9 +1,10 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import { FileText, Search } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent, SkillSummary } from "@multica/core/types";
import type { Agent } from "@multica/core/types";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import {
@@ -19,19 +20,18 @@ import {
DialogHeader,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { Input } from "@multica/ui/components/ui/input";
import { useT } from "../../i18n";
import { SkillPickerList } from "./skill-picker-list";
/**
* "Attach workspace skills to this agent." Multi-select with explicit
* Confirm — earlier iterations attached on a single row click, which
* meant the user couldn't tick several skills at once and the dialog
* closed before they could review their choice.
* Single source of truth for "attach a workspace skill to this agent".
* Used by both:
* - SkillsTab — full surface, "Add skill" button
* - Inspector → SkillAttach — inline dashed `+ Attach` chip
*
* Already-attached skills are filtered out of the list entirely (vs.
* showing them disabled). When there are no remaining workspace skills
* to attach, the empty-state copy explains why, and the Confirm button
* is naturally disabled because nothing can be selected.
* Owns the workspace-skill list query, the "what's still attachable" filter,
* the API call, and the optimistic invalidation. Callers only manage the
* open/close state — they don't repeat the attach logic.
*/
export function SkillAddDialog({
agent,
@@ -45,44 +45,34 @@ export function SkillAddDialog({
const { t } = useT("agents");
const wsId = useWorkspaceId();
const qc = useQueryClient();
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [query, setQuery] = useState("");
const attachedIds = useMemo(
() => new Set(agent.skills.map((s) => s.id)),
[agent.skills],
);
// Hide attached skills outright — the dialog is for adding new ones.
// If a user wants to see what's already on the agent, the SkillsTab
// list above shows it.
const availableSkills = useMemo(
() => workspaceSkills.filter((s) => !attachedIds.has(s.id)),
[workspaceSkills, attachedIds],
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
const availableSkills = workspaceSkills.filter(
(s) => !agentSkillIds.has(s.id),
);
const trimmedQuery = query.trim().toLowerCase();
const filteredSkills = trimmedQuery
? availableSkills.filter((s) => {
const name = s.name.toLowerCase();
const description = s.description?.toLowerCase() ?? "";
return (
name.includes(trimmedQuery) || description.includes(trimmedQuery)
);
})
: availableSkills;
const handleOpenChange = (v: boolean) => {
if (!v) setSelectedIds(new Set());
if (!v) setQuery("");
onOpenChange(v);
};
const handleToggle = (skill: SkillSummary) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(skill.id)) next.delete(skill.id);
else next.add(skill.id);
return next;
});
};
const handleConfirm = async () => {
if (selectedIds.size === 0) return;
const handleAdd = async (skillId: string) => {
setSaving(true);
try {
const newIds = [
...agent.skills.map((s) => s.id),
...selectedIds,
];
const newIds = [...agent.skills.map((s) => s.id), skillId];
await api.setAgentSkills(agent.id, { skill_ids: newIds });
qc.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
handleOpenChange(false);
@@ -93,46 +83,65 @@ export function SkillAddDialog({
}
};
const count = selectedIds.size;
const showSearch = availableSkills.length > 0;
const noMatch = showSearch && filteredSkills.length === 0;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="text-sm">
{t(($) => $.tab_body.skills.add_dialog_title)}
</DialogTitle>
<DialogTitle className="text-sm">{t(($) => $.tab_body.skills.add_dialog_title)}</DialogTitle>
<DialogDescription className="text-xs">
{t(($) => $.tab_body.skills.add_dialog_description)}
</DialogDescription>
</DialogHeader>
<SkillPickerList
skills={availableSkills}
selectedIds={selectedIds}
onToggle={handleToggle}
loading={isLoading}
emptyMessage={
workspaceSkills.length === 0
? t(($) => $.tab_body.skills.add_dialog_empty)
: t(($) => $.tab_body.skills.add_dialog_empty_partial)
}
/>
{showSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
aria-label={t(($) => $.tab_body.skills.add_dialog_search_placeholder)}
className="pl-7"
/>
</div>
)}
<div className="max-h-64 space-y-1 overflow-y-auto">
{filteredSkills.map((skill) => (
<button
key={skill.id}
onClick={() => handleAdd(skill.id)}
disabled={saving}
className="flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent/50"
>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{skill.name}</div>
{skill.description && (
<div className="truncate text-xs text-muted-foreground">
{skill.description}
</div>
)}
</div>
</button>
))}
{availableSkills.length === 0 && (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.add_dialog_empty)}
</p>
)}
{noMatch && (
<p className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.add_dialog_no_match)}
</p>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => handleOpenChange(false)}>
{t(($) => $.tab_body.skills.add_dialog_cancel)}
</Button>
<Button
onClick={handleConfirm}
disabled={count === 0 || saving}
>
{saving
? t(($) => $.tab_body.skills.add_dialog_saving)
: count > 0
? t(($) => $.tab_body.skills.add_dialog_confirm, { count })
: t(($) => $.tab_body.skills.add_dialog_confirm_default)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -1,104 +0,0 @@
"use client";
import { useState } from "react";
import { ChevronDown, Plus, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import type { SkillSummary } from "@multica/core/types";
import { useWorkspaceId } from "@multica/core/hooks";
import { skillListOptions } from "@multica/core/workspace/queries";
import { Button } from "@multica/ui/components/ui/button";
import { useT } from "../../i18n";
import { SkillPickerList } from "./skill-picker-list";
interface SkillMultiSelectProps {
/** Currently-selected skill IDs (controlled). */
selectedIds: ReadonlySet<string>;
/** Replaces the selection on every toggle. */
onChange: (next: Set<string>) => void;
}
/**
* Multi-select wrapper for the create-agent form. Collapsed by default;
* expands into a SkillPickerList configured for toggle behaviour
* (click adds to / removes from the local selection set).
*
* Shares its visual surface with SkillAddDialog via SkillPickerList —
* one component owns search + row rendering + indicators, so a tweak
* to either appears identically in both flows.
*/
export function SkillMultiSelect({
selectedIds,
onChange,
}: SkillMultiSelectProps) {
const { t } = useT("agents");
const wsId = useWorkspaceId();
const { data: workspaceSkills = [], isLoading } = useQuery(skillListOptions(wsId));
const [expanded, setExpanded] = useState(selectedIds.size > 0);
const label = t(($) => $.create_dialog.skills_section.label);
const toggle = (skill: SkillSummary) => {
const next = new Set(selectedIds);
if (next.has(skill.id)) next.delete(skill.id);
else next.add(skill.id);
onChange(next);
};
if (!expanded) {
return (
<div>
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
</div>
<button
type="button"
onClick={() => setExpanded(true)}
className="mt-1.5 flex w-full items-center gap-2.5 rounded-lg border bg-card px-3 py-3 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<Plus className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{selectedIds.size > 0
? t(($) => $.create_dialog.skills_section.selected, {
count: selectedIds.size,
})
: t(($) => $.create_dialog.skills_section.placeholder)}
</div>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40" />
</button>
</div>
);
}
return (
<div>
<div className="flex items-center justify-between">
<div className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{label}
{selectedIds.size > 0 ? (
<span className="ml-2 text-foreground/60">({selectedIds.size})</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setExpanded(false)}
className="h-6 gap-1 px-2 text-xs"
>
<X className="h-3 w-3" />
{t(($) => $.create_dialog.skills_section.collapse)}
</Button>
</div>
<div className="mt-1.5">
<SkillPickerList
skills={workspaceSkills}
selectedIds={selectedIds}
onToggle={toggle}
loading={isLoading}
emptyMessage={t(($) => $.create_dialog.skills_section.list_empty_multi)}
/>
</div>
</div>
);
}

View File

@@ -1,141 +0,0 @@
"use client";
import { useState } from "react";
import { FileText, Search } from "lucide-react";
import type { SkillSummary } from "@multica/core/types";
import { Checkbox } from "@multica/ui/components/ui/checkbox";
import { Input } from "@multica/ui/components/ui/input";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface SkillPickerListProps {
/** Skills to show. Callers filter (e.g. exclude already-attached
* skills in SkillAddDialog) before passing — this component just
* renders the rows. */
skills: readonly SkillSummary[];
/** Currently-toggled rows. Selected rows get a checked Checkbox and a
* subtle background; click toggles. */
selectedIds: ReadonlySet<string>;
/** Fires on every row click. Caller updates `selectedIds`. */
onToggle: (skill: SkillSummary) => void;
/** Show the search input at the top. Default true. */
searchable?: boolean;
/** Loading state for the skills query. */
loading?: boolean;
/** Caller-supplied empty / no-match copy. Falls back to generic i18n
* strings when omitted — the dialog and the create-form pass their
* own flavour-specific copy. */
emptyMessage?: string;
noMatchMessage?: string;
/** Outer-wrapper className. Defaults to `w-full`; callers pass
* e.g. `max-w-md` to constrain width. */
className?: string;
}
/**
* Headless multi-select list of workspace skills. Used by both
* SkillAddDialog (filtered to unattached skills) and SkillMultiSelect
* (create-form selection). One surface owns row layout, the search
* input, empty/loading states, and the shadcn Checkbox indicator, so
* tweaks land in one place.
*
* Rows truncate the name + description columns inside `flex-1 min-w-0`
* so long text doesn't push the Checkbox out of view.
*/
export function SkillPickerList({
skills,
selectedIds,
onToggle,
searchable = true,
loading = false,
emptyMessage,
noMatchMessage,
className,
}: SkillPickerListProps) {
const { t } = useT("agents");
const [query, setQuery] = useState("");
const trimmedQuery = query.trim().toLowerCase();
const filtered = trimmedQuery
? skills.filter((s) => {
const name = s.name.toLowerCase();
const description = s.description?.toLowerCase() ?? "";
return name.includes(trimmedQuery) || description.includes(trimmedQuery);
})
: skills;
const resolvedEmpty =
emptyMessage ?? t(($) => $.create_dialog.skills_section.list_empty_default);
const resolvedNoMatch =
noMatchMessage ?? t(($) => $.create_dialog.skills_section.list_no_match);
return (
<div className={cn("w-full overflow-hidden rounded-lg border bg-card", className)}>
{searchable && skills.length > 0 && (
<div className="border-b p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(($) => $.create_dialog.skills_section.search_placeholder)}
className="h-8 pl-7 text-xs"
/>
</div>
</div>
)}
<div className="max-h-64 space-y-0.5 overflow-y-auto p-1.5">
{loading ? (
<div className="py-6 text-center text-xs text-muted-foreground">
{t(($) => $.create_dialog.skills_section.list_loading)}
</div>
) : skills.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedEmpty}</div>
) : filtered.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">{resolvedNoMatch}</div>
) : (
filtered.map((skill) => {
const isSelected = selectedIds.has(skill.id);
return (
<button
key={skill.id}
type="button"
onClick={() => onToggle(skill)}
aria-pressed={isSelected}
className={cn(
"flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-left transition-colors",
isSelected ? "bg-accent" : "hover:bg-accent/50",
)}
>
{/* Indicator only — the wrapping <button> handles clicks,
so the Checkbox is non-interactive on its own. We
pass `checked` so the visual matches the row state. */}
<Checkbox
checked={isSelected}
tabIndex={-1}
className="pointer-events-none"
/>
<FileText className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{skill.name}</div>
{skill.description ? (
<div className="truncate text-xs text-muted-foreground">
{skill.description}
</div>
) : null}
</div>
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -24,16 +24,17 @@ export function SkillsTab({
const qc = useQueryClient();
const wsId = useWorkspaceId();
// Same query the SkillAddDialog uses (TanStack Query dedupes by key, so
// this isn't an extra request) — used here only to grey out the "Add
// skill" button when the workspace has zero skills total. When skills
// exist but are all already attached, we still open the dialog: it
// filters out attached skills and renders a localised "no more skills
// to add" empty state, which is more useful than a mysterious
// greyed-out button.
// this isn't an extra request) — used here only to grey out the "Add skill"
// button when there's nothing left to attach.
const { data: workspaceSkills = [] } = useQuery(skillListOptions(wsId));
const [removing, setRemoving] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const agentSkillIds = new Set(agent.skills.map((s) => s.id));
const availableCount = workspaceSkills.filter(
(s) => !agentSkillIds.has(s.id),
).length;
const handleRemove = async (skillId: string) => {
setRemoving(true);
try {
@@ -59,7 +60,7 @@ export function SkillsTab({
variant="outline"
size="sm"
onClick={() => setShowAdd(true)}
disabled={workspaceSkills.length === 0}
disabled={availableCount === 0}
className="shrink-0"
>
<Plus className="h-3 w-3" />
@@ -83,7 +84,7 @@ export function SkillsTab({
<p className="mt-1 max-w-xs text-center text-xs text-muted-foreground">
{t(($) => $.tab_body.skills.empty_hint)}
</p>
{workspaceSkills.length > 0 && (
{availableCount > 0 && (
<Button
onClick={() => setShowAdd(true)}
size="sm"

View File

@@ -1,173 +0,0 @@
"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>
</>
);
}

View File

@@ -1,203 +0,0 @@
"use client";
import { useMemo } from "react";
import {
Brush,
ChevronRight,
FileText,
FlaskConical,
LayoutDashboard,
ListChecks,
Loader2,
Megaphone,
Palette,
PenLine,
Presentation,
Search,
Sparkles,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { agentTemplateListOptions } from "@multica/core/agents/queries";
import type { AgentTemplateSummary } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface TemplatePickerProps {
/** Fired when a template card is clicked. The dialog advances to the
* detail step (which shows instructions + skills + Use button). */
onSelect: (template: AgentTemplateSummary) => void;
}
/**
* Step 2 of the create-agent flow: a 2-column grid of template cards,
* grouped by `category`. Clicking a card moves to the detail step.
*
* Templates are a static catalog (workspace-independent, only changes on
* server deploy), so the catalog is loaded through TanStack Query with
* `staleTime: Infinity` — re-opening the picker hits the cache instantly
* and there's no per-mount refetch.
*
* Icons and accent colors come from the template JSON itself (`icon` is a
* lucide-react name, `accent` is a Multica semantic token). Resolved
* through static maps (ICONS / ACCENTS) so Tailwind can JIT-scan every
* class variant — dynamic `bg-${accent}/10` strings would silently not
* generate.
*/
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
const { t } = useT("agents");
const { data: templates = [], isLoading, error } = useQuery(
agentTemplateListOptions(),
);
// Group by category. Templates without a category fall into the
// localised "Other" bucket so they still render. Preserves the load
// order within each group for deterministic UI (matches the
// alphabetic-by-filename order the loader uses on the server).
const otherCategory = t(($) => $.create_dialog.template_picker.other_category);
const groups = useMemo(() => {
const byCategory = new Map<string, AgentTemplateSummary[]>();
for (const tmpl of templates) {
const key = tmpl.category?.trim() ? tmpl.category : otherCategory;
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key)!.push(tmpl);
}
return Array.from(byCategory.entries());
}, [templates, otherCategory]);
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="text-sm text-destructive">
{error instanceof Error
? error.message
: t(($) => $.create_dialog.template_picker.load_failed)}
</div>
</div>
);
}
if (templates.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="text-sm text-muted-foreground">
{t(($) => $.create_dialog.template_picker.empty)}
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-6 p-6">
{groups.map(([category, tmpls]) => (
<section key={category}>
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{category}
</h2>
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
{tmpls.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
</section>
))}
</div>
</div>
);
}
interface TemplateCardProps {
template: AgentTemplateSummary;
onClick: () => void;
}
function TemplateCard({ template, onClick }: TemplateCardProps) {
const { t } = useT("agents");
const Icon = ICONS[template.icon ?? ""] ?? FileText;
const accentClass = ACCENTS[template.accent ?? ""] ?? ACCENTS.muted;
return (
<button
type="button"
onClick={onClick}
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
accentClass,
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="truncate text-sm font-semibold">{template.name}</span>
<ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
<div className="mt-2.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t(($) => $.create_dialog.template_card.skills, {
count: template.skills.length,
})}
</div>
</div>
</button>
);
}
// --- Static maps so Tailwind's JIT scanner picks up every variant ---
/** Lucide icon name → component. Add new entries when shipping templates
* that use icons not yet listed here. Unknown names fall back to FileText. */
const ICONS: Record<string, LucideIcon> = {
Search,
Palette,
FileText,
FlaskConical,
Sparkles,
ListChecks,
Brush,
PenLine,
Megaphone,
Presentation,
LayoutDashboard,
};
/** Semantic accent → Tailwind class string. The class strings are written
* out verbatim so JIT scans them; dynamic `bg-${name}/10` would not be
* generated. Mirrors the conventions in runtime-columns.tsx /
* usage-section.tsx (existing uses of these tokens). */
const DEFAULT_ACCENT = "bg-muted text-muted-foreground";
const ACCENTS: Record<string, string> = {
info: "bg-info/10 text-info",
success: "bg-success/10 text-success",
warning: "bg-warning/10 text-warning",
primary: "bg-primary/10 text-primary",
secondary: "bg-secondary text-secondary-foreground",
muted: DEFAULT_ACCENT,
};
/** Exposed for the detail / form steps so they can render the same icon
* badge as the picker card. Keeps visual continuity across steps. */
export function getTemplateIcon(iconName: string | undefined): LucideIcon {
return ICONS[iconName ?? ""] ?? FileText;
}
export function getAccentClass(accent: string | undefined): string {
return ACCENTS[accent ?? ""] ?? DEFAULT_ACCENT;
}

View File

@@ -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, Pencil } from "lucide-react";
import { Minus, Maximize2, Minimize2, ChevronDown, ChevronRight, Plus, Check, Trash2 } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
import {
@@ -46,7 +46,6 @@ import {
useCreateChatSession,
useDeleteChatSession,
useMarkChatSessionRead,
useUpdateChatSession,
} from "@multica/core/chat/mutations";
import { useChatStore } from "@multica/core/chat";
import { ChatMessageList, ChatMessageSkeleton } from "./chat-message-list";
@@ -211,12 +210,6 @@ 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> => {
@@ -230,6 +223,7 @@ export function ChatWindow() {
agent_id: activeAgent.id,
title: titleSeed.slice(0, 50),
});
setActiveSession(session.id);
return session.id;
} finally {
sessionPromiseRef.current = null;
@@ -238,25 +232,16 @@ export function ChatWindow() {
sessionPromiseRef.current = promise;
return promise;
},
[activeSessionId, activeAgent, createSession],
[activeSessionId, activeAgent, createSession, setActiveSession],
);
const handleUploadFile = useCallback(
async (file: File) => {
const sessionId = await ensureSession("");
if (!sessionId) return null;
// Prime the messages cache as empty before flipping activeSessionId so
// ChatMessageList mounts directly (no Skeleton frame). Skip the write
// when an entry already exists — a concurrent handleSend may have
// seeded an optimistic message we must not clobber.
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => old ?? [],
);
setActiveSession(sessionId);
return uploadWithToast(file, { chatSessionId: sessionId });
},
[ensureSession, uploadWithToast, qc, setActiveSession],
[ensureSession, uploadWithToast],
);
const handleSend = useCallback(
@@ -302,12 +287,6 @@ export function ChatWindow() {
task_id: null,
created_at: sentAt,
};
// Seed cache BEFORE flipping activeSessionId. If we set the active
// session first, useQuery's first subscription to the new key sees no
// cached data and renders ChatMessageSkeleton for one frame — the
// "new-chat first-message" white flash. Priming the cache first means
// the very first read after activeSessionId flips hits data
// synchronously and ChatMessageList mounts directly.
qc.setQueryData<ChatMessage[]>(
chatKeys.messages(sessionId),
(old) => (old ? [...old, optimistic] : [optimistic]),
@@ -322,9 +301,6 @@ 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);
@@ -349,7 +325,6 @@ export function ChatWindow() {
anchorCandidate,
ensureSession,
qc,
setActiveSession,
],
);
@@ -735,12 +710,7 @@ 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();
@@ -779,35 +749,14 @@ 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}
// 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);
}}
onClick={() => onSelectSession(session)}
className="group flex min-w-0 items-center gap-2"
>
{agent ? (
@@ -822,84 +771,45 @@ function SessionDropdown({
<span className="size-6 shrink-0" />
)}
<div className="min-w-0 flex-1">
{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 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.
*
* 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 ? (
* amber + pulse to read as activity. */}
{isRunning ? (
<span
aria-label={t(($) => $.window.running)}
title={t(($) => $.window.running)}
className="size-1.5 shrink-0 rounded-full bg-amber-500 animate-pulse"
/>
) : !isRenaming && session.has_unread ? (
) : session.has_unread ? (
<span
aria-label={t(($) => $.window.unread)}
title={t(($) => $.window.unread)}
className="size-1.5 shrink-0 rounded-full bg-brand"
/>
) : null}
{!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>
</>
)}
{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>
</DropdownMenuItem>
);
};
@@ -1016,86 +926,6 @@ 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) => {

View File

@@ -54,7 +54,6 @@ export function ActorAvatar({
avatarUrl={getActorAvatarUrl(actorType, actorId)}
isAgent={actorType === "agent"}
isSystem={actorType === "system"}
isSquad={actorType === "squad"}
size={size}
className={className}
/>

View File

@@ -1,112 +0,0 @@
"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>
);
}

View File

@@ -25,10 +25,6 @@ import { KpiCard } from "../../runtimes/components/shared";
import { DailyCostChart } 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 {
@@ -110,14 +106,9 @@ function Segmented<T extends string | number>({
*/
export function DashboardPage() {
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.
@@ -185,18 +176,12 @@ export function DashboardPage() {
return (
<div className="flex h-full flex-col">
{/* h-auto + min-h-12 + flex-wrap: the toolbar (project filter, range
switch, timezone select) overflows the single h-12 row on narrow
and medium widths once the timezone picker is added — letting the
right cluster wrap underneath keeps every control reachable
without an off-screen bleed. Wider viewports still render the
original single row. */}
<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>
<PageHeader className="justify-between px-5">
<div className="flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-muted-foreground" />
<h1 className="text-sm font-medium">{t(($) => $.title)}</h1>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<ProjectFilter
projects={projects}
value={projectValue}
@@ -207,12 +192,6 @@ 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>

View File

@@ -9,10 +9,6 @@ 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
@@ -40,16 +36,12 @@ 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 att = url && attachments?.length
? attachments.find((a) => a.url === url)
const id = url && attachments?.length
? attachments.find((a) => a.url === url)?.id
: undefined;
if (att) {
download(att.id);
if (id) {
download(id);
return;
}
if (url) openExternal(url);
@@ -78,7 +70,6 @@ export function useAttachmentDownloadResolver(): ResolvedDownload {
if (ctx) return ctx;
return {
resolveAttachmentId: () => undefined,
resolveAttachment: () => undefined,
openByUrl: (url) => {
if (url) openExternal(url);
},

View File

@@ -1,249 +0,0 @@
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();
});
});

View File

@@ -1,426 +0,0 @@
"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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ---------------------------------------------------------------------------
// 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";

View File

@@ -89,11 +89,9 @@ interface ContentEditorProps {
*/
currentIssueId?: string;
/**
* 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.
* 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).
*/
disableMentions?: boolean;
/**

View File

@@ -17,11 +17,9 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Eye, FileText, Loader2, Download } from "lucide-react";
import { 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";
// ---------------------------------------------------------------------------
@@ -37,23 +35,12 @@ function FileCardView({ node }: NodeViewProps) {
const href = (node.attrs.href as string) || "";
const filename = (node.attrs.filename as string) || "";
const uploading = node.attrs.uploading as boolean;
const { openByUrl, resolveAttachment } = useAttachmentDownloadResolver();
const preview = useAttachmentPreview();
const { openByUrl } = useAttachmentDownloadResolver();
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
@@ -69,27 +56,10 @@ 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();
@@ -100,7 +70,6 @@ function FileCardView({ node }: NodeViewProps) {
</button>
)}
</div>
{preview.modal}
</NodeViewWrapper>
);
}

View File

@@ -86,12 +86,11 @@ export interface EditorExtensionsOptions {
/** When true, bare Enter also submits (chat-style). Default false. */
submitOnEnter?: boolean;
/**
* 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.
* 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.
*/
disableMentions?: boolean;
}
@@ -129,14 +128,16 @@ export function createEditorExtensions(
// so users can copy rich content out as the original Markdown.
createMarkdownCopyExtension(),
FileCardExtension,
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(options.disableMentions
? { suggestion: { allow: () => false } }
: options.queryClient
? { suggestion: createMentionSuggestion(options.queryClient) }
: {}),
}),
...(options.disableMentions
? []
: [
BaseMentionExtension.configure({
HTMLAttributes: { class: "mention" },
...(options.queryClient
? { suggestion: createMentionSuggestion(options.queryClient) }
: {}),
}),
]),
Typography,
Placeholder.configure({ placeholder: placeholderText }),
createMarkdownPasteExtension(),

View File

@@ -24,7 +24,6 @@ import type {
ListIssuesCache,
MemberWithUser,
Agent,
Squad,
} from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { StatusIcon } from "../../issues/components/status-icon";
@@ -45,7 +44,7 @@ import {
export interface MentionItem {
id: string;
label: string;
type: "member" | "agent" | "squad" | "issue" | "all";
type: "member" | "agent" | "issue" | "all";
/** Secondary text shown beside the label (e.g. issue title) */
description?: string;
/** Issue status for StatusIcon rendering */
@@ -345,11 +344,6 @@ 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>
);
}
@@ -386,7 +380,6 @@ 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) : [];
@@ -423,16 +416,12 @@ 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, ...squadItems],
[...memberItems, ...agentItems],
recency,
);

View File

@@ -14,9 +14,3 @@ 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";

View File

@@ -30,7 +30,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { Maximize2, Download, Eye, Link as LinkIcon, FileText } from "lucide-react";
import { Maximize2, Download, Link as LinkIcon, FileText } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { useWorkspacePaths, useWorkspaceSlug } from "@multica/core/paths";
@@ -45,8 +45,6 @@ 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";
@@ -241,24 +239,18 @@ function ReadonlyImage({
function ReadonlyFileCard({
href,
filename,
resolveAttachment,
resolveAttachmentId,
onDownload,
onPreview,
}: {
href: string;
filename: string;
resolveAttachment: (url: string) => Attachment | undefined;
resolveAttachmentId: (url: string) => string | undefined;
onDownload: (attachmentId: string) => void;
onPreview: (att: Attachment) => boolean;
}) {
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);
const handleClick = () => {
const id = resolveAttachmentId(href);
if (id) {
onDownload(id);
return;
}
openExternal(href);
@@ -269,24 +261,11 @@ 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"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={handleDownloadClick}
onClick={handleClick}
>
<Download className="size-3.5" />
</button>
@@ -297,9 +276,7 @@ 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
@@ -327,9 +304,8 @@ function buildComponents(
<ReadonlyFileCard
href={href}
filename={filename}
resolveAttachment={resolveAttachment}
resolveAttachmentId={resolveAttachmentId}
onDownload={onDownload}
onPreview={onPreview}
/>
);
}
@@ -434,19 +410,9 @@ 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, resolveAttachment, download, preview.tryOpen),
[resolveAttachmentId, resolveAttachment, download, preview.tryOpen],
() => buildComponents(resolveAttachmentId, download),
[resolveAttachmentId, download],
);
return (
@@ -460,7 +426,6 @@ export const ReadonlyContent = memo(function ReadonlyContent({
{processed}
</ReactMarkdown>
<LinkHoverCard {...hover} />
{preview.modal}
</div>
);
});

View File

@@ -9,6 +9,10 @@ vi.mock("@multica/core/api", () => ({
api: { getAttachment: getAttachmentMock },
}));
vi.mock("../platform", () => ({
openExternal: vi.fn(),
}));
vi.mock("sonner", () => ({
toast: { error: vi.fn(), success: vi.fn() },
}));
@@ -18,6 +22,7 @@ vi.mock("../i18n", () => ({
}));
import { useDownloadAttachment } from "./use-download-attachment";
import { openExternal } from "../platform";
import { toast } from "sonner";
const SIGNED_URL =
@@ -77,10 +82,9 @@ describe("useDownloadAttachment (web)", () => {
});
describe("useDownloadAttachment (desktop)", () => {
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,
it("skips the placeholder tab and hands the signed URL to openExternal", async () => {
(window as unknown as { desktopAPI: { openExternal: () => void } }).desktopAPI = {
openExternal: vi.fn(),
};
getAttachmentMock.mockResolvedValueOnce({
id: "att-1",
@@ -99,23 +103,6 @@ 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(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());
expect(openExternal).toHaveBeenCalledWith(SIGNED_URL);
});
});

View File

@@ -3,19 +3,20 @@
import { useCallback } from "react";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { openExternal } from "../platform";
import { useT } from "../i18n";
interface DesktopBridge {
downloadURL?: (u: string) => Promise<void> | void;
openExternal?: (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 hasDesktopDownloadBridge(): boolean {
function hasDesktopBridge(): boolean {
if (typeof window === "undefined") return false;
const bridge = (window as unknown as { desktopAPI?: DesktopBridge }).desktopAPI;
return Boolean(bridge?.downloadURL);
return Boolean(bridge?.openExternal);
}
/**
@@ -34,11 +35,11 @@ function hasDesktopDownloadBridge(): 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**: 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.
* - **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.
*/
export function useDownloadAttachment(): (attachmentId: string) => Promise<void> {
const { t } = useT("editor");
@@ -46,17 +47,14 @@ export function useDownloadAttachment(): (attachmentId: string) => Promise<void>
async (attachmentId: string) => {
const failed = () => toast.error(t(($) => $.attachment.download_failed));
if (hasDesktopDownloadBridge()) {
if (hasDesktopBridge()) {
try {
const fresh = await api.getAttachment(attachmentId);
if (!fresh.download_url) {
failed();
return;
}
const bridge = (
window as unknown as { desktopAPI?: DesktopBridge }
).desktopAPI;
await bridge!.downloadURL!(fresh.download_url);
openExternal(fresh.download_url);
} catch {
failed();
}

View File

@@ -1,90 +0,0 @@
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();
});
});

View File

@@ -1,185 +0,0 @@
/**
* 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;
}

View File

@@ -1,8 +1,4 @@
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";
@@ -25,7 +21,6 @@ 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 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.
@@ -35,44 +30,33 @@ import type squads from "../locales/en/squads.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: I18nResources;
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;
usage: typeof usage;
};
enableSelector: true;
}
}

View File

@@ -48,18 +48,6 @@ 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", () => ({
@@ -170,29 +158,6 @@ 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(

View File

@@ -27,6 +27,20 @@ vi.mock("@multica/core/auth", () => ({
registerAuthStore: vi.fn(),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({
queryKey: ["workspaces", "ws-1", "members"],
queryFn: () =>
Promise.resolve([
{ user_id: "user-1", name: "Test User", email: "t@t.com", role: "admin" },
]),
}),
agentListOptions: () => ({
queryKey: ["workspaces", "ws-1", "agents"],
queryFn: () => Promise.resolve([]),
}),
}));
// Mutable so individual tests can seed the pin list.
const pinListRef: { value: Array<{ item_type: string; item_id: string }> } = {
value: [],
@@ -253,4 +267,12 @@ describe("useIssueActions", () => {
expect(mockOpenModal).not.toHaveBeenCalled();
});
it("members and filtered agents are exposed on the result", async () => {
const { result } = renderHook(() => useIssueActions(mockIssue), { wrapper });
await waitFor(() => {
expect(result.current.members.length).toBe(1);
});
expect(result.current.members[0]!.user_id).toBe("user-1");
expect(result.current.agents).toEqual([]);
});
});

View File

@@ -1,6 +1,6 @@
"use client";
import { useRef, useState, type ReactElement } from "react";
import type { ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
ContextMenu,
@@ -12,7 +12,6 @@ import {
IssueActionsMenuItems,
contextPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
interface IssueActionsContextMenuProps {
issue: Issue;
@@ -25,59 +24,16 @@ export function IssueActionsContextMenu({
children,
}: IssueActionsContextMenuProps) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// Right-click coordinates captured during contextmenu so the AssigneePicker
// opens where the context menu just was, instead of jumping to the row's
// top-left corner. Reset between opens; only consulted while the picker is
// mounted-open.
const clickPosRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const handleContextMenu = (e: React.MouseEvent) => {
clickPosRef.current = { x: e.clientX, y: e.clientY };
};
return (
<>
<ContextMenu>
<ContextMenuTrigger
render={children}
onContextMenu={handleContextMenu}
<ContextMenu>
<ContextMenuTrigger render={children} />
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
/>
<ContextMenuContent>
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={contextPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
/>
</ContextMenuContent>
</ContextMenu>
{/* Mount the picker only once the user actually opens it. Otherwise
every row in a list/board would subscribe to members/agents/squads
/frequency queries on mount, multiplying memory + render cost. */}
{assigneeOpen && (
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none fixed"
style={{
left: clickPosRef.current.x,
top: clickPosRef.current.y,
width: 0,
height: 0,
}}
/>
}
trigger={<span />}
align="start"
/>
)}
</>
</ContextMenuContent>
</ContextMenu>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, type ReactElement } from "react";
import type { ReactElement } from "react";
import type { Issue } from "@multica/core/types";
import {
DropdownMenu,
@@ -12,7 +12,6 @@ import {
IssueActionsMenuItems,
dropdownPrimitives,
} from "./issue-actions-menu-items";
import { AssigneePicker } from "../components/pickers";
interface IssueActionsDropdownProps {
issue: Issue;
@@ -30,46 +29,17 @@ export function IssueActionsDropdown({
onDeletedNavigateTo,
}: IssueActionsDropdownProps) {
const actions = useIssueActions(issue);
const [assigneeOpen, setAssigneeOpen] = useState(false);
// The outer `relative inline-flex` is the picker's anchor box: the
// absolute, pointer-events-none span inside `triggerRender` fills it, so
// the popover positions itself relative to the dropdown's 3-dot button
// without us having to thread a ref through Base UI's anchor API.
return (
<span className="relative inline-flex">
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onOpenAssignee={() => setAssigneeOpen(true)}
onDeletedNavigateTo={onDeletedNavigateTo}
/>
</DropdownMenuContent>
</DropdownMenu>
{/* Mount the picker only once the user actually opens it. Otherwise
every row in a list/board would subscribe to members/agents/squads
/frequency queries on mount, multiplying memory + render cost. */}
{assigneeOpen && (
<AssigneePicker
assigneeType={issue.assignee_type}
assigneeId={issue.assignee_id}
onUpdate={actions.updateField}
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
triggerRender={
<span
aria-hidden
className="pointer-events-none absolute inset-0"
/>
}
trigger={<span />}
align={align}
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align={align} className="w-auto">
<IssueActionsMenuItems
issue={issue}
actions={actions}
primitives={dropdownPrimitives}
onDeletedNavigateTo={onDeletedNavigateTo}
/>
)}
</span>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -26,6 +26,7 @@ import {
import { issueKeys } from "@multica/core/issues/queries";
import { StatusIcon } from "../components/status-icon";
import { PriorityIcon } from "../components/priority-icon";
import { ActorAvatar } from "../../common/actor-avatar";
import {
DropdownMenuItem,
DropdownMenuSub,
@@ -77,11 +78,6 @@ interface IssueActionsMenuItemsProps {
issue: Issue;
actions: UseIssueActionsResult;
primitives: MenuPrimitives;
/** Called when the user clicks the Assignee menu item. The parent should
* close the surrounding menu and open the shared `AssigneePicker` popover.
* Decoupled this way so the same item can drive both the dropdown
* (3-dot button) and the context menu (right-click) wrappers. */
onOpenAssignee: () => void;
/** If set, navigate here after the issue is deleted (used by the detail page). */
onDeletedNavigateTo?: string;
}
@@ -90,11 +86,12 @@ export function IssueActionsMenuItems({
issue,
actions,
primitives: P,
onOpenAssignee,
onDeletedNavigateTo,
}: IssueActionsMenuItemsProps) {
const { t } = useT("issues");
const {
members,
agents,
isPinned,
updateField,
togglePin,
@@ -187,15 +184,55 @@ export function IssueActionsMenuItems({
</P.SubContent>
</P.Sub>
{/* Assignee — closes this menu and hands off to the shared
AssigneePicker (members + agents + squads, with search and
permission checks). Keeps a single source of truth for the
assignee UX across detail sidebar, board cards, and right-click /
3-dot menus. */}
<P.Item onClick={onOpenAssignee}>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
</P.Item>
{/* Assignee */}
<P.Sub>
<P.SubTrigger>
<UserMinus className="h-3.5 w-3.5" />
{t(($) => $.actions.assignee)}
</P.SubTrigger>
<P.SubContent>
<P.Item
onClick={() =>
updateField({ assignee_type: null, assignee_id: null })
}
>
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
{t(($) => $.actions.unassigned)}
{!issue.assignee_type && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
{members.map((m) => (
<P.Item
key={m.user_id}
onClick={() =>
updateField({ assignee_type: "member", assignee_id: m.user_id })
}
>
<ActorAvatar actorType="member" actorId={m.user_id} size={16} />
{m.name}
{issue.assignee_type === "member" &&
issue.assignee_id === m.user_id && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
))}
{agents.map((a) => (
<P.Item
key={a.id}
onClick={() =>
updateField({ assignee_type: "agent", assignee_id: a.id })
}
>
<ActorAvatar actorType="agent" actorId={a.id} size={16} />
{a.name}
{issue.assignee_type === "agent" && issue.assignee_id === a.id && (
<span className="ml-auto text-xs text-muted-foreground">{"✓"}</span>
)}
</P.Item>
))}
</P.SubContent>
</P.Sub>
{/* Due date */}
<P.Sub>

View File

@@ -1,22 +1,36 @@
"use client";
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@multica/core/types";
import type {
Issue,
MemberWithUser,
Agent,
UpdateIssueRequest,
} from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import {
memberListOptions,
agentListOptions,
} from "@multica/core/workspace/queries";
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
import { canAssignAgent } from "../components/pickers";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
const BACKLOG_HINT_LS_KEY = "multica:backlog-agent-hint-dismissed";
export interface UseIssueActionsResult {
// Derived data for rendering menu rows
members: MemberWithUser[];
agents: Agent[];
isPinned: boolean;
// Handlers
updateField: (updates: Partial<UpdateIssueRequest>) => void;
togglePin: () => void;
copyLink: () => Promise<void>;
@@ -39,11 +53,24 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
const user = useAuthStore((s) => s.user);
const userId = user?.id;
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: pinnedItems = [] } = useQuery({
...pinListOptions(wsId, userId ?? ""),
enabled: !!userId,
});
const currentMemberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
[members, userId],
);
const filteredAgents = useMemo(
() =>
agents.filter(
(a) => !a.archived_at && canAssignAgent(a, userId, currentMemberRole),
),
[agents, userId, currentMemberRole],
);
const isPinned =
!!issue &&
pinnedItems.some(
@@ -134,6 +161,8 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
);
return {
members,
agents: filteredAgents,
isPinned,
updateField,
togglePin,

View File

@@ -1,7 +1,7 @@
"use client";
import { memo, useCallback, useRef, useState } from "react";
import { CheckCircle2, ChevronRight, Copy, Download, Eye, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { CheckCircle2, ChevronRight, Copy, Download, FileText, MoreHorizontal, Pencil, RotateCcw, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Card } from "@multica/ui/components/ui/card";
import { Button } from "@multica/ui/components/ui/button";
@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
import { cn } from "@multica/ui/lib/utils";
import { useActorName } from "@multica/core/workspace/hooks";
import { timeAgo } from "@multica/core/utils";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, isPreviewable } from "../../editor";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -122,9 +122,7 @@ function DeleteCommentDialog({
// ---------------------------------------------------------------------------
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
const { t } = useT("editor");
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
if (!attachments?.length) return null;
// Skip attachments whose URL is already referenced in the markdown content,
// and duplicates of the same file (same name/type/size) that are referenced.
@@ -158,29 +156,15 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{a.filename}</p>
</div>
{isPreviewable(a.content_type, a.filename) && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onClick={() => preview.tryOpen(a)}
>
<Eye className="size-3.5" />
</button>
)}
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onClick={() => download(a.id)}
>
<Download className="size-3.5" />
</button>
</div>
))}
{preview.modal}
</div>
);
}

View File

@@ -66,10 +66,6 @@ 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([]),
@@ -117,14 +113,6 @@ vi.mock("../../editor", () => ({
// real API singleton; tests that care about download wiring should write
// dedicated specs against `use-download-attachment.test.tsx`.
useDownloadAttachment: () => vi.fn(),
// Inert preview hook — comment-card's AttachmentList uses it to gate the
// Eye button. Dedicated coverage lives in attachment-preview-modal.test.tsx.
useAttachmentPreview: () => ({
open: vi.fn(),
tryOpen: () => false,
modal: null,
}),
isPreviewable: () => false,
ReadonlyContent: ({ content }: { content: string }) => (
<div data-testid="readonly-content">{content}</div>
),

View File

@@ -8,7 +8,7 @@ import { useAuthStore } from "@multica/core/auth";
import { canAssignAgentToIssue } from "@multica/core/permissions";
import { useActorName } from "@multica/core/workspace/hooks";
import { useWorkspaceId } from "@multica/core/hooks";
import { memberListOptions, agentListOptions, squadListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries";
import { memberListOptions, agentListOptions, assigneeFrequencyOptions } from "@multica/core/workspace/queries";
import { ActorAvatar } from "../../../common/actor-avatar";
import {
PropertyPicker,
@@ -64,7 +64,6 @@ export function AssigneePicker({
const wsId = useWorkspaceId();
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(wsId));
const { data: squads = [] } = useQuery(squadListOptions(wsId));
const { data: frequency = [] } = useQuery(assigneeFrequencyOptions(wsId));
const { getActorName } = useActorName();
@@ -89,9 +88,6 @@ export function AssigneePicker({
const filteredAgents = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(query))
.sort((a, b) => getFreq("agent", b.id) - getFreq("agent", a.id));
const filteredSquads = squads
.filter((s) => !s.archived_at && s.name.toLowerCase().includes(query))
.sort((a, b) => getFreq("squad", b.id) - getFreq("squad", a.id));
const isSelected = (type: string, id: string) =>
assigneeType === type && assigneeId === id;
@@ -201,32 +197,8 @@ export function AssigneePicker({
</PickerSection>
)}
{/* Squads — group ownership; assigning to a squad routes the issue to
its leader agent on the backend. */}
{filteredSquads.length > 0 && (
<PickerSection label={t(($) => $.pickers.assignee.squads_group)}>
{filteredSquads.map((s) => (
<PickerItem
key={s.id}
selected={isSelected("squad", s.id)}
onClick={() => {
onUpdate({
assignee_type: "squad",
assignee_id: s.id,
});
setOpen(false);
}}
>
<ActorAvatar actorType="squad" actorId={s.id} size={18} />
<span>{s.name}</span>
</PickerItem>
))}
</PickerSection>
)}
{filteredMembers.length === 0 &&
filteredAgents.length === 0 &&
filteredSquads.length === 0 &&
filter && <PickerEmpty />}
</PropertyPicker>
);

View File

@@ -90,7 +90,6 @@ vi.mock("@multica/core/paths", () => ({
projects: () => "/acme/projects",
autopilots: () => "/acme/autopilots",
agents: () => "/acme/agents",
squads: () => "/acme/squads",
usage: () => "/acme/usage",
runtimes: () => "/acme/runtimes",
skills: () => "/acme/skills",

View File

@@ -2,7 +2,6 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { AppLink, useNavigation } from "../navigation";
import { HelpLauncher } from "./help-launcher";
import {
@@ -33,7 +32,6 @@ import {
BarChart3,
X,
Zap,
Users,
} from "lucide-react";
import { WorkspaceAvatar } from "../workspace/workspace-avatar";
import { ActorAvatar } from "@multica/ui/components/common/actor-avatar";
@@ -109,7 +107,6 @@ type NavKey =
| "projects"
| "autopilots"
| "agents"
| "squads"
| "usage"
| "runtimes"
| "skills"
@@ -123,7 +120,6 @@ type NavLabelKey =
| "projects"
| "autopilots"
| "agents"
| "squads"
| "usage"
| "runtimes"
| "skills"
@@ -139,7 +135,6 @@ const workspaceNav: { key: NavKey; labelKey: NavLabelKey; icon: typeof Inbox }[]
{ key: "projects", labelKey: "projects", icon: FolderKanban },
{ key: "autopilots", labelKey: "autopilots", icon: Zap },
{ key: "agents", labelKey: "agents", icon: Bot },
{ key: "squads", labelKey: "squads", icon: Users },
{ key: "usage", labelKey: "usage", icon: BarChart3 },
];
@@ -368,8 +363,6 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
const deletePin = useDeletePin();
const reorderPins = useReorderPins();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const sidebarScrollRef = useRef<HTMLDivElement>(null);
const sidebarFadeStyle = useScrollFade(sidebarScrollRef, 24);
// Local presentational copy of pinnedItems for drop-animation stability.
// Follows TQ at rest; frozen during a drag gesture so a mid-drag cache
@@ -609,7 +602,7 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
</SidebarHeader>
{/* Navigation */}
<SidebarContent ref={sidebarScrollRef} style={sidebarFadeStyle}>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className="gap-0.5">

View File

@@ -218,68 +218,7 @@
"create": "Create",
"creating": "Creating...",
"cancel": "Cancel",
"create_failed_toast": "Failed to create agent",
"back_aria": "Back",
"no_runtime_toast": "No runtime available. Add a runtime first, then try again.",
"template_created_toast": "Agent \"{{name}}\" created",
"template_created_with_reuse_toast_one": "Agent \"{{name}}\" created. Reused {{count}} existing skill.",
"template_created_with_reuse_toast_other": "Agent \"{{name}}\" created. Reused {{count}} existing skills.",
"skill_attach_failed_toast": "Agent created, but failed to attach skills: {{error}}",
"chooser": {
"blank_title": "Start blank",
"blank_desc": "Write everything yourself.",
"template_title": "From template",
"template_desc": "Pre-configured agents with curated skills."
},
"template_picker": {
"title": "Pick a template",
"empty": "No templates available yet.",
"load_failed": "Failed to load templates",
"other_category": "Other"
},
"template_card": {
"skills_one": "{{count}} skill",
"skills_other": "{{count}} skills"
},
"template_detail": {
"load_failed": "Failed to load template",
"skill_count_one": "Includes {{count}} skill",
"skill_count_other": "Includes {{count}} skills",
"instructions_label": "Instructions",
"instructions_loading": "Loading instructions…",
"use": "Use this template",
"creating": "Creating…"
},
"template_failure": {
"title": "Some template skills are unavailable",
"body": "The template can't be applied because one or more upstream URLs returned an error. No changes were written. Ping the template maintainer with the URLs below."
},
"instructions": {
"label": "Instructions",
"placeholder_blank": "Click to write instructions…",
"placeholder_duplicate": "Click to view or edit the duplicated instructions",
"collapse": "Collapse",
"editor_placeholder": "Write what this agent should do, what to focus on, what to avoid…"
},
"skills_section": {
"label": "Skills",
"placeholder": "Add skills from workspace",
"selected_one": "{{count}} selected — click to edit",
"selected_other": "{{count}} selected — click to edit",
"collapse": "Collapse",
"list_empty_multi": "No skills in this workspace yet. Create or import one first.",
"list_loading": "Loading…",
"list_empty_default": "No skills available.",
"list_no_match": "No skills match.",
"search_placeholder": "Search skills…"
},
"avatar": {
"change_aria": "Change avatar",
"upload_aria": "Upload avatar",
"remove_aria": "Remove avatar",
"select_image_toast": "Please select an image file.",
"upload_failed_toast": "Avatar upload failed"
}
"create_failed_toast": "Failed to create agent"
},
"tab_body": {
"common": {
@@ -327,11 +266,6 @@
"add_dialog_search_placeholder": "Search skills",
"add_dialog_empty": "All workspace skills are already assigned.",
"add_dialog_no_match": "No skills match your search.",
"add_dialog_empty_partial": "No more skills to add — this agent already has all of them.",
"add_dialog_saving": "Adding…",
"add_dialog_confirm_default": "Add",
"add_dialog_confirm_one": "Add {{count}} skill",
"add_dialog_confirm_other": "Add {{count}} skills",
"add_dialog_cancel": "Cancel",
"add_failed_toast": "Failed to add skill"
},

View File

@@ -37,7 +37,6 @@
"days": "{{count}}d ago"
},
"row_delete_aria": "Delete chat session",
"row_rename_aria": "Rename chat session",
"delete_dialog": {
"title": "Delete chat session",
"description_with_title": "\"{{title}}\" and its messages will be permanently removed. This action cannot be undone.",

View File

@@ -34,13 +34,7 @@
"copy_link_failed": "Failed to copy link"
},
"attachment": {
"download_failed": "Couldn't fetch a download link. Try again in a moment.",
"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": "Couldn't fetch a download link. Try again in a moment."
},
"link_hover": {
"copy_link": "Copy link",

Some files were not shown because too many files have changed in this diff Show More