mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-21 22:56:21 +02:00
Compare commits
8 Commits
v0.2.29
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f83740a8 | ||
|
|
ce32a99a5c | ||
|
|
39e57b870f | ||
|
|
15c3886302 | ||
|
|
a6968c7485 | ||
|
|
00415de463 | ||
|
|
448e75ce53 | ||
|
|
e076bbafcc |
@@ -25,10 +25,6 @@ An autopilot has two execution modes. **Start with "create issue" mode.**
|
||||
- **Create issue mode** (`create_issue`) — default, **recommended**. Each trigger first creates an issue in the workspace (the title supports interpolation like `{{date}}`), then assigns the issue to the agent through the normal assignment flow. All work lands on the issue board with the same history, comments, and status as a manually assigned issue.
|
||||
- **Run-only mode** (`run_only`) — skips issue creation and enqueues a `task` directly. The run is invisible on the board — you can only see it in the autopilot's run history.
|
||||
|
||||
<Callout type="warning">
|
||||
**Run-only mode is currently unstable.** The CLI labels it "not yet supported end-to-end," and the dispatch path has known issues. New users should stick to create issue mode and wait for run-only mode to ship a stable release before switching.
|
||||
</Callout>
|
||||
|
||||
## Run it on a schedule
|
||||
|
||||
Every autopilot needs at least one `schedule` trigger. Cron uses the **standard 5-field format** (minute hour day month weekday), with **1-minute** minimum granularity (no seconds). Timezone is IANA-formatted (for example, `Asia/Shanghai`) and determines which timezone the cron expression is interpreted in.
|
||||
|
||||
@@ -25,10 +25,6 @@ Autopilot 有两种执行模式,**建议从"先建 issue 模式"开始**:
|
||||
- **先建 issue 模式**(`create_issue`)—— 默认,**推荐**。每次触发先在工作区里建一个 issue(标题支持 `{{date}}` 这样的插值),再按分配流程把 issue 派给智能体。所有工作都落在 issue 看板上,历史、评论、状态和手动分配的 issue 完全一致。
|
||||
- **直跑模式**(`run_only`)—— 不建 issue,直接入队一个 `task`。看板上看不到这一次运行——只能在 Autopilot 的运行历史里看到。
|
||||
|
||||
<Callout type="warning">
|
||||
**直跑模式当前不稳定**——目前在 CLI 里被标注为"not yet supported end-to-end",派发路径有已知问题。新用户只使用先建 issue 模式,等直跑模式 ship 稳定版再切。
|
||||
</Callout>
|
||||
|
||||
## 让它按时间跑
|
||||
|
||||
每个 Autopilot 至少要一个 `schedule` 触发器。Cron 是**标准 5 字段格式**(分 时 日 月 周),最小粒度 **1 分钟**(没有秒级)。时区用 IANA 格式(例如 `Asia/Shanghai`),决定 cron 表达式按哪个时区解读。
|
||||
|
||||
@@ -44,6 +44,17 @@ export function LandingHeader({
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2.5 sm:gap-3">
|
||||
<Link
|
||||
href="/changelog"
|
||||
className={cn(
|
||||
"hidden text-[13px] font-medium transition-colors sm:inline-flex",
|
||||
variant === "dark"
|
||||
? "text-white/72 hover:text-white"
|
||||
: "text-[#0a0d12]/64 hover:text-[#0a0d12]",
|
||||
)}
|
||||
>
|
||||
{t.header.changelog}
|
||||
</Link>
|
||||
<Link
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
import { useT } from "../../i18n";
|
||||
import { MermaidDiagram } from "../mermaid-diagram";
|
||||
|
||||
// Coalesces fast keystrokes before re-rendering the Mermaid preview.
|
||||
// `mermaid.initialize()` mutates a process-global config, so back-to-back
|
||||
// renders during typing can race a concurrent ReadonlyContent render
|
||||
// (e.g. a comment card) and clobber its theme variables. 200ms keeps the
|
||||
// "live preview" feel while making concurrent inits unlikely in practice.
|
||||
const MERMAID_PREVIEW_DEBOUNCE_MS = 200;
|
||||
|
||||
function useDebouncedValue<T>(value: T, delayMs: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => setDebounced(value), delayMs);
|
||||
return () => clearTimeout(id);
|
||||
}, [value, delayMs]);
|
||||
return debounced;
|
||||
}
|
||||
|
||||
function CodeBlockView({ node }: NodeViewProps) {
|
||||
const { t } = useT("editor");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = node.attrs.language || "";
|
||||
const isMermaid = language === "mermaid";
|
||||
const chart = node.textContent;
|
||||
const debouncedChart = useDebouncedValue(
|
||||
isMermaid ? chart : "",
|
||||
MERMAID_PREVIEW_DEBOUNCE_MS,
|
||||
);
|
||||
|
||||
const handleCopy = async () => {
|
||||
const text = node.textContent;
|
||||
@@ -21,6 +44,14 @@ function CodeBlockView({ node }: NodeViewProps) {
|
||||
|
||||
return (
|
||||
<NodeViewWrapper className="code-block-wrapper group/code relative my-2">
|
||||
{isMermaid && debouncedChart.trim() && (
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="mermaid-diagram-preview mb-1"
|
||||
>
|
||||
<MermaidDiagram chart={debouncedChart} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
contentEditable={false}
|
||||
className="code-block-header absolute top-0 right-0 z-10 flex items-center gap-1.5 px-2 py-1.5 opacity-0 transition-opacity group-hover/code:opacity-100"
|
||||
|
||||
294
packages/views/editor/mermaid-diagram.tsx
Normal file
294
packages/views/editor/mermaid-diagram.tsx
Normal file
@@ -0,0 +1,294 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* MermaidDiagram — sandboxed Mermaid diagram renderer.
|
||||
*
|
||||
* Extracted from `readonly-content.tsx` so the Tiptap CodeBlock NodeView
|
||||
* (`code-block-view.tsx`) can render the same component when a code block's
|
||||
* language is `mermaid`. Previously Mermaid only worked in read-only
|
||||
* markdown surfaces (comment cards) — issue descriptions, which always
|
||||
* stay in the Tiptap editor, never rendered diagrams.
|
||||
*
|
||||
* Theme variables are detected from the host's CSS custom properties so the
|
||||
* diagram colors match light/dark mode. The SVG is rendered inside a
|
||||
* sandboxed iframe to keep Mermaid's runtime stylesheet from leaking into
|
||||
* the page.
|
||||
*/
|
||||
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Maximize2 } from "lucide-react";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
type MermaidAPI = typeof import("mermaid").default;
|
||||
|
||||
type MermaidLayout = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let mermaidPromise: Promise<MermaidAPI> | null = null;
|
||||
|
||||
function getMermaid(): Promise<MermaidAPI> {
|
||||
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
|
||||
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
|
||||
const canvas = ownerDocument.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return fallback;
|
||||
|
||||
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
|
||||
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
|
||||
// 8-bit sRGB bytes that Mermaid can consume safely.
|
||||
context.fillStyle = "#000";
|
||||
context.fillStyle = color || fallback;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
return `rgb(${red}, ${green}, ${blue})`;
|
||||
}
|
||||
|
||||
function resolveCssColor(
|
||||
host: HTMLElement,
|
||||
variableName: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const probe = host.ownerDocument.createElement("span");
|
||||
probe.style.color = `var(${variableName})`;
|
||||
probe.style.display = "none";
|
||||
host.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
|
||||
}
|
||||
|
||||
function getMermaidThemeVariables(host: HTMLElement | null) {
|
||||
if (!host) {
|
||||
return {
|
||||
primaryColor: "rgb(245, 245, 245)",
|
||||
primaryBorderColor: "rgb(59, 130, 246)",
|
||||
primaryTextColor: "rgb(17, 24, 39)",
|
||||
lineColor: "rgb(107, 114, 128)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
|
||||
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
|
||||
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
|
||||
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
function getSandboxCssVariables(host: HTMLElement | null): string {
|
||||
const styles = host ? getComputedStyle(host) : null;
|
||||
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
|
||||
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getMermaidLayout(svg: string): MermaidLayout {
|
||||
const viewBoxMatch = svg.match(
|
||||
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
|
||||
);
|
||||
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
|
||||
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
|
||||
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
|
||||
|
||||
if (width && height && width > 0 && height > 0) {
|
||||
return {
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function useThemeVersion() {
|
||||
const [themeVersion, setThemeVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
|
||||
const observer = new MutationObserver(bumpThemeVersion);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", bumpThemeVersion);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener("change", bumpThemeVersion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return themeVersion;
|
||||
}
|
||||
|
||||
function MermaidLightbox({
|
||||
srcDoc,
|
||||
onClose,
|
||||
}: {
|
||||
srcDoc: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="mermaid-diagram-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid diagram fullscreen view"
|
||||
onClick={onClose}
|
||||
>
|
||||
<iframe
|
||||
className="mermaid-diagram-lightbox-frame"
|
||||
sandbox=""
|
||||
srcDoc={srcDoc}
|
||||
title="Mermaid diagram fullscreen"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const { t } = useT("editor");
|
||||
const reactId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramId = useMemo(
|
||||
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
|
||||
[reactId],
|
||||
);
|
||||
const themeVersion = useThemeVersion();
|
||||
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
|
||||
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<MermaidLayout>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function renderDiagram() {
|
||||
try {
|
||||
setError(null);
|
||||
setSandboxedDocument(null);
|
||||
setExpandedDocument(null);
|
||||
setLayout({});
|
||||
const mermaid = await getMermaid();
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: getMermaidThemeVariables(containerRef.current),
|
||||
});
|
||||
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
|
||||
if (!cancelled) {
|
||||
setLayout(getMermaidLayout(renderedSvg));
|
||||
setSandboxedDocument(
|
||||
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
setExpandedDocument(
|
||||
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderDiagram();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, diagramId, themeVersion]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
|
||||
<p>{t(($) => $.mermaid.render_error)}</p>
|
||||
<pre>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
|
||||
{sandboxedDocument ? (
|
||||
<>
|
||||
<iframe
|
||||
className="mermaid-diagram-frame"
|
||||
sandbox=""
|
||||
srcDoc={sandboxedDocument}
|
||||
style={{
|
||||
height: layout.height ? `${layout.height}px` : undefined,
|
||||
width: layout.width ? `${layout.width}px` : undefined,
|
||||
}}
|
||||
title="Mermaid diagram"
|
||||
/>
|
||||
<div className="mermaid-diagram-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
title="Open fullscreen"
|
||||
aria-label="Open Mermaid diagram fullscreen"
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{lightboxOpen && expandedDocument && (
|
||||
<MermaidLightbox
|
||||
srcDoc={expandedDocument}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,7 @@
|
||||
* - Rendering mentions with the same IssueMentionCard component and .mention class
|
||||
*/
|
||||
|
||||
import { isValidElement, memo, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { isValidElement, memo, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown, {
|
||||
defaultUrlTransform,
|
||||
type Components,
|
||||
@@ -42,6 +41,7 @@ import { ImageLightbox } from "./extensions/image-view";
|
||||
import { useLinkHover, LinkHoverCard } from "./link-hover-card";
|
||||
import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -51,140 +51,6 @@ import "./content-editor.css";
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
type MermaidAPI = typeof import("mermaid").default;
|
||||
|
||||
type MermaidLayout = {
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let mermaidPromise: Promise<MermaidAPI> | null = null;
|
||||
|
||||
function getMermaid(): Promise<MermaidAPI> {
|
||||
mermaidPromise ??= import("mermaid").then(({ default: mermaid }) => mermaid);
|
||||
|
||||
return mermaidPromise;
|
||||
}
|
||||
|
||||
function toLegacyColor(color: string, fallback: string, ownerDocument: Document): string {
|
||||
const canvas = ownerDocument.createElement("canvas");
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) return fallback;
|
||||
|
||||
// Mermaid's color parser only supports legacy color syntax. Canvas can parse
|
||||
// modern CSS Color 4 values such as oklch(), then getImageData gives concrete
|
||||
// 8-bit sRGB bytes that Mermaid can consume safely.
|
||||
context.fillStyle = "#000";
|
||||
context.fillStyle = color || fallback;
|
||||
context.fillRect(0, 0, 1, 1);
|
||||
const [red, green, blue] = context.getImageData(0, 0, 1, 1).data;
|
||||
|
||||
return `rgb(${red}, ${green}, ${blue})`;
|
||||
}
|
||||
|
||||
function resolveCssColor(
|
||||
host: HTMLElement,
|
||||
variableName: string,
|
||||
fallback: string,
|
||||
): string {
|
||||
const probe = host.ownerDocument.createElement("span");
|
||||
probe.style.color = `var(${variableName})`;
|
||||
probe.style.display = "none";
|
||||
host.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
|
||||
return toLegacyColor(color || fallback, fallback, host.ownerDocument);
|
||||
}
|
||||
|
||||
function getMermaidThemeVariables(host: HTMLElement | null) {
|
||||
if (!host) {
|
||||
return {
|
||||
primaryColor: "rgb(245, 245, 245)",
|
||||
primaryBorderColor: "rgb(59, 130, 246)",
|
||||
primaryTextColor: "rgb(17, 24, 39)",
|
||||
lineColor: "rgb(107, 114, 128)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
primaryColor: resolveCssColor(host, "--muted", "rgb(245, 245, 245)"),
|
||||
primaryBorderColor: resolveCssColor(host, "--primary", "rgb(59, 130, 246)"),
|
||||
primaryTextColor: resolveCssColor(host, "--foreground", "rgb(17, 24, 39)"),
|
||||
lineColor: resolveCssColor(host, "--muted-foreground", "rgb(107, 114, 128)"),
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
}
|
||||
|
||||
function getSandboxCssVariables(host: HTMLElement | null): string {
|
||||
const styles = host ? getComputedStyle(host) : null;
|
||||
return ["--muted", "--primary", "--foreground", "--muted-foreground"]
|
||||
.map((name) => `${name}: ${styles?.getPropertyValue(name).trim() || "initial"};`)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getMermaidLayout(svg: string): MermaidLayout {
|
||||
const viewBoxMatch = svg.match(
|
||||
/viewBox=["']\s*([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s+([\d.-]+)\s*["']/i,
|
||||
);
|
||||
const [, , , widthValue, heightValue] = viewBoxMatch ?? [];
|
||||
const width = widthValue ? Number.parseFloat(widthValue) : undefined;
|
||||
const height = heightValue ? Number.parseFloat(heightValue) : undefined;
|
||||
|
||||
if (width && height && width > 0 && height > 0) {
|
||||
return {
|
||||
width: Math.ceil(width),
|
||||
height: Math.ceil(height),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function buildSandboxedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } body { margin: 0; display: flex; justify-content: center; background: transparent; } svg { max-width: 100%; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function buildExpandedMermaidDocument(svg: string, host: HTMLElement | null): string {
|
||||
const cssVariables = getSandboxCssVariables(host);
|
||||
|
||||
return `<!doctype html><html><head><style>:root { ${cssVariables} } html, body { width: 100%; height: 100%; } body { margin: 0; display: flex; align-items: center; justify-content: center; background: transparent; } svg { max-width: 100%; max-height: 100%; width: auto; height: auto; }</style></head><body>${svg}</body></html>`;
|
||||
}
|
||||
|
||||
function useThemeVersion() {
|
||||
const [themeVersion, setThemeVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const bumpThemeVersion = () => setThemeVersion((version) => version + 1);
|
||||
const observer = new MutationObserver(bumpThemeVersion);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class", "style", "data-theme"],
|
||||
});
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mediaQuery.addEventListener("change", bumpThemeVersion);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mediaQuery.removeEventListener("change", bumpThemeVersion);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return themeVersion;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sanitization schema — extends GitHub defaults to allow file-card data attrs
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -294,145 +160,6 @@ function ReadonlyLink({
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidLightbox({
|
||||
srcDoc,
|
||||
onClose,
|
||||
}: {
|
||||
srcDoc: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [onClose]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="mermaid-diagram-lightbox"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid diagram fullscreen view"
|
||||
onClick={onClose}
|
||||
>
|
||||
<iframe
|
||||
className="mermaid-diagram-lightbox-frame"
|
||||
sandbox=""
|
||||
srcDoc={srcDoc}
|
||||
title="Mermaid diagram fullscreen"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function MermaidDiagram({ chart }: { chart: string }) {
|
||||
const { t } = useT("editor");
|
||||
const reactId = useId();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const diagramId = useMemo(
|
||||
() => `mermaid-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`,
|
||||
[reactId],
|
||||
);
|
||||
const themeVersion = useThemeVersion();
|
||||
const [sandboxedDocument, setSandboxedDocument] = useState<string | null>(null);
|
||||
const [expandedDocument, setExpandedDocument] = useState<string | null>(null);
|
||||
const [layout, setLayout] = useState<MermaidLayout>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function renderDiagram() {
|
||||
try {
|
||||
setError(null);
|
||||
setSandboxedDocument(null);
|
||||
setExpandedDocument(null);
|
||||
setLayout({});
|
||||
const mermaid = await getMermaid();
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: "strict",
|
||||
theme: "base",
|
||||
themeVariables: getMermaidThemeVariables(containerRef.current),
|
||||
});
|
||||
const { svg: renderedSvg } = await mermaid.render(diagramId, chart);
|
||||
if (!cancelled) {
|
||||
setLayout(getMermaidLayout(renderedSvg));
|
||||
setSandboxedDocument(
|
||||
buildSandboxedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
setExpandedDocument(
|
||||
buildExpandedMermaidDocument(renderedSvg, containerRef.current),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(err instanceof Error ? err.message : "Failed to render Mermaid diagram");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void renderDiagram();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chart, diagramId, themeVersion]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram mermaid-diagram-error">
|
||||
<p>{t(($) => $.mermaid.render_error)}</p>
|
||||
<pre>
|
||||
<code>{chart}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="mermaid-diagram" aria-label="Mermaid diagram">
|
||||
{sandboxedDocument ? (
|
||||
<>
|
||||
<iframe
|
||||
className="mermaid-diagram-frame"
|
||||
sandbox=""
|
||||
srcDoc={sandboxedDocument}
|
||||
style={{
|
||||
height: layout.height ? `${layout.height}px` : undefined,
|
||||
width: layout.width ? `${layout.width}px` : undefined,
|
||||
}}
|
||||
title="Mermaid diagram"
|
||||
/>
|
||||
<div className="mermaid-diagram-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
title="Open fullscreen"
|
||||
aria-label="Open Mermaid diagram fullscreen"
|
||||
>
|
||||
<Maximize2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{lightboxOpen && expandedDocument && (
|
||||
<MermaidLightbox
|
||||
srcDoc={expandedDocument}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="mermaid-diagram-loading">{t(($) => $.mermaid.rendering)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const components: Partial<Components> = {
|
||||
// Links — route mention:// to mention components, others show preview card
|
||||
a: ReadonlyLink,
|
||||
|
||||
@@ -19,8 +19,19 @@ import { useIssueSelectionStore } from "@multica/core/issues/stores/selection-st
|
||||
import { useBatchUpdateIssues, useBatchDeleteIssues } from "@multica/core/issues/mutations";
|
||||
import { StatusPicker, PriorityPicker, AssigneePicker } from "./pickers";
|
||||
import { useT } from "../../i18n";
|
||||
import { cn } from "@multica/ui/lib/utils";
|
||||
|
||||
export function BatchActionToolbar() {
|
||||
export function BatchActionToolbar({
|
||||
placement = "fixed-bottom",
|
||||
}: {
|
||||
/**
|
||||
* "fixed-bottom" — floats at the bottom of the viewport (default; used by
|
||||
* full-screen issue lists).
|
||||
* "inline" — renders in normal flow so callers can place it adjacent to
|
||||
* the selected rows (used inside scrollable sections like sub-issues).
|
||||
*/
|
||||
placement?: "fixed-bottom" | "inline";
|
||||
}) {
|
||||
const { t } = useT("issues");
|
||||
const selectedIds = useIssueSelectionStore((s) => s.selectedIds);
|
||||
const clear = useIssueSelectionStore((s) => s.clear);
|
||||
@@ -61,7 +72,14 @@ export function BatchActionToolbar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg">
|
||||
<div
|
||||
className={cn(
|
||||
"z-50 flex items-center gap-1 rounded-lg border bg-background px-2 py-1.5 shadow-lg",
|
||||
placement === "fixed-bottom"
|
||||
? "fixed bottom-6 left-1/2 -translate-x-1/2"
|
||||
: "mb-2 w-fit",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 pl-1 pr-2 border-r mr-1">
|
||||
<span className="text-sm font-medium">{t(($) => $.batch.selected, { count })}</span>
|
||||
<button
|
||||
|
||||
@@ -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>
|
||||
|
||||
132
packages/views/runtimes/utils.test.ts
Normal file
132
packages/views/runtimes/utils.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { collectUnmappedModels, estimateCost, isModelPriced } from "./utils";
|
||||
|
||||
const zeroUsage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
};
|
||||
|
||||
describe("estimateCost", () => {
|
||||
it("prices the canonical Anthropic Sonnet 4.6 SKU", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "claude-sonnet-4-6",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
});
|
||||
// 1M × $3 input + 1M × $15 output = $18.
|
||||
expect(cost).toBeCloseTo(18, 5);
|
||||
});
|
||||
|
||||
it("prices a Codex CLI session reporting gpt-5-codex", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5-codex",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
cache_read_tokens: 2_000_000,
|
||||
});
|
||||
// 1M × $1.25 + 1M × $10 + 2M × $0.125 = $11.50.
|
||||
expect(cost).toBeCloseTo(11.5, 5);
|
||||
});
|
||||
|
||||
it("strips dated snapshots before resolving (gpt-5-2025-08-07 → gpt-5)", () => {
|
||||
const cost = estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5-2025-08-07",
|
||||
input_tokens: 1_000_000,
|
||||
});
|
||||
expect(cost).toBeCloseTo(1.25, 5);
|
||||
});
|
||||
|
||||
it("prices each dotted Codex catalog SKU at its own tier, not gpt-5", () => {
|
||||
// Every dotted minor version is priced independently. The resolver does
|
||||
// exact-match-after-date-strip (no startsWith fallback), so each row
|
||||
// must exist on its own.
|
||||
expect(
|
||||
estimateCost({ ...zeroUsage, model: "gpt-5.5", input_tokens: 1_000_000 }),
|
||||
).toBeCloseTo(5, 5);
|
||||
expect(
|
||||
estimateCost({ ...zeroUsage, model: "gpt-5.4", output_tokens: 1_000_000 }),
|
||||
).toBeCloseTo(15, 5);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.4-mini",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
}),
|
||||
).toBeCloseTo(0.75 + 4.5, 5);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.3-codex",
|
||||
input_tokens: 1_000_000,
|
||||
output_tokens: 1_000_000,
|
||||
}),
|
||||
).toBeCloseTo(1.75 + 14, 5);
|
||||
});
|
||||
|
||||
it("flags catalog SKUs without a published price (gpt-5.5-mini) as unmapped", () => {
|
||||
// `gpt-5.5-mini` is in the Codex catalog but OpenAI hasn't published a
|
||||
// public rate. We refuse to absorb it into `gpt-5.5` — the diagnostic
|
||||
// surfaces it instead so the team knows to add an explicit row.
|
||||
expect(isModelPriced("gpt-5.5-mini")).toBe(false);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.5-mini",
|
||||
input_tokens: 1_000_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("flags hypothetical future variants as unmapped instead of inheriting a relative's price", () => {
|
||||
// No exact match → unmapped. Covers both dotted families (`gpt-5.99-codex`)
|
||||
// and unknown sub-variants (`gpt-5-foo`); both must miss rather than
|
||||
// silently inherit `gpt-5` pricing.
|
||||
expect(isModelPriced("gpt-5.99-codex")).toBe(false);
|
||||
expect(isModelPriced("gpt-5-foo")).toBe(false);
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "gpt-5.99-codex",
|
||||
input_tokens: 1_000_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("returns 0 for a genuinely unknown model so the UI can flag it", () => {
|
||||
expect(
|
||||
estimateCost({
|
||||
...zeroUsage,
|
||||
model: "totally-made-up-model",
|
||||
input_tokens: 1_000_000,
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelPriced", () => {
|
||||
it("recognises both Claude and Codex/GPT families", () => {
|
||||
expect(isModelPriced("claude-sonnet-4-6")).toBe(true);
|
||||
expect(isModelPriced("gpt-5-codex")).toBe(true);
|
||||
expect(isModelPriced("gpt-5-mini")).toBe(true);
|
||||
expect(isModelPriced("o3")).toBe(true);
|
||||
expect(isModelPriced("totally-made-up-model")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUnmappedModels", () => {
|
||||
it("only surfaces names that miss every pricing tier", () => {
|
||||
const rows = [
|
||||
{ ...zeroUsage, model: "claude-sonnet-4-6" },
|
||||
{ ...zeroUsage, model: "gpt-5-codex" },
|
||||
{ ...zeroUsage, model: "fictional-model-x" },
|
||||
];
|
||||
expect(collectUnmappedModels(rows)).toEqual(["fictional-model-x"]);
|
||||
});
|
||||
});
|
||||
@@ -114,21 +114,29 @@ export function formatTokens(n: number): string {
|
||||
// Cost estimation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Pricing per million tokens (USD). Sourced from
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing — keep in sync
|
||||
// when Anthropic releases new models or adjusts prices. cacheWrite reflects
|
||||
// the 5-minute cache TTL (1.25× input); the daemon reports
|
||||
// cache_creation_input_tokens without TTL metadata, so 5m is the safest /
|
||||
// cheapest assumption (matches the API default).
|
||||
// Pricing per million tokens (USD). Anthropic figures sourced from
|
||||
// https://platform.claude.com/docs/en/about-claude/pricing; OpenAI figures
|
||||
// from https://openai.com/api/pricing — keep in sync when providers release
|
||||
// new models or adjust prices.
|
||||
//
|
||||
// Iteration order matters: the resolver's startsWith() fallback walks this
|
||||
// object in insertion order, so MORE SPECIFIC keys (e.g. claude-sonnet-4-5)
|
||||
// must precede SHORTER prefixes (e.g. claude-sonnet-4) of the same family.
|
||||
// Anthropic's cacheWrite reflects the 5-minute cache TTL (1.25× input); the
|
||||
// daemon reports cache_creation_input_tokens without TTL metadata, so 5m is
|
||||
// the safest / cheapest assumption (matches the API default). OpenAI does
|
||||
// not bill cache writes separately (cached input is just discounted on
|
||||
// subsequent reads), so cacheWrite mirrors input there.
|
||||
//
|
||||
// The resolver matches exact keys after stripping a trailing date snapshot
|
||||
// (see `resolvePricing` below). It deliberately does NOT do startsWith
|
||||
// fallbacks: every catalog SKU needs its own row. That keeps unfamiliar
|
||||
// variants (`gpt-5.5-mini`, hypothetical `gpt-5.4-foo`) from silently
|
||||
// inheriting the price of a near-named relative; they surface in the
|
||||
// unmapped diagnostic instead. Mirror new entries in
|
||||
// `server/pkg/agent/models.go` so the catalog and pricing stay in sync.
|
||||
const MODEL_PRICING: Record<
|
||||
string,
|
||||
{ input: number; output: number; cacheRead: number; cacheWrite: number }
|
||||
> = {
|
||||
// -- Current generation (4.5+ — Opus dropped from 15/75 to 5/25 here) --
|
||||
// -- Anthropic: current generation (4.5+ — Opus dropped from 15/75 to 5/25 here) --
|
||||
"claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.10, cacheWrite: 1.25 },
|
||||
"claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
"claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
@@ -136,36 +144,55 @@ const MODEL_PRICING: Record<
|
||||
"claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||||
"claude-opus-4-7": { input: 5, output: 25, cacheRead: 0.50, cacheWrite: 6.25 },
|
||||
|
||||
// -- Pre-4.5 Opus (legacy, still served at original price tier) --
|
||||
// -- Anthropic: pre-4.5 Opus (legacy, still served at original price tier) --
|
||||
"claude-opus-4-1": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
||||
"claude-opus-4": { input: 15, output: 75, cacheRead: 1.50, cacheWrite: 18.75 },
|
||||
|
||||
// -- Sonnet 4.0 (deprecated; same price as the 4.x family) --
|
||||
// -- Anthropic: Sonnet 4.0 (deprecated; same price as the 4.x family) --
|
||||
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.30, cacheWrite: 3.75 },
|
||||
|
||||
// -- Older Haiku tier (defensive entry for the rare runtime still on it) --
|
||||
// -- Anthropic: older Haiku tier (defensive entry for the rare runtime still on it) --
|
||||
"claude-haiku-3-5": { input: 0.80, output: 4, cacheRead: 0.08, cacheWrite: 1.00 },
|
||||
|
||||
// -- OpenAI: dotted-minor Codex catalog SKUs. Each generation is priced
|
||||
// independently — no fallback to `gpt-5`. Entries track
|
||||
// `server/pkg/agent/models.go` (Codex provider list).
|
||||
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.50, cacheWrite: 5 },
|
||||
"gpt-5.4-mini": { input: 0.75, output: 4.50, cacheRead: 0.075, cacheWrite: 0.75 },
|
||||
"gpt-5.4": { input: 2.50, output: 15, cacheRead: 0.25, cacheWrite: 2.50 },
|
||||
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 1.75 },
|
||||
|
||||
// -- OpenAI: GPT-5 family (Codex CLI's default is gpt-5-codex; -codex/-mini/-nano variants priced per OpenAI tiers) --
|
||||
"gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
|
||||
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025, cacheWrite: 0.25 },
|
||||
"gpt-5-nano": { input: 0.05, output: 0.40, cacheRead: 0.005, cacheWrite: 0.05 },
|
||||
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 1.25 },
|
||||
|
||||
// -- OpenAI: o-series reasoning models --
|
||||
"o3-mini": { input: 1.10, output: 4.40, cacheRead: 0.55, cacheWrite: 1.10 },
|
||||
"o3": { input: 2, output: 8, cacheRead: 0.50, cacheWrite: 2 },
|
||||
"o4-mini": { input: 1.10, output: 4.40, cacheRead: 0.275, cacheWrite: 1.10 },
|
||||
|
||||
// -- OpenAI: GPT-4o family (legacy, kept for runtimes still configured against it) --
|
||||
"gpt-4o-mini": { input: 0.15, output: 0.60, cacheRead: 0.075, cacheWrite: 0.15 },
|
||||
"gpt-4o": { input: 2.50, output: 10, cacheRead: 1.25, cacheWrite: 2.50 },
|
||||
};
|
||||
|
||||
// Resolve a model string to its pricing tier. Two layers of fallback so the
|
||||
// daemon-reported model name doesn't have to match the keys exactly:
|
||||
// 1. Exact match.
|
||||
// 2. Strip a trailing date / "latest" tag (Claude Code typically reports
|
||||
// `claude-sonnet-4-5-20250929` — the date is volatile, the family is
|
||||
// what we price). Try exact match again on the stripped name.
|
||||
// 3. startsWith on either the raw or stripped name.
|
||||
// Anything that misses all three is genuinely unknown; we return undefined
|
||||
// so callers can distinguish "$0 spend" from "spent but model not priced".
|
||||
// Resolve a model string to its pricing tier. Exact match, with one
|
||||
// tolerance: providers ship dated snapshots (`claude-sonnet-4-5-20250929`,
|
||||
// `gpt-5-2025-08-07`) where the family is what we price and the date is
|
||||
// volatile, so we strip a trailing date / "latest" tag and try again.
|
||||
// Anything still unmapped after that is genuinely unknown; return
|
||||
// undefined so callers can distinguish "$0 spend" from "spent but model
|
||||
// not priced". No startsWith fallback: variants like `gpt-5.5-mini` must
|
||||
// have their own row to be priced (otherwise they'd inherit `gpt-5.5`).
|
||||
function resolvePricing(model: string) {
|
||||
if (!model) return undefined;
|
||||
if (MODEL_PRICING[model]) return MODEL_PRICING[model];
|
||||
|
||||
const stripped = model.replace(/-(20\d{6}|latest)$/, "");
|
||||
const stripped = model.replace(/-(20\d{2}-\d{2}-\d{2}|20\d{6}|latest)$/, "");
|
||||
if (stripped !== model && MODEL_PRICING[stripped]) return MODEL_PRICING[stripped];
|
||||
|
||||
for (const [key, p] of Object.entries(MODEL_PRICING)) {
|
||||
if (model.startsWith(key) || stripped.startsWith(key)) return p;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ func init() {
|
||||
autopilotCreateCmd.Flags().String("title", "", "Autopilot title (required)")
|
||||
autopilotCreateCmd.Flags().String("description", "", "Autopilot description (used as task prompt)")
|
||||
autopilotCreateCmd.Flags().String("agent", "", "Assignee agent (name or ID) — required")
|
||||
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue (required). run_only is not yet supported end-to-end.")
|
||||
autopilotCreateCmd.Flags().String("mode", "", "Execution mode: create_issue or run_only (required)")
|
||||
autopilotCreateCmd.Flags().String("priority", "none", "Priority for created issues (none, low, medium, high, urgent)")
|
||||
autopilotCreateCmd.Flags().String("project", "", "Project ID (optional)")
|
||||
autopilotCreateCmd.Flags().String("issue-title-template", "", "Template for issue titles (create_issue mode)")
|
||||
@@ -124,7 +124,7 @@ func init() {
|
||||
autopilotUpdateCmd.Flags().String("project", "", "New project ID (use empty string to clear)")
|
||||
autopilotUpdateCmd.Flags().String("priority", "", "New priority")
|
||||
autopilotUpdateCmd.Flags().String("status", "", "New status (active, paused)")
|
||||
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue)")
|
||||
autopilotUpdateCmd.Flags().String("mode", "", "New execution mode (create_issue or run_only)")
|
||||
autopilotUpdateCmd.Flags().String("issue-title-template", "", "New issue title template")
|
||||
autopilotUpdateCmd.Flags().String("output", "json", "Output format: table or json")
|
||||
|
||||
@@ -263,14 +263,10 @@ func runAutopilotCreate(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
mode, _ := cmd.Flags().GetString("mode")
|
||||
if mode == "" {
|
||||
return fmt.Errorf("--mode is required (create_issue)")
|
||||
return fmt.Errorf("--mode is required (create_issue or run_only)")
|
||||
}
|
||||
// run_only is a valid value server-side but the dispatch path is not wired
|
||||
// end-to-end (daemon /start resolves workspace only via issue/chat, and the
|
||||
// agent prompt expects an issue ID). Keep the CLI to create_issue until the
|
||||
// server path is fixed to avoid shipping a mode that returns 404 on start.
|
||||
if mode != "create_issue" {
|
||||
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
|
||||
if mode != "create_issue" && mode != "run_only" {
|
||||
return fmt.Errorf("--mode must be create_issue or run_only")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
@@ -370,8 +366,8 @@ func runAutopilotUpdate(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
if cmd.Flags().Changed("mode") {
|
||||
v, _ := cmd.Flags().GetString("mode")
|
||||
if v != "create_issue" {
|
||||
return fmt.Errorf("--mode must be create_issue (run_only is not yet supported end-to-end)")
|
||||
if v != "create_issue" && v != "run_only" {
|
||||
return fmt.Errorf("--mode must be create_issue or run_only")
|
||||
}
|
||||
body["execution_mode"] = v
|
||||
}
|
||||
|
||||
@@ -1708,7 +1708,8 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
}
|
||||
|
||||
// Inject runtime-specific config (meta skill) so the agent discovers .agent_context/.
|
||||
if err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx); err != nil {
|
||||
runtimeBrief, err := execenv.InjectRuntimeConfig(env.WorkDir, provider, taskCtx)
|
||||
if err != nil {
|
||||
d.logger.Warn("execenv: inject runtime config failed (non-fatal)", "error", err)
|
||||
}
|
||||
// NOTE: No cleanup — workdir is preserved for reuse by future tasks on
|
||||
@@ -1834,11 +1835,16 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, slot i
|
||||
// - hermes is driven through ACP and starts from a long-lived Hermes home;
|
||||
// deployments that cross a wrapper/container boundary can miss the
|
||||
// task-workdir AGENTS.md even when the prompt itself is delivered.
|
||||
// Pass Multica-defined identity/persona instructions inline so the backend
|
||||
// can prepend them to the turn payload instead of relying only on file
|
||||
// discovery.
|
||||
// - kiro and kimi are wrapped through their own CLIs whose cwd handling
|
||||
// is opaque enough that we can't trust the file-based path either.
|
||||
// Pass the full runtime brief inline (CLI catalog + workflow steps + agent
|
||||
// identity/persona + skills + project context) so the backend prepends the
|
||||
// same payload that file-based runtimes pick up from disk. Without this,
|
||||
// these providers silently miss the workflow section and never call
|
||||
// `multica issue status` / `multica issue comment add`, leaving issues
|
||||
// stuck in `todo`.
|
||||
if providerNeedsInlineSystemPrompt(provider) {
|
||||
execOpts.SystemPrompt = instructions
|
||||
execOpts.SystemPrompt = runtimeBrief
|
||||
}
|
||||
|
||||
result, tools, err := d.executeAndDrain(ctx, backend, prompt, execOpts, taskLog, task.ID)
|
||||
|
||||
@@ -202,7 +202,7 @@ func TestPrepareWithProjectResources(t *testing.T) {
|
||||
}
|
||||
|
||||
// CLAUDE.md should mention the project context block.
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
|
||||
@@ -250,7 +250,7 @@ func TestProjectReposReplaceWorkspaceReposInMetaSkill(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
@@ -302,7 +302,7 @@ func TestPrepareWithRepoContext(t *testing.T) {
|
||||
defer env.Cleanup(true)
|
||||
|
||||
// Inject runtime config (done separately in daemon, replicate here).
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -561,7 +561,7 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -585,6 +585,34 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for #2347: the runtime config injected into agent harnesses
|
||||
// must advertise both autopilot execution modes on create AND update, so an
|
||||
// agent acting as a CLI user is not confined to create_issue.
|
||||
func TestInjectRuntimeConfigAutopilotAdvertisesBothModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read CLAUDE.md: %v", err)
|
||||
}
|
||||
|
||||
s := string(content)
|
||||
for _, want := range []string{
|
||||
"multica autopilot create --title \"...\" --agent <name> --mode create_issue|run_only",
|
||||
"multica autopilot update <id>",
|
||||
"[--mode create_issue|run_only]",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("CLAUDE.md missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRuntimeConfigGemini(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
@@ -594,7 +622,7 @@ func TestInjectRuntimeConfigGemini(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Writing", Content: "Write clearly."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "gemini", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -632,7 +660,7 @@ func TestInjectRuntimeConfigCodex(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -656,7 +684,7 @@ func TestInjectRuntimeConfigNoSkills(t *testing.T) {
|
||||
|
||||
ctx := TaskContextForEnv{IssueID: "test-issue-id"}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -810,7 +838,7 @@ func TestInjectRuntimeConfigOpencode(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "opencode", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "opencode", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -846,7 +874,7 @@ func TestInjectRuntimeConfigKiro(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "kiro", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "kiro", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -890,7 +918,7 @@ func TestPrepareWithRepoContextOpencode(t *testing.T) {
|
||||
}
|
||||
defer env.Cleanup(true)
|
||||
|
||||
if err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(env.WorkDir, "opencode", taskCtx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -946,7 +974,7 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", tc.ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", tc.ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
@@ -992,7 +1020,7 @@ func TestInjectRuntimeConfigRequiresExplicitCommentPost(t *testing.T) {
|
||||
func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", TaskContextForEnv{IssueID: "issue-1"}); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
@@ -1018,7 +1046,7 @@ func TestInjectRuntimeConfigDirectsMultiLineWritesToStdin(t *testing.T) {
|
||||
func TestInjectRuntimeConfigCodexEmphasizesStdinForFormattedComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{
|
||||
if _, err := InjectRuntimeConfig(dir, "codex", TaskContextForEnv{
|
||||
IssueID: "issue-1",
|
||||
TriggerCommentID: "comment-1",
|
||||
}); err != nil {
|
||||
@@ -1056,7 +1084,7 @@ func TestInjectRuntimeConfigAutopilotRunOnlyNoIssueWorkflow(t *testing.T) {
|
||||
AutopilotSource: "manual",
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "codex", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md"))
|
||||
@@ -1092,7 +1120,7 @@ func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// Unknown provider should be a no-op.
|
||||
if err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "unknown", TaskContextForEnv{}); err != nil {
|
||||
t.Fatalf("expected no error for unknown provider, got: %v", err)
|
||||
}
|
||||
|
||||
@@ -1112,7 +1140,7 @@ func TestInjectRuntimeConfigHermes(t *testing.T) {
|
||||
AgentSkills: []SkillContextForEnv{{Name: "Coding", Content: "Write good code."}},
|
||||
}
|
||||
|
||||
if err := InjectRuntimeConfig(dir, "hermes", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "hermes", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -2093,7 +2121,7 @@ func TestInjectRuntimeConfigMentionLoopHardening(t *testing.T) {
|
||||
readClaudeMD := func(t *testing.T, ctx TaskContextForEnv) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(dir, "CLAUDE.md"))
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestInjectRuntimeConfigCommentTriggerUsesHelper(t *testing.T) {
|
||||
IssueID: issueID,
|
||||
TriggerCommentID: triggerID,
|
||||
}
|
||||
if err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
if _, err := InjectRuntimeConfig(dir, "claude", ctx); err != nil {
|
||||
t.Fatalf("InjectRuntimeConfig failed: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -56,19 +56,19 @@ func formatProjectResource(r ProjectResourceForEnv) string {
|
||||
// For Cursor: writes {workDir}/AGENTS.md (skills discovered natively from .cursor/skills/)
|
||||
// For Kimi: writes {workDir}/AGENTS.md (Kimi Code CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
||||
// For Kiro: writes {workDir}/AGENTS.md (Kiro CLI reads AGENTS.md natively; skills auto-discovered from project skills dirs)
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) error {
|
||||
func InjectRuntimeConfig(workDir, provider string, ctx TaskContextForEnv) (string, error) {
|
||||
content := buildMetaSkillContent(provider, ctx)
|
||||
|
||||
switch provider {
|
||||
case "claude":
|
||||
return os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
return content, os.WriteFile(filepath.Join(workDir, "CLAUDE.md"), []byte(content), 0o644)
|
||||
case "codex", "copilot", "opencode", "openclaw", "hermes", "pi", "cursor", "kimi", "kiro":
|
||||
return os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
return content, os.WriteFile(filepath.Join(workDir, "AGENTS.md"), []byte(content), 0o644)
|
||||
case "gemini":
|
||||
return os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
return content, os.WriteFile(filepath.Join(workDir, "GEMINI.md"), []byte(content), 0o644)
|
||||
default:
|
||||
// Unknown provider — skip config injection, prompt-only mode.
|
||||
return nil
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +146,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
|
||||
b.WriteString(" - The same rule applies to `--description` on `multica issue create` and `multica issue update` — use `--description-stdin` and pipe a HEREDOC for any multi-line description; the inline `--description \"...\"` form is for short single-line text only.\n")
|
||||
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
|
||||
b.WriteString("- `multica label create --name \"...\" --color \"#hex\"` — Define a new workspace label (use this only when the label you need does not exist yet; reuse existing labels via `multica label list` first)\n")
|
||||
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue [--description \"...\"]` — Create an autopilot\n")
|
||||
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused]` — Update an autopilot\n")
|
||||
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue|run_only [--description \"...\"]` — Create an autopilot\n")
|
||||
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused] [--mode create_issue|run_only]` — Update an autopilot\n")
|
||||
b.WriteString("- `multica autopilot trigger <id>` — Manually trigger an autopilot to run once\n")
|
||||
b.WriteString("- `multica autopilot delete <id>` — Delete an autopilot\n\n")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user