Compare commits
8 Commits
agent/lamb
...
quick-crea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e96909ea8 | ||
|
|
126df05671 | ||
|
|
e68976ce18 | ||
|
|
5f2222b83d | ||
|
|
68edf57f64 | ||
|
|
31ec36146f | ||
|
|
6fafd86ecc | ||
|
|
f435dd2cd4 |
6
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -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
|
||||
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -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
@@ -58,3 +58,6 @@ server/server
|
||||
data/
|
||||
.kilo
|
||||
.idea
|
||||
|
||||
# git worktrees
|
||||
.worktrees/
|
||||
|
||||
18
README.md
@@ -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.
|
||||
|
||||
@@ -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 一起高效完成项目。**
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 782 B |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 -----------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -45,5 +45,4 @@ import { Callout } from "fumadocs-ui/components/callout";
|
||||
|
||||
- [创建和配置智能体](/agents-create) —— 怎么把一个智能体捏出来
|
||||
- [Skills](/skills) —— 给智能体挂上专业知识包
|
||||
- [小队](/squads) —— 把智能体编成一组,由队长决定谁接手哪条 issue
|
||||
- [守护进程与运行时](/daemon-runtimes) —— 智能体真正跑起来需要什么
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) —— 让智能体定时自动开工
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
| 命令 | 用途 |
|
||||
|
||||
@@ -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 | 主题 / 语言 |
|
||||
|
||||
@@ -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 | 主题 / 语言 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 的语义
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---How agents run---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
"agents",
|
||||
"agents-create",
|
||||
"skills",
|
||||
"squads",
|
||||
"---智能体怎么运行---",
|
||||
"daemon-runtimes",
|
||||
"tasks",
|
||||
|
||||
@@ -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
|
||||
@@ -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 的完整权限对照
|
||||
@@ -1 +0,0 @@
|
||||
export { SquadDetailPage as default } from "@multica/views/squads";
|
||||
@@ -1 +0,0 @@
|
||||
export { SquadsPage as default } from "@multica/views/squads";
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
useAgentsViewStore,
|
||||
type AgentsScope,
|
||||
type AgentsViewState,
|
||||
} from "./view-store";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
export {
|
||||
ApiClient,
|
||||
ApiError,
|
||||
PreviewTooLargeError,
|
||||
PreviewUnsupportedError,
|
||||
} from "./client";
|
||||
export { ApiClient, ApiError } from "./client";
|
||||
export type {
|
||||
ApiClientOptions,
|
||||
ImportStarterContentPayload,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -24,7 +24,7 @@ export interface CardProperties {
|
||||
}
|
||||
|
||||
export interface ActorFilterValue {
|
||||
type: "member" | "agent" | "squad";
|
||||
type: "member" | "agent";
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ type ModalType =
|
||||
| "create-issue"
|
||||
| "quick-create-issue"
|
||||
| "create-project"
|
||||
| "create-squad"
|
||||
| "feedback"
|
||||
| "issue-set-parent"
|
||||
| "issue-add-child"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -79,7 +79,6 @@ export const RESERVED_SLUGS: ReadonlySet<string> = new Set([
|
||||
"projects",
|
||||
"autopilots",
|
||||
"agents",
|
||||
"squads",
|
||||
"inbox",
|
||||
"my-issues",
|
||||
"usage",
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./stores";
|
||||
@@ -1,5 +0,0 @@
|
||||
export {
|
||||
useSquadsViewStore,
|
||||
type SquadsScope,
|
||||
type SquadsViewState,
|
||||
} from "./view-store";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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?:\/\/[^)]+)\)$/
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
173
packages/views/agents/components/template-detail.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Check, ChevronRight, Loader2 } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
|
||||
import type { AgentTemplateSummary } from "@multica/core/types";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
import { getAccentClass, getTemplateIcon } from "./template-picker";
|
||||
|
||||
interface TemplateDetailProps {
|
||||
template: AgentTemplateSummary;
|
||||
/** Fired when the user clicks "Use this template" — the dialog calls
|
||||
* the create API and navigates to the new agent. */
|
||||
onUse: (template: AgentTemplateSummary) => void;
|
||||
/** True while the parent's create request is in flight; we disable the
|
||||
* Use button so the user can't double-click. */
|
||||
creating?: boolean;
|
||||
/** Upstream URLs the server reported as unreachable on the most recent
|
||||
* create attempt. Surfaces an inline error banner so the user knows
|
||||
* *why* Create didn't navigate. The detail step is the only place
|
||||
* this banner can render — `quickCreateFromTemplate` fires from here
|
||||
* and never advances to a different step on failure. */
|
||||
failedURLs?: readonly string[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3 of the create-agent flow: a read-only preview of the picked
|
||||
* template — instructions, skill list with cached descriptions, and a
|
||||
* "Use this template" CTA at the bottom. Clicking Use kicks off a
|
||||
* one-shot create with default settings (no form step in between).
|
||||
*
|
||||
* Instructions come from the lazy-fetched detail endpoint (the picker
|
||||
* only carries the summary). Cached through TanStack Query keyed by
|
||||
* slug with `staleTime: Infinity`, so navigating back and forth between
|
||||
* picker and detail doesn't re-fetch. Visual rhythm matches the picker
|
||||
* card so the transition feels seamless.
|
||||
*/
|
||||
export function TemplateDetail({
|
||||
template,
|
||||
onUse,
|
||||
creating = false,
|
||||
failedURLs,
|
||||
}: TemplateDetailProps) {
|
||||
const { t } = useT("agents");
|
||||
const { data: detail, isLoading, error } = useQuery(
|
||||
agentTemplateDetailOptions(template.slug),
|
||||
);
|
||||
|
||||
const Icon = getTemplateIcon(template.icon);
|
||||
const accentClass = getAccentClass(template.accent);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
{/* failedURLs banner — sits above the header so it's the first
|
||||
thing the user sees after the spinner clears on a 422. */}
|
||||
{failedURLs && failedURLs.length > 0 && (
|
||||
<div className="mb-5 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<div className="font-medium text-destructive">
|
||||
{t(($) => $.create_dialog.template_failure.title)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_failure.body)}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-0.5 text-xs">
|
||||
{failedURLs.map((u) => (
|
||||
<li key={u} className="break-all font-mono">
|
||||
{u}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header: icon + name + description. Same rhythm as the picker
|
||||
card so the user reads the transition as "the same item,
|
||||
expanded". */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-lg", accentClass)}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-lg font-semibold">{template.name}</h2>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">{template.description}</p>
|
||||
{template.category ? (
|
||||
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{template.category}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skill list — always visible (summary has cached descriptions) */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_detail.skill_count, {
|
||||
count: template.skills.length,
|
||||
})}
|
||||
</h3>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{template.skills.map((s) => (
|
||||
<li
|
||||
key={s.source_url}
|
||||
className="rounded-lg border bg-card px-3 py-2.5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="h-4 w-4 text-success" />
|
||||
<span className="font-mono text-xs font-medium">{s.cached_name}</span>
|
||||
</div>
|
||||
{s.cached_description ? (
|
||||
<p className="mt-1 ml-6 text-xs text-muted-foreground">
|
||||
{s.cached_description}
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Instructions — lazy fetch + loading/error states */}
|
||||
<section className="mt-6">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_detail.instructions_label)}
|
||||
</h3>
|
||||
<div className="mt-3 rounded-lg border bg-muted/30 px-4 py-3">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
{t(($) => $.create_dialog.template_detail.instructions_loading)}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $.create_dialog.template_detail.load_failed)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="max-h-60 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/80">
|
||||
{detail?.instructions ?? ""}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sticky CTA footer — click Use kicks off the create API call;
|
||||
parent shows a creating spinner and navigates on success. */}
|
||||
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
|
||||
<Button
|
||||
onClick={() => onUse(template)}
|
||||
disabled={creating}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t(($) => $.create_dialog.template_detail.creating)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t(($) => $.create_dialog.template_detail.use)}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
203
packages/views/agents/components/template-picker.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
Brush,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
LayoutDashboard,
|
||||
ListChecks,
|
||||
Loader2,
|
||||
Megaphone,
|
||||
Palette,
|
||||
PenLine,
|
||||
Presentation,
|
||||
Search,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { agentTemplateListOptions } from "@multica/core/agents/queries";
|
||||
import type { AgentTemplateSummary } from "@multica/core/types";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface TemplatePickerProps {
|
||||
/** Fired when a template card is clicked. The dialog advances to the
|
||||
* detail step (which shows instructions + skills + Use button). */
|
||||
onSelect: (template: AgentTemplateSummary) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of the create-agent flow: a 2-column grid of template cards,
|
||||
* grouped by `category`. Clicking a card moves to the detail step.
|
||||
*
|
||||
* Templates are a static catalog (workspace-independent, only changes on
|
||||
* server deploy), so the catalog is loaded through TanStack Query with
|
||||
* `staleTime: Infinity` — re-opening the picker hits the cache instantly
|
||||
* and there's no per-mount refetch.
|
||||
*
|
||||
* Icons and accent colors come from the template JSON itself (`icon` is a
|
||||
* lucide-react name, `accent` is a Multica semantic token). Resolved
|
||||
* through static maps (ICONS / ACCENTS) so Tailwind can JIT-scan every
|
||||
* class variant — dynamic `bg-${accent}/10` strings would silently not
|
||||
* generate.
|
||||
*/
|
||||
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
|
||||
const { t } = useT("agents");
|
||||
const { data: templates = [], isLoading, error } = useQuery(
|
||||
agentTemplateListOptions(),
|
||||
);
|
||||
|
||||
// Group by category. Templates without a category fall into the
|
||||
// localised "Other" bucket so they still render. Preserves the load
|
||||
// order within each group for deterministic UI (matches the
|
||||
// alphabetic-by-filename order the loader uses on the server).
|
||||
const otherCategory = t(($) => $.create_dialog.template_picker.other_category);
|
||||
const groups = useMemo(() => {
|
||||
const byCategory = new Map<string, AgentTemplateSummary[]>();
|
||||
for (const tmpl of templates) {
|
||||
const key = tmpl.category?.trim() ? tmpl.category : otherCategory;
|
||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||
byCategory.get(key)!.push(tmpl);
|
||||
}
|
||||
return Array.from(byCategory.entries());
|
||||
}, [templates, otherCategory]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="text-sm text-destructive">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: t(($) => $.create_dialog.template_picker.load_failed)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (templates.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_picker.empty)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
||||
{groups.map(([category, tmpls]) => (
|
||||
<section key={category}>
|
||||
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{category}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
|
||||
{tmpls.map((tmpl) => (
|
||||
<TemplateCard
|
||||
key={tmpl.slug}
|
||||
template={tmpl}
|
||||
onClick={() => onSelect(tmpl)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: AgentTemplateSummary;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function TemplateCard({ template, onClick }: TemplateCardProps) {
|
||||
const { t } = useT("agents");
|
||||
const Icon = ICONS[template.icon ?? ""] ?? FileText;
|
||||
const accentClass = ACCENTS[template.accent ?? ""] ?? ACCENTS.muted;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
accentClass,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-sm font-semibold">{template.name}</span>
|
||||
<ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
<div className="mt-2.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{t(($) => $.create_dialog.template_card.skills, {
|
||||
count: template.skills.length,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Static maps so Tailwind's JIT scanner picks up every variant ---
|
||||
|
||||
/** Lucide icon name → component. Add new entries when shipping templates
|
||||
* that use icons not yet listed here. Unknown names fall back to FileText. */
|
||||
const ICONS: Record<string, LucideIcon> = {
|
||||
Search,
|
||||
Palette,
|
||||
FileText,
|
||||
FlaskConical,
|
||||
Sparkles,
|
||||
ListChecks,
|
||||
Brush,
|
||||
PenLine,
|
||||
Megaphone,
|
||||
Presentation,
|
||||
LayoutDashboard,
|
||||
};
|
||||
|
||||
/** Semantic accent → Tailwind class string. The class strings are written
|
||||
* out verbatim so JIT scans them; dynamic `bg-${name}/10` would not be
|
||||
* generated. Mirrors the conventions in runtime-columns.tsx /
|
||||
* usage-section.tsx (existing uses of these tokens). */
|
||||
const DEFAULT_ACCENT = "bg-muted text-muted-foreground";
|
||||
|
||||
const ACCENTS: Record<string, string> = {
|
||||
info: "bg-info/10 text-info",
|
||||
success: "bg-success/10 text-success",
|
||||
warning: "bg-warning/10 text-warning",
|
||||
primary: "bg-primary/10 text-primary",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
muted: DEFAULT_ACCENT,
|
||||
};
|
||||
|
||||
/** Exposed for the detail / form steps so they can render the same icon
|
||||
* badge as the picker card. Keeps visual continuity across steps. */
|
||||
export function getTemplateIcon(iconName: string | undefined): LucideIcon {
|
||||
return ICONS[iconName ?? ""] ?? FileText;
|
||||
}
|
||||
|
||||
export function getAccentClass(accent: string | undefined): string {
|
||||
return ACCENTS[accent ?? ""] ?? DEFAULT_ACCENT;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fallback — used for 413 / 415 / unknown kinds
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UnsupportedFallback({
|
||||
message,
|
||||
onDownload,
|
||||
}: {
|
||||
message: string;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<FileText className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-border bg-background px-3 py-1.5 text-sm transition-colors hover:bg-muted"
|
||||
onClick={onDownload}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
{t(($) => $.image.download)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Re-export the predicate from the dispatch util so entry-point components
|
||||
// only need a single import to gate the Eye button.
|
||||
export { isPreviewable } from "./utils/preview";
|
||||
@@ -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",
|
||||
|
||||