Compare commits

..

8 Commits

Author SHA1 Message Date
Naiyuan Qing
5e96909ea8 chore: ignore .worktrees/
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:35:42 +08:00
Naiyuan Qing
126df05671 test(agents): wrap CreateAgentDialog tests in workspace/navigation providers
The dialog now calls useNavigation() and useWorkspacePaths(), both of
which throw outside their providers. The existing tests rendered the
dialog bare and tripped both new requirements:

- NavigationProvider — supply a stub adapter so push() works for the
  agent-detail redirect.
- WorkspaceSlugProvider — useWorkspacePaths() requires a slug.

The blank-vs-template chooser is now the default first step; the
existing tests target the runtime picker on the manual form, so the
helper auto-clicks "Start blank" when no template is passed
(duplicate-mode tests skip the chooser).

Manual afterEach(cleanup) + document.body wipe. Base UI's Dialog
portal renders into document.body and leaves focus-guard/inert wrapper
divs behind across tests, so the second test in the suite saw two
"All" / "My Runtime" matches and getByText failed. The wipe is local
to this file rather than the shared setup because it isn't a global
issue — only suites that open Base UI dialogs hit it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:13:12 +08:00
Naiyuan Qing
e68976ce18 fix(ci): align skill import test + drop next-only lint suppression
- TestFetchFromSkillsSh_ResolvesRootLevelSkillMd now expects assets/logo.png
  to be skipped; matches the new addFile binary-extension guard
  (6fafd86e). The .png is intentionally dropped so PG TEXT inserts don't
  hit SQLSTATE 22021.
