Compare commits

..

12 Commits

Author SHA1 Message Date
Jiayuan Zhang
f3af104bfe feat(notifications): wire web browser desktop notifications
The Settings → System Notifications toggle persisted but never fired
on the web app — `inbox:new` only invoked `desktopAPI.showNotification`,
which is undefined outside Electron. Web users saw the toggle, enabled
it, and got nothing (GH #2339).

- Extract a shared `showSystemNotification` helper in `core/notifications`
  that bridges the Electron preload IPC AND the browser Notifications API.
  On web, the click handler focuses the tab and navigates to the inbox
  with the issue selector.
- Settings tab now requests browser permission when the toggle flips on,
  shows actionable hints when permission is denied / not yet granted /
  unsupported, and offers a "Send test notification" button so users can
  verify their setup without waiting for an inbox event.
- Refresh permission state on visibilitychange so the UI reflects changes
  the user makes in browser site settings.
- Tests cover desktop bridge, granted/denied/missing permission, and
  click-routing.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:38:27 +08:00
Bohan Jiang
ce32a99a5c feat(web): add Changelog link to landing header (#2364)
Surfaces the changelog page from the marketing site's top navigation,
sitting alongside GitHub and the auth CTA. Hidden below the `sm`
breakpoint so the mobile header stays compact.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:20:16 +08:00
Bohan Jiang
39e57b870f fix(cli): allow --mode run_only on autopilot create/update (#2360)
* fix(cli): allow --mode run_only on autopilot create/update

The autopilot run_only dispatch path is wired end-to-end (handler accepts
the mode, AutopilotService.dispatchRunOnly enqueues a task with
AutopilotRunID, daemon resolves workspace via autopilot_run -> autopilot
in ClaimTaskByRuntime and TaskService.ResolveTaskWorkspaceID). The CLI
guard was added before those fixes landed and never removed.

Drop the CLI rejection on both create and update so callers can pick the
same modes the API and UI already support, and remove the stale "unstable"
callout from the autopilots docs.

Closes multica-ai/multica#2347

Co-authored-by: multica-agent <github@multica.ai>

* fix(daemon): advertise autopilot run_only in agent runtime instructions

The runtime config injected into AGENTS.md / CLAUDE.md only listed
`--mode create_issue` for autopilot create and didn't expose `--mode` on
update at all. So even after the CLI guard was lifted, agents reading
their harness instructions would still believe create_issue was the only
choice — undermining the "agents operate the same surface as humans"
intent.

Update both lines to advertise create_issue|run_only on create and on
update, and add an InjectRuntimeConfig assertion so the runtime prompt
can't drift away from the CLI surface again.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:12:34 +08:00
Bohan Jiang
15c3886302 docs(daemon): refresh stale comment for inline system prompt path (#2362)
The inline path now carries the full runtime brief (CLI catalog,
workflow steps, persona, skills, project context) rather than just
identity/persona instructions, after #2353 / #2355. The pre-existing
comment still described it as "identity/persona instructions inline",
which would mislead future maintainers about why the inline payload is
load-bearing.

Also call out kiro/kimi alongside openclaw/hermes since they were added
to providerNeedsInlineSystemPrompt in #2328, and document the concrete
failure mode (issues stuck in todo) so the rationale is searchable.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:00:08 +08:00
Kagura
a6968c7485 fix(daemon): inline runtime brief for providers that need system prompt (#2355)
InjectRuntimeConfig writes the full meta skill content (CLI catalog,
workflow instructions, project context, skills) to workdir/AGENTS.md,
but providers like OpenClaw, Hermes, Kiro, and Kimi read bootstrap
files from their own agent workspace — not the task workdir. The
inline system prompt path (providerNeedsInlineSystemPrompt) only
passed the agent persona instructions, so these providers never
received the runtime brief.

Have InjectRuntimeConfig return the rendered content so the daemon can
both write it to disk (for file-reading providers) and pass it inline
(for workspace-isolated providers). This avoids double-rendering and
keeps the file and inline payloads identical.

Fixes #2353
2026-05-10 13:57:05 +08:00
jiawen134
00415de463 feat(editor): render mermaid diagrams inside issue descriptions (#2297)
* feat(editor): render mermaid diagrams inside issue descriptions

Issue descriptions are rendered through the Tiptap-based ContentEditor
(not ReadonlyContent), so the mermaid handler that PR #1888 added to
ReadonlyContent never reached them. Comments worked because comment-card
toggles between ContentEditor (edit mode) and ReadonlyContent (display
mode); issue descriptions stay in ContentEditor permanently.

This patch teaches the Tiptap CodeBlock NodeView to render a Mermaid
preview when the language is `mermaid`, giving issue descriptions a
split view: live diagram on top, editable source below. Theme variables
(light/dark), the sandboxed iframe, the lightbox and error fallback all
come from the existing implementation — only the location moved.

Changes:
- Extract MermaidDiagram + helpers (theme detection, sandbox iframe,
  lightbox, useThemeVersion) from `readonly-content.tsx` into a new
  `editor/mermaid-diagram.tsx`. ReadonlyContent (~200 lines lighter)
  imports the same component, so comment-card / inbox rendering is
  unchanged byte-for-byte.
- Update `code-block-view.tsx` (the Tiptap CodeBlock NodeView) to render
  `<MermaidDiagram>` above the editable source whenever the block's
  language is `mermaid` and the source is non-empty.

Tested:
- pnpm --filter @multica/views typecheck — clean
- pnpm --filter @multica/views test — 327 tests pass (43 files)
- Manually verified a mermaid block in an issue description renders as
  an SVG flowchart while staying editable underneath.

Closes #2079

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* perf(editor): debounce mermaid preview re-renders during edits

Addresses review feedback on #2297. Previously every keystroke in a
Mermaid code block triggered `mermaid.initialize() + render()` on the
CodeBlockView preview. Because `mermaid.initialize()` mutates a
process-global config, those bursts could race a concurrent
ReadonlyContent render (e.g. a comment card) and clobber its theme
variables.

200ms is short enough that the preview still feels live during typing
but long enough to make concurrent inits unlikely in practice. The
ReadonlyContent path is unchanged: chart there is the saved markdown
and never changes after mount, so the race only existed on the new
edit-time path this PR introduced.

A small `useDebouncedValue` hook local to the file gates `chart` so
that it only flows into MermaidDiagram after 200ms of stable input.
When the language is non-Mermaid the hook short-circuits to "", so
non-Mermaid blocks pay no extra cost.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:11:20 +08:00
Jiayuan Zhang
448e75ce53 feat(issues): inline status & assignee pickers + batch select on sub-issue rows
- Sub-issue rows on the parent issue's detail page now expose inline StatusPicker and AssigneePicker, optimistically syncing the children cache via a useUpdateIssue parent-id fallback that scans loaded children caches.
- Hover-revealed checkbox + indeterminate select-all in the section header drive batch selection through the existing useIssueSelectionStore; the BatchActionToolbar gains a "placement" prop and renders inline directly under the sub-issues header so the action is right next to the rows.
- useBatchUpdateIssues / useBatchDeleteIssues now mirror their optimistic patches into every loaded children cache (with rollback) and invalidate children + childProgress on settle.
- SubIssueRow restructure: AppLink wraps only the identifier + title, so the checkbox / picker areas no longer accidentally fire navigation.

Refs MUL-2005.
2026-05-09 17:52:22 +02:00
Bohan Jiang
e076bbafcc fix(runtimes): price OpenAI Codex / GPT models so cost stops showing $0 (#2334)
* fix(runtimes): price OpenAI Codex / GPT models so cost stops showing $0

The runtime detail / usage charts compute cost client-side from
MODEL_PRICING, but the table only had Claude entries. Codex CLI
sessions report models like gpt-5-codex / gpt-5, so estimateCost()
returned 0 for every Codex runtime — the dashboard read $0 even on
runtimes with billions of tokens consumed.

Add pricing rows for the GPT-5 family (incl. -codex/-mini/-nano), the
o-series reasoning models, and GPT-4o, ordered so the startsWith()
fallback resolves the more-specific variants first. Cover the new
entries with a small unit test for utils.ts.

Co-authored-by: multica-agent <github@multica.ai>

* fix(runtimes): require explicit price rows for catalog SKUs (no startsWith fallback)

Per review: the previous startsWith() fallback let `gpt-5.5*` / `gpt-5.4*`
inherit the lower-tier `gpt-5` price. Address by:

- Add explicit rows for every dotted Codex catalog SKU listed in
  server/pkg/agent/models.go: gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex.
- Drop the startsWith fallback in resolvePricing entirely. Anything not
  exactly matching a row (after date-snapshot stripping) is now reported
  as unmapped — the diagnostic surfaces it rather than silently absorbing
  it into a near-named relative.
- Extend the date-strip regex to also handle `2025-08-07`-style dashes
  (OpenAI snapshot format) in addition to the `20250929` Anthropic format.
- Tests cover dotted SKUs at their own tier, gpt-5-2025-08-07 stripping,
  and explicitly assert that gpt-5.5-mini (catalog SKU without a published
  OpenAI price) is unmapped instead of borrowing gpt-5.5's row.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 19:21:55 +08:00
Bohan Jiang
1d4595ff8f docs(changelog): add 0.2.29 release notes for 2026-05-09 (#2335)
* docs(changelog): add 0.2.29 release notes for 2026-05-09

Summarizes the 31 PRs landed since v0.2.28 in EN and ZH changelog
data, organized into features, improvements, and fixes.

Co-authored-by: multica-agent <github@multica.ai>

* docs(changelog): remove PostHog feature note

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Eve <eve@multica-ai.local>
2026-05-09 17:51:28 +08:00
Bohan Jiang
b73a301bf9 fix(agent): drain stderr before deciding ACP failure promotion (#2333)
`hermes`, `kimi`, and `kiro` all wired stderr through
`cmd.Stderr = io.MultiWriter(logWriter, providerErrSniffer)`.
The OS-pipe → MultiWriter copy goroutine that exec spawns for
that form is only joined by `cmd.Wait()`, which the lifecycle
goroutine fires in deferred cleanup — *after*
`promoteACPResultOnProviderError` already consulted the sniffer.
When stopReason=end_turn (success) raced ahead of the stderr
drain, the sniffer's `lines` slice was empty, the helper fell
through to the synthetic agent-text fallback ("hermes provider
error: API call failed after 3 retries"), and the actionable
upstream signal (HTTP 429 / usage limit) was lost.

This was visible as a flaky
`TestHermesBackendPromotesProviderErrorWithNonEmptyOutput` in CI
under high parallelism — a real prod bug, not a test issue: live
runs hit the same race when an upstream LLM returns 429 and
hermes' synthetic agent turn beats the stderr drain to the
parent.

Replace the MultiWriter wiring with `cmd.StderrPipe()` + an
explicit copier goroutine that signals on `stderrDone`. The
lifecycle goroutine already awaits `<-readerDone` for stdout;
add `<-stderrDone` next to it before `promoteACPResultOnProviderError`
runs. The deferred `cmd.Wait()` ordering is unchanged — it just
becomes a cheap reap by the time it fires.

Verified: `go test ./pkg/agent/ -run "TestHermes|TestKimi|TestKiro"
-count=10 -race`, then full package `-count=3 -race`, all green.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 17:34:25 +08:00
Bohan Jiang
807201086c perf(issues): stop full timeline re-render on every WS event (#2329)
* perf(issues): stop full timeline re-render on every WS event (MUL-1941)

Two compounding causes made every Comment/reply WS event re-render every
sibling thread on the issue detail page — visible during AI streaming as
a flash across all 10 nested replies under a parent and as the green
reply-input losing its draft.

1) `useCreateComment.onSettled` invalidated the timeline query, forcing a
   full `GET /timeline` refetch on every comment submit. The response
   replaced every entry's reference even when the content was unchanged,
   poisoning every downstream React.memo. The `comment:created` WS
   broadcast already keeps the cache fresh and `useWSReconnect` invalidates
   on disconnect, so the redundant refetch had no upside. Drop it.

2) The `timelineView` useMemo passed the full `repliesByParent: Map` to
   every CommentCard. Each WS event rebuilt the Map (new ref), so React.memo
   on CommentCard fell back to a re-render for *every* card, not just the
   one whose thread changed. Replace the Map prop with a per-thread
   `replies: TimelineEntry[]` slice, precomputed once via
   `collectThreadReplies` and stabilized against the prior render — when a
   thread's flat list is shallow-equal to last time, reuse the previous
   array reference so unrelated cards keep their memo.

ResolvedThreadBar gets the same `replies` prop, so the collapsed count +
author list still match the expanded view without re-walking the graph.

Verified: pnpm typecheck + pnpm test for @multica/views and @multica/core
(334 + 214 tests, all passing).

Co-authored-by: multica-agent <github@multica.ai>

* fix(realtime): mark timeline stale without refetching active queries (MUL-1941)

Per GPT-Boy's review on PR #2329: dropping `useCreateComment.onSettled`'s
invalidate wasn't enough. The global `useRealtimeSync` runs in WSProvider
for the lifetime of the app and re-invalidates the timeline on every
`comment:created` / `comment:updated` / `comment:deleted` /
`comment:resolved` / `comment:unresolved` / `activity:created` /
`reaction:added` / `reaction:removed` event. With `staleTime: Infinity` on
the QueryClient default, the active timeline query refetches on every
invalidate — replacing every entry's reference and busting the per-thread
memoization the prior commit just put in place.

Switch the global handler's `invalidateQueries` to `refetchType: "none"`.
Active observers now stay fresh via the granular `setQueryData` handlers
in `useIssueTimeline`; inactive issues' caches are still marked stale, so
when IssueDetail mounts later, `refetchOnMount` triggers a fresh fetch
the same way it did before.

`comment:resolved` / `comment:unresolved` previously had no granular
handler — only the global invalidate kept the cache in sync. Add
useWSEvent handlers in `useIssueTimeline` that replace the matching
entry via `commentToTimelineEntry`, and extend that helper to carry the
resolved_at / resolved_by_type / resolved_by_id fields so resolved state
survives the round-trip (it was silently dropped on every
`comment:updated` too — fixed as a side effect).

Tests: 3 new cases covering resolved / unresolved / cross-issue isolation
in the timeline hook. All 337 + 214 unit tests + full monorepo typecheck
pass.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 17:20:08 +08:00
Bohan Jiang
d713b57072 fix(daemon): add kiro and kimi to providerNeedsInlineSystemPrompt whitelist (#2328)
Kiro and Kimi share Hermes' ACP architecture and already accept
SystemPrompt prepended in front of the user prompt (kiro.go:244-247,
kimi.go:256-257). Without daemon-side opt-in, ExecOptions.SystemPrompt
is never set, so per-task agent identity instructions are lost in
deployments that rely on inline injection (e.g. K3 Lens-style
daemon → wrapper → docker compose exec acp).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 16:54:27 +08:00
34 changed files with 1719 additions and 456 deletions

View File

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

View File

@@ -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 表达式按哪个时区解读。

View File

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

View File

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

View File

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

View File

@@ -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 前后端共享同一份 JSONCI 守住漂移",
"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",

View File

@@ -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).
});
}

View File

@@ -0,0 +1,7 @@
export {
showSystemNotification,
detectWebNotificationSupport,
requestWebNotificationPermission,
isDesktopApp,
} from "./system-notification";
export type { WebNotificationSupport, SystemNotificationPayload } from "./system-notification";

View 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);
});
});

View 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());
}

View File

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

View File

@@ -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) => {

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]) {

View File

@@ -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();
});
});

View File

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

View File

@@ -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": {

View File

@@ -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": {

View 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"]);
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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