mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-24 16:09:19 +02:00
Compare commits
12 Commits
agent/j/d3
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3af104bfe | ||
|
|
ce32a99a5c | ||
|
|
39e57b870f | ||
|
|
15c3886302 | ||
|
|
a6968c7485 | ||
|
|
00415de463 | ||
|
|
448e75ce53 | ||
|
|
e076bbafcc | ||
|
|
1d4595ff8f | ||
|
|
b73a301bf9 | ||
|
|
807201086c | ||
|
|
d713b57072 |
@@ -25,10 +25,6 @@ An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
@@ -25,10 +25,6 @@ Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
</Callout>
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
@@ -44,6 +44,17 @@ export function LandingHeader({
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className={cn(
|
||||
"hidden text-[13px] font-medium transition-colors sm:inline-flex",
|
||||
variant === "dark"
|
||||
? "text-white/72 hover:text-white"
|
||||
: "text-[#0a0d12]/64 hover:text-[#0a0d12]",
|
||||
)}
|
||||
>
|
||||
{t.header.changelog}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
github: "GitHub",
|
||||
login: "Log in",
|
||||
dashboard: "Dashboard",
|
||||
changelog: "Changelog",
|
||||
},
|
||||
|
||||
hero: {
|
||||
@@ -283,6 +284,43 @@ export function createEnDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "Bug Fixes",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.29",
|
||||
date: "2026-05-09",
|
||||
title: "Project Picker in Quick Create, Resolvable Comments & Timeline Performance",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Create lets you pick a project, and remembers your last choice",
|
||||
"Comment threads can be resolved and collapsed, keeping long discussions tidy",
|
||||
"Issue live banner now shows agent tasks waiting in queue",
|
||||
"Failed or cancelled tasks can be rerun in one click from the Execution Log",
|
||||
"Agent Create modal gains an expand button for editing long descriptions",
|
||||
],
|
||||
improvements: [
|
||||
"Issue timeline no longer fully re-renders on every WebSocket event — long issues scroll smoothly",
|
||||
"Editor skips parsing very large or JSON pastes, eliminating freezes",
|
||||
"Autopilot skips dispatch when the assignee runtime is offline, avoiding empty runs",
|
||||
"Inbox auto-archives `task_failed` rows once they reach a terminal state",
|
||||
"Hermes sends agent instructions inline with each request",
|
||||
"Timeline and Comment switched to client-side virtualization, dropping server-side pagination",
|
||||
"Reserved slugs share a single JSON between front and back end, with CI guarding drift",
|
||||
"ACP error messages include the JSON-RPC `error.data` field for clearer debugging",
|
||||
],
|
||||
fixes: [
|
||||
"429 / insufficient-balance agent runs are now marked `failed` instead of `completed`",
|
||||
"Agent sessions stuck on poisoned images can recover, so the issue resumes",
|
||||
"`pi --list-models` table format parses correctly, restoring model discovery",
|
||||
"`pi` colon-to-slash normalization only applies to the legacy format",
|
||||
"`kiro` and `kimi` added to the inline-system-prompt provider allowlist",
|
||||
"Priority dropdown badge colors aligned with PriorityIcon semantic tokens",
|
||||
"Long single-line agent messages now expand correctly",
|
||||
"Desktop \"copy issue link\" uses the current connection URL instead of localhost",
|
||||
"Mobile WebSocket handshake succeeds without cookies",
|
||||
"Workspace slug creation validates reserved words; slug error messages are translated",
|
||||
"Timeline correctly syncs `around` state when props flip to falsy",
|
||||
"DropdownMenu popovers size to their content",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
|
||||
@@ -20,7 +20,7 @@ type FooterGroup = {
|
||||
};
|
||||
|
||||
export type LandingDict = {
|
||||
header: { github: string; login: string; dashboard: string };
|
||||
header: { github: string; login: string; dashboard: string; changelog: string };
|
||||
hero: {
|
||||
headlineLine1: string;
|
||||
headlineLine2: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
github: "GitHub",
|
||||
login: "\u767b\u5f55",
|
||||
dashboard: "\u8fdb\u5165\u5de5\u4f5c\u53f0",
|
||||
changelog: "\u66f4\u65b0\u65e5\u5fd7",
|
||||
},
|
||||
|
||||
hero: {
|
||||
@@ -283,6 +284,43 @@ export function createZhDict(allowSignup: boolean): LandingDict {
|
||||
fixes: "问题修复",
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
version: "0.2.29",
|
||||
date: "2026-05-09",
|
||||
title: "Quick Create 项目选择器、评论可折叠与 Timeline 性能优化",
|
||||
changes: [],
|
||||
features: [
|
||||
"Quick Create 支持选择 project,并记住上一次的选项",
|
||||
"评论 thread 支持解决并折叠,长讨论看起来更清爽",
|
||||
"Issue Live Banner 显示 agent 队列中等待执行的任务",
|
||||
"失败 / 取消的任务可以在 Execution Log 一键重跑",
|
||||
"Agent Create 弹窗新增放大按钮,长描述编辑更舒服",
|
||||
],
|
||||
improvements: [
|
||||
"Issue Timeline 不再因每个 WS 事件做完整 re-render,长 Issue 滚动更顺",
|
||||
"Editor 跳过对超大文本 / JSON 粘贴的解析,避免卡顿",
|
||||
"Autopilot 在 assignee runtime 离线时跳过 dispatch,避免空跑",
|
||||
"Inbox 自动归档处于终态的 `task_failed` 行",
|
||||
"Hermes 把 agent instructions 直接随请求内联传入",
|
||||
"Timeline / Comment 改为纯客户端虚拟化,去掉服务端分页",
|
||||
"Reserved slugs 前后端共享同一份 JSON,CI 守住漂移",
|
||||
"ACP 错误消息现在带上 JSON-RPC 的 `error.data` 字段,排错更友好",
|
||||
],
|
||||
fixes: [
|
||||
"429 / 余额不足的 agent run 现在被标记为 `failed` 而不是 `completed`",
|
||||
"因 poisoned image 卡死的 agent session 可以恢复,issue 不再卡住",
|
||||
"`pi --list-models` 表格格式可被正确解析,模型发现恢复",
|
||||
"`pi` colon-to-slash 归一化只作用于 legacy 格式,避免误伤新格式",
|
||||
"`kiro` 与 `kimi` 加入 inline-system-prompt provider 白名单",
|
||||
"Priority Dropdown 徽章颜色对齐 PriorityIcon 的 semantic token",
|
||||
"Agent 单行长消息可正常展开",
|
||||
"桌面端复制 issue link 使用当前连接环境,不再硬编码 localhost",
|
||||
"移动端 WebSocket 在没有 cookie 的情况下也能握手",
|
||||
"创建 workspace 时校验保留字,slug 错误提示已 i18n",
|
||||
"Timeline 在 falsy prop 切换时正确同步 around 状态",
|
||||
"DropdownMenu 弹层尺寸跟随内容",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.2.28",
|
||||
date: "2026-05-08",
|
||||
|
||||
@@ -139,11 +139,26 @@ export function useUpdateIssue() {
|
||||
|
||||
// Resolve parent_issue_id from the freshest source so we can keep the
|
||||
// parent's children cache in sync (used by the parent issue's
|
||||
// sub-issues list).
|
||||
const parentId =
|
||||
// sub-issues list). Falls back to scanning loaded children caches —
|
||||
// when the user navigates straight to a parent's detail page, the
|
||||
// child may live only there, not in detail/list.
|
||||
let parentId: string | null =
|
||||
prevDetail?.parent_issue_id ??
|
||||
(prevList ? findIssueLocation(prevList, id)?.issue.parent_issue_id : null) ??
|
||||
null;
|
||||
if (!parentId) {
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => c.id === id)) continue;
|
||||
const candidate = key[key.length - 1];
|
||||
if (typeof candidate === "string") {
|
||||
parentId = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const prevChildren = parentId
|
||||
? qc.getQueryData<Issue[]>(issueKeys.children(wsId, parentId))
|
||||
: undefined;
|
||||
@@ -244,13 +259,46 @@ export function useBatchUpdateIssues() {
|
||||
for (const id of ids) next = patchIssueInBuckets(next, id, updates);
|
||||
return next;
|
||||
});
|
||||
return { prevList };
|
||||
|
||||
// Mirror the optimistic patch into any loaded children cache so
|
||||
// sub-issue rows on a parent's detail page reflect the change too.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
const affectedParentIds = new Set<string>();
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
affectedParentIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.map((c) => (idSet.has(c.id) ? { ...c, ...updates } : c)),
|
||||
);
|
||||
}
|
||||
|
||||
return { prevList, prevChildren, affectedParentIds };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
onSettled: (_data, _err, _vars, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
|
||||
for (const parentId of ctx.affectedParentIds) {
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.children(wsId, parentId),
|
||||
});
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -270,16 +318,40 @@ export function useBatchDeleteIssues() {
|
||||
if (loc?.issue.parent_issue_id) parentIssueIds.add(loc.issue.parent_issue_id);
|
||||
}
|
||||
}
|
||||
// Children cache may be the only place sub-issues live when the user
|
||||
// operates from a parent's detail page. Collect affected parents and
|
||||
// optimistically filter the deleted ids out of each children cache so
|
||||
// the row disappears immediately, mirroring the list-cache behaviour.
|
||||
const idSet = new Set(ids);
|
||||
const childrenCaches = qc.getQueriesData<Issue[]>({
|
||||
queryKey: [...issueKeys.all(wsId), "children"],
|
||||
});
|
||||
const prevChildren = new Map<string, Issue[] | undefined>();
|
||||
for (const [key, data] of childrenCaches) {
|
||||
if (!data?.some((c) => idSet.has(c.id))) continue;
|
||||
const parentId = key[key.length - 1];
|
||||
if (typeof parentId !== "string") continue;
|
||||
parentIssueIds.add(parentId);
|
||||
prevChildren.set(parentId, data);
|
||||
qc.setQueryData<Issue[]>(issueKeys.children(wsId, parentId), (old) =>
|
||||
old?.filter((c) => !idSet.has(c.id)),
|
||||
);
|
||||
}
|
||||
qc.setQueryData<ListIssuesCache>(issueKeys.list(wsId), (old) => {
|
||||
if (!old) return old;
|
||||
let next = old;
|
||||
for (const id of ids) next = removeIssueFromBuckets(next, id);
|
||||
return next;
|
||||
});
|
||||
return { prevList, parentIssueIds };
|
||||
return { prevList, prevChildren, parentIssueIds };
|
||||
},
|
||||
onError: (_err, _ids, ctx) => {
|
||||
if (ctx?.prevList) qc.setQueryData(issueKeys.list(wsId), ctx.prevList);
|
||||
if (ctx?.prevChildren) {
|
||||
for (const [parentId, snapshot] of ctx.prevChildren) {
|
||||
qc.setQueryData(issueKeys.children(wsId, parentId), snapshot);
|
||||
}
|
||||
}
|
||||
},
|
||||
onSettled: (_data, _err, _ids, ctx) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
|
||||
@@ -336,9 +408,13 @@ export function useCreateComment(issueId: string) {
|
||||
return [...old, entry];
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
},
|
||||
// No onSettled invalidate. The `comment:created` WS broadcast keeps
|
||||
// the timeline cache fresh after a successful create, and reconnect
|
||||
// recovery in useIssueTimeline already invalidates if the connection
|
||||
// dropped. Re-fetching on every submit replaces every entry's
|
||||
// reference, which forces every memoized CommentCard subtree to
|
||||
// re-render (visible as a flash across sibling threads during AI
|
||||
// streaming).
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
7
packages/core/notifications/index.ts
Normal file
7
packages/core/notifications/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
showSystemNotification,
|
||||
detectWebNotificationSupport,
|
||||
requestWebNotificationPermission,
|
||||
isDesktopApp,
|
||||
} from "./system-notification";
|
||||
export type { WebNotificationSupport, SystemNotificationPayload } from "./system-notification";
|
||||
183
packages/core/notifications/system-notification.test.ts
Normal file
183
packages/core/notifications/system-notification.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
detectWebNotificationSupport,
|
||||
isDesktopApp,
|
||||
showSystemNotification,
|
||||
} from "./system-notification";
|
||||
|
||||
interface NotificationMock {
|
||||
title: string;
|
||||
options?: NotificationOptions;
|
||||
listeners: Map<string, EventListener>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const notificationInstances: NotificationMock[] = [];
|
||||
|
||||
class FakeNotification {
|
||||
static permission: NotificationPermission = "default";
|
||||
static requestPermission = vi.fn();
|
||||
title: string;
|
||||
options?: NotificationOptions;
|
||||
listeners = new Map<string, EventListener>();
|
||||
close = vi.fn();
|
||||
|
||||
constructor(title: string, options?: NotificationOptions) {
|
||||
this.title = title;
|
||||
this.options = options;
|
||||
notificationInstances.push(this as unknown as NotificationMock);
|
||||
}
|
||||
|
||||
addEventListener(type: string, listener: EventListener) {
|
||||
this.listeners.set(type, listener);
|
||||
}
|
||||
}
|
||||
|
||||
const originalNotification = (globalThis as { Notification?: unknown }).Notification;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
notificationInstances.length = 0;
|
||||
FakeNotification.permission = "default";
|
||||
const win: Record<string, unknown> = {
|
||||
focus: vi.fn(),
|
||||
location: { assign: vi.fn() },
|
||||
};
|
||||
(globalThis as { window?: unknown }).window = win;
|
||||
(globalThis as { Notification?: unknown }).Notification = FakeNotification;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
if (originalNotification === undefined) {
|
||||
delete (globalThis as { Notification?: unknown }).Notification;
|
||||
} else {
|
||||
(globalThis as { Notification?: unknown }).Notification = originalNotification;
|
||||
}
|
||||
});
|
||||
|
||||
describe("detectWebNotificationSupport", () => {
|
||||
it("reports api_unavailable when Notification is missing", () => {
|
||||
delete (globalThis as { Notification?: unknown }).Notification;
|
||||
expect(detectWebNotificationSupport()).toBe("api_unavailable");
|
||||
});
|
||||
|
||||
it("reports permission_default when permission has not been asked", () => {
|
||||
FakeNotification.permission = "default";
|
||||
expect(detectWebNotificationSupport()).toBe("permission_default");
|
||||
});
|
||||
|
||||
it("reports permission_denied when permission is denied", () => {
|
||||
FakeNotification.permission = "denied";
|
||||
expect(detectWebNotificationSupport()).toBe("permission_denied");
|
||||
});
|
||||
|
||||
it("reports supported when permission is granted", () => {
|
||||
FakeNotification.permission = "granted";
|
||||
expect(detectWebNotificationSupport()).toBe("supported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("showSystemNotification", () => {
|
||||
it("uses desktopAPI when available", () => {
|
||||
const showNotification = vi.fn();
|
||||
(globalThis as { window?: { desktopAPI?: unknown } }).window = {
|
||||
desktopAPI: { showNotification },
|
||||
};
|
||||
|
||||
const result = showSystemNotification({
|
||||
slug: "acme",
|
||||
itemId: "item-1",
|
||||
issueKey: "issue-1",
|
||||
title: "Hello",
|
||||
body: "World",
|
||||
inboxPath: "/acme/inbox?issue=issue-1",
|
||||
});
|
||||
|
||||
expect(result).toBe("delivered_desktop");
|
||||
expect(showNotification).toHaveBeenCalledWith({
|
||||
slug: "acme",
|
||||
itemId: "item-1",
|
||||
issueKey: "issue-1",
|
||||
title: "Hello",
|
||||
body: "World",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a web Notification when permission is granted", () => {
|
||||
FakeNotification.permission = "granted";
|
||||
|
||||
const result = showSystemNotification({
|
||||
slug: "acme",
|
||||
itemId: "item-1",
|
||||
issueKey: "issue-1",
|
||||
title: "Hello",
|
||||
body: "World",
|
||||
inboxPath: "/acme/inbox?issue=issue-1",
|
||||
});
|
||||
|
||||
expect(result).toBe("supported");
|
||||
expect(notificationInstances).toHaveLength(1);
|
||||
expect(notificationInstances[0]?.title).toBe("Hello");
|
||||
expect(notificationInstances[0]?.options).toMatchObject({
|
||||
body: "World",
|
||||
tag: "item-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips when permission is denied", () => {
|
||||
FakeNotification.permission = "denied";
|
||||
|
||||
const result = showSystemNotification({
|
||||
slug: "acme",
|
||||
itemId: "item-1",
|
||||
issueKey: "issue-1",
|
||||
title: "Hello",
|
||||
body: "World",
|
||||
inboxPath: "/acme/inbox?issue=issue-1",
|
||||
});
|
||||
|
||||
expect(result).toBe("permission_denied");
|
||||
expect(notificationInstances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("navigates to inbox path on click", () => {
|
||||
FakeNotification.permission = "granted";
|
||||
const assign = vi.fn();
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
focus: vi.fn(),
|
||||
location: { assign },
|
||||
};
|
||||
|
||||
showSystemNotification({
|
||||
slug: "acme",
|
||||
itemId: "item-1",
|
||||
issueKey: "issue-1",
|
||||
title: "Hello",
|
||||
body: "World",
|
||||
inboxPath: "/acme/inbox?issue=issue-1",
|
||||
});
|
||||
|
||||
const click = notificationInstances[0]?.listeners.get("click");
|
||||
expect(click).toBeTypeOf("function");
|
||||
click?.(new Event("click"));
|
||||
expect(assign).toHaveBeenCalledWith("/acme/inbox?issue=issue-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDesktopApp", () => {
|
||||
it("is false when desktopAPI is missing", () => {
|
||||
expect(isDesktopApp()).toBe(false);
|
||||
});
|
||||
|
||||
it("is true when desktopAPI is injected", () => {
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
desktopAPI: { showNotification: vi.fn() },
|
||||
};
|
||||
expect(isDesktopApp()).toBe(true);
|
||||
});
|
||||
});
|
||||
132
packages/core/notifications/system-notification.ts
Normal file
132
packages/core/notifications/system-notification.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { createLogger } from "../logger";
|
||||
|
||||
const logger = createLogger("system-notification");
|
||||
|
||||
interface DesktopNotificationPayload {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface DesktopAPI {
|
||||
showNotification?: (payload: DesktopNotificationPayload) => void;
|
||||
}
|
||||
|
||||
function getDesktopAPI(): DesktopAPI | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as unknown as { desktopAPI?: DesktopAPI }).desktopAPI;
|
||||
}
|
||||
|
||||
export type WebNotificationSupport =
|
||||
| "supported"
|
||||
| "permission_default"
|
||||
| "permission_denied"
|
||||
| "api_unavailable"
|
||||
| "no_window";
|
||||
|
||||
export function detectWebNotificationSupport(): WebNotificationSupport {
|
||||
if (typeof window === "undefined") return "no_window";
|
||||
if (typeof Notification === "undefined") return "api_unavailable";
|
||||
switch (Notification.permission) {
|
||||
case "granted":
|
||||
return "supported";
|
||||
case "denied":
|
||||
return "permission_denied";
|
||||
default:
|
||||
return "permission_default";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SystemNotificationPayload extends DesktopNotificationPayload {
|
||||
/** Path to navigate to when the user clicks the banner (web fallback only). */
|
||||
inboxPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a native OS notification for an inbox item, abstracting over the
|
||||
* Electron preload bridge (`window.desktopAPI`) and the browser
|
||||
* Notifications API. Returns a status string useful for diagnostics; the
|
||||
* caller is expected to have already gated on focus + the user's
|
||||
* `system_notifications` preference.
|
||||
*
|
||||
* On the desktop app the click handler routing is wired in the main process
|
||||
* (see apps/desktop/src/main/index.ts). On web we wire it here: the Notification
|
||||
* click event focuses the tab and navigates to the inbox path with the issue
|
||||
* selector pre-populated, mirroring the desktop UX as closely as the browser
|
||||
* sandbox allows.
|
||||
*/
|
||||
export function showSystemNotification(payload: SystemNotificationPayload): WebNotificationSupport | "delivered_desktop" {
|
||||
const desktopAPI = getDesktopAPI();
|
||||
if (desktopAPI?.showNotification) {
|
||||
desktopAPI.showNotification({
|
||||
slug: payload.slug,
|
||||
itemId: payload.itemId,
|
||||
issueKey: payload.issueKey,
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
});
|
||||
return "delivered_desktop";
|
||||
}
|
||||
|
||||
const support = detectWebNotificationSupport();
|
||||
if (support !== "supported") {
|
||||
logger.debug("skip web notification", { support, title: payload.title });
|
||||
return support;
|
||||
}
|
||||
|
||||
try {
|
||||
const notification = new Notification(payload.title, {
|
||||
body: payload.body,
|
||||
tag: payload.itemId,
|
||||
});
|
||||
notification.addEventListener("click", () => {
|
||||
try {
|
||||
window.focus();
|
||||
} catch {
|
||||
// Some browsers reject window.focus() outside a user gesture; ignore.
|
||||
}
|
||||
window.location.assign(payload.inboxPath);
|
||||
notification.close();
|
||||
});
|
||||
return "supported";
|
||||
} catch (err) {
|
||||
logger.warn("web notification failed", err);
|
||||
return "api_unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the browser for notification permission. Must be invoked from a
|
||||
* user gesture (click, keypress) or the request is silently denied in many
|
||||
* browsers. Returns the resulting permission state, or "unsupported" if the
|
||||
* Notifications API is missing entirely.
|
||||
*/
|
||||
export async function requestWebNotificationPermission(): Promise<
|
||||
"granted" | "denied" | "default" | "unsupported"
|
||||
> {
|
||||
if (typeof window === "undefined" || typeof Notification === "undefined") {
|
||||
return "unsupported";
|
||||
}
|
||||
if (Notification.permission === "granted" || Notification.permission === "denied") {
|
||||
return Notification.permission;
|
||||
}
|
||||
try {
|
||||
const result = await Notification.requestPermission();
|
||||
return result;
|
||||
} catch (err) {
|
||||
logger.warn("requestPermission failed", err);
|
||||
return "denied";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this build is the Electron desktop app — the preload script
|
||||
* injects `window.desktopAPI`. Used by the settings UI to hide the
|
||||
* browser-permission affordance, since the main process owns notifications
|
||||
* on desktop.
|
||||
*/
|
||||
export function isDesktopApp(): boolean {
|
||||
return Boolean(getDesktopAPI());
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
"./notification-preferences": "./notification-preferences/index.ts",
|
||||
"./notification-preferences/queries": "./notification-preferences/queries.ts",
|
||||
"./notification-preferences/mutations": "./notification-preferences/mutations.ts",
|
||||
"./notifications": "./notifications/index.ts",
|
||||
"./chat": "./chat/index.ts",
|
||||
"./chat/queries": "./chat/queries.ts",
|
||||
"./chat/mutations": "./chat/mutations.ts",
|
||||
|
||||
@@ -29,10 +29,11 @@ import {
|
||||
import { onInboxNew, onInboxInvalidate, onInboxIssueStatusChanged, onInboxIssueDeleted } from "../inbox/ws-updaters";
|
||||
import { inboxKeys } from "../inbox/queries";
|
||||
import { notificationPreferenceOptions } from "../notification-preferences/queries";
|
||||
import { showSystemNotification } from "../notifications";
|
||||
import { workspaceKeys, workspaceListOptions } from "../workspace/queries";
|
||||
import { chatKeys } from "../chat/queries";
|
||||
import { useChatStore } from "../chat";
|
||||
import { resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import { paths, resolvePostAuthDestination, useHasOnboarded } from "../paths";
|
||||
import type {
|
||||
MemberAddedPayload,
|
||||
WorkspaceDeletedPayload,
|
||||
@@ -305,39 +306,43 @@ export function useRealtimeSync(
|
||||
// workspace B's inbox and 404.
|
||||
const slug = getCurrentSlug();
|
||||
if (!slug) return;
|
||||
const desktopAPI = (
|
||||
window as unknown as {
|
||||
desktopAPI?: {
|
||||
showNotification?: (payload: {
|
||||
slug: string;
|
||||
itemId: string;
|
||||
issueKey: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => void;
|
||||
};
|
||||
}
|
||||
).desktopAPI;
|
||||
// `issueKey` matches the inbox page's URL selector (issue id when the
|
||||
// item is attached to an issue, otherwise the inbox item id). `itemId`
|
||||
// is the inbox row's own id, needed to fire markInboxRead on click.
|
||||
desktopAPI?.showNotification?.({
|
||||
// showSystemNotification handles both the Electron preload bridge and
|
||||
// the browser Notifications API (web), so this single call works
|
||||
// across all platforms — see notifications/system-notification.ts.
|
||||
showSystemNotification({
|
||||
slug,
|
||||
itemId: item.id,
|
||||
issueKey: item.issue_id ?? item.id,
|
||||
title: item.title,
|
||||
body: item.body ?? "",
|
||||
inboxPath: `${paths.workspace(slug).inbox()}?issue=${encodeURIComponent(item.issue_id ?? item.id)}`,
|
||||
});
|
||||
});
|
||||
|
||||
// --- Timeline event handlers (global fallback) ---
|
||||
// These events are also handled granularly by useIssueTimeline when
|
||||
// IssueDetail is mounted. This global handler ensures the timeline cache
|
||||
// is invalidated even when IssueDetail is unmounted, so stale data
|
||||
// isn't served on next mount (staleTime: Infinity relies on this).
|
||||
|
||||
// IssueDetail is mounted. This global handler exists to mark the
|
||||
// timeline cache stale for issues whose IssueDetail is *not* mounted,
|
||||
// so stale data isn't served on next mount (staleTime: Infinity, set on
|
||||
// the QueryClient default, relies on this).
|
||||
//
|
||||
// `refetchType: "none"` is the load-bearing detail: without it, an
|
||||
// active IssueDetail observer would refetch the entire timeline on
|
||||
// every comment / activity / reaction event. The refetch replaces
|
||||
// every entry's reference and busts React.memo on every CommentCard
|
||||
// subtree (visible during AI streaming as a flash across all sibling
|
||||
// threads, MUL-1941). Inactive observers don't refetch either way;
|
||||
// when IssueDetail mounts later, the stale flag triggers the refetch
|
||||
// through `refetchOnMount`. Active observers stay fresh via the
|
||||
// granular setQueryData handlers in `useIssueTimeline`.
|
||||
const invalidateTimeline = (issueId: string) => {
|
||||
qc.invalidateQueries({ queryKey: issueKeys.timeline(issueId) });
|
||||
qc.invalidateQueries({
|
||||
queryKey: issueKeys.timeline(issueId),
|
||||
refetchType: "none",
|
||||
});
|
||||
};
|
||||
|
||||
const unsubCommentCreated = ws.on("comment:created", (p) => {
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { useT } from "../../i18n";
|
||||
import { MermaidDiagram } from "../mermaid-diagram";
|
||||
|
||||
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
|
||||
// `mermaid.initialize()` mutates a process-global config, so back-to-back
|
||||
// renders during typing can race a concurrent ReadonlyContent render
|
||||
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
|
||||
// "live preview" feel while making concurrent inits unlikely in practice.
|
||||
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function CodeBlockView({ node }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = node.attrs.language || "";
|
||||
const isMermaid = language === "mermaid";
|
||||
const chart = node.textContent;
|
||||
const debouncedChart = useDebouncedValue(
|
||||
isMermaid ? chart : "",
|
||||
MERMAID_PREVIEW_DEBOUNCE_MS,
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = node.textContent;
|
||||
@@ -21,6 +44,14 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
|
||||
{isMermaid && debouncedChart.trim() && (
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="mermaid-diagram-preview mb-1"
|
||||
>
|
||||
<MermaidDiagram chart={debouncedChart} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
|
||||
|
||||
294
packages/views/editor/mermaid-diagram.tsx
Normal file
294
packages/views/editor/mermaid-diagram.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MermaidDiagram — sandboxed Mermaid diagram renderer.
|
||||
*
|
||||
* Extracted from `readonly-content.tsx` so the Tiptap CodeBlock NodeView
|
||||
* (`code-block-view.tsx`) can render the same component when a code block's
|
||||
* language is `mermaid`. Previously Mermaid only worked in read-only
|
||||
* markdown surfaces (comment cards) — issue descriptions, which always
|
||||
* stay in the Tiptap editor, never rendered diagrams.
|
||||
*
|
||||
* Theme variables are detected from the host's CSS custom properties so the
|
||||
* diagram colors match light/dark mode. The SVG is rendered inside a
|
||||
* sandboxed iframe to keep Mermaid's runtime stylesheet from leaking into
|
||||
* the page.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
type MermaidAPI = typeof import("mermaid").default;
|
||||
|
||||
type MermaidLayout = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let mermaidPromise: Promise<MermaidAPI> | null = null;
|
||||
|
||||
function getMermaid(): Promise<MermaidAPI> {
|
||||
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
|
||||
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
|
||||
const canvas = ownerDocument.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return fallback;
|
||||
|
||||
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
|
||||
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
|
||||
// 8-bit sRGB bytes that Mermaid can consume safely.
|
||||
context.fillStyle = "#000";
|
||||
context.fillStyle = color || fallback;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
return `rgb(${red}, ${green}, ${blue})`;
|
||||
}
|
||||
|
||||
function resolveCssColor(
|
||||
host: HTMLElement,
|
||||
variableName: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const probe = host.ownerDocument.createElement("span");
|
||||
probe.style.color = `var(${variableName})`;
|
||||
probe.style.display = "none";
|
||||
host.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
|
||||
}
|
||||
|
||||
function getMermaidThemeVariables(host: HTMLElement | null) {
|
||||
if (!host) {
|
||||
return {
|
||||
primaryColor: "rgb(245, 245, 245)",
|
||||
primaryBorderColor: "rgb(59, 130, 246)",
|
||||
primaryTextColor: "rgb(17, 24, 39)",
|
||||
lineColor: "rgb(107, 114, 128)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
|
||||
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
|
||||
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
|
||||
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
function getSandboxCssVariables(host: HTMLElement | null): string {
|
||||
const styles = host ? getComputedStyle(host) : null;
|
||||
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
|
||||
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getMermaidLayout(svg: string): MermaidLayout {
|
||||
const viewBoxMatch = svg.match(
|
||||
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
|
||||
);
|
||||
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
|
||||
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
|
||||
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
|
||||
|
||||
if (width && height && width > 0 && height > 0) {
|
||||
return {
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function useThemeVersion() {
|
||||
const [themeVersion, setThemeVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
|
||||
const observer = new MutationObserver(bumpThemeVersion);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", bumpThemeVersion);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener("change", bumpThemeVersion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return themeVersion;
|
||||
}
|
||||
|
||||
function MermaidLightbox({
|
||||
srcDoc,
|
||||
onClose,
|
||||
}: {
|
||||
srcDoc: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="mermaid-diagram-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid diagram fullscreen view"
|
||||
onClick={onClose}
|
||||
>
|
||||
<iframe
|
||||
className="mermaid-diagram-lightbox-frame"
|
||||
sandbox=""
|
||||
srcDoc={srcDoc}
|
||||
title="Mermaid diagram fullscreen"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const { t } = useT("editor");
|
||||
const reactId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramId = useMemo(
|
||||
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
|
||||
[reactId],
|
||||
);
|
||||
const themeVersion = useThemeVersion();
|
||||
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
|
||||
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<MermaidLayout>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function renderDiagram() {
|
||||
try {
|
||||
setError(null);
|
||||
setSandboxedDocument(null);
|
||||
setExpandedDocument(null);
|
||||
setLayout({});
|
||||
const mermaid = await getMermaid();
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: getMermaidThemeVariables(containerRef.current),
|
||||
});
|
||||
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
|
||||
if (!cancelled) {
|
||||
setLayout(getMermaidLayout(renderedSvg));
|
||||
setSandboxedDocument(
|
||||
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
setExpandedDocument(
|
||||
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderDiagram();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, diagramId, themeVersion]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
|
||||
<p>{t(($) => $.mermaid.render_error)}</p>
|
||||
<pre>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
|
||||
{sandboxedDocument ? (
|
||||
<>
|
||||
<iframe
|
||||
className="mermaid-diagram-frame"
|
||||
sandbox=""
|
||||
srcDoc={sandboxedDocument}
|
||||
style={{
|
||||
height: layout.height ? `${layout.height}px` : undefined,
|
||||
width: layout.width ? `${layout.width}px` : undefined,
|
||||
}}
|
||||
title="Mermaid diagram"
|
||||
/>
|
||||
<div className="mermaid-diagram-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
title="Open fullscreen"
|
||||
aria-label="Open Mermaid diagram fullscreen"
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{lightboxOpen && expandedDocument && (
|
||||
<MermaidLightbox
|
||||
srcDoc={expandedDocument}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
* - Rendering mentions with the same IssueMentionCard component and .mention class
|
||||
*/
|
||||
|
||||
import { isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { isValidElement, memo, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown, {
|
||||
defaultUrlTransform,
|
||||
type Components,
|
||||
@@ -42,6 +41,7 @@ import { ImageLightbox } from "./extensions/image-view";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -51,140 +51,6 @@ import "./content-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
type MermaidAPI = typeof import("mermaid").default;
|
||||
|
||||
type MermaidLayout = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let mermaidPromise: Promise<MermaidAPI> | null = null;
|
||||
|
||||
function getMermaid(): Promise<MermaidAPI> {
|
||||
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
|
||||
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
|
||||
const canvas = ownerDocument.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return fallback;
|
||||
|
||||
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
|
||||
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
|
||||
// 8-bit sRGB bytes that Mermaid can consume safely.
|
||||
context.fillStyle = "#000";
|
||||
context.fillStyle = color || fallback;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
return `rgb(${red}, ${green}, ${blue})`;
|
||||
}
|
||||
|
||||
function resolveCssColor(
|
||||
host: HTMLElement,
|
||||
variableName: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const probe = host.ownerDocument.createElement("span");
|
||||
probe.style.color = `var(${variableName})`;
|
||||
probe.style.display = "none";
|
||||
host.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
|
||||
}
|
||||
|
||||
function getMermaidThemeVariables(host: HTMLElement | null) {
|
||||
if (!host) {
|
||||
return {
|
||||
primaryColor: "rgb(245, 245, 245)",
|
||||
primaryBorderColor: "rgb(59, 130, 246)",
|
||||
primaryTextColor: "rgb(17, 24, 39)",
|
||||
lineColor: "rgb(107, 114, 128)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
|
||||
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
|
||||
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
|
||||
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
function getSandboxCssVariables(host: HTMLElement | null): string {
|
||||
const styles = host ? getComputedStyle(host) : null;
|
||||
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
|
||||
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getMermaidLayout(svg: string): MermaidLayout {
|
||||
const viewBoxMatch = svg.match(
|
||||
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
|
||||
);
|
||||
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
|
||||
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
|
||||
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
|
||||
|
||||
if (width && height && width > 0 && height > 0) {
|
||||
return {
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function useThemeVersion() {
|
||||
const [themeVersion, setThemeVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
|
||||
const observer = new MutationObserver(bumpThemeVersion);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", bumpThemeVersion);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener("change", bumpThemeVersion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return themeVersion;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -294,145 +160,6 @@ function ReadonlyLink({
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidLightbox({
|
||||
srcDoc,
|
||||
onClose,
|
||||
}: {
|
||||
srcDoc: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="mermaid-diagram-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid diagram fullscreen view"
|
||||
onClick={onClose}
|
||||
>
|
||||
<iframe
|
||||
className="mermaid-diagram-lightbox-frame"
|
||||
sandbox=""
|
||||
srcDoc={srcDoc}
|
||||
title="Mermaid diagram fullscreen"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const { t } = useT("editor");
|
||||
const reactId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramId = useMemo(
|
||||
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
|
||||
[reactId],
|
||||
);
|
||||
const themeVersion = useThemeVersion();
|
||||
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
|
||||
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<MermaidLayout>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function renderDiagram() {
|
||||
try {
|
||||
setError(null);
|
||||
setSandboxedDocument(null);
|
||||
setExpandedDocument(null);
|
||||
setLayout({});
|
||||
const mermaid = await getMermaid();
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: getMermaidThemeVariables(containerRef.current),
|
||||
});
|
||||
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
|
||||
if (!cancelled) {
|
||||
setLayout(getMermaidLayout(renderedSvg));
|
||||
setSandboxedDocument(
|
||||
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
setExpandedDocument(
|
||||
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderDiagram();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, diagramId, themeVersion]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
|
||||
<p>{t(($) => $.mermaid.render_error)}</p>
|
||||
<pre>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
|
||||
{sandboxedDocument ? (
|
||||
<>
|
||||
<iframe
|
||||
className="mermaid-diagram-frame"
|
||||
sandbox=""
|
||||
srcDoc={sandboxedDocument}
|
||||
style={{
|
||||
height: layout.height ? `${layout.height}px` : undefined,
|
||||
width: layout.width ? `${layout.width}px` : undefined,
|
||||
}}
|
||||
title="Mermaid diagram"
|
||||
/>
|
||||
<div className="mermaid-diagram-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
title="Open fullscreen"
|
||||
aria-label="Open Mermaid diagram fullscreen"
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{lightboxOpen && expandedDocument && (
|
||||
<MermaidLightbox
|
||||
srcDoc={expandedDocument}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const components: Partial<Components> = {
|
||||
// Links — route mention:// to mention components, others show preview card
|
||||
a: ReadonlyLink,
|
||||
|
||||
@@ -19,8 +19,19 @@ import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-st
|
||||
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@multica/core/issues/mutations";
|
||||
import { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
|
||||
import { useT } from "../../i18n";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export function BatchActionToolbar() {
|
||||
export function BatchActionToolbar({
|
||||
placement = "fixed-bottom",
|
||||
}: {
|
||||
/**
|
||||
* "fixed-bottom" — floats at the bottom of the viewport (default; used by
|
||||
* full-screen issue lists).
|
||||
* "inline" — renders in normal flow so callers can place it adjacent to
|
||||
* the selected rows (used inside scrollable sections like sub-issues).
|
||||
*/
|
||||
placement?: "fixed-bottom" | "inline";
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const clear = useIssueSelectionStore((s) => s.clear);
|
||||
@@ -61,7 +72,14 @@ export function BatchActionToolbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"z-50 flex items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg",
|
||||
placement === "fixed-bottom"
|
||||
? "fixed bottom-6 left-1/2 -translate-x-1/2"
|
||||
: "mb-2 w-fit",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 pl-1 pr-2 border-r mr-1">
|
||||
<span className="text-sm font-medium">{t(($) => $.batch.selected, { count })}</span>
|
||||
<button
|
||||
|
||||
@@ -35,7 +35,6 @@ import { FileUploadButton } from "@multica/ui/components/common/file-upload-butt
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { api } from "@multica/core/api";
|
||||
import { ReplyInput } from "./reply-input";
|
||||
import { collectThreadReplies } from "./thread-utils";
|
||||
import type { TimelineEntry, Attachment } from "@multica/core/types";
|
||||
import { useCommentCollapseStore } from "@multica/core/issues/stores";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -47,7 +46,14 @@ import { useT } from "../../i18n";
|
||||
interface CommentCardProps {
|
||||
issueId: string;
|
||||
entry: TimelineEntry;
|
||||
allReplies: Map<string, TimelineEntry[]>;
|
||||
/**
|
||||
* Flat list of every nested reply under this thread root, in render order.
|
||||
* Computed once in `issue-detail.tsx`'s `timelineView` and stabilized so
|
||||
* the array reference only changes when *this* thread's replies change —
|
||||
* an unrelated thread receiving a new reply must NOT bust this card's
|
||||
* memo. Passing the full Map here used to do exactly that.
|
||||
*/
|
||||
replies: TimelineEntry[];
|
||||
currentUserId?: string;
|
||||
/**
|
||||
* True when the current user is a workspace owner/admin and can therefore
|
||||
@@ -363,7 +369,7 @@ function CommentRow({
|
||||
function CommentCardImpl({
|
||||
issueId,
|
||||
entry,
|
||||
allReplies,
|
||||
replies,
|
||||
currentUserId,
|
||||
canModerate = false,
|
||||
onReply,
|
||||
@@ -427,10 +433,10 @@ function CommentCardImpl({
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all nested replies recursively into a flat list. Helper is
|
||||
// shared with ResolvedThreadBar so the collapsed count matches what the
|
||||
// expanded card renders.
|
||||
const allNestedReplies = collectThreadReplies(entry.id, allReplies);
|
||||
// The parent precomputes the flat thread (using collectThreadReplies),
|
||||
// memoizes by thread, and stabilizes the array reference, so we render
|
||||
// straight from `replies` instead of re-walking the graph on every render.
|
||||
const allNestedReplies = replies;
|
||||
|
||||
const replyCount = allNestedReplies.length;
|
||||
const contentPreview = (entry.content ?? "").replace(/\n/g, " ").slice(0, 80);
|
||||
|
||||
@@ -37,14 +37,17 @@ import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, Command
|
||||
import { AvatarGroup, AvatarGroupCount } from "@multica/ui/components/ui/avatar";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { PropRow } from "../../common/prop-row";
|
||||
import type { IssueStatus, IssuePriority, TimelineEntry } from "@multica/core/types";
|
||||
import type { Issue, IssueStatus, IssuePriority, TimelineEntry, UpdateIssueRequest } from "@multica/core/types";
|
||||
import { STATUS_CONFIG, PRIORITY_CONFIG } from "@multica/core/issues/config";
|
||||
import { useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { toast } from "sonner";
|
||||
import { StatusIcon, PriorityIcon, StatusPicker, PriorityPicker, DueDatePicker, AssigneePicker, LabelPicker } from ".";
|
||||
import { IssueActionsDropdown, useIssueActions } from "../actions";
|
||||
import { ProjectPicker } from "../../projects/components/project-picker";
|
||||
import { CommentCard } from "./comment-card";
|
||||
import { CommentInput } from "./comment-input";
|
||||
import { ResolvedThreadBar } from "./resolved-thread-bar";
|
||||
import { collectThreadReplies } from "./thread-utils";
|
||||
import { AgentLiveCard } from "./agent-live-card";
|
||||
import { ExecutionLogSection } from "./execution-log-section";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -55,6 +58,8 @@ import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { issueListOptions, issueDetailOptions, childIssuesOptions, issueUsageOptions } from "@multica/core/issues/queries";
|
||||
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { useRecentIssuesStore } from "@multica/core/issues/stores";
|
||||
import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-store";
|
||||
import { BatchActionToolbar } from "./batch-action-toolbar";
|
||||
import { useIssueTimeline } from "../hooks/use-issue-timeline";
|
||||
import { useIssueReactions } from "../hooks/use-issue-reactions";
|
||||
import { useIssueSubscribers } from "../hooks/use-issue-subscribers";
|
||||
@@ -152,6 +157,23 @@ function formatTokenCount(n: number): string {
|
||||
return String(n);
|
||||
}
|
||||
|
||||
// Stable reference for threads with no replies. Inline `[]` would create a
|
||||
// new array on every render and bust React.memo on CommentCard / ResolvedThreadBar.
|
||||
const EMPTY_REPLIES: TimelineEntry[] = [];
|
||||
|
||||
// Shallow array equality by element identity. Used to reuse the previous
|
||||
// render's per-thread reply slice when nothing in *this* thread changed,
|
||||
// even if the surrounding `timeline` array was rebuilt by a WS event in
|
||||
// some unrelated thread.
|
||||
function shallowEqualEntries(a: TimelineEntry[], b: TimelineEntry[]): boolean {
|
||||
if (a === b) return true;
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function TimelineSkeleton() {
|
||||
return (
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
@@ -169,6 +191,108 @@ function TimelineSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SubIssueRow — sub-issue list item with inline status & assignee editing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SubIssueRow({ child }: { child: Issue }) {
|
||||
const { t } = useT("issues");
|
||||
const paths = useWorkspacePaths();
|
||||
const updateIssue = useUpdateIssue();
|
||||
const selected = useIssueSelectionStore((s) => s.selectedIds.has(child.id));
|
||||
const toggleSelected = useIssueSelectionStore((s) => s.toggle);
|
||||
const isDone = child.status === "done" || child.status === "cancelled";
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<UpdateIssueRequest>) => {
|
||||
updateIssue.mutate(
|
||||
{ id: child.id, ...updates },
|
||||
{ onError: () => toast.error(t(($) => $.detail.update_failed)) },
|
||||
);
|
||||
},
|
||||
[child.id, updateIssue, t],
|
||||
);
|
||||
|
||||
// AppLink wraps only the title/identifier area. Pickers and checkbox are
|
||||
// siblings, so their clicks never navigate — no stopPropagation acrobatics
|
||||
// and no risk of the native checkbox / picker triggers being blocked.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2 hover:bg-accent/50 transition-colors group/row",
|
||||
selected && "bg-accent/30",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center transition-opacity",
|
||||
selected
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/row:opacity-100 focus-within:opacity-100",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleSelected(child.id)}
|
||||
aria-label={`Select ${child.identifier}`}
|
||||
className="cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<StatusPicker
|
||||
status={child.status}
|
||||
onUpdate={handleUpdate}
|
||||
align="start"
|
||||
trigger={
|
||||
<StatusIcon
|
||||
status={child.status}
|
||||
className="h-[15px] w-[15px] shrink-0"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<AppLink
|
||||
href={paths.issueDetail(child.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums font-medium shrink-0">
|
||||
{child.identifier}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate flex-1",
|
||||
isDone
|
||||
? "text-muted-foreground"
|
||||
: "group-hover/row:text-foreground",
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
</span>
|
||||
</AppLink>
|
||||
<AssigneePicker
|
||||
assigneeType={child.assignee_type}
|
||||
assigneeId={child.assignee_id}
|
||||
onUpdate={handleUpdate}
|
||||
align="end"
|
||||
trigger={
|
||||
child.assignee_type && child.assignee_id ? (
|
||||
<ActorAvatar
|
||||
actorType={child.assignee_type}
|
||||
actorId={child.assignee_id}
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-5 w-5 rounded-full border border-dashed border-muted-foreground/30 shrink-0"
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Props
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -315,10 +439,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
[clearResolvedExpand, toggleResolveComment],
|
||||
);
|
||||
|
||||
// Memoized timeline grouping. The same Map / groups references are reused
|
||||
// across re-renders that don't change `timeline`, so React.memo on
|
||||
// CommentCard can skip re-rendering when the only thing that moved was
|
||||
// unrelated parent state (e.g. composer draft, sidebar toggle).
|
||||
// Memoized timeline grouping. Each render rebuilds the per-parent map from
|
||||
// the latest timeline, then pre-flattens each thread's reply subtree into a
|
||||
// dedicated `threadReplies` slice per root. Slices are stabilized against
|
||||
// the previous render via `prevThreadRepliesRef`: if a thread's flat list
|
||||
// is shallow-equal to the previous one, we reuse the previous array so
|
||||
// React.memo on CommentCard / ResolvedThreadBar can short-circuit. Without
|
||||
// this, every WS event (including reactions, edits, AI streaming on an
|
||||
// unrelated thread) hands every card a brand-new prop reference and forces
|
||||
// every thread subtree to re-render in lockstep.
|
||||
const prevThreadRepliesRef = useRef<Map<string, TimelineEntry[]>>(new Map());
|
||||
const timelineView = useMemo(() => {
|
||||
// Group entries: top-level = activities + root comments; replies are
|
||||
// bucketed under their parent's id and rendered nested inside CommentCard.
|
||||
@@ -336,6 +466,22 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-flatten each top-level comment's thread subtree (parent + every
|
||||
// descendant in render order). Reuse the previous array reference when
|
||||
// the thread is unchanged so unrelated CommentCards keep their memo.
|
||||
const prevThreadReplies = prevThreadRepliesRef.current;
|
||||
const threadReplies = new Map<string, TimelineEntry[]>();
|
||||
for (const root of topLevel) {
|
||||
if (root.type !== "comment") continue;
|
||||
const fresh = collectThreadReplies(root.id, repliesByParent);
|
||||
const previous = prevThreadReplies.get(root.id);
|
||||
threadReplies.set(
|
||||
root.id,
|
||||
previous && shallowEqualEntries(previous, fresh) ? previous : fresh,
|
||||
);
|
||||
}
|
||||
prevThreadRepliesRef.current = threadReplies;
|
||||
|
||||
// Coalesce consecutive activities from the same actor + action.
|
||||
// - task_completed / task_failed: no time limit (these repeat across runs)
|
||||
// - all other actions: within a 2-minute window
|
||||
@@ -375,7 +521,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
}
|
||||
}
|
||||
|
||||
return { repliesByParent, groups };
|
||||
return { threadReplies, groups };
|
||||
}, [timeline]);
|
||||
|
||||
const {
|
||||
@@ -409,6 +555,30 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
});
|
||||
const [subIssuesCollapsed, setSubIssuesCollapsed] = useState(false);
|
||||
|
||||
// Selection store is global (workspace-scoped); clear it whenever this
|
||||
// issue detail is mounted or switched, so leftover selections from the
|
||||
// main list view (or another sub-issue list) don't leak into this one.
|
||||
const clearSelection = useIssueSelectionStore((s) => s.clear);
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const selectIds = useIssueSelectionStore((s) => s.select);
|
||||
const deselectIds = useIssueSelectionStore((s) => s.deselect);
|
||||
useEffect(() => {
|
||||
clearSelection();
|
||||
return clearSelection;
|
||||
}, [id, clearSelection]);
|
||||
|
||||
const childIssueIds = useMemo(() => childIssues.map((c) => c.id), [childIssues]);
|
||||
const childSelectedCount = childIssueIds.filter((cid) =>
|
||||
selectedIds.has(cid),
|
||||
).length;
|
||||
const allChildrenSelected =
|
||||
childIssueIds.length > 0 && childSelectedCount === childIssueIds.length;
|
||||
const someChildrenSelected = childSelectedCount > 0;
|
||||
const handleToggleSelectAllChildren = useCallback(() => {
|
||||
if (allChildrenSelected) deselectIds(childIssueIds);
|
||||
else selectIds(childIssueIds);
|
||||
}, [allChildrenSelected, childIssueIds, deselectIds, selectIds]);
|
||||
|
||||
const loading = issueLoading;
|
||||
|
||||
// Scroll to highlighted comment once timeline loads (fire only once per highlightCommentId)
|
||||
@@ -834,7 +1004,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
{childIssues.length > 0 && (() => {
|
||||
const doneCount = childIssues.filter((c) => c.status === "done").length;
|
||||
return (
|
||||
<div className="mt-10">
|
||||
<div className="mt-10 group/sub-issues">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
@@ -856,6 +1026,21 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
{doneCount}/{childIssues.length}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allChildrenSelected}
|
||||
ref={(el) => {
|
||||
if (el) el.indeterminate = someChildrenSelected && !allChildrenSelected;
|
||||
}}
|
||||
onChange={handleToggleSelectAllChildren}
|
||||
aria-label="Select all sub-issues"
|
||||
className={cn(
|
||||
"ml-1 cursor-pointer accent-primary transition-opacity",
|
||||
someChildrenSelected
|
||||
? "opacity-100"
|
||||
: "opacity-0 group-hover/sub-issues:opacity-100 focus-visible:opacity-100",
|
||||
)}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
@@ -873,52 +1058,16 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Inline batch toolbar — appears next to the rows when
|
||||
selections exist, instead of as a far-away fixed bar. */}
|
||||
<BatchActionToolbar placement="inline" />
|
||||
|
||||
{/* List */}
|
||||
{!subIssuesCollapsed && (
|
||||
<div className="overflow-hidden rounded-lg border bg-card/30 divide-y divide-border/60">
|
||||
{childIssues.map((child) => {
|
||||
const isDone =
|
||||
child.status === "done" || child.status === "cancelled";
|
||||
return (
|
||||
<AppLink
|
||||
key={child.id}
|
||||
href={paths.issueDetail(child.id)}
|
||||
className="flex items-center gap-2.5 px-3 py-2 hover:bg-accent/50 transition-colors group/row"
|
||||
>
|
||||
<StatusIcon
|
||||
status={child.status}
|
||||
className="h-[15px] w-[15px] shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums font-medium shrink-0">
|
||||
{child.identifier}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate flex-1",
|
||||
isDone
|
||||
? "text-muted-foreground"
|
||||
: "group-hover/row:text-foreground",
|
||||
)}
|
||||
>
|
||||
{child.title}
|
||||
</span>
|
||||
{child.assignee_type && child.assignee_id ? (
|
||||
<ActorAvatar
|
||||
actorType={child.assignee_type}
|
||||
actorId={child.assignee_id}
|
||||
size={20}
|
||||
className="shrink-0"
|
||||
enableHoverCard
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-5 w-5 rounded-full border border-dashed border-muted-foreground/30 shrink-0"
|
||||
/>
|
||||
)}
|
||||
</AppLink>
|
||||
);
|
||||
})}
|
||||
{childIssues.map((child) => (
|
||||
<SubIssueRow key={child.id} child={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1039,7 +1188,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
<div key={entry.id} id={`comment-${entry.id}`}>
|
||||
<ResolvedThreadBar
|
||||
entry={entry}
|
||||
repliesByParent={timelineView.repliesByParent}
|
||||
replies={timelineView.threadReplies.get(entry.id) ?? EMPTY_REPLIES}
|
||||
onExpand={() => toggleResolvedExpand(entry.id, true)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1050,7 +1199,7 @@ export function IssueDetail({ issueId, onDelete, onDone, defaultSidebarOpen = tr
|
||||
<CommentCard
|
||||
issueId={id}
|
||||
entry={entry}
|
||||
allReplies={timelineView.repliesByParent}
|
||||
replies={timelineView.threadReplies.get(entry.id) ?? EMPTY_REPLIES}
|
||||
currentUserId={user?.id}
|
||||
canModerate={canModerateComments}
|
||||
onReply={submitReply}
|
||||
|
||||
@@ -2,29 +2,27 @@ import { CheckCircle2, ChevronRight } from "lucide-react";
|
||||
import { useActorName } from "@multica/core/workspace/hooks";
|
||||
import { Card } from "@multica/ui/components/ui/card";
|
||||
import type { TimelineEntry } from "@multica/core/types";
|
||||
import { collectThreadReplies } from "./thread-utils";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
interface ResolvedThreadBarProps {
|
||||
/** The resolved root comment. */
|
||||
entry: TimelineEntry;
|
||||
/**
|
||||
* Full reply graph keyed by parent_id. The bar walks the graph recursively
|
||||
* so the count + author list match what CommentCard would render in the
|
||||
* expanded view (direct-children-only would undercount nested replies).
|
||||
* Flat list of every nested reply under this thread root. Precomputed by
|
||||
* `issue-detail.tsx`'s `timelineView` from the same walk that CommentCard
|
||||
* uses, so the count + author list match what the expanded view renders
|
||||
* (direct-children-only would undercount nested replies).
|
||||
*/
|
||||
repliesByParent: Map<string, TimelineEntry[]>;
|
||||
replies: TimelineEntry[];
|
||||
onExpand: () => void;
|
||||
}
|
||||
|
||||
const MAX_NAMED_AUTHORS = 2;
|
||||
|
||||
export function ResolvedThreadBar({ entry, repliesByParent, onExpand }: ResolvedThreadBarProps) {
|
||||
export function ResolvedThreadBar({ entry, replies, onExpand }: ResolvedThreadBarProps) {
|
||||
const { t } = useT("issues");
|
||||
const { getActorName } = useActorName();
|
||||
|
||||
const replies = collectThreadReplies(entry.id, repliesByParent);
|
||||
|
||||
const authorKeys = new Set<string>();
|
||||
const authors: Array<{ type: string; id: string }> = [];
|
||||
for (const e of [entry, ...replies]) {
|
||||
|
||||
@@ -201,4 +201,172 @@ describe("useIssueTimeline", () => {
|
||||
// setQueryData should not have been invoked for a non-matching issue.
|
||||
expect(cacheUpdates.last).toBeNull();
|
||||
});
|
||||
|
||||
// The global useRealtimeSync handler now uses refetchType: "none" for
|
||||
// timeline events, which means useIssueTimeline must own the granular
|
||||
// cache update for every event that mutates the timeline — including
|
||||
// comment:resolved / comment:unresolved. Without these handlers the
|
||||
// resolve toggle on a thread root would only update the cache when the
|
||||
// user remounts IssueDetail (the stale flag triggers a refetch), so the
|
||||
// bar/expanded view would lag the click by a navigation cycle.
|
||||
it("comment:resolved updates the matching entry in place with the new resolved fields", () => {
|
||||
queryState.data = [
|
||||
{
|
||||
type: "comment",
|
||||
id: "c1",
|
||||
actor_type: "member",
|
||||
actor_id: "u",
|
||||
content: "hello",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T01:00:00Z",
|
||||
updated_at: "2026-05-06T01:00:00Z",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: null,
|
||||
resolved_by_type: null,
|
||||
resolved_by_id: null,
|
||||
},
|
||||
{
|
||||
type: "comment",
|
||||
id: "c2",
|
||||
actor_type: "member",
|
||||
actor_id: "u",
|
||||
content: "untouched",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T02:00:00Z",
|
||||
updated_at: "2026-05-06T02:00:00Z",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: null,
|
||||
resolved_by_type: null,
|
||||
resolved_by_id: null,
|
||||
},
|
||||
];
|
||||
renderHook(() => useIssueTimeline("issue-1", "user-1"));
|
||||
const handler = wsHandlers.get("comment:resolved");
|
||||
expect(handler).toBeDefined();
|
||||
act(() => {
|
||||
handler!({
|
||||
comment: {
|
||||
id: "c1",
|
||||
issue_id: "issue-1",
|
||||
author_type: "member",
|
||||
author_id: "u",
|
||||
content: "hello",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T01:00:00Z",
|
||||
updated_at: "2026-05-06T01:00:00Z",
|
||||
type: "comment",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: "2026-05-06T03:00:00Z",
|
||||
resolved_by_type: "member",
|
||||
resolved_by_id: "u",
|
||||
},
|
||||
});
|
||||
});
|
||||
const updated = cacheUpdates.last as Array<{
|
||||
id: string;
|
||||
resolved_at: string | null;
|
||||
resolved_by_type: string | null;
|
||||
resolved_by_id: string | null;
|
||||
}>;
|
||||
expect(updated.map((e) => e.id)).toEqual(["c1", "c2"]);
|
||||
expect(updated[0]!.resolved_at).toBe("2026-05-06T03:00:00Z");
|
||||
expect(updated[0]!.resolved_by_type).toBe("member");
|
||||
expect(updated[0]!.resolved_by_id).toBe("u");
|
||||
// Sibling entry must not change (identity preserved by .map).
|
||||
expect(updated[1]!.resolved_at).toBeNull();
|
||||
});
|
||||
|
||||
it("comment:unresolved clears the resolved fields on the matching entry", () => {
|
||||
queryState.data = [
|
||||
{
|
||||
type: "comment",
|
||||
id: "c1",
|
||||
actor_type: "member",
|
||||
actor_id: "u",
|
||||
content: "hello",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T01:00:00Z",
|
||||
updated_at: "2026-05-06T01:00:00Z",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: "2026-05-06T03:00:00Z",
|
||||
resolved_by_type: "member",
|
||||
resolved_by_id: "u",
|
||||
},
|
||||
];
|
||||
renderHook(() => useIssueTimeline("issue-1", "user-1"));
|
||||
const handler = wsHandlers.get("comment:unresolved");
|
||||
expect(handler).toBeDefined();
|
||||
act(() => {
|
||||
handler!({
|
||||
comment: {
|
||||
id: "c1",
|
||||
issue_id: "issue-1",
|
||||
author_type: "member",
|
||||
author_id: "u",
|
||||
content: "hello",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T01:00:00Z",
|
||||
updated_at: "2026-05-06T01:00:00Z",
|
||||
type: "comment",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: null,
|
||||
resolved_by_type: null,
|
||||
resolved_by_id: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
const updated = cacheUpdates.last as Array<{
|
||||
id: string;
|
||||
resolved_at: string | null;
|
||||
}>;
|
||||
expect(updated[0]!.resolved_at).toBeNull();
|
||||
});
|
||||
|
||||
it("comment:resolved ignores events from other issues", () => {
|
||||
queryState.data = [
|
||||
{
|
||||
type: "comment",
|
||||
id: "c1",
|
||||
actor_type: "member",
|
||||
actor_id: "u",
|
||||
content: "hello",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T01:00:00Z",
|
||||
updated_at: "2026-05-06T01:00:00Z",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: null,
|
||||
resolved_by_type: null,
|
||||
resolved_by_id: null,
|
||||
},
|
||||
];
|
||||
renderHook(() => useIssueTimeline("issue-1", "user-1"));
|
||||
const handler = wsHandlers.get("comment:resolved");
|
||||
act(() => {
|
||||
handler!({
|
||||
comment: {
|
||||
id: "c1",
|
||||
issue_id: "different-issue",
|
||||
author_type: "member",
|
||||
author_id: "u",
|
||||
content: "hello",
|
||||
parent_id: null,
|
||||
created_at: "2026-05-06T01:00:00Z",
|
||||
updated_at: "2026-05-06T01:00:00Z",
|
||||
type: "comment",
|
||||
reactions: [],
|
||||
attachments: [],
|
||||
resolved_at: "2026-05-06T03:00:00Z",
|
||||
resolved_by_type: "member",
|
||||
resolved_by_id: "u",
|
||||
},
|
||||
});
|
||||
});
|
||||
expect(cacheUpdates.last).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
CommentCreatedPayload,
|
||||
CommentUpdatedPayload,
|
||||
CommentDeletedPayload,
|
||||
CommentResolvedPayload,
|
||||
CommentUnresolvedPayload,
|
||||
ActivityCreatedPayload,
|
||||
ReactionAddedPayload,
|
||||
ReactionRemovedPayload,
|
||||
@@ -50,6 +52,9 @@ function commentToTimelineEntry(c: Comment): TimelineEntry {
|
||||
comment_type: c.type,
|
||||
reactions: c.reactions ?? [],
|
||||
attachments: c.attachments ?? [],
|
||||
resolved_at: c.resolved_at,
|
||||
resolved_by_type: c.resolved_by_type,
|
||||
resolved_by_id: c.resolved_by_id,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +123,44 @@ export function useIssueTimeline(issueId: string, userId?: string) {
|
||||
),
|
||||
);
|
||||
|
||||
// Granular handlers for comment:resolved / comment:unresolved. The payload
|
||||
// carries the full Comment with the new resolved_at/resolved_by_* fields,
|
||||
// which `commentToTimelineEntry` already preserves, so the existing
|
||||
// entry can simply be replaced in place. Without these handlers the only
|
||||
// path that updated the cache was `useRealtimeSync`'s global invalidate,
|
||||
// which forces a full timeline refetch and busts every CommentCard memo.
|
||||
useWSEvent(
|
||||
"comment:resolved",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentResolvedPayload;
|
||||
if (comment.issue_id !== issueId) return;
|
||||
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.map((e) =>
|
||||
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
||||
),
|
||||
);
|
||||
},
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:unresolved",
|
||||
useCallback(
|
||||
(payload: unknown) => {
|
||||
const { comment } = payload as CommentUnresolvedPayload;
|
||||
if (comment.issue_id !== issueId) return;
|
||||
qc.setQueryData<TLCache>(issueKeys.timeline(issueId), (old) =>
|
||||
old?.map((e) =>
|
||||
e.id === comment.id ? commentToTimelineEntry(comment) : e,
|
||||
),
|
||||
);
|
||||
},
|
||||
[qc, issueId],
|
||||
),
|
||||
);
|
||||
|
||||
useWSEvent(
|
||||
"comment:deleted",
|
||||
useCallback(
|
||||
|
||||
@@ -69,7 +69,15 @@
|
||||
"title": "System Notifications",
|
||||
"description": "Control native OS notification banners shown when Multica is in the background.",
|
||||
"label": "Show system notifications",
|
||||
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused."
|
||||
"hint": "Show a banner from your operating system for new inbox items when the app isn't focused.",
|
||||
"permission_default_hint": "Browser notifications are not yet authorized for this site. Toggling on again will prompt your browser for permission.",
|
||||
"permission_denied_hint": "Browser notifications are blocked. Open the site permissions for this page in your browser settings, allow Notifications, then reload.",
|
||||
"api_unavailable_hint": "This browser does not support Web Notifications. Try a recent version of Chrome, Edge, Firefox, or Safari, or use the Multica desktop app.",
|
||||
"permission_denied_toast": "Browser blocked notification permission. Update your browser site settings to allow it.",
|
||||
"unsupported_toast": "This browser does not support notifications.",
|
||||
"test_button": "Send test notification",
|
||||
"test_title": "Multica notifications are working",
|
||||
"test_body": "You'll see banners like this when new inbox items arrive while Multica isn't focused."
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
@@ -69,7 +69,15 @@
|
||||
"title": "系统通知",
|
||||
"description": "控制 Multica 在后台时是否显示操作系统的原生通知横幅。",
|
||||
"label": "显示系统通知",
|
||||
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。"
|
||||
"hint": "App 未获得焦点时,新的收件箱条目通过操作系统弹出通知横幅。",
|
||||
"permission_default_hint": "浏览器尚未授权当前站点发送通知。再次打开开关时,浏览器会向你请求权限。",
|
||||
"permission_denied_hint": "浏览器已拒绝通知权限。请在浏览器的站点权限设置中允许「通知」并刷新页面。",
|
||||
"api_unavailable_hint": "当前浏览器不支持 Web 通知。请使用较新版本的 Chrome、Edge、Firefox、Safari,或改用 Multica 桌面端。",
|
||||
"permission_denied_toast": "浏览器拒绝了通知权限。请在站点权限里手动允许。",
|
||||
"unsupported_toast": "当前浏览器不支持通知。",
|
||||
"test_button": "发送测试通知",
|
||||
"test_title": "Multica 通知已生效",
|
||||
"test_body": "Multica 不在前台时收到新的收件箱条目,会以这种横幅形式提醒你。"
|
||||
}
|
||||
},
|
||||
"tokens": {
|
||||
|
||||
132
packages/views/runtimes/utils.test.ts
Normal file
132
packages/views/runtimes/utils.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { collectUnmappedModels, estimateCost, isModelPriced } from "./utils";
|
||||
|
||||
const zeroUsage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
|
||||
describe("estimateCost", () => {
|
||||
it("prices the canonical Anthropic Sonnet 4.6 SKU", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
});
|
||||
// 1M × $3 input + 1M × $15 output = $18.
|
||||
expect(cost).toBeCloseTo(18, 5);
|
||||
});
|
||||
|
||||
it("prices a Codex CLI session reporting gpt-5-codex", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5-codex",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
cache_read_tokens: 2_000_000,
|
||||
});
|
||||
// 1M × $1.25 + 1M × $10 + 2M × $0.125 = $11.50.
|
||||
expect(cost).toBeCloseTo(11.5, 5);
|
||||
});
|
||||
|
||||
it("strips dated snapshots before resolving (gpt-5-2025-08-07 → gpt-5)", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5-2025-08-07",
|
||||
input_tokens: 1_000_000,
|
||||
});
|
||||
expect(cost).toBeCloseTo(1.25, 5);
|
||||
});
|
||||
|
||||
it("prices each dotted Codex catalog SKU at its own tier, not gpt-5", () => {
|
||||
// Every dotted minor version is priced independently. The resolver does
|
||||
// exact-match-after-date-strip (no startsWith fallback), so each row
|
||||
// must exist on its own.
|
||||
expect(
|
||||
estimateCost({ ...zeroUsage, model: "gpt-5.5", input_tokens: 1_000_000 }),
|
||||
).toBeCloseTo(5, 5);
|
||||
expect(
|
||||
estimateCost({ ...zeroUsage, model: "gpt-5.4", output_tokens: 1_000_000 }),
|
||||
).toBeCloseTo(15, 5);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.4-mini",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
}),
|
||||
).toBeCloseTo(0.75 + 4.5, 5);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.3-codex",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
}),
|
||||
).toBeCloseTo(1.75 + 14, 5);
|
||||
});
|
||||
|
||||
it("flags catalog SKUs without a published price (gpt-5.5-mini) as unmapped", () => {
|
||||
// `gpt-5.5-mini` is in the Codex catalog but OpenAI hasn't published a
|
||||
// public rate. We refuse to absorb it into `gpt-5.5` — the diagnostic
|
||||
// surfaces it instead so the team knows to add an explicit row.
|
||||
expect(isModelPriced("gpt-5.5-mini")).toBe(false);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.5-mini",
|
||||
input_tokens: 1_000_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("flags hypothetical future variants as unmapped instead of inheriting a relative's price", () => {
|
||||
// No exact match → unmapped. Covers both dotted families (`gpt-5.99-codex`)
|
||||
// and unknown sub-variants (`gpt-5-foo`); both must miss rather than
|
||||
// silently inherit `gpt-5` pricing.
|
||||
expect(isModelPriced("gpt-5.99-codex")).toBe(false);
|
||||
expect(isModelPriced("gpt-5-foo")).toBe(false);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.99-codex",
|
||||
input_tokens: 1_000_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for a genuinely unknown model so the UI can flag it", () => {
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "totally-made-up-model",
|
||||
input_tokens: 1_000_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelPriced", () => {
|
||||
it("recognises both Claude and Codex/GPT families", () => {
|
||||
expect(isModelPriced("claude-sonnet-4-6")).toBe(true);
|
||||
expect(isModelPriced("gpt-5-codex")).toBe(true);
|
||||
expect(isModelPriced("gpt-5-mini")).toBe(true);
|
||||
expect(isModelPriced("o3")).toBe(true);
|
||||
expect(isModelPriced("totally-made-up-model")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUnmappedModels", () => {
|
||||
it("only surfaces names that miss every pricing tier", () => {
|
||||
const rows = [
|
||||
{ ...zeroUsage, model: "claude-sonnet-4-6" },
|
||||
{ ...zeroUsage, model: "gpt-5-codex" },
|
||||
{ ...zeroUsage, model: "fictional-model-x" },
|
||||
];
|
||||
expect(collectUnmappedModels(rows)).toEqual(["fictional-model-x"]);
|
||||
});
|
||||
});
|
||||
@@ -114,21 +114,29 @@ export function formatTokens(n: number): string {
|
||||
// Cost estimation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Pricing per million tokens (USD). Sourced from
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing — keep in sync
|
||||
// when Anthropic releases new models or adjusts prices. cacheWrite reflects
|
||||
// the 5-minute cache TTL (1.25× input); the daemon reports
|
||||
// cache_creation_input_tokens without TTL metadata, so 5m is the safest /
|
||||
// cheapest assumption (matches the API default).
|
||||
// Pricing per million tokens (USD). Anthropic figures sourced from
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing; OpenAI figures
|
||||
// from https://openai.com/api/pricing — keep in sync when providers release
|
||||
// new models or adjust prices.
|
||||
//
|
||||
// Iteration order matters: the resolver's startsWith() fallback walks this
|
||||
// object in insertion order, so MORE SPECIFIC keys (e.g. claude-sonnet-4-5)
|
||||
// must precede SHORTER prefixes (e.g. claude-sonnet-4) of the same family.
|
||||
// Anthropic's cacheWrite reflects the 5-minute cache TTL (1.25× input); the
|
||||
// daemon reports cache_creation_input_tokens without TTL metadata, so 5m is
|
||||
// the safest / cheapest assumption (matches the API default). OpenAI does
|
||||
// not bill cache writes separately (cached input is just discounted on
|
||||
// subsequent reads), so cacheWrite mirrors input there.
|
||||
//
|
||||
// The resolver matches exact keys after stripping a trailing date snapshot
|
||||
// (see `resolvePricing` below). It deliberately does NOT do startsWith
|
||||
// fallbacks: every catalog SKU needs its own row. That keeps unfamiliar
|
||||
// variants (`gpt-5.5-mini`, hypothetical `gpt-5.4-foo`) from silently
|
||||
// inheriting the price of a near-named relative; they surface in the
|
||||
// unmapped diagnostic instead. Mirror new entries in
|
||||
// `server/pkg/agent/models.go` so the catalog and pricing stay in sync.
|
||||
const MODEL_PRICING: Record<
|
||||
string,
|
||||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
||||
> = {
|
||||
// -- Current generation (4.5+ — Opus dropped from 15/75 to 5/25 here) --
|
||||
// -- Anthropic: current generation (4.5+ — Opus dropped from 15/75 to 5/25 here) --
|
||||
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
|
||||
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
@@ -136,36 +144,55 @@ const MODEL_PRICING: Record<
|
||||
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||||
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||||
|
||||
// -- Pre-4.5 Opus (legacy, still served at original price tier) --
|
||||
// -- Anthropic: pre-4.5 Opus (legacy, still served at original price tier) --
|
||||
"claude-opus-4-1": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
||||
"claude-opus-4": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
||||
|
||||
// -- Sonnet 4.0 (deprecated; same price as the 4.x family) --
|
||||
// -- Anthropic: Sonnet 4.0 (deprecated; same price as the 4.x family) --
|
||||
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
|
||||
// -- Older Haiku tier (defensive entry for the rare runtime still on it) --
|
||||
// -- Anthropic: older Haiku tier (defensive entry for the rare runtime still on it) --
|
||||
"claude-haiku-3-5": { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
|
||||
|
||||
// -- OpenAI: dotted-minor Codex catalog SKUs. Each generation is priced
|
||||
// independently — no fallback to `gpt-5`. Entries track
|
||||
// `server/pkg/agent/models.go` (Codex provider list).
|
||||
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.50, cacheWrite: 5 },
|
||||
"gpt-5.4-mini": { input: 0.75, output: 4.50, cacheRead: 0.075, cacheWrite: 0.75 },
|
||||
"gpt-5.4": { input: 2.50, output: 15, cacheRead: 0.25, cacheWrite: 2.50 },
|
||||
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 1.75 },
|
||||
|
||||
// -- OpenAI: GPT-5 family (Codex CLI's default is gpt-5-codex; -codex/-mini/-nano variants priced per OpenAI tiers) --
|
||||
"gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
|
||||
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 },
|
||||
"gpt-5-nano": { input: 0.05, output: 0.40, cacheRead: 0.005, cacheWrite: 0.05 },
|
||||
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
|
||||
|
||||
// -- OpenAI: o-series reasoning models --
|
||||
"o3-mini": { input: 1.10, output: 4.40, cacheRead: 0.55, cacheWrite: 1.10 },
|
||||
"o3": { input: 2, output: 8, cacheRead: 0.50, cacheWrite: 2 },
|
||||
"o4-mini": { input: 1.10, output: 4.40, cacheRead: 0.275, cacheWrite: 1.10 },
|
||||
|
||||
// -- OpenAI: GPT-4o family (legacy, kept for runtimes still configured against it) --
|
||||
"gpt-4o-mini": { input: 0.15, output: 0.60, cacheRead: 0.075, cacheWrite: 0.15 },
|
||||
"gpt-4o": { input: 2.50, output: 10, cacheRead: 1.25, cacheWrite: 2.50 },
|
||||
};
|
||||
|
||||
// Resolve a model string to its pricing tier. Two layers of fallback so the
|
||||
// daemon-reported model name doesn't have to match the keys exactly:
|
||||
// 1. Exact match.
|
||||
// 2. Strip a trailing date / "latest" tag (Claude Code typically reports
|
||||
// `claude-sonnet-4-5-20250929` — the date is volatile, the family is
|
||||
// what we price). Try exact match again on the stripped name.
|
||||
// 3. startsWith on either the raw or stripped name.
|
||||
// Anything that misses all three is genuinely unknown; we return undefined
|
||||
// so callers can distinguish "$0 spend" from "spent but model not priced".
|
||||
// Resolve a model string to its pricing tier. Exact match, with one
|
||||
// tolerance: providers ship dated snapshots (`claude-sonnet-4-5-20250929`,
|
||||
// `gpt-5-2025-08-07`) where the family is what we price and the date is
|
||||
// volatile, so we strip a trailing date / "latest" tag and try again.
|
||||
// Anything still unmapped after that is genuinely unknown; return
|
||||
// undefined so callers can distinguish "$0 spend" from "spent but model
|
||||
// not priced". No startsWith fallback: variants like `gpt-5.5-mini` must
|
||||
// have their own row to be priced (otherwise they'd inherit `gpt-5.5`).
|
||||
function resolvePricing(model: string) {
|
||||
if (!model) return undefined;
|
||||
if (MODEL_PRICING[model]) return MODEL_PRICING[model];
|
||||
|
||||
const stripped = model.replace(/-(20\d{6}|latest)$/, "");
|
||||
const stripped = model.replace(/-(20\d{2}-\d{2}-\d{2}|20\d{6}|latest)$/, "");
|
||||
if (stripped !== model && MODEL_PRICING[stripped]) return MODEL_PRICING[stripped];
|
||||
|
||||
for (const [key, p] of Object.entries(MODEL_PRICING)) {
|
||||
if (model.startsWith(key) || stripped.startsWith(key)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { notificationPreferenceOptions } from "@multica/core/notification-preferences/queries";
|
||||
import { useUpdateNotificationPreferences } from "@multica/core/notification-preferences/mutations";
|
||||
import type { NotificationGroupKey, NotificationPreferences } from "@multica/core/types";
|
||||
import {
|
||||
detectWebNotificationSupport,
|
||||
isDesktopApp,
|
||||
requestWebNotificationPermission,
|
||||
showSystemNotification,
|
||||
type WebNotificationSupport,
|
||||
} from "@multica/core/notifications";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "../../i18n";
|
||||
|
||||
@@ -45,6 +54,51 @@ export function NotificationsTab() {
|
||||
|
||||
const systemEnabled = preferences.system_notifications !== "muted";
|
||||
|
||||
// Browser permission state — desktop app handles notifications natively
|
||||
// through the main process, so this UI only shows for the web app.
|
||||
const desktop = isDesktopApp();
|
||||
const [support, setSupport] = useState<WebNotificationSupport>(() =>
|
||||
desktop ? "supported" : detectWebNotificationSupport(),
|
||||
);
|
||||
|
||||
// Re-check permission on mount and when the page is re-shown (the user may
|
||||
// change browser-level permission in another tab, or grant via the URL bar).
|
||||
useEffect(() => {
|
||||
if (desktop) return;
|
||||
const refresh = () => setSupport(detectWebNotificationSupport());
|
||||
refresh();
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
return () => document.removeEventListener("visibilitychange", refresh);
|
||||
}, [desktop]);
|
||||
|
||||
const handleSystemToggle = async (enabled: boolean) => {
|
||||
if (enabled && !desktop) {
|
||||
// Permission requests must originate from a user gesture; doing it
|
||||
// here (synchronously inside the click handler) keeps that contract.
|
||||
const result = await requestWebNotificationPermission();
|
||||
setSupport(detectWebNotificationSupport());
|
||||
if (result === "denied") {
|
||||
toast.error(t(($) => $.notifications.system.permission_denied_toast));
|
||||
} else if (result === "unsupported") {
|
||||
toast.error(t(($) => $.notifications.system.unsupported_toast));
|
||||
}
|
||||
}
|
||||
handleToggle("system_notifications", enabled);
|
||||
};
|
||||
|
||||
const handleTest = () => {
|
||||
showSystemNotification({
|
||||
slug: "",
|
||||
itemId: "test",
|
||||
issueKey: "test",
|
||||
title: t(($) => $.notifications.system.test_title),
|
||||
body: t(($) => $.notifications.system.test_body),
|
||||
inboxPath: "/",
|
||||
});
|
||||
};
|
||||
|
||||
const showPermissionHint = !desktop && systemEnabled && support !== "supported";
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<section className="space-y-4">
|
||||
@@ -90,7 +144,7 @@ export function NotificationsTab() {
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<p className="text-sm font-medium">{t(($) => $.notifications.system.label)}</p>
|
||||
@@ -100,9 +154,31 @@ export function NotificationsTab() {
|
||||
</div>
|
||||
<Switch
|
||||
checked={systemEnabled}
|
||||
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
|
||||
onCheckedChange={handleSystemToggle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showPermissionHint && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/30 dark:text-amber-200">
|
||||
{support === "permission_denied" && (
|
||||
<p>{t(($) => $.notifications.system.permission_denied_hint)}</p>
|
||||
)}
|
||||
{support === "permission_default" && (
|
||||
<p>{t(($) => $.notifications.system.permission_default_hint)}</p>
|
||||
)}
|
||||
{support === "api_unavailable" && (
|
||||
<p>{t(($) => $.notifications.system.api_unavailable_hint)}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{systemEnabled && support === "supported" && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline" size="sm" onClick={handleTest}>
|
||||
{t(($) => $.notifications.system.test_button)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
@@ -111,7 +111,7 @@ func init() {
|
||||
autopilotCreateCmd.Flags().String("title", "", "Autopilot title (required)")
|
||||
autopilotCreateCmd.Flags().String("description", "", "Autopilot description (used as task prompt)")
|
||||
autopilotCreateCmd.Flags().String("agent", "", "Assignee agent (name or ID) — required")
|
||||
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue (required). run_only is not yet supported end-to-end.")
|
||||
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue or run_only (required)")
|
||||
autopilotCreateCmd.Flags().String("priority", "none", "Priority for created issues (none, low, medium, high, urgent)")
|
||||
autopilotCreateCmd.Flags().String("project", "", "Project ID (optional)")
|
||||
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode)")
|
||||
@@ -124,7 +124,7 @@ func init() {
|
||||
autopilotUpdateCmd.Flags().String("project", "", "New project ID (use empty string to clear)")
|
||||
autopilotUpdateCmd.Flags().String("priority", "", "New priority")
|
||||
autopilotUpdateCmd.Flags().String("status", "", "New status (active, paused)")
|
||||
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue)")
|
||||
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue or run_only)")
|
||||
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template")
|
||||
autopilotUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
@@ -263,14 +263,10 @@ func runAutopilotCreate(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
mode, _ := cmd.Flags().GetString("mode")
|
||||
if mode == "" {
|
||||
return fmt.Errorf("--mode is required (create_issue)")
|
||||
return fmt.Errorf("--mode is required (create_issue or run_only)")
|
||||
}
|
||||
// run_only is a valid value server-side but the dispatch path is not wired
|
||||
// end-to-end (daemon /start resolves workspace only via issue/chat, and the
|
||||
// agent prompt expects an issue ID). Keep the CLI to create_issue until the
|
||||
// server path is fixed to avoid shipping a mode that returns 404 on start.
|
||||
if mode != "create_issue" {
|
||||
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
|
||||
if mode != "create_issue" && mode != "run_only" {
|
||||
return fmt.Errorf("--mode must be create_issue or run_only")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
@@ -370,8 +366,8 @@ func runAutopilotUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
if cmd.Flags().Changed("mode") {
|
||||
v, _ := cmd.Flags().GetString("mode")
|
||||
if v != "create_issue" {
|
||||
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
|
||||
if v != "create_issue" && v != "run_only" {
|
||||
return fmt.Errorf("--mode must be create_issue or run_only")
|
||||
}
|
||||
body["execution_mode"] = v
|
||||
}
|
||||
|
||||
@@ -1708,7 +1708,8 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
}
|
||||
|
||||
// Inject runtime-specific config (meta skill) so the agent discovers .agent_context/.
|
||||
if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil {
|
||||
runtimeBrief, err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx)
|
||||
if err != nil {
|
||||
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
|
||||
}
|
||||
// NOTE: No cleanup — workdir is preserved for reuse by future tasks on
|
||||
@@ -1834,11 +1835,16 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
// - hermes is driven through ACP and starts from a long-lived Hermes home;
|
||||
// deployments that cross a wrapper/container boundary can miss the
|
||||
// task-workdir AGENTS.md even when the prompt itself is delivered.
|
||||
// Pass Multica-defined identity/persona instructions inline so the backend
|
||||
// can prepend them to the turn payload instead of relying only on file
|
||||
// discovery.
|
||||
// - kiro and kimi are wrapped through their own CLIs whose cwd handling
|
||||
// is opaque enough that we can't trust the file-based path either.
|
||||
// Pass the full runtime brief inline (CLI catalog + workflow steps + agent
|
||||
// identity/persona + skills + project context) so the backend prepends the
|
||||
// same payload that file-based runtimes pick up from disk. Without this,
|
||||
// these providers silently miss the workflow section and never call
|
||||
// `multica issue status` / `multica issue comment add`, leaving issues
|
||||
// stuck in `todo`.
|
||||
if providerNeedsInlineSystemPrompt(provider) {
|
||||
execOpts.SystemPrompt = instructions
|
||||
execOpts.SystemPrompt = runtimeBrief
|
||||
}
|
||||
|
||||
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestPrepareWithProjectResources(t *testing.T) {
|
||||
}
|
||||
|
||||
// CLAUDE.md should mention the project context block.
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
|
||||
@@ -250,7 +250,7 @@ func TestProjectReposReplaceWorkspaceReposInMetaSkill(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
@@ -302,7 +302,7 @@ func TestPrepareWithRepoContext(t *testing.T) {
|
||||
defer env.Cleanup(true)
|
||||
|
||||
// Inject runtime config (done separately in daemon, replicate here).
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -585,6 +585,34 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for #2347: the runtime config injected into agent harnesses
|
||||
// must advertise both autopilot execution modes on create AND update, so an
|
||||
// agent acting as a CLI user is not confined to create_issue.
|
||||
func TestInjectRuntimeConfigAutopilotAdvertisesBothModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read CLAUDE.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"multica autopilot create --title \"...\" --agent <name> --mode create_issue|run_only",
|
||||
"multica autopilot update <id>",
|
||||
"[--mode create_issue|run_only]",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("CLAUDE.md missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigGemini(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
@@ -594,7 +622,7 @@ func TestInjectRuntimeConfigGemini(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Writing", Content: "Write clearly."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -632,7 +660,7 @@ func TestInjectRuntimeConfigCodex(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -656,7 +684,7 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
||||
|
||||
ctx := TaskContextForEnv{IssueID: "test-issue-id"}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -810,7 +838,7 @@ func TestInjectRuntimeConfigOpencode(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "opencode", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "opencode", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -846,7 +874,7 @@ func TestInjectRuntimeConfigKiro(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "kiro", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "kiro", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -890,7 +918,7 @@ func TestPrepareWithRepoContextOpencode(t *testing.T) {
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -946,7 +974,7 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", tc.ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", tc.ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
@@ -992,7 +1020,7 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
|
||||
func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
@@ -1018,7 +1046,7 @@ func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) {
|
||||
func TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{
|
||||
if _, err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{
|
||||
IssueID: "issue-1",
|
||||
TriggerCommentID: "comment-1",
|
||||
}); err != nil {
|
||||
@@ -1056,7 +1084,7 @@ func TestInjectRuntimeConfigAutopilotRunOnlyNoIssueWorkflow(t *testing.T) {
|
||||
AutopilotSource: "manual",
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
|
||||
@@ -1092,7 +1120,7 @@ func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Unknown provider should be a no-op.
|
||||
if err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
|
||||
t.Fatalf("expected no error for unknown provider, got: %v", err)
|
||||
}
|
||||
|
||||
@@ -1112,7 +1140,7 @@ func TestInjectRuntimeConfigHermes(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "hermes", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "hermes", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -2093,7 +2121,7 @@ func TestInjectRuntimeConfigMentionLoopHardening(t *testing.T) {
|
||||
readClaudeMD := func(t *testing.T, ctx TaskContextForEnv) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestInjectRuntimeConfigCommentTriggerUsesHelper(t *testing.T) {
|
||||
IssueID: issueID,
|
||||
TriggerCommentID: triggerID,
|
||||
}
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,19 +56,19 @@ func formatProjectResource(r ProjectResourceForEnv) string {
|
||||
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
|
||||
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
||||
// For Kiro: writes {workDir}/AGENTS.md (Kiro CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
return content, os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
return content, os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
case "gemini":
|
||||
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
return content, os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
default:
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
return nil
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +146,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString(" - The same rule applies to `--description` on `multica issue create` and `multica issue update` — use `--description-stdin` and pipe a HEREDOC for any multi-line description; the inline `--description \"...\"` form is for short single-line text only.\n")
|
||||
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
|
||||
b.WriteString("- `multica label create --name \"...\" --color \"#hex\"` — Define a new workspace label (use this only when the label you need does not exist yet; reuse existing labels via `multica label list` first)\n")
|
||||
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue [--description \"...\"]` — Create an autopilot\n")
|
||||
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused]` — Update an autopilot\n")
|
||||
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue|run_only [--description \"...\"]` — Create an autopilot\n")
|
||||
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused] [--mode create_issue|run_only]` — Update an autopilot\n")
|
||||
b.WriteString("- `multica autopilot trigger <id>` — Manually trigger an autopilot to run once\n")
|
||||
b.WriteString("- `multica autopilot delete <id>` — Delete an autopilot\n\n")
|
||||
|
||||
|
||||
@@ -76,14 +76,35 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
// without this we'd report a misleading "empty output" and hide
|
||||
// the real cause (wrong model for the current provider, bad
|
||||
// credentials, rate limit, …) in the daemon log.
|
||||
//
|
||||
// We use StderrPipe + an explicit copier goroutine instead of
|
||||
// `cmd.Stderr = io.MultiWriter(...)` so we have a join point
|
||||
// (`stderrDone`) before the failure-promotion decision. With the
|
||||
// MultiWriter form, exec's internal copy goroutine is only
|
||||
// joined by `cmd.Wait()`, which runs in the deferred cleanup —
|
||||
// after `promoteACPResultOnProviderError` already consulted the
|
||||
// sniffer. That race lost the 429 / usage-limit message under
|
||||
// CI load and surfaced as a flaky test
|
||||
// (TestHermesBackendPromotesProviderErrorWithNonEmptyOutput).
|
||||
providerErr := newACPProviderErrorSniffer("hermes")
|
||||
cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[hermes:stderr] "), providerErr)
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("hermes stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start hermes: %w", err)
|
||||
}
|
||||
|
||||
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[hermes:stderr] "), providerErr)
|
||||
stderrDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stderrDone)
|
||||
_, _ = io.Copy(stderrSink, stderr)
|
||||
}()
|
||||
|
||||
b.cfg.Logger.Info("hermes acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
@@ -307,6 +328,13 @@ func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOpt
|
||||
|
||||
// Wait for the reader goroutine to finish so all output is accumulated.
|
||||
<-readerDone
|
||||
// Wait for the stderr copier as well so the provider-error sniffer
|
||||
// has every byte the child wrote before we consult it for failure
|
||||
// promotion. Skipping this leaves a small race where stopReason=
|
||||
// end_turn arrives over stdout while the stderr 429 / usage-limit
|
||||
// lines are still in transit, causing the promoted error message
|
||||
// to fall through to the synthetic agent-text fallback.
|
||||
<-stderrDone
|
||||
|
||||
outputMu.Lock()
|
||||
finalOutput := output.String()
|
||||
|
||||
@@ -75,14 +75,30 @@ func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptio
|
||||
// without this the daemon reports a misleading "empty output"
|
||||
// and the actionable error (expired token, rate limit, upstream
|
||||
// 5xx, …) stays buried in the daemon log.
|
||||
//
|
||||
// StderrPipe + an explicit copier give us a join point
|
||||
// (`stderrDone`) that fires before the failure-promotion
|
||||
// decision; see the matching comment in hermes.go for why the
|
||||
// io.MultiWriter form races with stopReason=end_turn under load.
|
||||
providerErr := newACPProviderErrorSniffer("kimi")
|
||||
cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[kimi:stderr] "), providerErr)
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("kimi stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start kimi: %w", err)
|
||||
}
|
||||
|
||||
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[kimi:stderr] "), providerErr)
|
||||
stderrDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stderrDone)
|
||||
_, _ = io.Copy(stderrSink, stderr)
|
||||
}()
|
||||
|
||||
b.cfg.Logger.Info("kimi acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
@@ -297,6 +313,9 @@ func (b *kimiBackend) Execute(ctx context.Context, prompt string, opts ExecOptio
|
||||
cancel()
|
||||
|
||||
<-readerDone
|
||||
// Ensure the stderr copier has drained before consulting the
|
||||
// provider-error sniffer; see hermes.go for the failure mode.
|
||||
<-stderrDone
|
||||
|
||||
outputMu.Lock()
|
||||
finalOutput := output.String()
|
||||
|
||||
@@ -69,14 +69,29 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio
|
||||
cancel()
|
||||
return nil, fmt.Errorf("kiro stdin pipe: %w", err)
|
||||
}
|
||||
// StderrPipe + an explicit copier give us a join point
|
||||
// (`stderrDone`) that fires before the failure-promotion
|
||||
// decision; see the matching comment in hermes.go for why the
|
||||
// io.MultiWriter form races with stopReason=end_turn under load.
|
||||
providerErr := newACPProviderErrorSniffer("kiro")
|
||||
cmd.Stderr = io.MultiWriter(newLogWriter(b.cfg.Logger, "[kiro:stderr] "), providerErr)
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("kiro stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start kiro: %w", err)
|
||||
}
|
||||
|
||||
stderrSink := io.MultiWriter(newLogWriter(b.cfg.Logger, "[kiro:stderr] "), providerErr)
|
||||
stderrDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stderrDone)
|
||||
_, _ = io.Copy(stderrSink, stderr)
|
||||
}()
|
||||
|
||||
b.cfg.Logger.Info("kiro acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
|
||||
|
||||
msgCh := make(chan Message, 256)
|
||||
@@ -292,6 +307,9 @@ func (b *kiroBackend) Execute(ctx context.Context, prompt string, opts ExecOptio
|
||||
cancel()
|
||||
|
||||
<-readerDone
|
||||
// Ensure the stderr copier has drained before consulting the
|
||||
// provider-error sniffer; see hermes.go for the failure mode.
|
||||
<-stderrDone
|
||||
|
||||
outputMu.Lock()
|
||||
finalOutput := output.String()
|
||||
|
||||
Reference in New Issue
Block a user