Compare commits

..

8 Commits

Author SHA1 Message Date
Jiayuan Zhang
f4f83740a8 feat(web): surface latest release in landing hero
Add a "What's new" badge above the hero headline that pulls the most
recent entry from the existing changelog dictionary and links straight
to its anchor on /changelog. Visitors now see at a glance that the
product is shipping every few days, addressing the GitHub feedback that
the changelog was buried.

Refs multica-ai/multica#2361

Co-authored-by: multica-agent <github@multica.ai>
2026-05-10 14:33:41 +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
20 changed files with 894 additions and 405 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

@@ -2,7 +2,7 @@
import Image from "next/image";
import Link from "next/link";
import { Download } from "lucide-react";
import { ArrowRight, Download } from "lucide-react";
import { useAuthStore } from "@multica/core/auth";
import { captureDownloadIntent } from "@multica/core/analytics";
import { useLocale } from "../i18n";
@@ -18,6 +18,7 @@ import {
export function LandingHero() {
const { t } = useLocale();
const user = useAuthStore((s) => s.user);
const latestRelease = t.changelog.entries[0];
return (
<div className="relative min-h-full overflow-hidden bg-[#05070b] text-white">
@@ -29,6 +30,13 @@ export function LandingHero() {
className="mx-auto max-w-[1320px] px-4 pb-16 pt-28 sm:px-6 sm:pt-32 lg:px-8 lg:pb-24 lg:pt-36"
>
<div className="mx-auto max-w-[1120px] text-center">
{latestRelease && (
<WhatsNewBadge
label={t.hero.whatsNewLabel}
version={latestRelease.version}
title={latestRelease.title}
/>
)}
<h1 className="font-[family-name:var(--font-serif)] text-[3.65rem] leading-[0.93] tracking-[-0.038em] text-white drop-shadow-[0_10px_34px_rgba(0,0,0,0.32)] sm:text-[4.85rem] lg:text-[6.4rem]">
{t.hero.headlineLine1}
<br />
@@ -91,6 +99,39 @@ export function LandingHero() {
);
}
function WhatsNewBadge({
label,
version,
title,
}: {
label: string;
version: string;
title: string;
}) {
const anchor = `release-${version.replace(/\./g, "-")}`;
return (
<div className="mb-7 flex justify-center">
<Link
href={`/changelog#${anchor}`}
className="group inline-flex max-w-full items-center gap-2 rounded-full border border-white/18 bg-white/8 px-3 py-1.5 text-[12px] font-medium text-white/85 backdrop-blur-sm transition-colors hover:border-white/32 hover:bg-white/12 sm:gap-2.5 sm:text-[13px]"
>
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/12 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-[0.08em] text-white">
{label}
</span>
<span className="tabular-nums text-white/70">v{version}</span>
<span aria-hidden className="hidden h-3 w-px bg-white/18 sm:inline-block" />
<span className="hidden truncate sm:inline-block sm:max-w-[420px]">
{title}
</span>
<ArrowRight
aria-hidden
className="size-3.5 shrink-0 text-white/60 transition-transform group-hover:translate-x-0.5 group-hover:text-white"
/>
</Link>
</div>
);
}
function LandingBackdrop() {
return (
<div className="pointer-events-none absolute inset-0">

View File

@@ -7,6 +7,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
github: "GitHub",
login: "Log in",
dashboard: "Dashboard",
changelog: "Changelog",
},
hero: {
@@ -18,6 +19,7 @@ export function createEnDict(allowSignup: boolean): LandingDict {
downloadDesktop: "Download Desktop",
worksWith: "Works with",
imageAlt: "Multica board view \u2014 issues managed by humans and agents",
whatsNewLabel: "What\u2019s new",
},
features: {

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;
@@ -29,6 +29,7 @@ export type LandingDict = {
downloadDesktop: string;
worksWith: string;
imageAlt: string;
whatsNewLabel: string;
};
features: {
teammates: FeatureSection;

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: {
@@ -18,6 +19,7 @@ export function createZhDict(allowSignup: boolean): LandingDict {
downloadDesktop: "下载桌面端",
worksWith: "支持",
imageAlt: "Multica \u770b\u677f\u89c6\u56fe\u2014\u2014\u4eba\u7c7b\u548c 智能体 \u534f\u540c\u7ba1\u7406\u4efb\u52a1",
whatsNewLabel: "\u6700\u65b0\u66f4\u65b0",
},
features: {

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

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

@@ -37,8 +37,10 @@ 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";
@@ -56,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";
@@ -187,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
// ---------------------------------------------------------------------------
@@ -449,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)
@@ -874,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
@@ -896,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={
@@ -913,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>

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

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