diff --git a/desktop/src/features/chats/lib/chatWorkAutomation.ts b/desktop/src/features/chats/lib/chatWorkAutomation.ts
index a8e76af32..d9c753501 100644
--- a/desktop/src/features/chats/lib/chatWorkAutomation.ts
+++ b/desktop/src/features/chats/lib/chatWorkAutomation.ts
@@ -7,6 +7,17 @@ import * as React from "react";
const STORAGE_PREFIX = "buzz:chat-work-automation:v1";
const STORAGE_EVENT = "buzz:chat-work-automation-changed";
+/**
+ * Tag attached to automation-generated prompts (auto-fix CI, address
+ * comments). The message still reaches the agent like any user message, but
+ * the chat timeline renders only its activity — not the message bubble — so
+ * armed automation feels ambient instead of ventriloquized.
+ */
+export const CHAT_AUTOMATION_TAG: [string, string] = [
+ "automation",
+ "work-panel",
+];
+
export type ChatWorkAutomation = {
autoFixCi: boolean;
addressComments: boolean;
diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx
index 0338d6187..0fcf8c4f5 100644
--- a/desktop/src/features/chats/ui/ChatDetail.tsx
+++ b/desktop/src/features/chats/ui/ChatDetail.tsx
@@ -27,6 +27,7 @@ import {
NO_PROJECT_SELECTION_ID,
} from "@/features/chats/lib/chatSetup";
import { ChatActivityTranscript } from "@/features/chats/ui/ChatActivityTranscript";
+import { CHAT_AUTOMATION_TAG } from "@/features/chats/lib/chatWorkAutomation";
import {
deriveBranchFromAgentMessages,
deriveChatWorkBranch,
@@ -562,6 +563,13 @@ export function ChatDetail({
"chat_context",
"source",
);
+ // Automation prompts stay invisible: the agent's
+ // activity and reply anchor here, but no bubble.
+ const isAutomationMessage = eventHasTag(
+ message,
+ CHAT_AUTOMATION_TAG[0],
+ CHAT_AUTOMATION_TAG[1],
+ );
const isAgentMessage =
defaultAgent?.pubkey != null &&
normalizePubkey(message.pubkey) ===
@@ -579,7 +587,7 @@ export function ChatDetail({
)}
messageId={message.id}
>
- {isContextMessage ? (
+ {isAutomationMessage ? null : isContextMessage ? (
) : (
void onSend(content, [])}
+ onAutomationPrompt={(content) =>
+ void onSend(content, [], [CHAT_AUTOMATION_TAG])
+ }
open={showWorkPanel}
prHref={workPanelHref}
projectPath={metadata?.projectPath ?? selectedProject?.path ?? null}
diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts
index dc15e42dc..0c4935de8 100644
--- a/desktop/tests/e2e/chats-first-message.spec.ts
+++ b/desktop/tests/e2e/chats-first-message.spec.ts
@@ -187,6 +187,37 @@ test("first message in a new chat is sent and rendered", async ({ page }) => {
await expect(page.getByTestId("automation-auto-fix-ci")).toBeVisible();
await expect(page.getByTestId("automation-address-comments")).toBeVisible();
+ // Arming address-comments fires the automation prompt (2 open threads in
+ // the mock). The message goes out tagged — but no bubble renders: the
+ // automation stays invisible in the timeline.
+ await page.getByTestId("automation-address-comments").click();
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ (
+ (
+ window as Window & {
+ __BUZZ_E2E_COMMAND_PAYLOADS__?: Array<{
+ command: string;
+ payload?: { content?: string; mediaTags?: string[][] };
+ }>;
+ }
+ ).__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []
+ ).some(
+ (entry) =>
+ entry.command === "send_channel_message" &&
+ entry.payload?.content?.includes("unanswered review comments") &&
+ entry.payload?.mediaTags?.some(
+ (tag) => tag[0] === "automation" && tag[1] === "work-panel",
+ ),
+ ),
+ ),
+ )
+ .toBe(true);
+ await expect(
+ page.getByLabel("Chat messages").getByText("unanswered review comments"),
+ ).toHaveCount(0);
+
// The header's PR button toggles the panel.
await page.getByTestId("toggle-work-panel").click();
await expect(workPanel).not.toBeVisible();