Compare commits

..

2 Commits

Author SHA1 Message Date
Bohan Jiang
78d668a2f2 fix(agent): clarify Antigravity daemon mode
Fixes MUL-3726
2026-06-28 13:13:36 +08:00
Bohan Jiang
e2103a240d fix(server): emit issue:updated when failed-task handler resets stuck issue (#4662)
HandleFailedTasks resets a stuck in_progress issue back to todo via a direct UpdateIssueStatus, bypassing the HTTP handler that emits issue:updated. Without that event the frontend realtime reconcile never runs, so status-filtered board columns/lists stay stale until the next write. Publish issue:updated (status_changed + prev_status) after the reset. Fixes #4648 (MUL-3782).
2026-06-28 13:01:00 +08:00
17 changed files with 261 additions and 619 deletions

View File

@@ -159,14 +159,14 @@ Agentic coding CLI using the ACP protocol over stdio (shares the transport with
### Antigravity (Google)
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
Google's Antigravity CLI (`agy`). Pairs with Google's Antigravity service and runs Gemini-backed models. Multica launches it with `agy -p`, the daemon-compatible non-interactive mode; current Antigravity CLI releases can execute tools from that mode, while `agy -i` requires an attached TTY. Session resumption works through `--conversation <id>`, captured by the daemon from the CLI log file. Model selection is managed inside the Antigravity CLI itself — Multica disables the per-agent model picker for this provider. Skills are written to `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity docs](https://antigravity.google/docs/gcli-migration)).
| | |
|---|---|
| Daemon looks for | `agy` |
| Install | Follow the official guide at [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview). The CLI ships pre-built — run `agy install` once to wire up PATH and shell aliases. |
| Authentication | Run `agy` once interactively and complete the Google account login, or sign in via the Antigravity desktop app — the CLI reuses the keyring entry the GUI writes. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text. |
| Notes | The CLI emits plain assistant text on stdout, not a structured event stream; intermediate "I will run X" lines and the final reply are both relayed to Multica as text, and per-tool telemetry is not available today. |
## After installing

View File

@@ -159,14 +159,14 @@ ACP 协议 agent和 Kimi 共享传输层。会话续接可用MCP 配置
### AntigravityGoogle
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
Google 的 Antigravity CLI`agy`)。搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动它,这是适合 daemon 后台任务的一次性非交互模式;当前 Antigravity CLI 在这个模式下仍可执行工具,而 `agy -i` 需要连接 TTY不适合 daemon 驱动。会话续接通过 `--conversation <id>` 工作——守护进程从 CLI 的日志文件里抓取 conversation UUID。模型选择保存在 Antigravity CLI 自己的设置里——Multica 里这款工具的「模型」选择项被禁用。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 文档](https://antigravity.google/docs/gcli-migration))。
| | |
|---|---|
| 守护进程扫描 | `agy` |
| 安装 | 看官方指引 [antigravity.google/docs/cli-overview](https://antigravity.google/docs/cli-overview)。CLI 是预编译的,跑一次 `agy install` 配好 PATH 和 shell 别名即可。 |
| 认证 | 交互式跑一次 `agy` 走 Google 账号登录流程;或者通过 Antigravity 桌面端登录——CLI 会复用 GUI 写入 keyring 的凭据。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 思考过程和最终回复都会作为 text 消息送回 Multica。 |
| 备注 | CLI 的 stdout 是纯文本,不是结构化事件流;中间的 "I will run X" 过程和最终回复都会作为 text 消息送回 Multica,目前无法展示 Antigravity 的逐工具 telemetry。 |
## 装完之后

View File

@@ -31,7 +31,7 @@ For guidance on picking a tool when creating an agent, see [Creating and configu
### Antigravity
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file because stdout is plain text rather than a structured event stream. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
From Google. CLI binary name is `agy`. Pairs with Google's Antigravity service and ships with a Gemini-backed default model. Multica launches Antigravity with `agy -p` because that is the daemon-compatible non-interactive mode; `agy -i` needs an attached TTY and is not suitable for background task execution. Current Antigravity CLI releases can still execute tools from this mode, but stdout is plain assistant text rather than a structured event stream, so Multica relays the transcript as text and cannot show per-tool telemetry for Antigravity today. **Session resumption works** via `--conversation <id>`; the daemon captures the conversation UUID from the CLI's log file. **Model selection works** via the `--model` flag (added in agy 1.0.6): the daemon enumerates the catalog with `agy models` and ships the chosen value verbatim. Note these are human display strings such as `Claude Opus 4.6 (Thinking)`, not `provider/model` slugs — and agy silently no-ops on a value it doesn't recognise, so prefer picking from the discovered list over typing a custom one. Skills land in `.agents/skills/` (the CLI inherits Gemini CLI's workspace skill layout — see [Antigravity migration docs](https://antigravity.google/docs/gcli-migration)).
### Claude Code

View File

@@ -31,7 +31,7 @@ Multica 内置支持 **13 款 AI 编程工具**。它们都实现了同一套接
### Antigravity
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。**会话恢复真用**——通过 `--conversation <id>`;因为 stdout 是纯文本而非结构化事件流,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑所以优先从发现列表里挑选不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
Google 出品。CLI 二进制名为 `agy`,搭配 Google Antigravity 服务,默认走 Gemini 系列模型。Multica 使用 `agy -p` 启动 Antigravity因为这是适合 daemon 后台任务的一次性非交互模式;`agy -i` 需要连接 TTY不适合后台执行。当前 Antigravity CLI 在 `agy -p` 下仍可执行工具,但 stdout 是纯文本而非结构化事件流,所以 Multica 会把 transcript 作为 text 转发,暂时无法展示逐工具 telemetry。**会话恢复真用**——通过 `--conversation <id>`,守护进程从 CLI 的日志文件里抓取 conversation UUID。**模型选择真用**——通过 `--model` flagagy 1.0.6 新增):守护进程用 `agy models` 枚举可选项,并把选中的值原样传入。注意这些是 `Claude Opus 4.6 (Thinking)` 这样的人类可读显示名,而非 `provider/model` slug而且 agy 遇到无法识别的值会静默空跑所以优先从发现列表里挑选不要手填。Skill 文件写入 `.agents/skills/`CLI 沿用 Gemini CLI 的 workspace 布局——见 [Antigravity 迁移文档](https://antigravity.google/docs/gcli-migration))。
### Claude Code

View File

@@ -3,7 +3,7 @@ import { api } from "../api";
import { useWorkspaceId } from "../hooks";
import { chatKeys } from "./queries";
import { createLogger } from "../logger";
import type { ChatMessage, ChatSession } from "../types";
import type { ChatSession } from "../types";
const logger = createLogger("chat.mut");
@@ -28,58 +28,6 @@ export function useCreateChatSession() {
});
}
/**
* Sends a user message to an existing chat session. Optimistically appends
* the message to the cached thread so it shows instantly, then invalidates
* the thread, the session's pending-task signal, and the sessions list (the
* reply bumps updated_at / re-orders the inbox feed) once the request
* settles. Rolls the optimistic message back on failure.
*
* Scoped to an existing session id — the floating chat's lazy
* session-creation path is separate; this is the inline (inbox) surface.
*/
export function useSendChatMessage(sessionId: string) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (vars: { content: string; attachmentIds?: string[] }) => {
logger.info("sendChatMessage.start", {
sessionId,
contentLength: vars.content.length,
attachmentCount: vars.attachmentIds?.length ?? 0,
});
return api.sendChatMessage(sessionId, vars.content, vars.attachmentIds);
},
onMutate: async (vars) => {
await qc.cancelQueries({ queryKey: chatKeys.messages(sessionId) });
const prev = qc.getQueryData<ChatMessage[]>(chatKeys.messages(sessionId));
const optimistic: ChatMessage = {
id: `optimistic-${Date.now()}`,
chat_session_id: sessionId,
role: "user",
content: vars.content,
task_id: null,
created_at: new Date().toISOString(),
attachments: [],
};
qc.setQueryData<ChatMessage[]>(chatKeys.messages(sessionId), (old) =>
old ? [...old, optimistic] : [optimistic],
);
return { prev };
},
onError: (err, _vars, ctx) => {
logger.error("sendChatMessage.error.rollback", err);
if (ctx?.prev) qc.setQueryData(chatKeys.messages(sessionId), ctx.prev);
},
onSettled: () => {
qc.invalidateQueries({ queryKey: chatKeys.messages(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.pendingTask(sessionId) });
qc.invalidateQueries({ queryKey: chatKeys.sessions(wsId) });
},
});
}
/**
* Clears the session's unread state server-side. Optimistically flips
* has_unread to false in the cached list so the FAB badge drops

View File

@@ -5,12 +5,13 @@ import { useDefaultLayout } from "react-resizable-panels";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import {
inboxListOptions,
deduplicateInboxItems,
useInboxUnreadCount,
} from "@multica/core/inbox/queries";
import { chatSessionsOptions } from "@multica/core/chat/queries";
import {
useMarkInboxRead,
useArchiveInbox,
@@ -50,65 +51,29 @@ import {
} from "@multica/ui/components/ui/dropdown-menu";
import { useIsMobile } from "@multica/ui/hooks/use-mobile";
import { PageHeader } from "../../layout/page-header";
import {
getInboxItemRenderer,
notificationEntry,
conversationEntry,
type InboxFeedEntry,
} from "../item-types";
import { InboxListItem, useTimeAgo } from "./inbox-list-item";
import { useTypeLabels } from "./inbox-detail-label";
import { getInboxDisplayTitle } from "./inbox-display";
import { useT } from "../../i18n";
export function InboxPage() {
const { t } = useT("inbox");
const { searchParams, replace } = useNavigation();
const urlIssue = searchParams.get("issue") ?? "";
const urlConversation = searchParams.get("conversation") ?? "";
const wsPaths = useWorkspacePaths();
const [selectedKey, setSelectedKeyState] = useState(() => urlIssue);
const [selectedConversationId, setSelectedConversationId] = useState(
() => urlConversation,
);
// Sync from URL when searchParams change (e.g. navigation)
useEffect(() => {
setSelectedKeyState(urlIssue);
}, [urlIssue]);
useEffect(() => {
setSelectedConversationId(urlConversation);
}, [urlConversation]);
const wsId = useWorkspaceId();
const { data: rawItems = [], isLoading: loading } = useQuery(inboxListOptions(wsId));
const items = useMemo(() => deduplicateInboxItems(rawItems), [rawItems]);
// Agent conversations are surfaced inline in the same feed as notifications.
const { data: rawSessions = [] } = useQuery(chatSessionsOptions(wsId));
const sessions = useMemo(
() => rawSessions.filter((s) => s.status === "active"),
[rawSessions],
);
// The merged typed feed: notifications + conversations, newest first. Each
// entry dispatches to its item-type renderer for the row.
const feed = useMemo<InboxFeedEntry[]>(() => {
const entries = [
...items.map(notificationEntry),
...sessions.map(conversationEntry),
];
return entries.sort((a, b) => b.sortAt.localeCompare(a.sortAt));
}, [items, sessions]);
const selected = items.find((i) => (i.issue_id ?? i.id) === selectedKey) ?? null;
const selectedConversation =
sessions.find((s) => s.id === selectedConversationId) ?? null;
// Notifications backed by an issue defer to the shared IssueDetail surface;
// everything else renders the detail pane its item-type renderer provides.
const SelectedDetail =
selected && !selected.issue_id
? getInboxItemRenderer("issue_notification").Detail
: undefined;
// Track the last key we actually resolved against the inbox list. Lets the
// fallback effect distinguish "shared-link to a notification not in our
@@ -121,21 +86,11 @@ export function InboxPage() {
const setSelectedKey = useCallback((key: string) => {
setSelectedKeyState(key);
setSelectedConversationId("");
const inboxPath = wsPaths.inbox();
const url = key ? `${inboxPath}?issue=${key}` : inboxPath;
replace(url);
}, [replace, wsPaths]);
// Open a conversation in the detail pane (like opening an issue), clearing
// any notification selection. The floating chat window is intentionally not
// involved — it is being deprecated in favour of this inline surface.
const selectConversation = useCallback((id: string) => {
setSelectedConversationId(id);
setSelectedKeyState("");
replace(`${wsPaths.inbox()}?conversation=${id}`);
}, [replace, wsPaths]);
// Shared inbox links (?issue=<id>) may point to notifications not in this
// user's inbox (archived, or never received). Fall back to the issue page
// so the URL still resolves to something meaningful. But if the key was
@@ -166,6 +121,8 @@ export function InboxPage() {
const archiveAllMutation = useArchiveAllInbox();
const archiveAllReadMutation = useArchiveAllReadInbox();
const archiveCompletedMutation = useArchiveCompletedInbox();
const timeAgo = useTimeAgo();
const typeLabels = useTypeLabels();
// Auto-mark-read whenever a selected item is unread — covers both click-
@@ -310,46 +267,26 @@ export function InboxPage() {
</PageHeader>
);
const listBody = feed.length === 0 ? (
const listBody = items.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Inbox className="mb-3 h-8 w-8 text-muted-foreground/50" />
<p className="text-sm">{t(($) => $.list.empty)}</p>
</div>
) : (
<div>
{feed.map((entry) => {
const { Row } = getInboxItemRenderer(entry.kind);
const isNotification = entry.kind === "issue_notification";
return (
<Row
key={`${entry.kind}:${entry.id}`}
entry={entry}
isSelected={
isNotification
? (entry.notification.issue_id ?? entry.notification.id) === selectedKey
: entry.id === selectedConversationId
}
onSelect={() => {
// Both kinds open into the same detail pane.
if (isNotification) handleSelect(entry.notification);
else selectConversation(entry.id);
}}
onArchive={() => {
if (isNotification) handleArchive(entry.notification.id);
}}
/>
);
})}
{items.map((item) => (
<InboxListItem
key={item.id}
item={item}
isSelected={(item.issue_id ?? item.id) === selectedKey}
onClick={() => handleSelect(item)}
onArchive={() => handleArchive(item.id)}
/>
))}
</div>
);
const ConversationDetailComp = getInboxItemRenderer("conversation").Detail;
const detailContent = selectedConversation && ConversationDetailComp ? (
<ConversationDetailComp
entry={conversationEntry(selectedConversation)}
onArchive={() => {}}
/>
) : selected?.issue_id ? (
const detailContent = selected?.issue_id ? (
// Key by issue_id (not inbox-item id): a new comment/reaction generates a
// new inbox notification for the same issue, and the dedup helper picks the
// newest one — keying on its id would remount IssueDetail on every event,
@@ -373,11 +310,58 @@ export function InboxPage() {
}}
/>
</ErrorBoundary>
) : selected && SelectedDetail ? (
<SelectedDetail
entry={notificationEntry(selected)}
onArchive={() => handleArchive(selected.id)}
/>
) : selected ? (
<div className="p-6">
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(selected)}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{typeLabels[selected.type]} · {timeAgo(selected.created_at)}
</p>
{selected.body && (
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
{selected.body}
</div>
)}
{selected.type === "quick_create_failed" && selected.details?.original_prompt && (
<div className="mt-4 rounded-md border bg-muted/40 p-3">
<p className="text-xs font-medium text-muted-foreground">
{t(($) => $.detail.original_input)}
</p>
<p className="mt-1 whitespace-pre-wrap text-sm">{selected.details.original_prompt}</p>
</div>
)}
<div className="mt-4 flex gap-2">
{selected.type === "quick_create_failed" && (
<Button
size="sm"
onClick={() => {
// Seed the legacy advanced form with the original prompt so the
// user can recover their input in the full editor instead of
// retyping. The agent picker hint becomes the assignee
// candidate (still editable).
const prompt = selected.details?.original_prompt ?? "";
const agentId = selected.details?.agent_id;
useIssueDraftStore.getState().setDraft({
description: prompt,
...(agentId
? { assigneeType: "agent" as const, assigneeId: agentId }
: {}),
});
useModalStore.getState().open("create-issue");
}}
>
{t(($) => $.detail.edit_advanced)}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleArchive(selected.id)}
>
<Archive className="mr-1.5 h-3.5 w-3.5" />
{t(($) => $.detail.archive)}
</Button>
</div>
</div>
) : null;
// -- Mobile layout: list / detail toggle -----------------------------------
@@ -405,7 +389,7 @@ export function InboxPage() {
}
// Mobile: show detail full-screen when an item is selected
if (selected || selectedConversation) {
if (selected) {
return (
<div className="flex flex-1 flex-col min-h-0">
<div className="flex h-12 shrink-0 items-center border-b px-2">
@@ -488,7 +472,7 @@ export function InboxPage() {
<div className="flex h-full flex-col items-center justify-center text-muted-foreground">
<Inbox className="mb-3 h-10 w-10 text-muted-foreground/30" />
<p className="text-sm">
{feed.length === 0
{items.length === 0
? t(($) => $.detail.empty)
: t(($) => $.detail.select_prompt)}
</p>

View File

@@ -1,79 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ChatSession, InboxItem } from "@multica/core/types";
import {
conversationEntry,
getInboxItemRenderer,
hasInboxItemRenderer,
notificationEntry,
} from "./index";
function item(overrides: Partial<InboxItem> = {}): InboxItem {
return {
id: "inbox-1",
workspace_id: "workspace-1",
recipient_type: "member",
recipient_id: "member-1",
actor_type: "agent",
actor_id: "agent-1",
type: "new_comment",
severity: "info",
issue_id: "issue-1",
title: "Issue title",
body: null,
issue_status: null,
read: false,
archived: false,
created_at: "2026-04-29T12:00:00Z",
details: null,
...overrides,
};
}
function session(overrides: Partial<ChatSession> = {}): ChatSession {
return {
id: "session-1",
workspace_id: "workspace-1",
agent_id: "agent-1",
creator_id: "member-1",
title: "Fix login redirect",
status: "active",
has_unread: true,
created_at: "2026-04-29T11:00:00Z",
updated_at: "2026-04-29T13:00:00Z",
...overrides,
};
}
describe("inbox feed entries", () => {
it("projects a notification onto an issue_notification entry", () => {
const entry = notificationEntry(item({ id: "n1", read: false }));
expect(entry.kind).toBe("issue_notification");
expect(entry.id).toBe("n1");
expect(entry.unread).toBe(true);
expect(entry.sortAt).toBe("2026-04-29T12:00:00Z");
});
it("projects a chat session onto a conversation entry sorted by updated_at", () => {
const entry = conversationEntry(session({ id: "s1", has_unread: false }));
expect(entry.kind).toBe("conversation");
expect(entry.id).toBe("s1");
expect(entry.unread).toBe(false);
expect(entry.sortAt).toBe("2026-04-29T13:00:00Z");
});
});
describe("inbox item-type registry", () => {
it("registers renderers for both shipping kinds", () => {
expect(hasInboxItemRenderer("issue_notification")).toBe(true);
expect(hasInboxItemRenderer("conversation")).toBe(true);
const notif = getInboxItemRenderer("issue_notification");
expect(typeof notif.Row).toBe("function");
expect(typeof notif.Detail).toBe("function");
const convo = getInboxItemRenderer("conversation");
expect(typeof convo.Row).toBe("function");
// Conversations open inline in the detail pane (like an issue).
expect(typeof convo.Detail).toBe("function");
});
});

View File

@@ -1,113 +0,0 @@
import type { ComponentType } from "react";
import type { ChatSession, InboxItem } from "@multica/core/types";
/**
* Inbox is a *typed feed*: one list whose entries each declare a high-level
* `kind`, and a registered renderer decides how that kind looks in the list
* and behaves when opened. The envelope (id, sort time, unread) and the
* list/triage machinery are shared across kinds; only the row + detail are
* polymorphic.
*
* Two kinds ship today, both backed by real data:
* - `issue_notification` — an {@link InboxItem} from the inbox feed.
* - `conversation` — an agent {@link ChatSession} surfaced inline.
*
* Further kinds (approval, digest, …) register another renderer without
* touching the list, filtering, sorting, or read machinery. See MUL-3788.
*/
export type InboxItemKind = "issue_notification" | "conversation";
/**
* A normalized entry in the merged feed. Sources as different as a
* notification and a chat session are projected onto one shape — `id`,
* `sortAt`, `unread` form the shared envelope the list sorts/filters on —
* while the kind-specific payload rides along for its renderer.
*/
export type InboxFeedEntry =
| {
kind: "issue_notification";
id: string;
sortAt: string;
unread: boolean;
notification: InboxItem;
}
| {
kind: "conversation";
id: string;
sortAt: string;
unread: boolean;
conversation: ChatSession;
};
/** Project a raw notification onto a feed entry. */
export function notificationEntry(item: InboxItem): InboxFeedEntry {
return {
kind: "issue_notification",
id: item.id,
sortAt: item.created_at,
unread: !item.read,
notification: item,
};
}
/** Project a chat session onto a feed entry. */
export function conversationEntry(session: ChatSession): InboxFeedEntry {
return {
kind: "conversation",
id: session.id,
sortAt: session.updated_at,
unread: session.has_unread,
conversation: session,
};
}
/** Props every kind's list-row component receives. */
export interface InboxItemRowProps {
entry: InboxFeedEntry;
isSelected: boolean;
onSelect: () => void;
onArchive: () => void;
}
/** Props every kind's detail-pane component receives. */
export interface InboxItemDetailProps {
entry: InboxFeedEntry;
onArchive: () => void;
}
/**
* A renderer is the per-kind half of the contract. `Row` is required (a kind
* must be listable); `Detail` is optional — a kind may open elsewhere (e.g. a
* conversation opens the chat window) or defer to a shared surface (an
* issue-backed notification defers to `IssueDetail`) instead of rendering its
* own pane.
*/
export interface InboxItemRenderer {
kind: InboxItemKind;
Row: ComponentType<InboxItemRowProps>;
Detail?: ComponentType<InboxItemDetailProps>;
}
const registry = new Map<InboxItemKind, InboxItemRenderer>();
/** Register a renderer for an item kind. Last registration wins. */
export function registerInboxItemType(renderer: InboxItemRenderer): void {
registry.set(renderer.kind, renderer);
}
export function hasInboxItemRenderer(kind: InboxItemKind): boolean {
return registry.has(kind);
}
/**
* Resolve the renderer for a kind. Throws if none is registered — a missing
* renderer is a wiring bug (the registering module was not imported), not a
* runtime condition to handle.
*/
export function getInboxItemRenderer(kind: InboxItemKind): InboxItemRenderer {
const renderer = registry.get(kind);
if (!renderer) {
throw new Error(`No inbox item renderer registered for kind "${kind}"`);
}
return renderer;
}

View File

@@ -1,141 +0,0 @@
"use client";
import { useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { MessageCircle } from "lucide-react";
import {
chatMessagesOptions,
pendingChatTaskOptions,
} from "@multica/core/chat/queries";
import {
useSendChatMessage,
useMarkChatSessionRead,
} from "@multica/core/chat/mutations";
import { useActorName } from "@multica/core/workspace/hooks";
import { ActorAvatar } from "../../common/actor-avatar";
import { ChatMessageList } from "../../chat/components/chat-message-list";
import { ChatInput } from "../../chat/components/chat-input";
import { useTimeAgo } from "../components/inbox-list-item";
import {
registerInboxItemType,
type InboxItemDetailProps,
type InboxItemRowProps,
} from "./contract";
/**
* Renderer for the `conversation` kind — an agent chat session surfaced inline
* in the inbox feed. Selecting it opens the conversation in the inbox detail
* pane (the same surface an issue notification opens into), NOT the floating
* chat window. The detail pane reuses the chat thread + composer so it stays
* in sync with the chat surface that will eventually replace the floating one.
*/
function ConversationRow({ entry, isSelected, onSelect }: InboxItemRowProps) {
const { getActorName } = useActorName();
const timeAgo = useTimeAgo();
if (entry.kind !== "conversation") return null;
const session = entry.conversation;
return (
<button
type="button"
onClick={onSelect}
className={`group flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
isSelected ? "bg-accent" : "hover:bg-accent/50"
}`}
>
<ActorAvatar actorType="agent" actorId={session.agent_id} size={28} enableHoverCard />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5">
{entry.unread && (
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-brand" />
)}
<span
className={`truncate text-sm ${entry.unread ? "font-medium" : "text-muted-foreground"}`}
>
{session.title}
</span>
</div>
<MessageCircle className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-xs text-muted-foreground">
{getActorName("agent", session.agent_id)}
</p>
<span className="shrink-0 text-xs text-muted-foreground">
{timeAgo(session.updated_at)}
</span>
</div>
</div>
</button>
);
}
function ConversationDetail({ entry }: InboxItemDetailProps) {
const session = entry.kind === "conversation" ? entry.conversation : null;
const sessionId = session?.id ?? "";
const agentId = session?.agent_id ?? "";
const { getActorName } = useActorName();
const { data: messages = [] } = useQuery(chatMessagesOptions(sessionId));
const { data: pendingTask } = useQuery(pendingChatTaskOptions(sessionId));
const sendMessage = useSendChatMessage(sessionId);
const markRead = useMarkChatSessionRead();
// Mark read when the conversation is opened in the pane (mirrors the inbox
// notification auto-mark-read). Optimistic, so it settles in one pass.
const markReadMutate = markRead.mutate;
const isUnread = entry.kind === "conversation" && entry.unread;
useEffect(() => {
if (!sessionId || !isUnread) return;
markReadMutate(sessionId);
}, [sessionId, isUnread, markReadMutate]);
if (!session) return null;
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2.5 border-b px-4">
<ActorAvatar actorType="agent" actorId={agentId} size={24} enableHoverCard />
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{session.title}</p>
<p className="truncate text-xs text-muted-foreground">
{getActorName("agent", agentId)}
</p>
</div>
</div>
<div className="min-h-0 flex-1">
<ChatMessageList
key={sessionId}
messages={messages}
pendingTask={pendingTask}
availability={undefined}
/>
</div>
<ChatInput
onSend={async (content, attachmentIds, commitInput) => {
const text = content.trim();
if (!text) return false;
commitInput?.({ clearEditor: true });
try {
await sendMessage.mutateAsync({ content: text, attachmentIds });
return true;
} catch {
toast.error("Failed to send message");
return false;
}
}}
isRunning={!!pendingTask?.task_id}
agentName={getActorName("agent", agentId)}
/>
</div>
);
}
registerInboxItemType({
kind: "conversation",
Row: ConversationRow,
Detail: ConversationDetail,
});

View File

@@ -1,18 +0,0 @@
// Importing this module registers the built-in inbox item-type renderers as a
// side effect. Import it once (the inbox page does) before resolving a renderer
// via getInboxItemRenderer.
import "./issue-notification";
import "./conversation";
export {
conversationEntry,
getInboxItemRenderer,
hasInboxItemRenderer,
notificationEntry,
registerInboxItemType,
type InboxFeedEntry,
type InboxItemDetailProps,
type InboxItemKind,
type InboxItemRenderer,
type InboxItemRowProps,
} from "./contract";

View File

@@ -1,105 +0,0 @@
"use client";
import { Archive } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { useModalStore } from "@multica/core/modals";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useT } from "../../i18n";
import { InboxListItem, useTimeAgo } from "../components/inbox-list-item";
import { useTypeLabels } from "../components/inbox-detail-label";
import { getInboxDisplayTitle } from "../components/inbox-display";
import {
registerInboxItemType,
type InboxItemDetailProps,
type InboxItemRowProps,
} from "./contract";
/**
* Renderer for the `issue_notification` kind — every notification the server
* issues today. The list row reuses {@link InboxListItem}; the detail pane
* below is only used for notifications that have NO backing issue (e.g. a
* failed quick-create). Notifications that point at an issue defer to the
* shared `IssueDetail` surface, so this `Detail` deliberately omits that path.
*/
function IssueNotificationRow({
entry,
isSelected,
onSelect,
onArchive,
}: InboxItemRowProps) {
if (entry.kind !== "issue_notification") return null;
return (
<InboxListItem
item={entry.notification}
isSelected={isSelected}
onClick={onSelect}
onArchive={onArchive}
/>
);
}
function IssueNotificationDetail({ entry, onArchive }: InboxItemDetailProps) {
const { t } = useT("inbox");
const typeLabels = useTypeLabels();
const timeAgo = useTimeAgo();
if (entry.kind !== "issue_notification") return null;
const item = entry.notification;
return (
<div className="p-6">
<h2 className="text-lg font-semibold">{getInboxDisplayTitle(item)}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{typeLabels[item.type]} · {timeAgo(item.created_at)}
</p>
{item.body && (
<div className="mt-4 whitespace-pre-wrap text-sm leading-relaxed text-foreground/80">
{item.body}
</div>
)}
{item.type === "quick_create_failed" && item.details?.original_prompt && (
<div className="mt-4 rounded-md border bg-muted/40 p-3">
<p className="text-xs font-medium text-muted-foreground">
{t(($) => $.detail.original_input)}
</p>
<p className="mt-1 whitespace-pre-wrap text-sm">
{item.details.original_prompt}
</p>
</div>
)}
<div className="mt-4 flex gap-2">
{item.type === "quick_create_failed" && (
<Button
size="sm"
onClick={() => {
// Seed the legacy advanced form with the original prompt so the
// user can recover their input in the full editor instead of
// retyping. The agent picker hint becomes the assignee
// candidate (still editable).
const prompt = item.details?.original_prompt ?? "";
const agentId = item.details?.agent_id;
useIssueDraftStore.getState().setDraft({
description: prompt,
...(agentId
? { assigneeType: "agent" as const, assigneeId: agentId }
: {}),
});
useModalStore.getState().open("create-issue");
}}
>
{t(($) => $.detail.edit_advanced)}
</Button>
)}
<Button variant="outline" size="sm" onClick={onArchive}>
<Archive className="mr-1.5 h-3.5 w-3.5" />
{t(($) => $.detail.archive)}
</Button>
</div>
</div>
);
}
registerInboxItemType({
kind: "issue_notification",
Row: IssueNotificationRow,
Detail: IssueNotificationDetail,
});

View File

@@ -1833,15 +1833,23 @@ func (s *TaskService) HandleFailedTasks(ctx context.Context, tasks []db.AgentTas
"error", checkErr,
)
} else if !hasActive {
if _, updateErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
updatedIssue, updateErr := s.Queries.UpdateIssueStatus(ctx, db.UpdateIssueStatusParams{
ID: t.IssueID,
Status: "todo",
WorkspaceID: issue.WorkspaceID,
}); updateErr != nil {
})
if updateErr != nil {
slog.Warn("handle failed tasks: reset stuck issue failed",
"issue_id", issueKey,
"error", updateErr,
)
} else {
// This direct reset bypasses the HTTP UpdateIssue
// handler that normally emits issue:updated, so emit
// it here too. Without it the board / status-filter
// caches keep showing the issue as in_progress until
// the next write touches it (#4648 / MUL-3782).
s.broadcastIssueUpdated(updatedIssue, issue.Status)
}
}
}
@@ -2261,14 +2269,32 @@ func (s *TaskService) broadcastChatDone(ctx context.Context, task db.AgentTaskQu
})
}
func (s *TaskService) broadcastIssueUpdated(issue db.Issue) {
// broadcastIssueUpdated publishes the issue:updated event the frontend's
// realtime reconcile (onIssueUpdated) relies on to move an issue between status
// columns / status filters and reconcile their bucket counts. prevStatus is the
// issue's status before the write so the client can gate that reconcile on
// status_changed.
//
// The `issue` payload is a map (issueToMap), which the workspace WS fanout
// (listeners.go SubscribeAll) marshals and broadcasts as-is — that is what
// drives the UI reconcile. Note this does NOT cover the full HTTP UpdateIssue
// side effects: the activity-log and inbox listeners type-assert `issue` to a
// handler.IssueResponse and skip a map, so a background status reset does not
// emit status-change activity / notifications. That is intentional for the
// realtime-staleness fix (#4648 / MUL-3782); folding those side effects in
// would mean unifying the payload type and is left as a follow-up.
func (s *TaskService) broadcastIssueUpdated(issue db.Issue, prevStatus string) {
prefix := s.getIssuePrefix(issue.WorkspaceID)
s.Bus.Publish(events.Event{
Type: protocol.EventIssueUpdated,
WorkspaceID: util.UUIDToString(issue.WorkspaceID),
ActorType: "system",
ActorID: "",
Payload: map[string]any{"issue": issueToMap(issue, prefix)},
Payload: map[string]any{
"issue": issueToMap(issue, prefix),
"status_changed": prevStatus != issue.Status,
"prev_status": prevStatus,
},
})
}

View File

@@ -0,0 +1,119 @@
package service
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// noRowsDBTX makes every read return pgx.ErrNoRows so getIssuePrefix's
// GetWorkspace lookup falls back to an empty prefix without needing a DB. The
// helper under test still publishes regardless of the prefix result.
type noRowsDBTX struct{}
func (noRowsDBTX) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) {
return pgconn.NewCommandTag(""), nil
}
func (noRowsDBTX) Query(context.Context, string, ...any) (pgx.Rows, error) {
return nil, pgx.ErrNoRows
}
func (noRowsDBTX) QueryRow(context.Context, string, ...any) pgx.Row { return noRow{} }
type noRow struct{}
func (noRow) Scan(...any) error { return pgx.ErrNoRows }
// TestBroadcastIssueUpdated_EmitsStatusChange pins the realtime contract behind
// #4648 / MUL-3782: when a background path resets an issue's status (e.g. the
// failed-task handler flipping a stuck in_progress issue back to todo), it must
// publish issue:updated with status_changed=true and the new status so the
// frontend's onIssueUpdated reconcile moves the card between status columns /
// filters instead of leaving it stale until the next unrelated write.
func TestBroadcastIssueUpdated_EmitsStatusChange(t *testing.T) {
bus := events.New()
var got []events.Event
bus.SubscribeAll(func(e events.Event) { got = append(got, e) })
svc := &TaskService{
Queries: db.New(noRowsDBTX{}),
Bus: bus,
}
issue := db.Issue{
ID: testUUID(1),
WorkspaceID: testUUID(2),
Number: 7,
Status: "todo",
}
svc.broadcastIssueUpdated(issue, "in_progress")
if len(got) != 1 {
t.Fatalf("expected exactly 1 published event, got %d", len(got))
}
e := got[0]
if e.Type != protocol.EventIssueUpdated {
t.Fatalf("expected event type %q, got %q", protocol.EventIssueUpdated, e.Type)
}
if e.WorkspaceID != util.UUIDToString(issue.WorkspaceID) {
t.Fatalf("workspace mismatch: got %q want %q", e.WorkspaceID, util.UUIDToString(issue.WorkspaceID))
}
payload, ok := e.Payload.(map[string]any)
if !ok {
t.Fatalf("payload is not map[string]any: %T", e.Payload)
}
if payload["status_changed"] != true {
t.Errorf("expected status_changed=true, got %v", payload["status_changed"])
}
if payload["prev_status"] != "in_progress" {
t.Errorf("expected prev_status=in_progress, got %v", payload["prev_status"])
}
issueMap, ok := payload["issue"].(map[string]any)
if !ok {
t.Fatalf("issue payload is not map[string]any: %T", payload["issue"])
}
if issueMap["status"] != "todo" {
t.Errorf("expected issue.status=todo, got %v", issueMap["status"])
}
if issueMap["id"] != util.UUIDToString(issue.ID) {
t.Errorf("issue.id mismatch: got %v want %q", issueMap["id"], util.UUIDToString(issue.ID))
}
}
// TestBroadcastIssueUpdated_NoStatusChange guards the gate: a same-status
// broadcast reports status_changed=false so the client skips the status-bucket
// reconcile for non-status field updates.
func TestBroadcastIssueUpdated_NoStatusChange(t *testing.T) {
bus := events.New()
var got []events.Event
bus.SubscribeAll(func(e events.Event) { got = append(got, e) })
svc := &TaskService{
Queries: db.New(noRowsDBTX{}),
Bus: bus,
}
issue := db.Issue{
ID: testUUID(1),
WorkspaceID: testUUID(2),
Status: "todo",
}
svc.broadcastIssueUpdated(issue, "todo")
if len(got) != 1 {
t.Fatalf("expected exactly 1 published event, got %d", len(got))
}
payload, ok := got[0].Payload.(map[string]any)
if !ok {
t.Fatalf("payload is not map[string]any: %T", got[0].Payload)
}
if payload["status_changed"] != false {
t.Errorf("expected status_changed=false, got %v", payload["status_changed"])
}
}

View File

@@ -217,7 +217,7 @@ func DetectVersion(ctx context.Context, executablePath string) (string, error) {
// environment variables are deliberately omitted so the string is a hint
// about *what* users are extending, not a dump of the full command line.
var launchHeaders = map[string]string{
"antigravity": "agy -p (print mode)",
"antigravity": "agy -p (non-interactive)",
"claude": "claude (stream-json)",
"codebuddy": "codebuddy (stream-json)",
"codex": "codex app-server",

View File

@@ -2,6 +2,7 @@ package agent
import (
"context"
"strings"
"testing"
"time"
)
@@ -115,6 +116,18 @@ func TestLaunchHeaderCoversAllSupportedBackends(t *testing.T) {
}
}
func TestLaunchHeaderAntigravityAvoidsTextOnlyPrintModeLabel(t *testing.T) {
t.Parallel()
header := LaunchHeader("antigravity")
if header != "agy -p (non-interactive)" {
t.Fatalf("unexpected Antigravity launch header: %q", header)
}
if strings.Contains(header, "print mode") {
t.Fatalf("Antigravity launch header must not imply a text-only mode: %q", header)
}
}
func TestLaunchHeaderReturnsEmptyForUnknownType(t *testing.T) {
t.Parallel()
if header := LaunchHeader("made-up-agent"); header != "" {

View File

@@ -14,12 +14,14 @@ import (
)
// antigravityBackend implements Backend by spawning Google's Antigravity CLI
// (`agy -p <prompt>`) in non-interactive print mode. Unlike Claude / Codex /
// Cursor / Gemini, the Antigravity CLI does not expose a structured event
// stream — stdout is plain assistant text (intermediate "I will run X" lines
// and the final reply, all interleaved). The backend therefore streams stdout
// line-by-line as `MessageText` events and accumulates the same text as the
// final `Result.Output`.
// with a one-shot prompt (`agy -p <prompt>`). Despite the upstream flag name,
// current agy print mode is still capable of running Antigravity tools; it is
// the daemon-compatible mode because `agy -i` requires an attached TTY. Unlike
// Claude / Codex / Cursor / Gemini, the Antigravity CLI does not expose a
// structured event stream — stdout is plain assistant text (intermediate "I
// will run X" lines and the final reply, all interleaved). The backend
// therefore streams stdout line-by-line as `MessageText` events and accumulates
// the same text as the final `Result.Output`.
//
// Session resumption uses `--conversation <id>`. The conversation id is not
// emitted on stdout; we capture it by routing `--log-file` to a temp file and
@@ -154,7 +156,7 @@ func (b *antigravityBackend) Execute(ctx context.Context, prompt string, opts Ex
// success the user can't distinguish from a finished task (MUL-3570).
finalStatus = "timeout"
finalError = fmt.Sprintf(
"agy print mode timed out after %s waiting for the agent response; a long-running command likely outlived --print-timeout",
"agy --print-timeout elapsed after %s waiting for the agent response; a long-running command likely outlived the print timeout",
antigravityPrintTimeout(timeout),
)
} else if providerErr := antigravityProviderError(logPath); finalStatus == "completed" && providerErr != "" {
@@ -270,7 +272,7 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
"-p": blockedWithValue,
"--print": blockedWithValue,
"--prompt": blockedWithValue,
"-i": blockedStandalone, // interactive mode would block the daemon
"-i": blockedStandalone, // interactive mode requires a TTY and cannot run under the daemon
"--prompt-interactive": blockedStandalone,
"-c": blockedStandalone, // resume via --conversation, not --continue
"--continue": blockedStandalone,
@@ -281,7 +283,8 @@ var antigravityBlockedArgs = map[string]blockedArgMode{
"--log-file": blockedWithValue, // daemon needs it for session capture
}
// buildAntigravityArgs assembles the argv for a one-shot agy invocation.
// buildAntigravityArgs assembles the argv for a daemon-compatible one-shot agy
// invocation.
//
// agy -p <prompt> --dangerously-skip-permissions [--model <display name>]
// --print-timeout <duration> --log-file <tmp>

View File

@@ -219,6 +219,8 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
// resume-aware operation.
CustomArgs: []string{
"-p", "hijacked-prompt",
"-i",
"--prompt-interactive",
"--continue",
"-c",
"--conversation", "bad-id",
@@ -247,6 +249,9 @@ func TestBuildAntigravityArgsFiltersBlockedCustomArgs(t *testing.T) {
if strings.Contains(joined, "hijacked-prompt") {
t.Errorf("custom -p value leaked through filter: %v", args)
}
if strings.Contains(joined, "-i") || strings.Contains(joined, "--prompt-interactive") {
t.Errorf("interactive-mode flags leaked through filter: %v", args)
}
if strings.Contains(joined, "bad-id") {
t.Errorf("custom --conversation value leaked through filter: %v", args)
}
@@ -389,8 +394,8 @@ func TestAntigravityBackendPrintTimeoutSurfacesAsTimeout(t *testing.T) {
if result.Status != "timeout" {
t.Fatalf("expected status=timeout, got %q (error=%q)", result.Status, result.Error)
}
if !strings.Contains(result.Error, "print mode timed out") {
t.Errorf("expected error to explain the print-mode timeout, got %q", result.Error)
if !strings.Contains(result.Error, "agy --print-timeout elapsed") {
t.Errorf("expected error to explain the agy print timeout, got %q", result.Error)
}
// Narration streamed before the cut-off must still reach the result so
// the user sees how far the turn got.