diff --git a/src/browser/components/ChatPane/ChatPane.tsx b/src/browser/components/ChatPane/ChatPane.tsx index f392fa9c9a..aabf7452e0 100644 --- a/src/browser/components/ChatPane/ChatPane.tsx +++ b/src/browser/components/ChatPane/ChatPane.tsx @@ -922,7 +922,8 @@ const ChatPaneContent: React.FC = (props) => { const userMessageNavigationByHistoryId = useMemo(() => { const userHistoryIds: string[] = []; for (const message of deferredMessages) { - if (message.type === "user") { + // Monitor wake events should not interrupt navigation between human prompts. + if (message.type === "user" && message.bashMonitorWake == null) { userHistoryIds.push(message.historyId); } } diff --git a/src/browser/features/Messages/BashMonitorWakeMessage.tsx b/src/browser/features/Messages/BashMonitorWakeMessage.tsx new file mode 100644 index 0000000000..8d874c8d47 --- /dev/null +++ b/src/browser/features/Messages/BashMonitorWakeMessage.tsx @@ -0,0 +1,72 @@ +import { useState, type ReactElement } from "react"; +import { ChevronRight, Radar } from "lucide-react"; +import { cn } from "@/common/lib/utils"; +import type { BashMonitorWakeDisplayRecord, DisplayedMessage } from "@/common/types/message"; +import { TranscriptQuoteRoot } from "./TranscriptQuoteBoundary"; + +interface BashMonitorWakeMessageProps { + message: DisplayedMessage & { type: "user" }; + className?: string; +} + +function summarizeRecords(records: BashMonitorWakeDisplayRecord[]): string { + if (records.length === 1) { + const record = records[0]; + return record.kind === "monitor-lost" + ? `${record.displayName} monitor stopped after restart` + : `${record.displayName} monitor matched`; + } + + const matchCount = records.filter((record) => record.kind === "match").length; + if (matchCount === records.length) { + return `${records.length} background monitors matched`; + } + if (matchCount === 0) { + return `${records.length} background monitors stopped after restart`; + } + return `${records.length} background monitor updates`; +} + +/** + * Monitor wakes are machine-authored events, not user prompts. Keep them visible + * for transcript continuity without giving them a full user bubble, metadata row, + * or duplicate status badge. The model-facing prompt stays available on demand. + */ +export function BashMonitorWakeMessage(props: BashMonitorWakeMessageProps): ReactElement { + const [expanded, setExpanded] = useState(false); + const records = props.message.bashMonitorWake?.records ?? []; + const summary = summarizeRecords(records); + + return ( +
+ + {expanded && ( + +
+            {props.message.content}
+          
+
+ )} +
+ ); +} diff --git a/src/browser/features/Messages/BashMonitorWakeMessageContent.tsx b/src/browser/features/Messages/BashMonitorWakeMessageContent.tsx deleted file mode 100644 index 3a9fdab019..0000000000 --- a/src/browser/features/Messages/BashMonitorWakeMessageContent.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useState, type ReactElement } from "react"; -import { ChevronRight, Radar } from "lucide-react"; -import { cn } from "@/common/lib/utils"; -import type { BashMonitorWakeDisplayRecord } from "@/common/types/message"; - -interface BashMonitorWakeMessageContentProps { - /** Full wake prompt text (matched lines, task_await guidance) shown when expanded. */ - content: string; - records: BashMonitorWakeDisplayRecord[]; -} - -function describeRecord(record: BashMonitorWakeDisplayRecord): string { - const inverted = record.filterExclude ? " (inverted)" : ""; - const lost = record.kind === "monitor-lost" ? " — monitor lost" : ""; - return `${record.displayName} · /${record.filter}/${inverted}${lost}`; -} - -/** - * Compact card for synthetic bash-monitor wake turns. The raw prompt is - * model-facing plumbing (matched lines, task_await guidance), so the transcript - * shows a one-line summary per monitor and keeps the full prompt collapsed - * behind a details toggle. Follows the plain-text-inside-the-bubble layout of - * GoalSyntheticMessageContent — the user bubble already provides the framing. - */ -export function BashMonitorWakeMessageContent( - props: BashMonitorWakeMessageContentProps -): ReactElement { - const [expanded, setExpanded] = useState(false); - - const hasMatch = props.records.some((record) => record.kind === "match"); - const hasLost = props.records.some((record) => record.kind === "monitor-lost"); - const title = hasLost - ? hasMatch - ? "Background monitor updates" - : "Background monitors lost (Mux restarted)" - : "Background monitor matched output"; - - return ( -
-
-
- - {expanded && ( -
-          {props.content}
-        
- )} -
- ); -} diff --git a/src/browser/features/Messages/MessageRenderer.stories.tsx b/src/browser/features/Messages/MessageRenderer.stories.tsx index feb452351e..f2cd81bfcd 100644 --- a/src/browser/features/Messages/MessageRenderer.stories.tsx +++ b/src/browser/features/Messages/MessageRenderer.stories.tsx @@ -409,12 +409,19 @@ const BASH_MONITOR_WAKE_LOST_PROMPT = [ ].join("\n"); /** - * Bash monitor wake messages render as compact cards: title + per-monitor - * summary with the raw prompt collapsed behind a "Show details" toggle. - * The play expands the first (match) card so the snapshot covers both the - * expanded prompt and the collapsed monitor-lost card below it. + * Bash monitor wakes render as quiet inline events instead of user bubbles. + * The play expands the first (match) event so the snapshot covers both the + * on-demand raw prompt and the collapsed monitor-lost event below it. */ export const BashMonitorWakeMessages: AppStory = { + globals: { + viewport: { value: "mobile1", isRotated: false }, + }, + parameters: { + pixel: { + matrix: { themes: ["dark", "light"], viewports: ["phone", "laptop"] }, + }, + }, render: () => ( { @@ -473,7 +480,7 @@ export const BashMonitorWakeMessages: AppStory = { () => { const found = canvas.getAllByRole("button", { name: /show details/i }); if (found.length !== 2) { - throw new Error(`Expected 2 collapsed wake cards, found ${found.length}`); + throw new Error(`Expected 2 collapsed monitor events, found ${found.length}`); } return found; }, diff --git a/src/browser/features/Messages/MessageRenderer.test.tsx b/src/browser/features/Messages/MessageRenderer.test.tsx index 54a9cf602c..187bcb4cac 100644 --- a/src/browser/features/Messages/MessageRenderer.test.tsx +++ b/src/browser/features/Messages/MessageRenderer.test.tsx @@ -424,21 +424,25 @@ This is a condition-driven wake-up. Continue from this event.`; }; } - test("collapses the raw wake prompt behind a details toggle by default", () => { - const { getByText, getByRole, queryByText } = render( + test("renders a quiet inline event with the raw wake prompt collapsed", () => { + const { container, getByText, getByRole, queryByRole, queryByText } = render( ); - // Compact summary is visible; the raw prompt body stays hidden until expanded. - expect(getByText("Dev Server · /error|ready/")).toBeDefined(); - expect(getByRole("button", { name: /show details/i }).getAttribute("aria-expanded")).toBe( - "false" - ); + expect(getByText("Dev Server monitor matched")).toBeDefined(); + const toggle = getByRole("button", { name: /show details/i }); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(toggle.className).toContain("focus-visible:ring-2"); expect(queryByText(/failed to load tailwind config/)).toBeNull(); expect(queryByText(/condition-driven wake-up/)).toBeNull(); - // The dedicated pill replaces the generic synthetic "auto" pill. + + // A machine-authored event should not look or behave like a user prompt. + expect(container.querySelector("[data-bash-monitor-wake]")).not.toBeNull(); + expect(container.querySelector("[data-message-meta]")).toBeNull(); + expect(queryByRole("button", { name: "Copy" })).toBeNull(); + expect(queryByText("monitor wake")).toBeNull(); expect(queryByText("auto")).toBeNull(); }); @@ -452,7 +456,11 @@ This is a condition-driven wake-up. Continue from this event.`; const toggle = getByRole("button", { name: /show details/i }); fireEvent.click(toggle); expect(toggle.getAttribute("aria-expanded")).toBe("true"); - expect(queryByText(/failed to load tailwind config/)).toBeDefined(); + const details = queryByText(/failed to load tailwind config/); + expect(details).toBeDefined(); + expect( + details?.closest("[data-transcript-quote-root]")?.getAttribute("data-transcript-quote-text") + ).toBe(wakePrompt); fireEvent.click(toggle); expect(toggle.getAttribute("aria-expanded")).toBe("false"); diff --git a/src/browser/features/Messages/MessageRenderer.tsx b/src/browser/features/Messages/MessageRenderer.tsx index d6b735b408..12ca8a3785 100644 --- a/src/browser/features/Messages/MessageRenderer.tsx +++ b/src/browser/features/Messages/MessageRenderer.tsx @@ -5,6 +5,7 @@ import type { TaskReportLinking } from "@/browser/utils/messages/taskReportLinki import type { ReviewNoteData } from "@/common/types/review"; import type { EditingMessageState } from "@/browser/utils/chatEditing"; import { UserMessage, type UserMessageNavigation } from "./UserMessage"; +import { BashMonitorWakeMessage } from "./BashMonitorWakeMessage"; import { AssistantMessage } from "./AssistantMessage"; import { ToolMessage } from "./ToolMessage"; import { ReasoningMessage } from "./ReasoningMessage"; @@ -87,15 +88,18 @@ export const MessageRenderer = React.memo( // Route based on message type switch (message.type) { case "user": - renderedMessage = ( - - ); + renderedMessage = + message.bashMonitorWake != null ? ( + + ) : ( + + ); break; case "assistant": renderedMessage = ( diff --git a/src/browser/features/Messages/UserMessage.tsx b/src/browser/features/Messages/UserMessage.tsx index 07c85a8758..f5c540453f 100644 --- a/src/browser/features/Messages/UserMessage.tsx +++ b/src/browser/features/Messages/UserMessage.tsx @@ -11,7 +11,6 @@ import type { ButtonConfig } from "./MessageWindow"; import { MessageWindow } from "./MessageWindow"; import { UserMessageContent } from "./UserMessageContent"; import { GoalSyntheticMessageContent } from "./GoalSyntheticMessageContent"; -import { BashMonitorWakeMessageContent } from "./BashMonitorWakeMessageContent"; import { formatSubagentStructuredOutput, parseSubagentReportEnvelope, @@ -36,7 +35,6 @@ import { ClipboardCheck, MessageCircleQuestion, Pencil, - Radar, Target, } from "lucide-react"; import { @@ -96,7 +94,6 @@ export const UserMessage: React.FC = ({ const isSynthetic = message.isSynthetic === true; const isGoalContinuation = message.isGoalContinuation === true; const isBudgetLimitWrapup = message.isBudgetLimitWrapup === true; - const bashMonitorWake = message.bashMonitorWake; const content = message.content; const visibleContent = stripStagedAttachmentNotice(content); // Only backend-authored synthetic messages may opt into protocol-aware presentation. A user who @@ -242,13 +239,6 @@ export const UserMessage: React.FC = ({ goal continuation ); - } else if (bashMonitorWake) { - label = ( - - - ); } else if (subagentReport) { const isInProgress = subagentReport.status === "in_progress"; label = ( @@ -292,10 +282,6 @@ export const UserMessage: React.FC = ({ kind={isBudgetLimitWrapup ? "budget-limit" : "continuation"} /> ); - } else if (bashMonitorWake) { - renderedContent = ( - - ); } else if (subagentReport) { renderedContent = ; } else { diff --git a/src/browser/utils/messages/displayedMessageBuilder.bashMonitorWake.test.ts b/src/browser/utils/messages/displayedMessageBuilder.bashMonitorWake.test.ts index 7c688186f2..e34d581bd2 100644 --- a/src/browser/utils/messages/displayedMessageBuilder.bashMonitorWake.test.ts +++ b/src/browser/utils/messages/displayedMessageBuilder.bashMonitorWake.test.ts @@ -22,7 +22,7 @@ function buildUserRow(muxMetadata: MuxMessageMetadata) { } describe("buildDisplayedMessagesForMessage bash monitor wake metadata", () => { - test("surfaces well-formed wake records for compact rendering", () => { + test("surfaces well-formed wake records for inline event rendering", () => { const row = buildUserRow({ type: "bash-monitor-wake", records: [