- packages/views shares zero next/* deps, so the @next/next/no-img-element
  eslint plugin isn't loaded there. The eslint-disable directive
  referencing it produced a hard "rule not found" error in CI lint. Raw
  <img> is the right primitive in views; remove the disable comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 17:02:17 +08:00
Naiyuan Qing
5f2222b83d feat(agents): one-click create-from-template UI
Frontend half of Phase 1. CreateAgentDialog becomes a state machine
spanning four steps:

  chooser          → Start blank / From template cards
  blank-form       → existing manual form (post-chooser)
  duplicate-form   → existing form pre-filled from a duplicated agent
  template-picker  → grid of templates, click navigates to detail
  template-detail  → instructions + skill list preview + one-click Use

Picking a template never lands on the form: name auto-deduped against
existingAgentNames, runtime = first usable one, visibility = private.
Refinement happens on the agent detail page if needed. Same rationale
the doc spells out — templates exist precisely to skip configuration.

New components, all collapsible-by-default so quick-create stays fast:
  - template-picker.tsx — categorised grid, lucide icons + semantic
    accent tokens resolved through static maps so Tailwind's JIT picks
    up every variant (dynamic class strings would silently miss).
  - template-detail.tsx — instructions preview, skill list with cached
    descriptions, Use CTA. Renders the failedURLs banner when a 422
    fires — the only step that can trigger that response.
  - instructions-editor.tsx — collapsed preview-card / expanded full
    ContentEditor.
  - skill-multi-select.tsx + skill-picker-list.tsx — shared multi-
    select surface, also adopted by the existing skill-add-dialog.
  - avatar-picker.tsx — agent avatar upload, mirrors the inspector's
    visual language.

Schema-defended client (CLAUDE.md → API Response Compatibility): the
three new endpoints are wired through parseWithFallback with lenient
zod schemas. Desktop builds outlive any given server — a future
field rename / wrapping must not white-screen older installs.
listAgentTemplates accepts both the current bare array and a future
{templates: [...]} envelope. Coverage: 7 new schema-test cases in
schema.test.ts (null body, missing skills/instructions, malformed
create response, envelope migration).

Catalog + detail go through TanStack Query with staleTime: Infinity —
workspace-independent static data, no per-mount refetch.

Other:
- skill-add-dialog becomes a true multi-select (Confirm button +
  checkbox list); attached skills are filtered out of the list.
- agents-page hands the freshly-created Agent back to the dialog so a
  follow-up setAgentSkills can attach the form-selected skills.
- agent-overview-pane drops the mx-auto/max-w-2xl frame on config-
  tab content; the wider dialog visual language reads better with
  tabs filling the column.
- Every new UI string lives in both en/agents.json and
  zh-Hans/agents.json under create_dialog.* / tab_body.skills.* —
  locales/parity.test.ts blocks drift in CI.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:47:21 +08:00
Naiyuan Qing
68edf57f64 feat(agents): agent template catalog + create-from-template endpoint
Server-side foundation for Phase 1 of the quick-create roadmap (see
docs/agent-quick-create-plan.md). Adds:

- server/internal/agenttmpl/ — embed-loaded catalog of curated agent
  templates. Each template ships pre-written instructions plus a list
  of skill URLs that get materialised into the workspace at create
  time. Validation runs at startup (init() panics on a malformed
  template) so a bad JSON ships as a deploy-time defect, not a
  runtime 500. Slug must equal the filename basename so the URL
  router is mirror-symmetric with the file layout.

- 11 starter templates covering Engineering / Writing / Building /
  Testing (code-reviewer, frontend-builder, planner, docs-writer,
  one-pager, html-slides, full-stack-engineer, …).

- Three new endpoints, all behind RequireWorkspaceMember:
    GET  /api/agent-templates           — picker list (no instructions)
    GET  /api/agent-templates/:slug     — detail with instructions
    POST /api/agents/from-template      — materialise + create

  Create flow:
    1. Auth + runtime authorization happen BEFORE the GitHub fan-out
       so a 403 never wastes 20s of upstream fetches.
    2. Pre-flight dedupe by cached_name reuses workspace skills
       without an HTTP fetch — second create-from-the-same-template
       drops from 20s to <100ms.
    3. Parallel fetch (30s per-URL timeout) for the remaining skills.
    4. Single transaction: every skill insert, the agent insert, and
       the agent_skill bindings. On any upstream fetch failure the TX
       rolls back and the API returns 422 with `failed_urls` so the
       UI can name the bad source(s).
    5. extra_skill_ids (user-supplied additions) are verified through
       GetSkillInWorkspace per id before attach, so a malicious client
       can't graft a skill from another workspace via UUID guessing.

- multica agent create --from-template <slug> CLI flag dispatches to
  the new endpoint with a 60s ceiling, matching `multica skill import`.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:45:04 +08:00
Naiyuan Qing
31ec36146f refactor(skills): split createSkillWithFiles for tx composition + add workspace find-or-create query
Two soft blockers cleared so create-from-template (next commit) can
fold N skill creates and the agent + binding writes into one outer
transaction:

1. createSkillWithFiles used to Begin/Commit its own tx. Caller
   composition was impossible — N invocations meant N separate
   transactions and no atomicity over the whole materialise step.
   Pull the body into createSkillWithFilesInTx(ctx, qtx, input); the
   original function becomes a thin wrapper that manages its own tx
   for standalone callers. Existing call sites: zero behaviour change.

2. Add GetSkillByWorkspaceAndName sqlc query — workspace skill lookup
   by name, anchored to UNIQUE(workspace_id, name) from migration
   008. Lets the template materialiser implement find-or-create:
   reuse the workspace's existing skill row when a template
   references the same name, rather than crashing on the unique
   constraint or polluting the workspace with `<name>-2` clones.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:45:04 +08:00
Naiyuan Qing
6fafd86ecc fix(skills): harden import against invalid UTF-8 and binary files
PG rejects two byte patterns in a TEXT column. Both crashed real skill
imports we hit while assembling the template catalog:

- Embedded NUL (0x00) -> SQLSTATE 22021. Already stripped by
  sanitizeNullBytes, kept as-is.
- Other invalid UTF-8 (e.g. 0x91 — Windows-1252 smart quote in a skill
  whose author saved prose from Word). sanitizeNullBytes now also runs
  strings.ToValidUTF8 over the content so the second class no longer
  takes the whole import down.

For non-text payloads (images, fonts, archives, compiled binaries),
sanitization isn't the right fix — agents never read those as text,
and the bytes can't survive a TEXT column at all. addFile now skips
them by extension before the per-bundle cap counters tick, logging
the skip so an unexpected drop leaves a breadcrumb.

Function name kept for compatibility with the many call sites; both
behaviours are strict supersets of the original.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:45:04 +08:00
Naiyuan Qing
f435dd2cd4 docs(agents): three-phase agent quick-create plan
Captures the full design for moving agent creation from manual form +
one-by-one skill attachment to a tiered experience:

- Phase 1 (this PR): one-click curated templates, AI-free.
- Phase 2 (next): AI-recommended skills via the existing quick-create
  task mechanism — no new server-side LLM dependency.
- Phase 3 (later): AI creates the whole agent end-to-end, composing
  Phase 2 with a new `multica agent create` CLI driver.

Documents the architectural decisions that keep all three phases on
existing infrastructure (no SSE, no server-side LLM SDK, no new WS
channels), the two soft blockers Phase 1 unlocks for later phases
(createSkillWithFiles TX composability + skill same-name dedupe), and
the scope decisions we explicitly opted out of (Anthropic plugin
marketplace, ClawHub UI affordances).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 16:45:04 +08:00
274 changed files with 2259 additions and 15074 deletions

View File

@@ -7,10 +7,10 @@ body:
id: deployment
attributes:
label: Deployment type
description: Are you using the Official App (multica.ai) or a self-hosted instance?
description: Are you using the hosted version or a self-hosted instance?
options:
- Official App
- self-host
- multica.ai (hosted)
- Self-hosted
validations:
required: true

View File

@@ -7,10 +7,10 @@ body:
id: deployment
attributes:
label: Deployment type
description: Are you using the Official App (multica.ai) or a self-hosted instance?
description: Are you using the hosted version or a self-hosted instance?
options:
- Official App
- self-host
- multica.ai (hosted)
- Self-hosted
validations:
required: true

3
.gitignore vendored
View File

@@ -58,3 +58,6 @@ server/server
data/
.kilo
.idea
# git worktrees
.worktrees/

View File

@@ -32,8 +32,6 @@ Multica turns coding agents into real teammates. Assign issues to an agent like
No more copy-pasting prompts. No more babysitting runs. Your agents show up on the board, participate in conversations, and compound reusable skills over time. Think of it as open-source infrastructure for managed agents — vendor-neutral, self-hosted, and designed for human + AI teams. Works with **Claude Code**, **Codex**, **GitHub Copilot CLI**, **OpenClaw**, **OpenCode**, **Hermes**, **Gemini**, **Pi**, **Cursor Agent**, **Kimi**, and **Kiro CLI**.
For larger teams, Squads add a stable routing layer: assign work to a group led by an agent, and the leader delegates to the right member.
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica board view" width="800">
</p>
@@ -55,7 +53,6 @@ Like Multics before it, the bet is on multiplexing: a small team shouldn't feel
Multica manages the full agent lifecycle: from task assignment to execution monitoring to skill reuse.
- **Agents as Teammates** — assign to an agent like you'd assign to a colleague. They have profiles, show up on the board, post comments, create issues, and report blockers proactively.
- **Squads** — group agents (and humans) under a leader agent and assign work to the *squad*. The leader decides who should pick it up, so routing stays stable as the team grows. `@FrontendTeam` instead of `@alice-or-bob-or-carol`.
- **Autonomous Execution** — set it and forget it. Full task lifecycle management (enqueue, claim, start, complete/fail) with real-time progress streaming via WebSocket.
- **Reusable Skills** — every solution becomes a reusable skill for the whole team. Deployments, migrations, code reviews — skills compound your team's capabilities over time.
- **Unified Runtimes** — one dashboard for all your compute. Local daemons and cloud runtimes, auto-detection of available CLIs, real-time monitoring.
@@ -131,6 +128,21 @@ Create an issue from the board (or via `multica issue create`), then assign it t
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **Focus** | Team AI agent collaboration platform | Solo AI agent company simulator |
| **User model** | Multi-user teams with roles & permissions | Single board operator |
| **Agent interaction** | Issues + Chat conversations | Issues + Heartbeat |
| **Deployment** | Cloud-first | Local-first |
| **Management depth** | Lightweight (Issues / Projects / Labels) | Heavy governance (Org chart / Approvals / Budgets) |
| **Extensibility** | Skills system | Skills + Plugin system |
**TL;DR — Multica is built for teams that want to collaborate with AI agents on real projects together.**
---
## CLI
The `multica` CLI connects your local machine to Multica — authenticate, manage workspaces, and run the agent daemon.

View File

@@ -32,8 +32,6 @@ Multica 将编码 Agent 变成真正的队友。像分配给同事一样分配
不再需要复制粘贴 prompt不再需要盯着运行过程。你的 Agent 出现在看板上、参与对话、随着时间积累可复用的技能。可以理解为开源的 Managed Agents 基础设施——厂商中立、可自部署、专为人类 + AI 团队设计。支持 **Claude Code**、**Codex**、**GitHub Copilot CLI**、**OpenClaw**、**OpenCode**、**Hermes**、**Gemini**、**Pi**、**Cursor Agent**、**Kimi** 和 **Kiro CLI**
面向更大的团队Squads小队提供稳定的路由层把任务分给由 Agent 带队的小队,由队长判断谁最适合接手。
<p align="center">
<img src="docs/assets/hero-screenshot.png" alt="Multica 看板视图" width="800">
</p>
@@ -55,7 +53,6 @@ Multica——**Mul**tiplexed **I**nformation and **C**omputing **A**gent。
Multica 管理完整的 Agent 生命周期:从任务分配到执行监控再到技能复用。
- **Agent 即队友** — 像分配给同事一样分配给 Agent。它们有个人档案、出现在看板上、发表评论、创建 Issue、主动报告阻塞问题。
- **Squads小队** — 把多个 Agent以及人类成员组合成由 leader agent 带队的小队直接把任务分配给小队本身。Leader 会判断谁最适合接手,团队扩容时路由方式保持不变。用 `@前端组` 代替 `@小张或小李或小王`
- **自主执行** — 设置后无需管理。完整的任务生命周期管理(排队、认领、执行、完成/失败),通过 WebSocket 实时推送进度。
- **可复用技能** — 每个解决方案都成为全团队可复用的技能。部署、数据库迁移、代码审查——技能让团队能力随时间持续增长。
- **统一运行时** — 一个控制台管理所有算力。本地 daemon 和云端运行时,自动检测可用 CLI实时监控。
@@ -134,6 +131,19 @@ daemon 在后台运行,保持你的机器与 Multica 的连接。它会自动
---
## Multica vs Paperclip
| | Multica | Paperclip |
|---|---------|-----------|
| **定位** | 团队 AI Agent 协作平台 | 个人 AI Agent 公司模拟器 |
| **用户模型** | 多人团队,角色权限 | 单人 Board Operator |
| **Agent 交互** | Issue + Chat 对话 | Issue + Heartbeat |
| **部署** | 云端优先 | 本地优先 |
| **管理深度** | 轻量Issue / Project / Labels | 重度(组织架构 / 审批 / 预算) |
| **扩展** | Skills 系统 | Skills + 插件系统 |
**简单来说Multica 专为团队协作打造,让团队和 AI Agent 一起高效完成项目。**
## 架构
```

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

@@ -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

@@ -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 -----------------------------------------------

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

@@ -45,5 +45,4 @@ New agents default to **private**. To make one available to the whole workspace,
- [Create and configure an agent](/agents-create) — how to build one
- [Skills](/skills) — attach knowledge packs to an agent
- [Squads](/squads) — group agents under a leader so the right one picks up the right issue
- [Daemon and runtimes](/daemon-runtimes) — what an agent needs to actually run

View File

@@ -45,5 +45,4 @@ import { Callout } from "fumadocs-ui/components/callout";
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
- [Skills](/skills) —— 给智能体挂上专业知识包
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么

View File

@@ -5,7 +5,7 @@ description: Hand an issue to an agent and it takes over as the official assigne
import { Callout } from "fumadocs-ui/components/callout";
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths. The same flow also accepts a [squad](/squads) as the assignee — Multica then triggers the squad's **leader agent** instead.
Assign an [issue](/issues) to an [agent](/agents) and it works as the **official assignee** until the work is done — it can read the full issue context (description + all [comments](/comments)) and change status, post comments, and edit fields. This is the **most common and heaviest** of Multica's four trigger paths.
| Path | When to use | Changes the issue | Context | Priority | Auto retry |
|---|---|---|---|---|---|
@@ -18,7 +18,7 @@ Assign an [issue](/issues) to an [agent](/agents) and it works as the **official
## Assign from the UI
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace, all non-archived agents, and every non-archived [squad](/squads). Pick an agent (or squad) and the issue is assigned right away.
On the issue detail page, click the **Assignee** picker. It lists every member in the workspace plus all non-archived agents. Pick an agent and the issue is assigned right away.
A few rules:
@@ -78,6 +78,5 @@ But **different agents can work on the same issue in parallel** — for example,
## Next
- [**@-mention an agent in a comment**](/mentioning-agents) — a lighter trigger that leaves assignee and status untouched
- [**Squads**](/squads) — assign to a group of agents and let the leader decide who picks it up
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule

View File

@@ -5,7 +5,7 @@ description: 把 issue 交给智能体,它作为正式负责人一直工作到
import { Callout } from "fumadocs-ui/components/callout";
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。同样的流程也接受 [小队squad](/squads) 作为 assignee——这种情况下 Multica 会触发小队的**队长智能体**。
把 [issue](/issues) 分配给 [智能体](/agents),它会作为**正式负责人**一直工作到结束——能读到 issue 的完整上下文(描述 + 所有 [评论](/comments)),也能改状态、发评论、改字段。这是 Multica 四种触发方式里**最常见也最"重"**的一种。
| 方式 | 何时用 | 改 issue | 上下文 | 优先级 | 自动重试 |
|---|---|---|---|---|---|
@@ -18,7 +18,7 @@ import { Callout } from "fumadocs-ui/components/callout";
## 在界面里分配
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员未归档的智能体、以及未归档的 [小队](/squads)。选一个智能体(或小队)issue 立刻分
在 issue 详情页点 **Assignee** 选择器,会列出工作区里所有成员未归档的智能体。选一个智能体issue 立刻分给它
几条规则:
@@ -78,6 +78,5 @@ multica issue assign MUL-42 --unassign
## 下一步
- [**在评论里 @ 智能体**](/mentioning-agents) —— 更轻量的触发方式,不改 assignee / status
- [**小队**](/squads) —— 把 issue 分给一组智能体,由队长决定谁接手
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工

View File

@@ -79,20 +79,6 @@ For the difference between token types, see [Authentication and tokens](/auth-to
| `multica skill import ...` | Import a skill from GitHub, ClawHub, or the local machine |
| `multica skill files ...` | Nested: manage a skill's files |
## Squads
| Command | Purpose |
|---|---|
| `multica squad list` | List squads in the workspace |
| `multica squad get <id>` | Show a single squad |
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
| `multica squad update <id> ...` | Update name, description, instructions, leader, or avatar |
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list/add/remove <squad-id>` | Manage squad members |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Used by squad leader agents to record an evaluation per turn |
See [Squads](/squads) for the full model.
## Autopilots
| Command | Purpose |

View File

@@ -79,20 +79,6 @@ Token 类型的详细区分见 [认证与令牌](/auth-tokens)。
| `multica skill import ...` | 从 GitHub / ClawHub / 本机导入 Skill |
| `multica skill files ...` | 嵌套:管理 Skill 的文件 |
## 小队
| 命令 | 用途 |
|---|---|
| `multica squad list` | 列出工作区里的小队 |
| `multica squad get <id>` | 查看一个小队 |
| `multica squad create --name "..." --leader <agent>` | 创建小队owner / admin|
| `multica squad update <id> ...` | 修改名字、描述、instructions、队长、头像 |
| `multica squad delete <id>` | 归档(软删除)—— 同时把分配给小队的 issue 转给队长 |
| `multica squad member list/add/remove <squad-id>` | 管理小队成员 |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长智能体每轮结束时调用,记录 evaluation |
完整模型见 [小队](/squads)。
## Autopilots
| 命令 | 用途 |

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

@@ -16,10 +16,6 @@ Same as mentioning a member — type `@` to open the picker and select an agent.
The `@mention` Markdown syntax, the picker, and `@all` semantics are covered in [**Comments**](/comments).
<Callout type="info">
**You can also `@`-mention a [squad](/squads) in a comment.** The same picker surfaces squads alongside members and agents; selecting one inserts `[@SquadName](mention://squad/<uuid>)` and triggers the squad's **leader agent** to coordinate a response — assignee and status stay untouched.
</Callout>
## How it differs from assignment
Both put the agent to work, but the mechanics are entirely different:
@@ -57,7 +53,6 @@ This guard **only blocks direct self-references.** Agent A @-mentioning agent B
## Next
- [**Squads**](/squads) — `@`-mention a squad to have the leader route the question to the right member
- [**Chat**](/chat) — one-to-one conversation outside any issue
- [**Autopilots**](/autopilots) — let agents start work automatically on a schedule
- [**Comments**](/comments) — `@mention` syntax, the picker, and `@all` semantics

View File

@@ -16,10 +16,6 @@ import { Callout } from "fumadocs-ui/components/callout";
`@mention` 的 Markdown 语法、picker 的用法、`@all` 的语义见 [**评论**](/comments)。
<Callout type="info">
**`@` 也可以指向 [小队squad](/squads)。** picker 里小队和成员、智能体并列;选中后会插入 `[@SquadName](mention://squad/<uuid>)`,触发小队的**队长智能体**来协调响应——assignee 和 status 都不会变。
</Callout>
## 和分配的差别
同样是让智能体工作,但机制完全不同:
@@ -57,7 +53,6 @@ import { Callout } from "fumadocs-ui/components/callout";
## 下一步
- [**小队**](/squads) —— `@` 一个小队,由队长把问题派给合适的成员
- [**对话**](/chat) —— 脱离 issue 和智能体一对一聊
- [**Autopilots**](/autopilots) —— 让智能体定时自动开工
- [**评论**](/comments) —— `@mention` 的语法、picker、`@all` 的语义

View File

@@ -16,7 +16,6 @@
"agents",
"agents-create",
"skills",
"squads",
"---How agents run---",
"daemon-runtimes",
"tasks",

View File

@@ -15,7 +15,6 @@
"agents",
"agents-create",
"skills",
"squads",
"---智能体怎么运行---",
"daemon-runtimes",
"tasks",

View File

@@ -1,136 +0,0 @@
---
title: Squads
description: "A squad is a group of agents (and optionally human members) led by one designated leader agent. Assign an issue to a squad and the leader decides who picks it up."
---
import { Callout } from "fumadocs-ui/components/callout";
A squad is a **named group of [agents](/agents) and human [members](/members-roles)**, with one designated **leader agent**. The squad is itself a first-class assignee: pick it from any **Assignee** picker and the leader takes the trigger, reads the issue, then `@`-mentions the squad member best suited to do the work. Squads let you assemble specialists once and dispatch them **by topic instead of by name** — the team grows, the routing stays the same.
## What a squad is, in mechanics
- **One leader, many members.** The leader must be an agent; members can be agents or human members. A squad with only the leader is allowed (the leader briefing notes "no other members"), and the same agent can sit in multiple squads.
- **Assignable everywhere a person is.** Squads appear in the Assignee picker, the @-mention picker, and the quick-create modal — anywhere you'd pick an agent or member, you can pick a squad.
- **Soft-deleted via archive.** Archive a squad and it disappears from pickers and lists; any issue currently assigned to it is **transferred to the leader agent** so the work doesn't go silent. Archived squads can't be assigned to new issues.
## When to use a squad versus a single agent
| Pick a squad when… | Pick a single agent when… |
|---|---|
| You have several specialists and don't know which one fits this issue in advance | The work is well-scoped to one specialty and you know who should do it |
| You want one stable assignee (the squad) while the actual responder changes per issue | You want the agent's name on the issue and clear individual accountability |
| You want a `@FrontendTeam` style routing target in comments | One-on-one `@agent-name` is enough |
The squad doesn't add capability — it adds **routing**. The members are still ordinary agents; the leader's only job is to pick the right one.
## Permissions
| Action | Who can do it |
|---|---|
| Create / update / archive a squad | Workspace **owner** or **admin** |
| Add or remove members, change roles | Workspace **owner** or **admin** |
| Assign an issue to a squad | Any workspace member (same as assigning to an agent) |
| `@`-mention a squad in a comment | Any workspace member |
| Record a squad-leader evaluation | The squad leader agent only (via CLI) |
The full role matrix lives in [Members and roles](/members-roles).
## Create a squad
In the sidebar, open **Squads → New squad** and fill in:
- **Name** — e.g. `Frontend Team`, `Bug Triage`. Doesn't need to be unique within the workspace.
- **Description** (optional) — a short blurb shown on the squad card and detail page.
- **Leader** — pick an existing agent. The leader is added to the squad automatically with role `leader`.
After creation, open the squad's detail page to:
- **Add members** — pick agents or human members, optionally give each a short role description (e.g. "owns the migrations", "reviewer of last resort"). The leader uses these roles when deciding who to delegate to.
- **Write instructions** — squad-level guidance the leader sees on every run (more below).
- **Set an avatar** — picked from the same picker used for agents.
CLI equivalent:
```bash
multica squad create --name "Frontend Team" --leader frontend-lead-agent
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
```
## How a squad-assigned issue runs
When a non-Backlog issue is assigned to a squad, Multica immediately enqueues a `task` for the **leader agent** (not for every member). The flow then looks like this:
1. **Leader claims the task.** The agent runtime picks up the task on its next poll, same as any other agent assignment.
2. **Leader is briefed.** On claim, Multica appends three sections to the leader's system prompt — see [What the leader sees on every turn](#what-the-leader-sees-on-every-turn) below.
3. **Leader posts one delegation comment.** The comment `@`-mentions the chosen member(s) using the exact mention markdown from the roster — that mention triggers a new `task` for each mentioned agent.
4. **Leader records its evaluation** via `multica squad activity <issue-id> action --reason "..."`. This writes an entry to the issue's activity timeline so humans can see the leader actually evaluated the trigger.
5. **Leader stops.** The leader does not do the implementation itself. When the delegated member posts back, the leader is re-triggered to read the update and either delegate the next step, escalate, or stay silent.
If the issue is in **Backlog**, the leader is not triggered — Backlog is a parking lot, same rule as for direct agent assignment.
### What the leader sees on every turn
On each squad-leader run, three blocks are appended to the leader's instructions:
- **Squad Operating Protocol** — a hard-coded rule set: read the issue, delegate by `@`-mention, be terse (don't restate the issue body — the assignee can read it), record an evaluation every turn, and **stop after dispatching**. This protocol is system-managed and not editable.
- **Squad Roster** — the leader's self-row plus one row per non-archived member. Each row carries the exact mention markdown (`[@Name](mention://agent/<uuid>)` or `[@Name](mention://member/<uuid>)`) the leader should paste — typing a plain `@name` won't trigger anyone.
- **Squad Instructions** — your custom guidance for this squad (set on the squad detail page or via `multica squad update --instructions`). Use this for routing rules ("send DB work to Alice, frontend to Bob"), escalation policies, or anything else the leader needs to know that isn't already in the issue.
## When the leader is re-triggered
After the first dispatch, the leader is woken up automatically by **most subsequent comments** on the issue. The exact rules:
| Event | Leader triggered? |
|---|---|
| A non-member (human reporter, external agent) posts a comment | **Yes** |
| A squad member posts a progress update with no `@mention` | **Yes** — the leader re-evaluates whether the next step is needed |
| Anyone posts a comment that explicitly `@`-mentions another agent / member / squad / `@all` | **No** — the explicit `@` is the routing signal; the leader gets out of the way |
| The leader's own comment (self-trigger) | **No** — guarded to prevent a loop |
| A comment containing only an issue cross-reference (`[MUL-123](mention://issue/...)`) | **Yes** — issue references aren't routing |
Dedup applies on top of these rules: if the leader already has a `queued` or `dispatched` task on this issue, a new trigger won't enqueue a duplicate.
<Callout type="info">
**Why the leader doesn't trigger when a member posts an `@`-mention.** Once a squad member directly `@`s someone, that comment is a deliberate hand-off — having the leader wake up to "observe" the routing would just produce a no-op turn and clutter the timeline. Agent-authored comments are the exception: when an agent posts a result that `@`s another agent, the leader still wakes up so it can coordinate the thread.
</Callout>
## `@`-mention a squad in a comment
Squads appear in the `@` picker alongside members and agents. Mentioning a squad inserts `[@SquadName](mention://squad/<uuid>)` and triggers the **squad leader** as if you had assigned the issue to the squad — without changing the assignee or the status. Use this when you want the squad to pick someone for a question or sub-task while keeping the current owner.
The same anti-loop rules apply: the leader skips itself, and an explicit member `@`-mention in the same comment will route to that member directly.
## Reassign or archive a squad
**Reassigning an issue away from a squad** behaves like any other assignee change: all of the issue's active tasks (including the leader's) are cancelled, and the new assignee — agent, member, or another squad — is enqueued. There is no separate "remove squad without changing assignee" action; pick a different assignee.
**Archiving a squad** (`multica squad delete <id>`, or the Archive button on the detail page):
1. **Transfers issues currently assigned to the squad to the leader agent**, so the work continues against a concrete agent instead of going silent.
2. Marks the squad with `archived_at` / `archived_by` — the row is preserved so historical activity entries still resolve, but the squad disappears from lists, pickers, and the @-mention dropdown.
3. **Rejects future assignments** to this squad with `cannot assign to an archived squad`.
There is currently no unarchive command; create a new squad if you need the routing back.
## Squad operations from the CLI
| Command | Purpose |
|---|---|
| `multica squad list` | List squads in the workspace |
| `multica squad get <id>` | Show one squad's name, leader, description, instructions |
| `multica squad create --name "..." --leader <agent>` | Create a squad (owner / admin) |
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | Update one or more fields |
| `multica squad delete <id>` | Archive (soft-delete) — transfers assigned issues to the leader |
| `multica squad member list <id>` | List a squad's members |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | Add a member (owner / admin) |
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | Remove a member (the leader cannot be removed — change leader first) |
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | Recorded by the leader agent at the end of every turn |
`--leader` accepts an agent name or UUID; for everything else, IDs come from `multica agent list --output json`, `multica workspace members --output json`, and `multica squad list --output json`.
## Next
- [Assign issues to agents](/assigning-issues) — same flow, applies to squad assignees too
- [`@`-mention agents in comments](/mentioning-agents) — the `@` picker also surfaces squads
- [Agents](/agents) — what an agent is, the building block of every squad
- [Members and roles](/members-roles) — the full owner / admin / member permission matrix

View File

@@ -1,136 +0,0 @@
---
title: 小队
description: 小队squad是一组智能体可选附带成员由一名指定的"队长"智能体leader领导。把 issue 分配给小队,队长来决定谁接手。
---
import { Callout } from "fumadocs-ui/components/callout";
小队squad是一组 [智能体](/agents) 和 [人类成员](/members-roles) 的**命名集合**,其中有一名指定的**队长leader必须是智能体**。小队本身是一等可分配对象——在任意 **Assignee** 选择器里直接挑它,触发会落到队长身上:队长读 issue、判断谁最合适然后用 `@` 提及把活派给那个成员。小队让你把一组专家**一次性编好队**,之后**按主题派活,而不是按名字派活**——队伍扩展,路由不变。
## 小队的运转机制
- **一个队长,多名成员。** 队长必须是智能体;成员可以是智能体或人类成员。只有队长一个人的小队也是允许的(队长 briefing 会注明"没有其他成员"),同一个智能体也能加入多个小队。
- **任何能选人的地方都能选小队。** Assignee picker、@ 提及 picker、快速创建 modal——只要能选智能体或成员的位置小队都会出现。
- **删除走"归档"软删除。** 归档一个小队后,它会从 picker 和列表里消失;当前分配给它的 issue 会被**自动转给队长智能体**,让工作不至于卡住。归档的小队不能再被分配新 issue。
## 什么时候用小队,什么时候用单个智能体
| 用小队的场景 | 用单个智能体的场景 |
|---|---|
| 有几个专家,但事先不知道这条 issue 该归谁 | 工作范围很明确,明确知道该谁干 |
| 想让 assignee小队稳定实际响应人按 issue 变 | 希望 issue 上挂的是这个智能体的名字,责任清晰 |
| 想要一个 `@FrontendTeam` 那样的路由目标 | 一对一 `@agent-name` 就够用 |
小队不增加能力——它增加**路由**。成员还是那些智能体,队长唯一的工作是**挑对人**。
## 权限
| 操作 | 谁能做 |
|---|---|
| 创建 / 更新 / 归档小队 | 工作区 **owner** 或 **admin** |
| 增删成员、改成员角色 | 工作区 **owner** 或 **admin** |
| 把 issue 分配给小队 | 任何工作区成员(和分配给智能体一样)|
| 在评论里 `@` 小队 | 任何工作区成员 |
| 记录小队队长的 evaluation | 只有队长智能体本人(通过 CLI|
完整角色权限对照见 [成员与权限](/members-roles)。
## 创建小队
在侧边栏打开 **Squads → New squad**,填几个字段:
- **名字Name** —— 例如 `Frontend Team`、`Bug Triage`。在工作区里**不要求唯一**。
- **描述Description可选** —— 一句话简介,展示在小队卡片和详情页上。
- **队长Leader** —— 选一个已有的智能体。创建后队长会自动以 `leader` 角色加入小队。
创建完打开小队详情页可以:
- **加成员** —— 选智能体或人类成员;可以给每个成员加一句"角色描述"(例如 "owns the migrations"、"reviewer of last resort")。队长派活时会参考这些角色。
- **写 instructions** —— 小队级别的指令,队长每次执行都能看到(见下文)。
- **设头像** —— 用和智能体一样的头像选择器。
CLI 等价命令:
```bash
multica squad create --name "Frontend Team" --leader frontend-lead-agent
multica squad member add <squad-id> --member-id <agent-or-user-uuid> --type agent --role "Owns Tailwind / shadcn surface"
```
## 分配给小队的 issue 是怎么跑的
非 Backlog 状态的 issue 一旦分配给小队Multica 会立刻给**队长智能体**入队一个 `task`(不是给每个成员都入一个)。整个流程是这样的:
1. **队长领走 task。** 队长所在的 daemon 在下次轮询时把 task 领走,和普通智能体的分配流程一样。
2. **队长拿到 briefing。** 领走的瞬间Multica 会在队长的系统提示后面追加三段内容——详见下文 [队长每次执行看到的内容](#队长每次执行看到的内容)。
3. **队长发一条"派活"评论。** 评论里用 roster 里给好的 mention markdown `@` 选中的成员——这个 `@` 会触发被派的成员入队新 `task`。
4. **队长记录 evaluation** `multica squad activity <issue-id> action --reason "..."`。这一行会写进 issue 的 activity 时间线,方便人类回溯队长确实评估过这一次触发。
5. **队长停下。** 派完活,队长**不动手干活**。当被派的成员有回复时,队长会被自动唤醒,决定下一步:继续派活、上抛给人类、还是保持沉默。
如果 issue 是 **Backlog** 状态队长不会被触发——Backlog 是停泊场,规则和直接分配给智能体一样。
### 队长每次执行看到的内容
每次队长被触发,三段内容会被附加到它的 instructions 上:
- **Squad Operating Protocol小队工作规范** —— 一段硬编码的规则集:读 issue → 用 `@` 派活 → 简洁(**不要**复述 issue 内容,被派的成员自己能读)→ 每次都记 evaluation → **派完就停**。这段是系统管理的,不可编辑。
- **Squad Roster小队花名册** —— 队长自己一行 + 每个未归档成员一行。每一行带上**确切可用**的 mention markdown`[@Name](mention://agent/<uuid>)` 或 `[@Name](mention://member/<uuid>)`)让队长直接复制——纯文本 `@name` 是**不会**触发任何人的。
- **Squad Instructions小队自定义指令** —— 你为这个小队写的私货(在详情页里编辑,或用 `multica squad update --instructions`)。用来写路由规则("DB 相关派给 Alice前端派给 Bob")、上报策略,或者任何 issue 本身不会有的背景。
## 队长什么时候会被再次触发
第一次派活完之后,**大多数后续评论**都会自动唤醒队长。具体规则:
| 事件 | 触发队长?|
|---|---|
| 非小队成员(人类 reporter、外部智能体发评论 | **会** |
| 小队成员发"进展更新"**不带任何** `@mention` | **会**——队长重新评估是否需要下一步 |
| 任何人发的评论里**显式 `@`** 智能体 / 成员 / 小队 / `@all` | **不会**——显式 `@` 就是路由信号,队长让位 |
| 队长自己发的评论 | **不会**——硬编码防自触发 |
| 评论里只有 issue 互链 `[MUL-123](mention://issue/...)` | **会**——issue 引用不算路由 |
以上规则之上还有去重:如果队长在这个 issue 上已经有 `queued` 或 `dispatched` 的 task新一次触发不会重复入队。
<Callout type="info">
**为什么成员发的 `@` 评论不会唤醒队长。** 小队成员一旦直接 `@` 谁,那条评论就是**有意识的交接**——再让队长唤醒一次"观察"路由,只会产出一次空回合、把时间线搞乱。智能体作者的评论是个例外:当某个智能体发出一条结果还顺手 `@` 了另一个智能体时,队长仍然会被唤醒,以便协调整条线程。
</Callout>
## 在评论里 `@` 一个小队
小队会出现在 `@` picker 里,和成员、智能体并列。点选小队会插入 `[@SquadName](mention://squad/<uuid>)`,效果等同于把这个 issue 分配给小队触发的**队长**——但**不改 assignee、不改 status**。适合"我想让小队挑个人回答一下/做一小步,但 issue 还归原来的人"这种场景。
防循环规则同样适用:队长跳过自己;同一条评论里如果还显式 `@` 了某个成员,路由会直接落到那个成员。
## 重新分配或归档一个小队
**把分配人从小队改成别的**,行为和换 assignee 完全一致:当前 issue 上所有活跃 task包括队长的会被取消新的 assignee智能体、成员、或另一个小队被入队。没有"不改 assignee 只移除小队"的单独操作;要换就选新的 assignee。
**归档小队**`multica squad delete <id>`,或详情页的 Archive 按钮):
1. **当前分配给这个小队的 issue 会被自动转给队长智能体**,让工作落到一个具体智能体上,避免无人接手。
2. 在 squad 表上写入 `archived_at` / `archived_by`——记录被保留下来,历史的 activity 还能解析但从列表、picker、`@` 下拉里它都消失。
3. **拒绝后续分配**——`cannot assign to an archived squad`。
目前没有"反归档"命令;要恢复路由,重新建一个小队即可。
## CLI 命令
| 命令 | 用途 |
|---|---|
| `multica squad list` | 列出工作区里的小队 |
| `multica squad get <id>` | 查看小队的名字、队长、描述、instructions |
| `multica squad create --name "..." --leader <agent>` | 创建小队owner / admin|
| `multica squad update <id> [--name X] [--description X] [--instructions X] [--leader Y] [--avatar-url Z]` | 修改一个或多个字段 |
| `multica squad delete <id>` | 归档(软删除)——同时把当前分配给小队的 issue 转给队长 |
| `multica squad member list <id>` | 列出小队成员 |
| `multica squad member add <id> --member-id <uuid> --type agent\|member [--role "..."]` | 加成员owner / admin|
| `multica squad member remove <id> --member-id <uuid> --type agent\|member` | 移除成员(**不能移除队长**——先换队长)|
| `multica squad activity <issue-id> <action\|no_action\|failed> --reason "..."` | 队长每次结束前由它自己调用 |
`--leader` 接受智能体名字或 UUID其它 ID 从 `multica agent list --output json`、`multica workspace members --output json`、`multica squad list --output json` 拿。
## 下一步
- [分配 issue 给智能体](/assigning-issues) —— 流程相同,对小队 assignee 也适用
- [在评论里 `@` 智能体](/mentioning-agents) —— `@` picker 同样能选到小队
- [智能体](/agents) —— 小队的"零件"
- [成员与权限](/members-roles) —— owner / admin / member 的完整权限对照

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,54 +284,6 @@ export function createEnDict(allowSignup: boolean): LandingDict {
fixes: "Bug Fixes",
},
entries: [
{
version: "0.3.0",
date: "2026-05-14",
title: "Squads & Attachment Previews",
changes: [],
features: [
"Squads let teams assign work to a group, with a leader agent coordinating the next step",
"Attachments can be previewed in place for PDFs, audio, video, markdown, code, logs, and plain text",
"Chinese names can be found by pinyin across mentions, assignees, subscribers, agents, projects, and squads",
],
improvements: [
"Squad pages now include member management, faster agent creation from a squad, clearer row actions, and a wider detail layout",
"Quick-create and picker flows are easier to search and now include squad-aware routing",
"Usage charts can switch between cost and token views, with the same timezone controls used by runtimes",
"Workspace operators get command-line controls for managing squads and stopping a runaway issue run",
"Shared interface labels are translated more consistently in English and Chinese",
],
fixes: [
"Squad leaders stay quiet when a human already routed the conversation to someone specific",
"Mentioning a squad now wakes the right leader while preserving private-agent access rules",
"Issue lists stay fresher after deletes and follow-up comments no longer trigger stale Done replies",
"Attachment previews keep working for files added while writing or editing issues and comments",
],
},
{
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,54 +284,6 @@ export function createZhDict(allowSignup: boolean): LandingDict {
fixes: "问题修复",
},
entries: [
{
version: "0.3.0",
date: "2026-05-14",
title: "Squads 与附件预览",
changes: [],
features: [
"Squads 支持把任务交给一个小组,由 leader agent 负责协调下一步",
"附件可以直接预览,支持 PDF、音频、视频、Markdown、代码、日志和纯文本",
"中文姓名支持用拼音搜索,适用于 mention、负责人、订阅人、agents、projects 和 squads",
],
improvements: [
"Squad 页面补齐成员管理、从 squad 内快速创建 agent、清晰的成员操作按钮以及更宽的详情布局",
"快速创建和各类选择器更容易搜索,并能识别 squad 相关的指派和提及",
"Usage 图表可以在费用和 token 视图之间切换,并复用 runtime 的时区控制",
"工作区管理员可以通过命令行管理 squads并在必要时停止失控的 issue 执行",
"共享界面文案的中英文翻译更完整",
],
fixes: [
"当成员已经明确把讨论指向某个人或小组时Squad leader 不再重复发言",
"提及 squad 时会正确唤起对应 leader同时保留私有 agent 的访问限制",
"删除 Issue 后列表刷新更准确,后续评论也不再触发过期的 Done 回复",
"在撰写或编辑 issue 和评论时新增的附件,也可以稳定使用预览",
],
},
{
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,5 +0,0 @@
export {
useAgentsViewStore,
type AgentsScope,
type AgentsViewState,
} from "./view-store";

View File

@@ -1,96 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useAgentsViewStore } from "./view-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
beforeEach(() => {
localStorage.clear();
useAgentsViewStore.setState({ scope: "mine" });
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
describe("useAgentsViewStore", () => {
it("defaults to 'mine'", () => {
expect(useAgentsViewStore.getState().scope).toBe("mine");
});
it("setScope mutates the store", () => {
useAgentsViewStore.getState().setScope("all");
expect(useAgentsViewStore.getState().scope).toBe("all");
});
it("partialize persists only scope under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useAgentsViewStore.getState().setScope("all");
const raw = localStorage.getItem("multica_agents_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ scope: "all" });
});
it("rehydrates a different saved scope on workspace switch", async () => {
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
localStorage.setItem(
"multica_agents_view:beta",
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("mine");
});
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
localStorage.setItem(
"multica_agents_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useAgentsViewStore.getState().scope).toBe("mine");
expect(localStorage.getItem("multica_agents_view:acme")).not.toBeNull();
});
});

View File

@@ -1,40 +0,0 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type AgentsScope = "mine" | "all";
export interface AgentsViewState {
scope: AgentsScope;
setScope: (scope: AgentsScope) => void;
}
export const useAgentsViewStore = create<AgentsViewState>()(
persist(
(set) => ({
scope: "mine",
setScope: (scope) => set({ scope }),
}),
{
name: "multica_agents_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ scope: state.scope }),
// On rehydrate, if the new workspace has no persisted value, reset to
// the default "mine" instead of leaving the previous workspace's in-
// memory scope in place. Default merge keeps current state when
// persisted is undefined, which would leak "all" across workspaces.
merge: (persisted, current) => {
if (!persisted) return { ...current, scope: "mine" };
return { ...current, ...(persisted as Partial<AgentsViewState>) };
},
},
),
);
registerForWorkspaceRehydration(() => useAgentsViewStore.persist.rehydrate());

View File

@@ -1,22 +1,20 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { agentListOptions, squadListOptions } from "../workspace/queries";
import { agentListOptions } from "../workspace/queries";
import { runtimeListOptions } from "../runtimes/queries";
import { agentTaskSnapshotOptions } from "./queries";
// Subscribe to the queries that power agent presence and the @mention
// suggestion list so they're warm by the time any hover card / inline
// indicator / mention popup first renders. Without this warm-up, surfaces
// that don't otherwise touch the snapshot (inbox, issues, chat) flash a
// skeleton on first hover while the fetch is in flight, and the @mention
// list may show incomplete results (e.g. missing squads).
// Subscribe to the three queries that power agent presence so they're warm
// by the time any hover card / inline indicator first renders. Without this
// warm-up, surfaces that don't otherwise touch the snapshot (inbox, issues,
// chat) flash a skeleton on first hover while the fetch is in flight.
//
// useRealtimeSync (WS task / agent / daemon / squad invalidations) and the
// 30s presence tick keep these caches fresh after the initial fetch — this
// hook only collapses the cold-start window.
// useRealtimeSync (WS task / agent / daemon invalidations) and the 30s
// presence tick keep these caches fresh after the initial fetch — this hook
// only collapses the cold-start window.
//
// All queries are workspace-scoped; the queryKeys include wsId so workspace
// All three are workspace-scoped; the queryKeys include wsId so workspace
// switch automatically refetches the new workspace's data with no extra
// wiring here. The workspace-scoped layouts on both apps gate rendering on
// "workspace resolved", so callers can safely pass useWorkspaceId() — by the
@@ -25,5 +23,4 @@ export function useWorkspacePresencePrefetch(wsId: string | undefined): void {
useQuery({ ...agentListOptions(wsId ?? ""), enabled: !!wsId });
useQuery({ ...runtimeListOptions(wsId ?? ""), enabled: !!wsId });
useQuery({ ...agentTaskSnapshotOptions(wsId ?? ""), enabled: !!wsId });
useQuery({ ...squadListOptions(wsId ?? ""), enabled: !!wsId });
}

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

@@ -91,8 +91,6 @@ import type {
GitHubPullRequest,
ListGitHubInstallationsResponse,
GitHubConnectResponse,
Squad,
SquadMember,
} from "../types";
import type { OnboardingCompletionPath } from "../onboarding/types";
import { type Logger, noopLogger } from "../logger";
@@ -208,27 +206,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 +280,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 +309,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>;
}
@@ -500,12 +463,7 @@ export class ApiClient {
});
}
async quickCreateIssue(data: {
agent_id?: string;
squad_id?: string;
prompt: string;
project_id?: string | null;
}): Promise<{ task_id: string }> {
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),
@@ -592,10 +550,10 @@ export class ApiClient {
return this.fetch("/api/assignee-frequency");
}
async updateComment(commentId: string, content: string, attachmentIds?: string[]): Promise<Comment> {
async updateComment(commentId: string, content: string): Promise<Comment> {
return this.fetch(`/api/comments/${commentId}`, {
method: "PUT",
body: JSON.stringify({ content, attachment_ids: attachmentIds }),
body: JSON.stringify({ content }),
});
}
@@ -1235,13 +1193,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`);
}
@@ -1296,38 +1247,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();
@@ -1450,43 +1369,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

@@ -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

@@ -5,11 +5,11 @@ import type { ApiClient } from "../api/client";
import type { Attachment } from "../types";
import { MAX_FILE_SIZE } from "../constants/upload";
// Carries the full Attachment so editors that need preview metadata
// (`content_type`, `download_url`) get it directly; `link` is kept as an
// alias for `url` because many callers persist it into Markdown / avatar
// fields by that name.
export type UploadResult = Attachment & { link: string };
export interface UploadResult {
id: string;
filename: string;
link: string;
}
export interface UploadContext {
issueId?: string;
@@ -36,7 +36,7 @@ export function useFileUpload(
commentId: ctx?.commentId,
chatSessionId: ctx?.chatSessionId,
});
return { ...att, link: att.url };
return { id: att.id, filename: att.filename, link: att.url };
} finally {
setUploading(false);
}

View File

@@ -1,166 +0,0 @@
import type { QueryClient, QueryKey } from "@tanstack/react-query";
import {
agentActivityKeys,
agentRunCountsKeys,
agentTaskSnapshotKeys,
agentTasksKeys,
} from "../agents/queries";
import { labelKeys } from "../labels/queries";
import type { Issue, ListIssuesCache } from "../types";
import { findIssueLocation, removeIssueFromBuckets } from "./cache-helpers";
import { issueKeys } from "./queries";
export type DeletedIssueCacheMetadata = {
parentIssueIds: string[];
};
function collectParentId(
parentIssueIds: Set<string>,
parentId: string | null | undefined,
) {
if (parentId) parentIssueIds.add(parentId);
}
function collectParentFromListCache(
parentIssueIds: Set<string>,
data: ListIssuesCache | undefined,
issueId: string,
) {
const parentId = data
? findIssueLocation(data, issueId)?.issue.parent_issue_id
: undefined;
collectParentId(parentIssueIds, parentId);
}
function parentIdFromChildrenKey(key: QueryKey) {
const parentId = key[key.length - 1];
return typeof parentId === "string" ? parentId : null;
}
export function collectDeletedIssueCacheMetadata(
qc: QueryClient,
wsId: string,
issueId: string,
): DeletedIssueCacheMetadata {
const parentIssueIds = new Set<string>();
const detail = qc.getQueryData<Issue>(issueKeys.detail(wsId, issueId));
collectParentId(parentIssueIds, detail?.parent_issue_id);
collectParentFromListCache(
parentIssueIds,
qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId)),
issueId,
);
for (const [, data] of qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
})) {
collectParentFromListCache(parentIssueIds, data, issueId);
}
for (const [key, data] of qc.getQueriesData<Issue[]>({
queryKey: [...issueKeys.all(wsId), "children"],
})) {
const child = data?.find((issue) => issue.id === issueId);
if (!child) continue;
collectParentId(parentIssueIds, child.parent_issue_id);
collectParentId(parentIssueIds, parentIdFromChildrenKey(key));
}
return { parentIssueIds: Array.from(parentIssueIds) };
}
export function pruneDeletedIssueFromListCaches(
qc: QueryClient,
wsId: string,
issueId: string,
) {
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
for (const [key] of qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
})) {
qc.setQueryData<ListIssuesCache>(key, (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
}
}
export function pruneDeletedIssueFromParentChildrenCaches(
qc: QueryClient,
wsId: string,
issueId: string,
metadata: DeletedIssueCacheMetadata,
) {
for (const parentId of metadata.parentIssueIds) {
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.filter((issue) => issue.id !== issueId),
);
}
}
export function invalidateDeletedIssueParentCaches(
qc: QueryClient,
wsId: string,
metadata: DeletedIssueCacheMetadata,
) {
if (metadata.parentIssueIds.length === 0) return;
for (const parentId of metadata.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
export function invalidateDeletedIssueDependentCaches(
qc: QueryClient,
wsId: string,
) {
qc.invalidateQueries({ queryKey: agentTaskSnapshotKeys.list(wsId) });
qc.invalidateQueries({ queryKey: agentActivityKeys.last30d(wsId) });
qc.invalidateQueries({ queryKey: agentRunCountsKeys.last30d(wsId) });
qc.invalidateQueries({ queryKey: agentTasksKeys.all(wsId) });
}
export function invalidateIssueScopedCaches(
qc: QueryClient,
wsId: string,
issueId: string,
) {
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
qc.invalidateQueries({ queryKey: issueKeys.reactions(issueId) });
qc.invalidateQueries({ queryKey: issueKeys.subscribers(issueId) });
qc.invalidateQueries({ queryKey: issueKeys.usage(issueId) });
qc.invalidateQueries({ queryKey: issueKeys.attachments(issueId) });
qc.invalidateQueries({ queryKey: issueKeys.tasks(issueId) });
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issueId) });
qc.invalidateQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
}
export function cleanupDeletedIssueCaches(
qc: QueryClient,
wsId: string,
issueId: string,
metadata = collectDeletedIssueCacheMetadata(qc, wsId, issueId),
) {
pruneDeletedIssueFromListCaches(qc, wsId, issueId);
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, issueId, metadata);
invalidateDeletedIssueParentCaches(qc, wsId, metadata);
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
qc.removeQueries({ queryKey: issueKeys.usage(issueId) });
qc.removeQueries({ queryKey: issueKeys.attachments(issueId) });
qc.removeQueries({ queryKey: issueKeys.tasks(issueId) });
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
qc.removeQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
invalidateDeletedIssueDependentCaches(qc, wsId);
}

View File

@@ -11,17 +11,9 @@ import {
findIssueLocation,
getBucket,
patchIssueInBuckets,
removeIssueFromBuckets,
setBucket,
} from "./cache-helpers";
import {
cleanupDeletedIssueCaches,
collectDeletedIssueCacheMetadata,
invalidateDeletedIssueDependentCaches,
invalidateDeletedIssueParentCaches,
invalidateIssueScopedCaches,
pruneDeletedIssueFromListCaches,
pruneDeletedIssueFromParentChildrenCaches,
} from "./delete-cache";
import { useWorkspaceId } from "../hooks";
import { useRecentIssuesStore } from "./stores";
import type { Issue, IssueReaction, IssueStatus } from "../types";
@@ -200,13 +192,6 @@ export function useUpdateIssue() {
onSettled: (_data, _err, vars, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
// payload mutates the attachment join table.
if (vars.attachment_ids?.length) {
qc.invalidateQueries({ queryKey: issueKeys.attachments(vars.id) });
}
// Invalidate old parent's children cache
if (ctx?.parentId) {
qc.invalidateQueries({
@@ -232,56 +217,24 @@ export function useDeleteIssue() {
return useMutation({
mutationFn: (id: string) => api.deleteIssue(id),
onMutate: async (id) => {
await Promise.all([
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
]);
const metadata = collectDeletedIssueCacheMetadata(qc, wsId, id);
await Promise.all(
metadata.parentIssueIds.map((parentId) =>
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
),
);
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
});
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
const prevChildren = new Map<string, Issue[] | undefined>();
for (const parentId of metadata.parentIssueIds) {
prevChildren.set(
parentId,
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
);
}
pruneDeletedIssueFromListCaches(qc, wsId, id);
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
const deleted = prevList ? findIssueLocation(prevList, id)?.issue : undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, id) : old,
);
qc.removeQueries({ queryKey: issueKeys.detail(wsId, id) });
return { id, metadata, prevList, prevMyLists, prevDetail, prevChildren };
return { prevList, parentIssueId: deleted?.parent_issue_id };
},
onError: (_err, _id, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevMyLists) {
for (const [key, snapshot] of ctx.prevMyLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevDetail) {
qc.setQueryData(issueKeys.detail(wsId, ctx.id), ctx.prevDetail);
}
if (ctx?.prevChildren) {
for (const [parentId, snapshot] of ctx.prevChildren) {
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
}
}
},
onSuccess: (_data, id, ctx) => {
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadata);
},
onSettled: (_data, _err, _id, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.metadata) invalidateDeletedIssueParentCaches(qc, wsId, ctx.metadata);
if (ctx?.parentIssueId) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, ctx.parentIssueId) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
}
@@ -356,92 +309,57 @@ export function useBatchDeleteIssues() {
return useMutation({
mutationFn: (ids: string[]) => api.batchDeleteIssues(ids),
onMutate: async (ids) => {
await Promise.all([
qc.cancelQueries({ queryKey: issueKeys.list(wsId) }),
qc.cancelQueries({ queryKey: issueKeys.myAll(wsId) }),
]);
const metadataById = new Map(
ids.map((id) => [
id,
collectDeletedIssueCacheMetadata(qc, wsId, id),
]),
);
await qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const parentIssueIds = new Set<string>();
for (const metadata of metadataById.values()) {
for (const parentId of metadata.parentIssueIds) {
parentIssueIds.add(parentId);
if (prevList) {
for (const id of ids) {
const loc = findIssueLocation(prevList, id);
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
}
}
await Promise.all(
Array.from(parentIssueIds).map((parentId) =>
qc.cancelQueries({ queryKey: issueKeys.children(wsId, parentId) }),
),
);
const prevList = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const prevMyLists = qc.getQueriesData<ListIssuesCache>({
queryKey: issueKeys.myAll(wsId),
// Children cache may be the only place sub-issues live when the user
// operates from a parent's detail page. Collect affected parents and
// optimistically filter the deleted ids out of each children cache so
// the row disappears immediately, mirroring the list-cache behaviour.
const idSet = new Set(ids);
const childrenCaches = qc.getQueriesData<Issue[]>({
queryKey: [...issueKeys.all(wsId), "children"],
});
const prevChildren = new Map<string, Issue[] | undefined>();
for (const parentId of parentIssueIds) {
prevChildren.set(
parentId,
qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId)),
for (const [key, data] of childrenCaches) {
if (!data?.some((c) => idSet.has(c.id))) continue;
const parentId = key[key.length - 1];
if (typeof parentId !== "string") continue;
parentIssueIds.add(parentId);
prevChildren.set(parentId, data);
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
old?.filter((c) => !idSet.has(c.id)),
);
}
for (const id of ids) {
const metadata = metadataById.get(id);
pruneDeletedIssueFromListCaches(qc, wsId, id);
if (metadata) {
pruneDeletedIssueFromParentChildrenCaches(qc, wsId, id, metadata);
}
}
return { prevList, prevMyLists, prevChildren, parentIssueIds, metadataById };
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
if (!old) return old;
let next = old;
for (const id of ids) next = removeIssueFromBuckets(next, id);
return next;
});
return { prevList, prevChildren, parentIssueIds };
},
onError: (_err, _ids, ctx) => {
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevMyLists) {
for (const [key, snapshot] of ctx.prevMyLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevChildren) {
for (const [parentId, snapshot] of ctx.prevChildren) {
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
}
}
},
onSuccess: (data, ids, ctx) => {
if (data.deleted === ids.length) {
for (const id of ids) {
cleanupDeletedIssueCaches(qc, wsId, id, ctx?.metadataById.get(id));
}
return;
}
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
if (ctx?.prevMyLists) {
for (const [key, snapshot] of ctx.prevMyLists) {
qc.setQueryData(key, snapshot);
}
}
if (ctx?.prevChildren) {
for (const [parentId, snapshot] of ctx.prevChildren) {
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
}
}
for (const id of ids) {
invalidateIssueScopedCaches(qc, wsId, id);
}
qc.invalidateQueries({ queryKey: issueKeys.all(wsId) });
invalidateDeletedIssueDependentCaches(qc, wsId);
},
onSettled: (_data, _err, _ids, ctx) => {
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
if (ctx?.parentIssueIds && ctx.parentIssueIds.size > 0) {
invalidateDeletedIssueParentCaches(qc, wsId, {
parentIssueIds: Array.from(ctx.parentIssueIds),
});
for (const parentId of ctx.parentIssueIds) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, parentId) });
}
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
},
});
@@ -503,8 +421,8 @@ export function useCreateComment(issueId: string) {
export function useUpdateComment(issueId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ commentId, content, attachmentIds }: { commentId: string; content: string; attachmentIds?: string[] }) =>
api.updateComment(commentId, content, attachmentIds),
mutationFn: ({ commentId, content }: { commentId: string; content: string }) =>
api.updateComment(commentId, content),
onMutate: async ({ commentId, content }) => {
await qc.cancelQueries({ queryKey: issueKeys.timeline(issueId) });
const prev = qc.getQueryData<TimelineCache>(issueKeys.timeline(issueId));

View File

@@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest";
import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastActorType: null,
lastActorId: null,
lastAgentId: null,
lastProjectId: null,
prompt: "",
keepOpen: false,
@@ -35,20 +34,4 @@ describe("quick create store", () => {
setLastProjectId(null);
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
});
it("remembers the last actor (agent or squad) and clears both fields together", () => {
const { setLastActor } = useQuickCreateStore.getState();
setLastActor("agent", "agent-1");
expect(useQuickCreateStore.getState().lastActorType).toBe("agent");
expect(useQuickCreateStore.getState().lastActorId).toBe("agent-1");
setLastActor("squad", "squad-1");
expect(useQuickCreateStore.getState().lastActorType).toBe("squad");
expect(useQuickCreateStore.getState().lastActorId).toBe("squad-1");
setLastActor(null, null);
expect(useQuickCreateStore.getState().lastActorType).toBeNull();
expect(useQuickCreateStore.getState().lastActorId).toBeNull();
});
});

View File

@@ -5,26 +5,17 @@ import { createJSONStorage, persist } from "zustand/middleware";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type QuickCreateActorType = "agent" | "squad";
// Per-workspace memory of the last actor (agent or squad) and project the
// user picked in the Quick Create modal. Defaulted to those values on next
// open so frequent users skip the pickers entirely — without this, anyone
// targeting a single project ends up retyping "in project A" on every
// prompt. Persisted with the workspace-aware StateStorage so switching
// workspaces shows the right default automatically. Per-user scoping comes
// for free from localStorage being browser-profile-local — matches how
// draft-store / issues-scope-store / comment-collapse-store already
// namespace themselves.
//
// lastActorType + lastActorId replace the prior `lastAgentId` field once
// squads became selectable. Users who had a persisted agent preference
// land back on whatever the picker shows first; a one-time re-pick is
// preferable to the type-tag ambiguity of overloading a single UUID.
// Per-workspace memory of the last agent and project the user picked in the
// Quick Create modal. Defaulted to those values on next open so frequent
// users skip the pickers entirely — without this, anyone targeting a single
// project ends up retyping "in project A" on every prompt. Persisted with
// the workspace-aware StateStorage so switching workspaces shows the right
// default automatically. Per-user scoping comes for free from localStorage
// being browser-profile-local — matches how draft-store /
// issues-scope-store / comment-collapse-store already namespace themselves.
interface QuickCreateState {
lastActorType: QuickCreateActorType | null;
lastActorId: string | null;
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
lastProjectId: string | null;
setLastProjectId: (id: string | null) => void;
prompt: string;
@@ -37,9 +28,8 @@ interface QuickCreateState {
export const useQuickCreateStore = create<QuickCreateState>()(
persist(
(set) => ({
lastActorType: null,
lastActorId: null,
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
lastProjectId: null,
setLastProjectId: (id) => set({ lastProjectId: id }),
prompt: "",

View File

@@ -24,7 +24,7 @@ export interface CardProperties {
}
export interface ActorFilterValue {
type: "member" | "agent" | "squad";
type: "member" | "agent";
id: string;
}

View File

@@ -1,34 +1,17 @@
import { beforeEach, describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import {
agentActivityKeys,
agentRunCountsKeys,
agentTaskSnapshotKeys,
agentTasksKeys,
} from "../agents/queries";
import { onIssueDeleted, onIssueLabelsChanged } from "./ws-updaters";
import { onIssueLabelsChanged } from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import type {
AgentActivityBucket,
AgentRunCount,
AgentTask,
Attachment,
Issue,
IssueReaction,
IssueLabelsResponse,
IssueSubscriber,
IssueUsageSummary,
Label,
ListIssuesCache,
TimelineEntry,
} from "../types";
const WS_ID = "ws-1";
const ISSUE_ID = "issue-1";
const OTHER_ISSUE_ID = "issue-2";
const PARENT_ISSUE_ID = "parent-1";
const AGENT_ID = "agent-1";
const labelA: Label = {
id: "label-a",
@@ -70,47 +53,6 @@ const baseIssue: Issue = {
updated_at: "2025-01-01T00:00:00Z",
};
const parentedIssue: Issue = {
...baseIssue,
parent_issue_id: PARENT_ISSUE_ID,
};
const otherIssue: Issue = {
...baseIssue,
id: OTHER_ISSUE_ID,
identifier: "MUL-2",
title: "Other",
};
function makeListCache(...issues: Issue[]): ListIssuesCache {
return {
byStatus: {
todo: { issues, total: issues.length },
},
};
}
function makeTask(issueId = ISSUE_ID): AgentTask {
return {
id: `task-${issueId}`,
agent_id: AGENT_ID,
runtime_id: "runtime-1",
issue_id: issueId,
status: "completed",
priority: 0,
dispatched_at: null,
started_at: "2025-01-01T00:00:00Z",
completed_at: "2025-01-01T00:01:00Z",
result: null,
error: null,
created_at: "2025-01-01T00:00:00Z",
};
}
function expectInvalidated(qc: QueryClient, queryKey: readonly unknown[]) {
expect(qc.getQueryState(queryKey)?.isInvalidated).toBe(true);
}
describe("onIssueLabelsChanged", () => {
let qc: QueryClient;
@@ -151,243 +93,3 @@ describe("onIssueLabelsChanged", () => {
expect(detail?.labels).toEqual([labelB]);
});
});
describe("onIssueDeleted", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("removes every cache entry scoped directly to the deleted issue", () => {
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(ISSUE_ID), [
{
type: "activity",
id: "activity-1",
actor_type: "member",
actor_id: "user-1",
action: "created",
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<IssueReaction[]>(issueKeys.reactions(ISSUE_ID), [
{
id: "reaction-1",
issue_id: ISSUE_ID,
actor_type: "member",
actor_id: "user-1",
emoji: "+1",
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<IssueSubscriber[]>(issueKeys.subscribers(ISSUE_ID), [
{
issue_id: ISSUE_ID,
user_type: "member",
user_id: "user-1",
reason: "manual",
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<IssueUsageSummary>(issueKeys.usage(ISSUE_ID), {
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
task_count: 1,
});
qc.setQueryData<Attachment[]>(issueKeys.attachments(ISSUE_ID), [
{
id: "attachment-1",
workspace_id: WS_ID,
issue_id: ISSUE_ID,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
filename: "evidence.png",
url: "s3://bucket/evidence.png",
download_url: "https://example.test/evidence.png",
content_type: "image/png",
size_bytes: 1,
created_at: "2025-01-01T00:00:00Z",
},
]);
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [otherIssue]);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
labels: [labelA],
});
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, OTHER_ISSUE_ID), otherIssue);
qc.setQueryData<TimelineEntry[]>(issueKeys.timeline(OTHER_ISSUE_ID), []);
qc.setQueryData<IssueLabelsResponse>(
labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID),
{ labels: [labelB] },
);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expect(qc.getQueryData(issueKeys.detail(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.timeline(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.reactions(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.subscribers(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.usage(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.attachments(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
expect(qc.getQueryData(issueKeys.detail(WS_ID, OTHER_ISSUE_ID))).toEqual(
otherIssue,
);
expect(qc.getQueryData(issueKeys.timeline(OTHER_ISSUE_ID))).toEqual([]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, OTHER_ISSUE_ID))).toEqual({
labels: [labelB],
});
});
it("removes the deleted issue from workspace and my-issues list caches immediately", () => {
const myFilter = { assignee_id: AGENT_ID };
qc.setQueryData<ListIssuesCache>(
issueKeys.list(WS_ID),
makeListCache(baseIssue, otherIssue),
);
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
makeListCache(baseIssue, otherIssue),
);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
const myList = qc.getQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
);
expect(list?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
OTHER_ISSUE_ID,
]);
expect(list?.byStatus.todo?.total).toBe(1);
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
OTHER_ISSUE_ID,
]);
expect(myList?.byStatus.todo?.total).toBe(1);
expectInvalidated(qc, issueKeys.list(WS_ID));
expectInvalidated(qc, issueKeys.myList(WS_ID, "assigned", myFilter));
});
it("invalidates parent progress when the parent id only exists in detail cache", () => {
qc.setQueryData<Issue>(
issueKeys.detail(WS_ID, ISSUE_ID),
parentedIssue,
);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
parentedIssue,
otherIssue,
]);
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const parentChildren = qc.getQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
);
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates parent progress when the deleted issue is only present in a children cache", () => {
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
parentedIssue,
otherIssue,
]);
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const parentChildren = qc.getQueryData<Issue[]>(
issueKeys.children(WS_ID, PARENT_ISSUE_ID),
);
expect(parentChildren?.map((i) => i.id)).toEqual([OTHER_ISSUE_ID]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates parent progress when the parent id only exists in a my-issues cache", () => {
const myFilter = { assignee_id: AGENT_ID };
qc.setQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
makeListCache(parentedIssue, otherIssue),
);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, PARENT_ISSUE_ID), [
otherIssue,
]);
qc.setQueryData(issueKeys.childProgress(WS_ID), new Map());
onIssueDeleted(qc, WS_ID, ISSUE_ID);
const myList = qc.getQueryData<ListIssuesCache>(
issueKeys.myList(WS_ID, "assigned", myFilter),
);
expect(myList?.byStatus.todo?.issues.map((i) => i.id)).toEqual([
OTHER_ISSUE_ID,
]);
expectInvalidated(qc, issueKeys.children(WS_ID, PARENT_ISSUE_ID));
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates child progress when the deleted issue is itself a parent", () => {
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
qc.setQueryData<Issue[]>(issueKeys.children(WS_ID, ISSUE_ID), [
{
...otherIssue,
parent_issue_id: ISSUE_ID,
},
]);
qc.setQueryData(
issueKeys.childProgress(WS_ID),
new Map([[ISSUE_ID, { done: 0, total: 1 }]]),
);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expect(qc.getQueryData(issueKeys.children(WS_ID, ISSUE_ID))).toBeUndefined();
expectInvalidated(qc, issueKeys.childProgress(WS_ID));
});
it("invalidates agent task and activity caches that can reference the deleted issue", () => {
qc.setQueryData<AgentTask[]>(
agentTaskSnapshotKeys.list(WS_ID),
[makeTask()],
);
qc.setQueryData<AgentActivityBucket[]>(
agentActivityKeys.last30d(WS_ID),
[
{
agent_id: AGENT_ID,
bucket_at: "2025-01-01T00:00:00Z",
task_count: 1,
failed_count: 0,
},
],
);
qc.setQueryData<AgentRunCount[]>(agentRunCountsKeys.last30d(WS_ID), [
{ agent_id: AGENT_ID, run_count: 1 },
]);
qc.setQueryData<AgentTask[]>(agentTasksKeys.detail(WS_ID, AGENT_ID), [
makeTask(),
]);
qc.setQueryData<AgentTask[]>(issueKeys.tasks(ISSUE_ID), [makeTask()]);
onIssueDeleted(qc, WS_ID, ISSUE_ID);
expectInvalidated(qc, agentTaskSnapshotKeys.list(WS_ID));
expectInvalidated(qc, agentActivityKeys.last30d(WS_ID));
expectInvalidated(qc, agentRunCountsKeys.last30d(WS_ID));
expectInvalidated(qc, agentTasksKeys.detail(WS_ID, AGENT_ID));
expect(qc.getQueryData(issueKeys.tasks(ISSUE_ID))).toBeUndefined();
});
});

View File

@@ -5,8 +5,8 @@ import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import { cleanupDeletedIssueCaches } from "./delete-cache";
import type { Issue, IssueLabelsResponse, Label } from "../types";
import type { ListIssuesCache } from "../types";
@@ -107,5 +107,21 @@ export function onIssueDeleted(
wsId: string,
issueId: string,
) {
cleanupDeletedIssueCaches(qc, wsId, issueId);
// Look up the issue before removing it to check for parent_issue_id
const listData = qc.getQueryData<ListIssuesCache>(issueKeys.list(wsId));
const deleted = listData ? findIssueLocation(listData, issueId)?.issue : undefined;
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) =>
old ? removeIssueFromBuckets(old, issueId) : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
qc.removeQueries({ queryKey: issueKeys.detail(wsId, issueId) });
qc.removeQueries({ queryKey: issueKeys.timeline(issueId) });
qc.removeQueries({ queryKey: issueKeys.reactions(issueId) });
qc.removeQueries({ queryKey: issueKeys.subscribers(issueId) });
qc.removeQueries({ queryKey: issueKeys.children(wsId, issueId) });
if (deleted?.parent_issue_id) {
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, deleted.parent_issue_id) });
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
}
}

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

@@ -54,9 +54,6 @@
"./agents/derive-presence": "./agents/derive-presence.ts",
"./agents/use-agent-presence": "./agents/use-agent-presence.ts",
"./agents/visibility-label": "./agents/visibility-label.ts",
"./agents/stores": "./agents/stores/index.ts",
"./squads": "./squads/index.ts",
"./squads/stores": "./squads/stores/index.ts",
"./permissions": "./permissions/index.ts",
"./projects": "./projects/index.ts",
"./projects/queries": "./projects/queries.ts",

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

@@ -171,14 +171,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 +259,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
@@ -733,33 +724,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 +784,6 @@ export function useRealtimeSync(
unsubTaskFailed();
unsubChatSessionRead();
unsubChatSessionDeleted();
unsubChatSessionUpdated();
timers.forEach(clearTimeout);
timers.clear();
};

View File

@@ -1 +0,0 @@
export * from "./stores";

View File

@@ -1,5 +0,0 @@
export {
useSquadsViewStore,
type SquadsScope,
type SquadsViewState,
} from "./view-store";

View File

@@ -1,96 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { useSquadsViewStore } from "./view-store";
import { setCurrentWorkspace } from "../../platform/workspace-storage";
const flush = () => new Promise((resolve) => queueMicrotask(() => resolve(null)));
// Node 25 ships a partial `localStorage` shim under jsdom that's missing
// `clear`/`removeItem`; replace it with a real in-memory Storage so persist
// can round-trip values.
beforeAll(() => {
if (typeof globalThis.localStorage?.clear !== "function") {
const values = new Map<string, string>();
const storage: Storage = {
get length() { return values.size; },
clear: () => values.clear(),
getItem: (k) => values.get(k) ?? null,
key: (i) => Array.from(values.keys())[i] ?? null,
removeItem: (k) => { values.delete(k); },
setItem: (k, v) => { values.set(k, v); },
};
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: storage });
Object.defineProperty(window, "localStorage", { configurable: true, value: storage });
}
});
beforeEach(() => {
localStorage.clear();
useSquadsViewStore.setState({ scope: "mine" });
setCurrentWorkspace(null, null);
});
afterEach(() => {
setCurrentWorkspace(null, null);
});
describe("useSquadsViewStore", () => {
it("defaults to 'mine'", () => {
expect(useSquadsViewStore.getState().scope).toBe("mine");
});
it("setScope mutates the store", () => {
useSquadsViewStore.getState().setScope("all");
expect(useSquadsViewStore.getState().scope).toBe("all");
});
it("partialize persists only scope under the workspace-namespaced key", async () => {
setCurrentWorkspace("acme", "ws_a");
await flush();
useSquadsViewStore.getState().setScope("all");
const raw = localStorage.getItem("multica_squads_view:acme");
expect(raw).not.toBeNull();
const parsed = JSON.parse(raw as string);
expect(parsed.state).toEqual({ scope: "all" });
});
it("rehydrates a different saved scope on workspace switch", async () => {
localStorage.setItem(
"multica_squads_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
localStorage.setItem(
"multica_squads_view:beta",
JSON.stringify({ state: { scope: "mine" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("mine");
});
it("resets to 'mine' when switching to a workspace with no persisted value", async () => {
localStorage.setItem(
"multica_squads_view:acme",
JSON.stringify({ state: { scope: "all" }, version: 0 }),
);
setCurrentWorkspace("acme", "ws_a");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("all");
setCurrentWorkspace("beta", "ws_b");
await flush();
await flush();
expect(useSquadsViewStore.getState().scope).toBe("mine");
expect(localStorage.getItem("multica_squads_view:acme")).not.toBeNull();
});
});

View File

@@ -1,40 +0,0 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import {
createWorkspaceAwareStorage,
registerForWorkspaceRehydration,
} from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type SquadsScope = "mine" | "all";
export interface SquadsViewState {
scope: SquadsScope;
setScope: (scope: SquadsScope) => void;
}
export const useSquadsViewStore = create<SquadsViewState>()(
persist(
(set) => ({
scope: "mine",
setScope: (scope) => set({ scope }),
}),
{
name: "multica_squads_view",
storage: createJSONStorage(() => createWorkspaceAwareStorage(defaultStorage)),
partialize: (state) => ({ scope: state.scope }),
// On rehydrate, if the new workspace has no persisted value, reset to
// the default "mine" instead of leaving the previous workspace's in-
// memory scope in place. Default merge keeps current state when
// persisted is undefined, which would leak "all" across workspaces.
merge: (persisted, current) => {
if (!persisted) return { ...current, scope: "mine" };
return { ...current, ...(persisted as Partial<SquadsViewState>) };
},
},
),
);
registerForWorkspaceRehydration(() => useSquadsViewStore.persist.rehydrate());

View File

@@ -27,10 +27,6 @@ export interface UpdateIssueRequest {
due_date?: string | null;
parent_issue_id?: string | null;
project_id?: string | null;
/** Attachment IDs to bind to this issue alongside the description update.
* Used by the description editor to register newly uploaded files so they
* surface in `issueAttachments` and keep their preview Eye on refresh. */
attachment_ids?: string[];
}
export interface ListIssuesParams {

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"

View File

@@ -100,16 +100,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

@@ -9,7 +9,7 @@ import remarkMath from 'remark-math'
import { FileText, Download } from 'lucide-react'
import { cn } from '@multica/ui/lib/utils'
import { CodeBlock, InlineCode } from './CodeBlock'
import { isAllowedFileCardHref, preprocessFileCards } from './file-cards'
import { preprocessFileCards } from './file-cards'
import { preprocessLinks } from './linkify'
import { preprocessMentionShortcodes } from './mentions'
import 'katex/dist/katex.min.css'
@@ -120,7 +120,8 @@ function createComponents(
const dataType = node?.properties?.dataType as string | undefined
if (dataType === 'fileCard') {
const rawHref = (node?.properties?.dataHref as string) || ''
const href = isAllowedFileCardHref(rawHref) ? rawHref : ''
// Only allow http(s) URLs to prevent javascript: and other dangerous schemes.
const href = /^https?:\/\//i.test(rawHref) ? rawHref : ''
const filename = (node?.properties?.dataFilename as string) || ''
return (
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">

View File

@@ -15,28 +15,8 @@
const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|svg|ico|bmp|tiff?)$/i
/**
* URL alternation accepted inside `!file[name](url)` markdown.
*
* Restricted to:
* - `/uploads/...` site-relative paths (LocalStorage backend with no LOCAL_UPLOAD_BASE_URL)
* - `http(s)://...` absolute URLs (S3 / CloudFront / hosted)
*
* Anything else — `javascript:`, `data:`, protocol-relative `//host/x`, other
* APIs `/api/…`, path-traversal `/../…` — is rejected so a stored file-card
* cannot be turned into an out-of-band navigation.
*/
export const FILE_CARD_URL_PATTERN = /\/uploads\/[^)]*|https?:\/\/[^)]+/
/** Prefix test applied by renderers to validate `data-href` before opening it. */
export function isAllowedFileCardHref(href: string): boolean {
return /^(https?:\/\/|\/uploads\/)/i.test(href)
}
/** New syntax: !file[name](url) — unambiguous, no hostname matching needed. */
const NEW_FILE_CARD_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)$`,
)
const NEW_FILE_CARD_RE = /^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)$/
/** Legacy syntax: [name](cdnUrl) on its own line — matched by CDN hostname. */
const FILE_LINK_LINE = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/

View File

@@ -3,10 +3,4 @@ export { CodeBlock, InlineCode, type CodeBlockProps } from './CodeBlock'
export { StreamingMarkdown, type StreamingMarkdownProps } from './StreamingMarkdown'
export { preprocessLinks, detectLinks, hasLinks } from './linkify'
export { preprocessMentionShortcodes } from './mentions'
export {
preprocessFileCards,
isCdnUrl,
isFileCardUrl,
isAllowedFileCardHref,
FILE_CARD_URL_PATTERN,
} from './file-cards'
export { preprocessFileCards, isCdnUrl, isFileCardUrl } from './file-cards'

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

@@ -1,251 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enAgents from "../../locales/en/agents.json";
const TEST_RESOURCES = { en: { common: enCommon, agents: enAgents } };
// useWorkspaceId is a Context-backed hook in core; stub it to a static id so
// the card runs outside a WorkspaceIdProvider in tests.
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
// Paths only needs issueDetail for the "Now on" link. A simple stub keeps the
// test free of WorkspaceSlugProvider wiring.
vi.mock("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
issueDetail: (id: string) => `/test/issues/${id}`,
}),
}));
// AppLink is just a plain anchor here — wiring the navigation adapter would
// add nothing to these assertions.
vi.mock("../../navigation", () => ({
AppLink: ({
href,
children,
...rest
}: {
href: string;
children: React.ReactNode;
[k: string]: unknown;
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}));
// Each test sets these up via beforeEach.
const mockAgents = vi.hoisted(() => ({ current: [] as unknown[] }));
const mockSnapshot = vi.hoisted(() => ({ current: [] as unknown[] }));
const mockIssue = vi.hoisted(() => ({ current: null as unknown }));
const mockPresence = vi.hoisted(
() => ({ current: "loading" as unknown }),
);
// Distinguish queries by the function reference of the queryFn — the agent
// list, snapshot, and issue detail are all `queryOptions(...)` records that
// the component spreads into useQuery. Match on `queryKey[2]` which we know
// is unique per query factory.
vi.mock("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQuery: (opts: { queryKey: readonly unknown[]; enabled?: boolean }) => {
const key = opts.queryKey;
// Distinguish by the third segment which is the factory tag:
// ["workspaces", wsId, "agents"] — agent list
// ["workspaces", wsId, "agent-task-snapshot", "list"] — snapshot
// ["issues", wsId, "detail", id] — issue detail
const root = key[0];
const marker = key[2];
if (root === "workspaces" && marker === "agents") {
return { data: mockAgents.current, isLoading: false };
}
if (root === "workspaces" && marker === "agent-task-snapshot") {
return { data: mockSnapshot.current, isLoading: false };
}
if (root === "issues" && marker === "detail") {
return {
data: opts.enabled ? mockIssue.current : undefined,
isLoading: false,
};
}
return { data: undefined, isLoading: false };
},
};
});
vi.mock("@multica/core/agents", async () => {
const actual =
await vi.importActual<typeof import("@multica/core/agents")>(
"@multica/core/agents",
);
return {
...actual,
useAgentPresenceDetail: () => mockPresence.current,
};
});
import { AgentLivePeekCard } from "./agent-live-peek-card";
function makeAgent(overrides: Record<string, unknown> = {}) {
return {
id: "agent-1",
workspace_id: "ws-1",
runtime_id: "rt-1",
name: "Squirtle",
description: "",
instructions: "",
avatar_url: null,
runtime_mode: "local" as const,
runtime_config: {},
custom_env: {},
custom_args: [],
custom_env_redacted: false,
visibility: "private" as const,
status: "idle" as const,
max_concurrent_tasks: 1,
model: "",
owner_id: "user-me",
skills: [],
created_at: "2026-04-01T00:00:00Z",
updated_at: "2026-04-01T00:00:00Z",
archived_at: null,
archived_by: null,
...overrides,
};
}
function makeTask(overrides: Record<string, unknown>) {
return {
id: "task-x",
agent_id: "agent-1",
runtime_id: "rt-1",
issue_id: "",
status: "completed" as const,
priority: 0,
dispatched_at: null,
started_at: null,
completed_at: null,
result: null,
error: null,
created_at: "2026-05-14T00:00:00Z",
...overrides,
};
}
function renderCard() {
return render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<AgentLivePeekCard agentId="agent-1" />
</I18nProvider>,
);
}
beforeEach(() => {
vi.clearAllMocks();
cleanup();
mockAgents.current = [makeAgent()];
mockSnapshot.current = [];
mockIssue.current = null;
mockPresence.current = {
availability: "online",
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: 1,
};
});
describe("AgentLivePeekCard", () => {
it("renders Working state with the linked current issue", () => {
mockSnapshot.current = [
makeTask({
id: "task-running",
status: "running",
issue_id: "issue-42",
started_at: "2026-05-14T08:00:00Z",
}),
];
mockIssue.current = {
id: "issue-42",
identifier: "MUL-42",
title: "Wire up live peek",
};
mockPresence.current = {
availability: "online",
workload: "working",
runningCount: 1,
queuedCount: 0,
capacity: 1,
};
renderCard();
expect(screen.getByText("Working")).toBeInTheDocument();
// identifier + title both render under the same link.
const link = screen.getByRole("link", { name: /MUL-42/ });
expect(link).toHaveAttribute("href", "/test/issues/issue-42");
expect(link.textContent).toContain("Wire up live peek");
});
it("renders Idle + empty issue copy when nothing is running", () => {
mockPresence.current = {
availability: "online",
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: 1,
};
mockSnapshot.current = [
makeTask({
id: "task-done",
status: "completed",
completed_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
}),
];
renderCard();
expect(screen.getByText("Idle")).toBeInTheDocument();
expect(screen.getByText(enAgents.live_peek.no_current_issue)).toBeInTheDocument();
// "5m ago" — proves last activity falls back to the most recent terminal
// task in the snapshot.
expect(screen.getByText(/5m ago/)).toBeInTheDocument();
// No failed indicator on a completed terminal state.
expect(screen.queryByText(enAgents.live_peek.failed_indicator)).toBeNull();
});
it("shows the failed indicator on the last-activity row when the most recent terminal task failed", () => {
mockPresence.current = {
availability: "online",
// Per the project's deliberate split, workload is current-only — so
// a failed terminal task does NOT flip workload to anything besides
// idle / queued / working.
workload: "idle",
runningCount: 0,
queuedCount: 0,
capacity: 1,
};
mockSnapshot.current = [
makeTask({
id: "task-failed",
status: "failed",
completed_at: new Date(Date.now() - 2 * 60 * 1000).toISOString(),
}),
];
renderCard();
expect(screen.getByText("Idle")).toBeInTheDocument();
expect(screen.getByText(enAgents.live_peek.failed_indicator)).toBeInTheDocument();
});
});

View File

@@ -1,228 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { agentListOptions } from "@multica/core/workspace/queries";
import {
agentTaskSnapshotOptions,
useAgentPresenceDetail,
} from "@multica/core/agents";
import { issueDetailOptions } from "@multica/core/issues";
import { timeAgo } from "@multica/core/utils";
import type { AgentTask } from "@multica/core/types";
import { AlertTriangle } from "lucide-react";
import { AppLink } from "../../navigation";
import { useT } from "../../i18n";
import { workloadConfig } from "../presence";
interface AgentLivePeekCardProps {
agentId: string;
}
// Live "peek" card for an agent avatar — shows the three live signals the
// squad members tab cares about (workload, current issue, last activity).
// Companion to AgentProfileCard, which surfaces static identity (description,
// runtime, skills, owner). Keeping them separate avoids polluting the 23+
// existing AgentProfileCard call sites with live-only concerns.
export function AgentLivePeekCard({ agentId }: AgentLivePeekCardProps) {
const { t } = useT("agents");
const wsId = useWorkspaceId();
const p = useWorkspacePaths();
const { data: agents = [], isLoading: agentsLoading } = useQuery(
agentListOptions(wsId),
);
const { data: snapshot = [] } = useQuery(agentTaskSnapshotOptions(wsId));
const presence = useAgentPresenceDetail(wsId, agentId);
const agent = agents.find((a) => a.id === agentId);
if (agentsLoading && !agent) {
return (
<div className="flex items-center gap-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-1.5">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-20" />
</div>
</div>
);
}
if (!agent) {
return (
<div className="text-xs text-muted-foreground">
{t(($) => $.profile_card.unavailable)}
</div>
);
}
const agentTasks = snapshot.filter((t) => t.agent_id === agentId);
const runningTask = agentTasks.find(
(t) => t.status === "running" && !!t.issue_id,
);
const currentIssueId = runningTask?.issue_id ?? null;
const lastTerminal = pickLatestTerminal(agentTasks);
const initials = agent.name
.split(" ")
.map((w) => w[0])
.join("")
.toUpperCase()
.slice(0, 2);
const workload = presence === "loading" ? null : presence.workload;
const workloadVisual = workload ? workloadConfig[workload] : null;
return (
<div className="flex flex-col gap-3 text-left">
{/* Header — avatar + name. */}
<div className="flex items-start gap-3">
<ActorAvatarBase
name={agent.name}
initials={initials}
avatarUrl={agent.avatar_url}
isAgent
size={40}
className="rounded-md"
/>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{agent.name}</p>
<div className="mt-0.5 inline-flex items-center gap-1.5">
{workloadVisual ? (
<>
<workloadVisual.icon
className={`h-3 w-3 shrink-0 ${workloadVisual.textClass}`}
/>
<span className={`text-xs ${workloadVisual.textClass}`}>
{t(($) => $.workload[workload!])}
</span>
</>
) : (
<Skeleton className="h-3 w-12" />
)}
</div>
</div>
</div>
{/* Meta rows. */}
<div className="flex flex-col gap-1.5 text-xs">
<CurrentIssueRow
wsId={wsId}
issueId={currentIssueId}
label={t(($) => $.live_peek.current_issue_label)}
emptyLabel={t(($) => $.live_peek.no_current_issue)}
issueHref={(id) => p.issueDetail(id)}
/>
<LastActivityRow
task={lastTerminal}
label={t(($) => $.live_peek.last_activity_label)}
emptyLabel={t(($) => $.live_peek.no_recent_activity)}
failedLabel={t(($) => $.live_peek.failed_indicator)}
/>
</div>
</div>
);
}
// Pick the most recent terminal task for last-activity display. Snapshot
// already caps this to one terminal row per agent (see queries.ts header),
// but a defensive max-by-completed_at keeps the card honest if that shape
// ever changes.
function pickLatestTerminal(tasks: readonly AgentTask[]): AgentTask | null {
let best: AgentTask | null = null;
for (const t of tasks) {
if (t.status !== "completed" && t.status !== "failed" && t.status !== "cancelled") {
continue;
}
if (!t.completed_at) continue;
if (!best || (best.completed_at && t.completed_at > best.completed_at)) {
best = t;
}
}
return best;
}
function CurrentIssueRow({
wsId,
issueId,
label,
emptyLabel,
issueHref,
}: {
wsId: string;
issueId: string | null;
label: string;
emptyLabel: string;
issueHref: (id: string) => string;
}) {
// Lazy issue detail — only enabled while the card is mounted AND we have
// a running issue id. snapshot already gives us the id; this hook just
// resolves the human identifier (MUL-123) + title.
const { data: issue } = useQuery({
...issueDetailOptions(wsId, issueId ?? ""),
enabled: !!issueId,
});
return (
<div className="flex items-center gap-1.5">
<span className="w-16 shrink-0 text-muted-foreground">{label}</span>
{issueId ? (
issue ? (
<AppLink
href={issueHref(issueId)}
className="min-w-0 truncate text-brand hover:underline"
title={`${issue.identifier} ${issue.title}`}
>
<span className="mr-1 font-mono text-[11px]">{issue.identifier}</span>
<span>{issue.title}</span>
</AppLink>
) : (
<Skeleton className="h-3 w-24" />
)
) : (
<span className="text-muted-foreground">{emptyLabel}</span>
)}
</div>
);
}
function LastActivityRow({
task,
label,
emptyLabel,
failedLabel,
}: {
task: AgentTask | null;
label: string;
emptyLabel: string;
failedLabel: string;
}) {
return (
<div className="flex items-center gap-1.5">
<span className="w-16 shrink-0 text-muted-foreground">{label}</span>
{task && task.completed_at ? (
<span className="inline-flex min-w-0 items-center gap-1 truncate">
<span className="truncate">{timeAgo(task.completed_at)}</span>
{task.status === "failed" && (
// Failed terminal state shows here only — workload above stays a
// clean "what's on the plate now" reading (working/queued/idle),
// matching the project's deliberate split between current and
// historical state.
<span
className="inline-flex items-center gap-0.5 rounded bg-warning/10 px-1 py-0.5 text-[10px] font-medium text-warning"
title={failedLabel}
>
<AlertTriangle className="h-2.5 w-2.5" />
{failedLabel}
</span>
)}
</span>
) : (
<span className="text-muted-foreground">{emptyLabel}</span>
)}
</div>
);
}

View File

@@ -19,7 +19,6 @@ import {
useWorkspaceActivityMap,
useWorkspacePresenceMap,
} from "@multica/core/agents";
import { useAgentsViewStore } from "@multica/core/agents/stores";
import { api } from "@multica/core/api";
import { useAuthStore } from "@multica/core/auth";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -47,7 +46,6 @@ import { availabilityConfig, availabilityOrder } from "../presence";
import { CreateAgentDialog } from "./create-agent-dialog";
import { type AgentRow, createAgentColumns } from "./agent-columns";
import { useT } from "../../i18n";
import { matchesPinyin } from "../../editor/extensions/pinyin-match";
// Filter axes:
//
@@ -101,10 +99,10 @@ export function AgentsPage() {
const { byAgent: activityMap } = useWorkspaceActivityMap(wsId);
const [view, setView] = useState<View>("active");
// Scope (Mine/All) is persisted per workspace so it survives list →
// detail → back navigation. Default is "mine" on first visit.
const scope = useAgentsViewStore((s) => s.scope);
const setScope = useAgentsViewStore((s) => s.setScope);
// Default to "mine" — matches runtimes page convention and the visual
// ordering (Mine first). All is one click away when users want the
// workspace-wide view.
const [scope, setScope] = useState<Scope>("mine");
const [availabilityFilter, setAvailabilityFilter] =
useState<AvailabilityFilter>("all");
const [sort, setSort] = useState<SortKey>("recent");
@@ -198,7 +196,6 @@ export function AgentsPage() {
if (q) {
if (
!a.name.toLowerCase().includes(q) &&
!matchesPinyin(a.name, q) &&
!(a.description ?? "").toLowerCase().includes(q)
) {
return false;
@@ -459,6 +456,7 @@ export function AgentsPage() {
members={members}
currentUserId={currentUser?.id ?? null}
template={duplicateTemplate}
existingAgentNames={agents.map((a) => a.name)}
onClose={() => {
setShowCreate(false);
setDuplicateTemplate(null);

View File

@@ -146,6 +146,13 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
</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 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -97,10 +97,8 @@ export function ModelDropdown({
if (!supported && !modelsQuery.isLoading) {
return (
<div className="flex flex-col min-w-0">
<div className="flex h-6 items-center">
<Label className="text-xs text-muted-foreground">{t(($) => $.model_dropdown.label)}</Label>
</div>
<div className="min-w-0">
<Label className="text-xs text-muted-foreground">{t(($) => $.model_dropdown.label)}</Label>
<div className="mt-1.5 flex items-start gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0">
@@ -115,8 +113,8 @@ export function ModelDropdown({
}
return (
<div className="flex flex-col min-w-0">
<div className="flex h-6 items-center justify-between">
<div className="min-w-0">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">{t(($) => $.model_dropdown.label)}</Label>
{modelsQuery.isError && (
<span className="text-xs text-muted-foreground">{t(($) => $.model_dropdown.discovery_failed)}</span>
@@ -129,11 +127,8 @@ export function ModelDropdown({
>
<Cpu className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
{/* Wrapped in flex to mirror RuntimePicker's trigger DOM. The
two pickers sit side-by-side; inline-in-flex vs block-line-
box height calc would otherwise leave them ~1px misaligned. */}
<div className="flex items-center gap-2">
<span className="truncate font-medium">{triggerLabel}</span>
<div className="truncate font-medium">
{triggerLabel}
</div>
{value && (
<div className="truncate text-xs text-muted-foreground">

View File

@@ -1,263 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Cloud, Loader2, Lock } from "lucide-react";
import { ProviderLogo } from "../../runtimes/components/provider-logo";
import { ActorAvatar } from "../../common/actor-avatar";
import type { MemberWithUser, RuntimeDevice } from "@multica/core/types";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@multica/ui/components/ui/popover";
import { Label } from "@multica/ui/components/ui/label";
import { useT } from "../../i18n";
export type RuntimeFilter = "mine" | "all";
export function RuntimePicker({
runtimes,
runtimesLoading,
members,
currentUserId,
selectedRuntimeId,
onSelect,
}: {
runtimes: RuntimeDevice[];
runtimesLoading?: boolean;
members: MemberWithUser[];
currentUserId: string | null;
selectedRuntimeId: string;
onSelect: (id: string) => void;
}) {
const { t } = useT("agents");
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState<RuntimeFilter>("mine");
const getOwnerMember = (ownerId: string | null) => {
if (!ownerId) return null;
return members.find((m) => m.user_id === ownerId) ?? null;
};
const hasOtherRuntimes = runtimes.some((r) => r.owner_id !== currentUserId);
const filteredRuntimes = useMemo(
() => computeFilteredRuntimes(runtimes, filter, currentUserId),
[runtimes, filter, currentUserId],
);
const selectedRuntime =
runtimes.find((d) => d.id === selectedRuntimeId) ?? null;
// Sole source of truth for seeding the parent's selection when it's empty
// — first mount with no template runtime, runtimes arriving later over
// WS, or filter toggle clearing to a set with no usable item. Only fires
// when `selectedRuntimeId === ""` so a duplicate-mode pre-fill (template
// runtime) is never silently overwritten.
useEffect(() => {
if (selectedRuntimeId !== "") return;
const firstUsable = filteredRuntimes.find((r) =>
isRuntimeUsableForUser(r, currentUserId),
);
if (firstUsable) onSelect(firstUsable.id);
}, [filteredRuntimes, selectedRuntimeId, currentUserId, onSelect]);
// On filter toggle, recompute the picker's selection to a usable item
// in the new filter set. Pushes `""` when nothing matches; the seeding
// effect above is a no-op in that case (correct: no usable item to pick).
const handleFilterChange = (next: RuntimeFilter) => {
if (next === filter) return;
setFilter(next);
const nextList = computeFilteredRuntimes(runtimes, next, currentUserId);
const firstUsable = nextList.find((r) =>
isRuntimeUsableForUser(r, currentUserId),
);
onSelect(firstUsable?.id ?? "");
};
return (
<div className="flex flex-col min-w-0">
<div className="flex h-6 items-center justify-between">
<Label className="text-xs text-muted-foreground">
{t(($) => $.create_dialog.runtime_label)}
</Label>
{hasOtherRuntimes && (
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5">
<button
type="button"
onClick={() => handleFilterChange("mine")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
filter === "mine"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(($) => $.create_dialog.runtime_filter_mine)}
</button>
<button
type="button"
onClick={() => handleFilterChange("all")}
className={`rounded px-2 py-0.5 text-xs font-medium transition-colors ${
filter === "all"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(($) => $.create_dialog.runtime_filter_all)}
</button>
</div>
)}
</div>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={runtimes.length === 0 && !runtimesLoading}
className="flex w-full min-w-0 items-center gap-3 rounded-lg border border-border bg-background px-3 py-2.5 mt-1.5 text-left text-sm transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
>
{runtimesLoading ? (
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
) : selectedRuntime ? (
<ProviderLogo
provider={selectedRuntime.provider}
className="h-4 w-4 shrink-0"
/>
) : (
<Cloud className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">
{runtimesLoading
? t(($) => $.create_dialog.runtime_loading)
: (selectedRuntime?.name ??
t(($) => $.create_dialog.runtime_none))}
</span>
{selectedRuntime?.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
{t(($) => $.create_dialog.runtime_cloud_badge)}
</span>
)}
</div>
{selectedRuntime && (
<div className="truncate text-xs text-muted-foreground">
{getOwnerMember(selectedRuntime.owner_id)?.name ??
selectedRuntime.device_info}
</div>
)}
</div>
<ChevronDown
className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${
open ? "rotate-180" : ""
}`}
/>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--anchor-width)] p-1 max-h-60 overflow-y-auto"
>
{filteredRuntimes.map((device) => {
const ownerMember = getOwnerMember(device.owner_id);
const disabled = !isRuntimeUsableForUser(device, currentUserId);
const disabledTitle = disabled
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
: undefined;
return (
<button
key={device.id}
type="button"
disabled={disabled}
title={disabledTitle}
onClick={() => {
if (disabled) return;
onSelect(device.id);
setOpen(false);
}}
className={`flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors ${
disabled
? "cursor-not-allowed opacity-50"
: device.id === selectedRuntimeId
? "bg-accent"
: "hover:bg-accent/50"
}`}
>
<ProviderLogo
provider={device.provider}
className="h-4 w-4 shrink-0"
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{device.name}</span>
{device.runtime_mode === "cloud" && (
<span className="shrink-0 rounded bg-info/10 px-1.5 py-0.5 text-xs font-medium text-info">
{t(($) => $.create_dialog.runtime_cloud_badge)}
</span>
)}
{disabled && (
<span className="shrink-0 inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
<Lock className="h-3 w-3" />
{t(($) => $.create_dialog.runtime_private_badge)}
</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
{ownerMember ? (
<>
<ActorAvatar
actorType="member"
actorId={ownerMember.user_id}
size={14}
/>
<span className="truncate">{ownerMember.name}</span>
</>
) : (
<span className="truncate">{device.device_info}</span>
)}
</div>
</div>
<span
className={`h-2 w-2 shrink-0 rounded-full ${
device.status === "online"
? "bg-success"
: "bg-muted-foreground/40"
}`}
/>
</button>
);
})}
</PopoverContent>
</Popover>
</div>
);
}
// Visibility gate exposed so the parent can defend Create against a locked
// selection (e.g. duplicate of an agent whose runtime is now private).
export function isRuntimeUsableForUser(
r: RuntimeDevice,
currentUserId: string | null,
): boolean {
if (!currentUserId) return true;
if (r.owner_id === currentUserId) return true;
return r.visibility === "public";
}
function computeFilteredRuntimes(
runtimes: RuntimeDevice[],
filter: RuntimeFilter,
currentUserId: string | null,
): RuntimeDevice[] {
const filtered =
filter === "mine" && currentUserId
? runtimes.filter((r) => r.owner_id === currentUserId)
: runtimes;
return [...filtered].sort((a, b) => {
const aMine = a.owner_id === currentUserId;
const bMine = b.owner_id === currentUserId;
if (aMine && !bMine) return -1;
if (!aMine && bMine) return 1;
const aUsable = isRuntimeUsableForUser(a, currentUserId);
const bUsable = isRuntimeUsableForUser(b, currentUserId);
if (aUsable && !bUsable) return -1;
if (!aUsable && bUsable) return 1;
return 0;
});
}

View File

@@ -0,0 +1,173 @@
"use client";
import { Check, ChevronRight, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
import type { AgentTemplateSummary } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { getAccentClass, getTemplateIcon } from "./template-picker";
interface TemplateDetailProps {
template: AgentTemplateSummary;
/** Fired when the user clicks "Use this template" — the dialog calls
* the create API and navigates to the new agent. */
onUse: (template: AgentTemplateSummary) => void;
/** True while the parent's create request is in flight; we disable the
* Use button so the user can't double-click. */
creating?: boolean;
/** Upstream URLs the server reported as unreachable on the most recent
* create attempt. Surfaces an inline error banner so the user knows
* *why* Create didn't navigate. The detail step is the only place
* this banner can render — `quickCreateFromTemplate` fires from here
* and never advances to a different step on failure. */
failedURLs?: readonly string[] | null;
}
/**
* Step 3 of the create-agent flow: a read-only preview of the picked
* template — instructions, skill list with cached descriptions, and a
* "Use this template" CTA at the bottom. Clicking Use kicks off a
* one-shot create with default settings (no form step in between).
*
* Instructions come from the lazy-fetched detail endpoint (the picker
* only carries the summary). Cached through TanStack Query keyed by
* slug with `staleTime: Infinity`, so navigating back and forth between
* picker and detail doesn't re-fetch. Visual rhythm matches the picker
* card so the transition feels seamless.
*/
export function TemplateDetail({
template,
onUse,
creating = false,
failedURLs,
}: TemplateDetailProps) {
const { t } = useT("agents");
const { data: detail, isLoading, error } = useQuery(
agentTemplateDetailOptions(template.slug),
);
const Icon = getTemplateIcon(template.icon);
const accentClass = getAccentClass(template.accent);
return (
<>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl p-6">
{/* failedURLs banner — sits above the header so it's the first
thing the user sees after the spinner clears on a 422. */}
{failedURLs && failedURLs.length > 0 && (
<div className="mb-5 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
<div className="font-medium text-destructive">
{t(($) => $.create_dialog.template_failure.title)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t(($) => $.create_dialog.template_failure.body)}
</div>
<ul className="mt-2 space-y-0.5 text-xs">
{failedURLs.map((u) => (
<li key={u} className="break-all font-mono">
{u}
</li>
))}
</ul>
</div>
)}
{/* Header: icon + name + description. Same rhythm as the picker
card so the user reads the transition as "the same item,
expanded". */}
<div className="flex items-start gap-3">
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-lg", accentClass)}>
<Icon className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold">{template.name}</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{template.description}</p>
{template.category ? (
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{template.category}
</div>
) : null}
</div>
</div>
{/* Skill list — always visible (summary has cached descriptions) */}
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(($) => $.create_dialog.template_detail.skill_count, {
count: template.skills.length,
})}
</h3>
<ul className="mt-3 space-y-2">
{template.skills.map((s) => (
<li
key={s.source_url}
className="rounded-lg border bg-card px-3 py-2.5"
>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-success" />
<span className="font-mono text-xs font-medium">{s.cached_name}</span>
</div>
{s.cached_description ? (
<p className="mt-1 ml-6 text-xs text-muted-foreground">
{s.cached_description}
</p>
) : null}
</li>
))}
</ul>
</section>
{/* Instructions — lazy fetch + loading/error states */}
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(($) => $.create_dialog.template_detail.instructions_label)}
</h3>
<div className="mt-3 rounded-lg border bg-muted/30 px-4 py-3">
{isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t(($) => $.create_dialog.template_detail.instructions_loading)}
</div>
) : error ? (
<div className="text-xs text-destructive">
{error instanceof Error
? error.message
: t(($) => $.create_dialog.template_detail.load_failed)}
</div>
) : (
<pre className="max-h-60 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/80">
{detail?.instructions ?? ""}
</pre>
)}
</div>
</section>
</div>
</div>
{/* Sticky CTA footer — click Use kicks off the create API call;
parent shows a creating spinner and navigates on success. */}
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
<Button
onClick={() => onUse(template)}
disabled={creating}
className="gap-1.5"
>
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t(($) => $.create_dialog.template_detail.creating)}
</>
) : (
<>
{t(($) => $.create_dialog.template_detail.use)}
<ChevronRight className="h-4 w-4" />
</>
)}
</Button>
</div>
</>
);
}

View File

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

View File

@@ -12,7 +12,6 @@ import {
PickerEmpty,
} from "../../../issues/components/pickers/property-picker";
import { useT } from "../../../i18n";
import { matchesPinyin } from "../../../editor/extensions/pinyin-match";
export function AgentPicker({
agentId,
@@ -37,7 +36,7 @@ export function AgentPicker({
const query = filter.trim().toLowerCase();
const filteredAgents = query
? active.filter((a) => a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query))
? active.filter((a) => a.name.toLowerCase().includes(query))
: active;
return (

View File

@@ -2,28 +2,9 @@ import { forwardRef, useRef, useImperativeHandle } from "react";
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import enCommon from "../../locales/en/common.json";
import enChat from "../../locales/en/chat.json";
function makeUpload(overrides: Partial<UploadResult> & { id: string; link: string; filename: string }): UploadResult {
return {
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
url: overrides.link,
download_url: overrides.link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
...overrides,
};
}
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
// Track drop-zone callbacks so the test can simulate a real drop.
@@ -47,7 +28,7 @@ vi.mock("../../editor", () => ({
defaultValue?: string;
onUpdate?: (md: string) => void;
placeholder?: string;
onUploadFile?: (file: File) => Promise<UploadResult | null>;
onUploadFile?: (file: File) => Promise<{ id: string; link: string; filename: string } | null>;
},
ref: React.Ref<unknown>,
) {
@@ -114,9 +95,11 @@ function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}
const onSend = props.onSend ?? vi.fn();
const onUploadFile =
props.onUploadFile ??
vi.fn(async (_file: File) =>
makeUpload({ id: "att-1", link: "https://cdn.example/att-1.png", filename: "img.png" }),
);
vi.fn(async (_file: File) => ({
id: "att-1",
link: "https://cdn.example/att-1.png",
filename: "img.png",
}));
render(
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatInput onSend={onSend} onUploadFile={onUploadFile} agentName="Multica" {...props} />
@@ -139,9 +122,11 @@ describe("ChatInput attachment wiring", () => {
it("passes attachment_ids to onSend for uploads still referenced in the content", async () => {
const onSend = vi.fn();
const onUploadFile = vi.fn(async (_file: File) =>
makeUpload({ id: "att-42", link: "https://cdn.example/att-42.png", filename: "x.png" }),
);
const onUploadFile = vi.fn(async (_file: File) => ({
id: "att-42",
link: "https://cdn.example/att-42.png",
filename: "x.png",
}));
renderInput({ onSend, onUploadFile });
// Simulate the drop → editor.uploadFile → onUploadFile happy path. The
@@ -167,8 +152,8 @@ describe("ChatInput attachment wiring", () => {
});
it("disables send while an upload is in flight, re-enables after it resolves", async () => {
let resolveUpload: (v: UploadResult) => void;
const uploadPromise = new Promise<UploadResult>((res) => {
let resolveUpload: (v: { id: string; link: string; filename: string }) => void;
const uploadPromise = new Promise<{ id: string; link: string; filename: string }>((res) => {
resolveUpload = res;
});
const onSend = vi.fn();
@@ -192,7 +177,11 @@ describe("ChatInput attachment wiring", () => {
expect(sendButton).toBeDisabled();
});
resolveUpload!(makeUpload({ id: "att-slow", link: "https://cdn.example/att-slow.png", filename: "slow.png" }));
resolveUpload!({
id: "att-slow",
link: "https://cdn.example/att-slow.png",
filename: "slow.png",
});
let sendButton: HTMLElement;
await waitFor(() => {

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";
@@ -735,12 +734,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 +773,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 +795,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 +950,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

@@ -11,24 +11,9 @@ import { useActorName } from "@multica/core/workspace/hooks";
import { useAgentPresenceDetail } from "@multica/core/agents";
import { useCurrentWorkspace } from "@multica/core/paths";
import { AgentProfileCard } from "../agents/components/agent-profile-card";
import { AgentLivePeekCard } from "../agents/components/agent-live-peek-card";
import { MemberProfileCard } from "../members/member-profile-card";
import { availabilityConfig } from "../agents/presence";
/**
* Selects which agent hover-card payload to render when `enableHoverCard` is
* on. Two surfaces, two intents:
* - `"profile"` (default) — static identity (description, runtime, skills,
* owner). Used by 20+ "who is this agent?" surfaces (comment authors,
* pickers, list rows).
* - `"live"` — live activity peek (workload, current issue, last activity).
* Used where the user already knows the identity and wants the live state,
* e.g. the squad members tab.
*
* Has no effect for non-agent actors (members always render the member card).
*/
export type AgentHoverCardVariant = "profile" | "live";
interface ActorAvatarProps {
actorType: string;
actorId: string;
@@ -48,12 +33,6 @@ interface ActorAvatarProps {
* popover inside the dropdown.
*/
showStatusDot?: boolean;
/**
* When `enableHoverCard` is on for an agent, choose which payload to
* render. See {@link AgentHoverCardVariant}. Defaults to `"profile"` so
* existing call sites keep their identity-card behaviour.
*/
hoverCardVariant?: AgentHoverCardVariant;
}
const FOCUSABLE_ANCESTOR_SELECTOR =
@@ -66,7 +45,6 @@ export function ActorAvatar({
className,
enableHoverCard,
showStatusDot,
hoverCardVariant = "profile",
}: ActorAvatarProps) {
const { getActorName, getActorInitials, getActorAvatarUrl } = useActorName();
const avatar = (
@@ -76,7 +54,6 @@ export function ActorAvatar({
avatarUrl={getActorAvatarUrl(actorType, actorId)}
isAgent={actorType === "agent"}
isSystem={actorType === "system"}
isSquad={actorType === "squad"}
size={size}
className={className}
/>
@@ -100,11 +77,7 @@ export function ActorAvatar({
return dotted;
}
if (actorType === "agent") {
return (
<AgentAvatarHoverCard agentId={actorId} variant={hoverCardVariant}>
{dotted}
</AgentAvatarHoverCard>
);
return <AgentAvatarHoverCard agentId={actorId}>{dotted}</AgentAvatarHoverCard>;
}
if (actorType === "member") {
return <MemberAvatarHoverCard userId={actorId}>{dotted}</MemberAvatarHoverCard>;
@@ -142,21 +115,13 @@ function AgentStatusDot({ agentId, size }: { agentId: string; size?: number }) {
*/
function AgentAvatarHoverCard({
agentId,
variant,
children,
}: {
agentId: string;
variant: AgentHoverCardVariant;
children: React.ReactNode;
}) {
const content =
variant === "live" ? (
<AgentLivePeekCard agentId={agentId} />
) : (
<AgentProfileCard agentId={agentId} />
);
return (
<ActorAvatarHoverCardShell content={content}>
<ActorAvatarHoverCardShell content={<AgentProfileCard agentId={agentId} />}>
{children}
</ActorAvatarHoverCardShell>
);

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

@@ -22,19 +22,14 @@ import {
import { useCustomPricingStore } from "@multica/core/runtimes/custom-pricing-store";
import { PageHeader } from "../../layout/page-header";
import { KpiCard } from "../../runtimes/components/shared";
import { DailyCostChart, DailyTokensChart } from "../../runtimes/components/charts";
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 {
aggregateAgentTokens,
aggregateDailyCost,
aggregateDailyTokens,
computeDailyTotals,
formatDuration,
mergeAgentDashboardRows,
@@ -111,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.
@@ -161,7 +151,6 @@ export function DashboardPage() {
// Cost / token math — re-derived when usage, days, or pricings change.
const totals = useMemo(() => computeDailyTotals(dailyUsage), [dailyUsage]);
const dailyCost = useMemo(() => aggregateDailyCost(dailyUsage), [dailyUsage]);
const dailyTokens = useMemo(() => aggregateDailyTokens(dailyUsage), [dailyUsage]);
const agentTokenRows = useMemo(
() => aggregateAgentTokens(byAgentUsage),
[byAgentUsage],
@@ -187,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}
@@ -209,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>
@@ -265,10 +242,8 @@ export function DashboardPage() {
/>
</div>
{/* Daily trend chart — toggle picks Cost vs Tokens axis,
mirroring the runtime-detail Usage section so both
surfaces share one chart language. */}
<DailyTrendBlock dailyCost={dailyCost} dailyTokens={dailyTokens} />
{/* Daily cost chart — reuses the runtime DailyCostChart. */}
<DailyCostBlock dailyCost={dailyCost} />
{/* Per-agent leaderboard — user picks the ranking metric;
the progress bar and column emphasis follow the metric. */}
@@ -342,58 +317,28 @@ function ProjectFilter({
);
}
type DailyMetric = "cost" | "tokens";
function DailyTrendBlock({
function DailyCostBlock({
dailyCost,
dailyTokens,
}: {
dailyCost: ReturnType<typeof aggregateDailyCost>;
dailyTokens: ReturnType<typeof aggregateDailyTokens>;
}) {
const { t } = useT("usage");
const [metric, setMetric] = useState<DailyMetric>("tokens");
// Empty-state is per-metric so a workspace that recorded tokens but
// has no priced models (unmapped) still gets a real Tokens chart while
// its Cost view falls through to the empty-state — same convention as
// the runtimes-side DailyTab in usage-section.tsx.
const totalCost = dailyCost.reduce((sum, d) => sum + d.total, 0);
const totalTokens = dailyTokens.reduce(
(sum, d) => sum + d.input + d.output + d.cacheRead + d.cacheWrite,
0,
);
const isEmpty = metric === "cost" ? totalCost === 0 : totalTokens === 0;
const total = dailyCost.reduce((sum, d) => sum + d.total, 0);
return (
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h4 className="text-sm font-semibold">
{metric === "cost"
? t(($) => $.daily.title_cost)
: t(($) => $.daily.title_tokens)}
</h4>
<Segmented
value={metric}
onChange={setMetric}
options={[
{ label: t(($) => $.daily.metric_tokens), value: "tokens" as const },
{ label: t(($) => $.daily.metric_cost), value: "cost" as const },
]}
/>
<div className="mb-3 flex items-center justify-between">
<h4 className="text-sm font-semibold">{t(($) => $.daily.title)}</h4>
</div>
<div className="min-h-[240px]">
{isEmpty ? (
{total === 0 ? (
<div className="flex aspect-[3/1] flex-col items-center justify-center gap-2 rounded-md border border-dashed bg-muted/20 p-6 text-center">
<BarChart3 className="h-5 w-5 text-muted-foreground/50" />
<p className="text-xs text-muted-foreground">
{t(($) => $.daily.no_data)}
</p>
</div>
) : metric === "cost" ? (
<DailyCostChart data={dailyCost} />
) : (
<DailyTokensChart data={dailyTokens} />
<DailyCostChart data={dailyCost} />
)}
</div>
</div>

View File

@@ -3,7 +3,7 @@ import type {
DashboardUsageByAgent,
DashboardAgentRunTime,
} from "@multica/core/types";
import { estimateCost, estimateCostBreakdown, type DailyTokenData } from "../runtimes/utils";
import { estimateCost, estimateCostBreakdown } from "../runtimes/utils";
// ---------------------------------------------------------------------------
// Dashboard data aggregations
@@ -66,42 +66,6 @@ export function aggregateDailyCost(usage: DashboardUsageDaily[]): DailyCostStack
});
}
// Per-(date, model) rows → 1 row per date with raw token counts split
// across the four chart segments. Independent of pricing — unmapped
// models still contribute here, even if they're excluded from cost.
// Mirrors `aggregateByDate(...).dailyTokens` from the runtimes utils so
// the Tokens chart on the Usage page consumes the same shape as the one
// on the runtime-detail page.
export function aggregateDailyTokens(usage: DashboardUsageDaily[]): DailyTokenData[] {
const map = new Map<
string,
{ input: number; output: number; cacheRead: number; cacheWrite: number }
>();
for (const u of usage) {
const entry = map.get(u.date) ?? {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
entry.input += u.input_tokens;
entry.output += u.output_tokens;
entry.cacheRead += u.cache_read_tokens;
entry.cacheWrite += u.cache_write_tokens;
map.set(u.date, entry);
}
return [...map.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, t]) => ({
date,
label: formatDateLabel(date),
input: t.input,
output: t.output,
cacheRead: t.cacheRead,
cacheWrite: t.cacheWrite,
}));
}
export interface DashboardTokenTotals {
input: number;
output: number;

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,367 +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";
const openExternalMock = vi.hoisted(() => vi.fn());
vi.mock("../platform", () => ({
openExternal: openExternalMock,
}));
// 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,
useAttachmentPreview,
} from "./attachment-preview-modal";
import { renderHook, act as hookAct } from "@testing-library/react";
// 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", 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 source={{ kind: "full", attachment: att }} open onClose={onClose} />);
const dialog = screen.getByRole("dialog");
fireEvent.click(dialog);
expect(onClose).toHaveBeenCalled();
});
});
describe("AttachmentPreviewModal — URL-only source", () => {
it("renders a PDF iframe from the URL when no attachment record is available", () => {
const url = "https://cdn.example.test/orphan.pdf?Signature=s";
render(
<AttachmentPreviewModal
source={{ kind: "url", url, filename: "orphan.pdf" }}
open
onClose={() => {}}
/>,
);
const iframe = document.querySelector("iframe");
expect(iframe).toBeTruthy();
expect(iframe?.getAttribute("src")).toBe(url);
});
it("renders <video> from the URL when no attachment record is available", () => {
const url = "https://cdn.example.test/clip.mp4?Signature=s";
render(
<AttachmentPreviewModal
source={{ kind: "url", url, filename: "clip.mp4" }}
open
onClose={() => {}}
/>,
);
const video = document.querySelector("video");
expect(video?.getAttribute("src")).toBe(url);
});
it("falls back to unsupported when a text kind is forced through a URL source", () => {
// The tryOpen gate normally prevents this; direct mount tests the
// defensive branch inside PreviewContent.
render(
<AttachmentPreviewModal
source={{ kind: "url", url: "https://x/y.md", filename: "y.md" }}
open
onClose={() => {}}
/>,
);
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
});
it("Download button opens the raw URL externally when no attachment id is available", () => {
const url = "https://cdn.example.test/orphan.pdf?Signature=s";
render(
<AttachmentPreviewModal
source={{ kind: "url", url, filename: "orphan.pdf" }}
open
onClose={() => {}}
/>,
);
const button = screen.getAllByTitle("Download")[0]!;
fireEvent.click(button);
expect(openExternalMock).toHaveBeenCalledWith(url);
expect(downloadMock).not.toHaveBeenCalled();
});
});
describe("useAttachmentPreview — tryOpen gate", () => {
it("accepts a full attachment for a media kind", () => {
const { result } = renderHook(() => useAttachmentPreview());
const att = makeAttachment({ filename: "x.pdf", content_type: "application/pdf" });
let opened = false;
hookAct(() => {
opened = result.current.tryOpen({ kind: "full", attachment: att });
});
expect(opened).toBe(true);
});
it("accepts a URL source for a media kind", () => {
const { result } = renderHook(() => useAttachmentPreview());
let opened = false;
hookAct(() => {
opened = result.current.tryOpen({
kind: "url",
url: "https://x/y.pdf",
filename: "y.pdf",
});
});
expect(opened).toBe(true);
});
it("rejects a URL source for a text kind — /content proxy needs an id", () => {
const { result } = renderHook(() => useAttachmentPreview());
let opened = true;
hookAct(() => {
opened = result.current.tryOpen({
kind: "url",
url: "https://x/y.md",
filename: "y.md",
});
});
expect(opened).toBe(false);
});
it("rejects a source whose filename isn't a previewable type", () => {
const { result } = renderHook(() => useAttachmentPreview());
let opened = true;
hookAct(() => {
opened = result.current.tryOpen({
kind: "url",
url: "https://x/y.zip",
filename: "y.zip",
});
});
expect(opened).toBe(false);
});
});

View File

@@ -1,514 +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 { openExternal } from "../platform";
import { ReadonlyContent } from "./readonly-content";
import {
extensionToLanguage,
getPreviewKind,
type PreviewKind,
} from "./utils/preview";
import { useDownloadAttachment } from "./use-download-attachment";
// ---------------------------------------------------------------------------
// Preview source — full attachment, or URL-only (media types only)
// ---------------------------------------------------------------------------
//
// `full` carries the resolved Attachment record and supports every PreviewKind
// (text types require the attachment id to call /api/attachments/{id}/content).
//
// `url` carries just the signed URL + filename. It is what NodeViews fall back
// to when `resolveAttachment(href)` returns undefined — typical when the URL
// was copy-pasted across comments so the attachment record isn't reachable
// from the current entity's `attachments` prop. Only media kinds (pdf / video
// / audio) can be opened from a `url` source because those render directly
// from the URL without hitting the text-content proxy.
export type PreviewSource =
| { kind: "full"; attachment: Attachment }
| { kind: "url"; url: string; filename: string };
// PreviewKinds that can render from a URL-only source. Text-based kinds
// (markdown / html / text) need the /content proxy which is ID-keyed.
const URL_ONLY_KINDS = new Set<PreviewKind>(["pdf", "video", "audio"]);
// Normalized view used everywhere downstream of `useAttachmentPreview`.
// `attachmentId === null` signals URL-only mode (download falls back to
// `openExternal`, text rendering branches are unreachable by the gate).
interface PreviewState {
filename: string;
contentType: string;
mediaUrl: string;
attachmentId: string | null;
}
function normalize(source: PreviewSource): PreviewState {
if (source.kind === "full") {
return {
filename: source.attachment.filename,
contentType: source.attachment.content_type,
mediaUrl: source.attachment.download_url,
attachmentId: source.attachment.id,
};
}
return {
filename: source.filename,
contentType: "",
mediaUrl: source.url,
attachmentId: null,
};
}
// ---------------------------------------------------------------------------
// Public props
// ---------------------------------------------------------------------------
interface AttachmentPreviewModalProps {
source: PreviewSource;
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 source. Returns false when the file type
* isn't previewable, OR when the source is URL-only but the kind requires
* a full attachment (text/markdown/html). Callers can fall back to a
* download flow. */
tryOpen: (source: PreviewSource) => boolean;
/** Force-open a preview, skipping the previewable() guard. Use for cases
* where the caller has already filtered. */
open: (source: PreviewSource) => 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<PreviewSource | null>(null);
const open = useCallback((source: PreviewSource) => setCurrent(source), []);
const tryOpen = useCallback((source: PreviewSource) => {
const state = normalize(source);
const kind = getPreviewKind(state.contentType, state.filename);
if (!kind) return false;
// URL-only sources cannot drive text kinds — the /content proxy is ID-keyed.
if (source.kind === "url" && !URL_ONLY_KINDS.has(kind)) return false;
setCurrent(source);
return true;
}, []);
const modal = current ? (
<AttachmentPreviewModal
source={current}
open
onClose={() => setCurrent(null)}
/>
) : null;
return useMemo(() => ({ open, tryOpen, modal }), [open, tryOpen, modal]);
}
// ---------------------------------------------------------------------------
// Modal — frame + dispatch
// ---------------------------------------------------------------------------
export function AttachmentPreviewModal({
source,
open,
onClose,
}: AttachmentPreviewModalProps) {
const { t } = useT("editor");
const download = useDownloadAttachment();
const state = normalize(source);
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(state.contentType, state.filename);
// Download dispatcher: re-sign through `getAttachment` when an id is
// available; otherwise fall back to opening the (possibly stale) URL
// externally — same tradeoff as the file-card NodeView's download path.
const handleDownload = () => {
if (state.attachmentId) {
download(state.attachmentId);
} else {
openExternal(state.mediaUrl);
}
};
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={state.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">{state.filename}</p>
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
{state.contentType || "—"}
</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={handleDownload}
>
<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}
source={source}
state={state}
onDownload={handleDownload}
/>
</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,
source,
state,
onDownload,
}: {
kind: PreviewKind | null;
source: PreviewSource;
state: PreviewState;
onDownload: () => void;
}) {
const { t } = useT("editor");
if (kind === null) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
// Text kinds need the attachment id for the /content proxy. The tryOpen
// gate prevents URL-only sources from reaching here for text kinds, but
// be defensive — a direct mount of <AttachmentPreviewModal> with a URL
// source whose filename later resolves to a text kind would otherwise
// crash on a null id.
if (
(kind === "markdown" || kind === "html" || kind === "text") &&
!state.attachmentId
) {
return (
<UnsupportedFallback
message={t(($) => $.attachment.preview_unsupported)}
onDownload={onDownload}
/>
);
}
switch (kind) {
case "pdf":
return (
<iframe
src={state.mediaUrl}
className="h-full w-full bg-background"
title={state.filename}
/>
);
case "video":
return (
<div className="flex h-full w-full items-center justify-center bg-black">
<video
src={state.mediaUrl}
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={state.mediaUrl} controls className="w-full max-w-xl" />
</div>
);
case "markdown":
return (
<TextBackedPreview
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<ReadonlyContent
content={text}
className="px-6 py-4"
attachments={source.kind === "full" ? [source.attachment] : []}
/>
)}
/>
);
case "html":
return (
<TextBackedPreview
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<iframe
srcDoc={text}
sandbox=""
className="h-full w-full bg-background"
title={state.filename}
/>
)}
/>
);
case "text":
return (
<TextBackedPreview
attachmentId={state.attachmentId!}
onDownload={onDownload}
render={(text) => (
<CodeBlock language={extensionToLanguage(state.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

@@ -17,16 +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 { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown";
import { FileText, Loader2, Download } from "lucide-react";
import { useT } from "../../i18n";
import { useAttachmentDownloadResolver } from "../attachment-download-context";
import { useAttachmentPreview } from "../attachment-preview-modal";
import { getPreviewKind } from "../utils/preview";
const FILE_CARD_MARKDOWN_RE = new RegExp(
`^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`,
);
// ---------------------------------------------------------------------------
@@ -42,35 +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);
};
// Preview gate mirrors the Download gate (href is enough). We attempt
// to resolve the full Attachment from the surrounding provider, but its
// absence is no longer fatal — media kinds (pdf/video/audio) only need
// the URL, so they remain previewable even when `resolveAttachment`
// misses (e.g. the URL was copy-pasted across comments and isn't in the
// current entity's attachments). Text kinds still require the id because
// the preview proxy is ID-keyed.
const attachment = href ? resolveAttachment(href) : undefined;
const kind = filename
? getPreviewKind(attachment?.content_type ?? "", filename)
: null;
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
const openPreview = () => {
if (attachment) {
preview.tryOpen({ kind: "full", attachment });
} else if (href) {
preview.tryOpen({ kind: "url", url: href, filename });
}
};
return (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
<div
@@ -86,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 && canPreview && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
openPreview();
}}
>
<Eye className="size-3.5" />
</button>
)}
{!uploading && href && (
<button
type="button"
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -117,7 +70,6 @@ function FileCardView({ node }: NodeViewProps) {
</button>
)}
</div>
{preview.modal}
</NodeViewWrapper>
);
}
@@ -189,7 +141,7 @@ export const FileCardExtension = Node.create({
return src.search(/^!file\[/m);
},
tokenize(src: string) {
const match = src.match(FILE_CARD_MARKDOWN_RE);
const match = src.match(/^!file\[([^\]]*)\]\((https?:\/\/[^)]+)\)/);
if (!match) return undefined;
return {
type: "fileCard",

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