Skip to content

Commit 087ebb9

Browse files
authored
🤖 feat: collapse consecutive bash_output calls in UI (#1045)
Groups 3+ consecutive `bash_output` tool calls to the same `process_id` in the UI: - Shows first call with 'start' indicator - Shows collapsed indicator with squiggly line showing count of hidden calls - Shows last call with 'end' indicator This reduces visual clutter when agents poll background processes repeatedly, while preserving access to first and last output. ## Changes - Added `groupConsecutiveBashOutput()` function in `messageUtils.ts` to transform message arrays - Created `BashOutputCollapsedIndicator` component with squiggly line SVG - Updated `BashOutputToolCall` to show group position (start/end) - Applied grouping in `AIView.tsx` message pipeline - Added unit tests and a story to demonstrate the behavior _Generated with `mux`_
1 parent 385830d commit 087ebb9

File tree

9 files changed

+541
-20
lines changed

9 files changed

+541
-20
lines changed

src/browser/components/AIView.tsx

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ import { useResizableSidebar } from "@/browser/hooks/useResizableSidebar";
2525
import {
2626
shouldShowInterruptedBarrier,
2727
mergeConsecutiveStreamErrors,
28+
computeBashOutputGroupInfo,
2829
} from "@/browser/utils/messages/messageUtils";
30+
import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator";
2931
import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility";
3032
import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
3133
import { ModeProvider } from "@/browser/contexts/ModeContext";
@@ -175,23 +177,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
175177
undefined
176178
);
177179

180+
// Track which bash_output groups are expanded (keyed by first message ID)
181+
const [expandedBashGroups, setExpandedBashGroups] = useState<Set<string>>(new Set());
182+
178183
// Extract state from workspace state
179184
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;
180185

181-
// Merge consecutive identical stream errors.
186+
// Apply message transformations:
187+
// 1. Merge consecutive identical stream errors
188+
// (bash_output grouping is done at render-time, not as a transformation)
182189
// Use useDeferredValue to allow React to defer the heavy message list rendering
183190
// during rapid updates (streaming), keeping the UI responsive.
184191
// Must be defined before any early returns to satisfy React Hooks rules.
185-
const mergedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]);
186-
const deferredMergedMessages = useDeferredValue(mergedMessages);
192+
const transformedMessages = useMemo(() => mergeConsecutiveStreamErrors(messages), [messages]);
193+
const deferredTransformedMessages = useDeferredValue(transformedMessages);
187194

188195
// CRITICAL: When message count changes (new message sent/received), show immediately.
189196
// Only defer content changes within existing messages (streaming deltas).
190197
// This ensures user messages appear instantly while keeping streaming performant.
191198
const deferredMessages =
192-
mergedMessages.length !== deferredMergedMessages.length
193-
? mergedMessages
194-
: deferredMergedMessages;
199+
transformedMessages.length !== deferredTransformedMessages.length
200+
? transformedMessages
201+
: deferredTransformedMessages;
195202

196203
// Get active stream message ID for token counting
197204
const activeStreamMessageId = aggregator?.getActiveStreamMessageId();
@@ -311,8 +318,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
311318
}
312319

313320
// Otherwise, edit last user message
314-
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
315-
const lastUserMessage = [...mergedMessages]
321+
const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
322+
const lastUserMessage = [...transformedMessages]
316323
.reverse()
317324
.find((msg): msg is Extract<DisplayedMessage, { type: "user" }> => msg.type === "user");
318325
if (lastUserMessage) {
@@ -432,8 +439,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
432439
useEffect(() => {
433440
if (!workspaceState || !editingMessage) return;
434441

435-
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
436-
const editCutoffHistoryId = mergedMessages.find(
442+
const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
443+
const editCutoffHistoryId = transformedMessages.find(
437444
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
438445
msg.type !== "history-hidden" &&
439446
msg.type !== "workspace-init" &&
@@ -469,7 +476,7 @@ const AIViewInner: React.FC<AIViewProps> = ({
469476

470477
// When editing, find the cutoff point
471478
const editCutoffHistoryId = editingMessage
472-
? mergedMessages.find(
479+
? transformedMessages.find(
473480
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
474481
msg.type !== "history-hidden" &&
475482
msg.type !== "workspace-init" &&
@@ -480,8 +487,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
480487
// Find the ID of the latest propose_plan tool call for external edit detection
481488
// Only the latest plan should fetch fresh content from disk
482489
let latestProposePlanId: string | null = null;
483-
for (let i = mergedMessages.length - 1; i >= 0; i--) {
484-
const msg = mergedMessages[i];
490+
for (let i = transformedMessages.length - 1; i >= 0; i--) {
491+
const msg = transformedMessages[i];
485492
if (msg.type === "tool" && msg.toolName === "propose_plan") {
486493
latestProposePlanId = msg.id;
487494
break;
@@ -577,7 +584,21 @@ const AIViewInner: React.FC<AIViewProps> = ({
577584
</div>
578585
) : (
579586
<>
580-
{deferredMessages.map((msg) => {
587+
{deferredMessages.map((msg, index) => {
588+
// Compute bash_output grouping at render-time
589+
const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index);
590+
591+
// For bash_output groups, use first message ID as expansion key
592+
const groupKey = bashOutputGroup
593+
? deferredMessages[bashOutputGroup.firstIndex]?.id
594+
: undefined;
595+
const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false;
596+
597+
// Skip rendering middle items in a bash_output group (unless expanded)
598+
if (bashOutputGroup?.position === "middle" && !isGroupExpanded) {
599+
return null;
600+
}
601+
581602
const isAtCutoff =
582603
editCutoffHistoryId !== undefined &&
583604
msg.type !== "history-hidden" &&
@@ -607,8 +628,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
607628
}
608629
foregroundBashToolCallIds={foregroundToolCallIds}
609630
onSendBashToBackground={handleSendBashToBackground}
631+
bashOutputGroup={bashOutputGroup}
610632
/>
611633
</div>
634+
{/* Show collapsed indicator after the first item in a bash_output group */}
635+
{bashOutputGroup?.position === "first" && groupKey && (
636+
<BashOutputCollapsedIndicator
637+
processId={bashOutputGroup.processId}
638+
collapsedCount={bashOutputGroup.collapsedCount}
639+
isExpanded={isGroupExpanded}
640+
onToggle={() => {
641+
setExpandedBashGroups((prev) => {
642+
const next = new Set(prev);
643+
if (next.has(groupKey)) {
644+
next.delete(groupKey);
645+
} else {
646+
next.add(groupKey);
647+
}
648+
return next;
649+
});
650+
}}
651+
/>
652+
)}
612653
{isAtCutoff && (
613654
<div className="edit-cutoff-divider text-edit-mode bg-edit-mode/10 my-5 px-[15px] py-3 text-center text-xs font-medium">
614655
⚠️ Messages below this line will be removed when you submit the edit

src/browser/components/Messages/MessageRenderer.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import React from "react";
22
import type { DisplayedMessage } from "@/common/types/message";
3+
import type { BashOutputGroupInfo } from "@/browser/utils/messages/messageUtils";
34
import type { ReviewNoteData } from "@/common/types/review";
45
import { UserMessage } from "./UserMessage";
56
import { AssistantMessage } from "./AssistantMessage";
@@ -26,6 +27,8 @@ interface MessageRendererProps {
2627
foregroundBashToolCallIds?: Set<string>;
2728
/** Callback to send a foreground bash to background */
2829
onSendBashToBackground?: (toolCallId: string) => void;
30+
/** Optional bash_output grouping info (computed at render-time) */
31+
bashOutputGroup?: BashOutputGroupInfo;
2932
}
3033

3134
// Memoized to prevent unnecessary re-renders when parent (AIView) updates
@@ -40,6 +43,7 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
4043
isLatestProposePlan,
4144
foregroundBashToolCallIds,
4245
onSendBashToBackground,
46+
bashOutputGroup,
4347
}) => {
4448
// Route based on message type
4549
switch (message.type) {
@@ -71,6 +75,7 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
7175
isLatestProposePlan={isLatestProposePlan}
7276
foregroundBashToolCallIds={foregroundBashToolCallIds}
7377
onSendBashToBackground={onSendBashToBackground}
78+
bashOutputGroup={bashOutputGroup}
7479
/>
7580
);
7681
case "reasoning":

src/browser/components/Messages/ToolMessage.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type {
3939
WebFetchToolResult,
4040
} from "@/common/types/tools";
4141
import type { ReviewNoteData } from "@/common/types/review";
42+
import type { BashOutputGroupInfo } from "@/browser/utils/messages/messageUtils";
4243

4344
interface ToolMessageProps {
4445
message: DisplayedMessage & { type: "tool" };
@@ -52,6 +53,8 @@ interface ToolMessageProps {
5253
foregroundBashToolCallIds?: Set<string>;
5354
/** Callback to send a foreground bash to background */
5455
onSendBashToBackground?: (toolCallId: string) => void;
56+
/** Optional bash_output grouping info */
57+
bashOutputGroup?: BashOutputGroupInfo;
5558
}
5659

5760
// Type guards using Zod schemas for single source of truth
@@ -133,6 +136,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
133136
isLatestProposePlan,
134137
foregroundBashToolCallIds,
135138
onSendBashToBackground,
139+
bashOutputGroup,
136140
}) => {
137141
// Route to specialized components based on tool name
138142
if (isBashTool(message.toolName, message.args)) {
@@ -284,12 +288,20 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
284288
}
285289

286290
if (isBashOutputTool(message.toolName, message.args)) {
291+
// Note: "middle" position items are filtered out in AIView.tsx render loop,
292+
// and the collapsed indicator is rendered there. ToolMessage only sees first/last.
293+
const groupPosition =
294+
bashOutputGroup?.position === "first" || bashOutputGroup?.position === "last"
295+
? bashOutputGroup.position
296+
: undefined;
297+
287298
return (
288299
<div className={className}>
289300
<BashOutputToolCall
290301
args={message.args}
291302
result={message.result as BashOutputToolResult | undefined}
292303
status={message.status}
304+
groupPosition={groupPosition}
293305
/>
294306
</div>
295307
);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React from "react";
2+
3+
interface BashOutputCollapsedIndicatorProps {
4+
processId: string;
5+
collapsedCount: number;
6+
isExpanded: boolean;
7+
onToggle: () => void;
8+
}
9+
10+
/**
11+
* Visual indicator showing collapsed bash_output calls.
12+
* Renders as a squiggly line with count badge between the first and last calls.
13+
* Clickable to expand/collapse the hidden calls.
14+
*/
15+
export const BashOutputCollapsedIndicator: React.FC<BashOutputCollapsedIndicatorProps> = ({
16+
processId,
17+
collapsedCount,
18+
isExpanded,
19+
onToggle,
20+
}) => {
21+
return (
22+
<div className="px-3 py-1">
23+
<button
24+
onClick={onToggle}
25+
className="text-muted hover:bg-background-highlight inline-flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 transition-colors"
26+
>
27+
{/* Squiggly line SVG - rotates when expanded */}
28+
<svg
29+
className={`text-border shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
30+
width="16"
31+
height="24"
32+
viewBox="0 0 16 24"
33+
fill="none"
34+
xmlns="http://www.w3.org/2000/svg"
35+
>
36+
<path
37+
d="M8 0 Q12 4, 8 8 Q4 12, 8 16 Q12 20, 8 24"
38+
stroke="currentColor"
39+
strokeWidth="1.5"
40+
strokeLinecap="round"
41+
fill="none"
42+
/>
43+
</svg>
44+
<span className="text-[10px] font-medium">
45+
{isExpanded ? "Hide" : "Show"} {collapsedCount} more output check
46+
{collapsedCount === 1 ? "" : "s"} for{" "}
47+
<code className="font-monospace text-text-muted">{processId}</code>
48+
</span>
49+
</button>
50+
</div>
51+
);
52+
};

src/browser/components/tools/BashOutputToolCall.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React from "react";
2-
import { Layers } from "lucide-react";
2+
import { Layers, Link } from "lucide-react";
33
import type { BashOutputToolArgs, BashOutputToolResult } from "@/common/types/tools";
44
import {
55
ToolContainer,
@@ -23,6 +23,8 @@ interface BashOutputToolCallProps {
2323
args: BashOutputToolArgs;
2424
result?: BashOutputToolResult;
2525
status?: ToolStatus;
26+
/** Position in a group of consecutive bash_output calls (undefined if not grouped) */
27+
groupPosition?: "first" | "last";
2628
}
2729

2830
/**
@@ -33,6 +35,7 @@ export const BashOutputToolCall: React.FC<BashOutputToolCallProps> = ({
3335
args,
3436
result,
3537
status = "pending",
38+
groupPosition,
3639
}) => {
3740
const { expanded, toggleExpanded } = useToolExpansion();
3841

@@ -50,6 +53,11 @@ export const BashOutputToolCall: React.FC<BashOutputToolCallProps> = ({
5053
output
5154
{args.timeout_secs > 0 && ` • wait ${args.timeout_secs}s`}
5255
{args.filter && ` • filter: ${args.filter}`}
56+
{groupPosition && (
57+
<span className="text-muted ml-1 flex items-center gap-0.5">
58+
<Link size={8} /> {groupPosition === "first" ? "start" : "end"}
59+
</span>
60+
)}
5361
</span>
5462
{result?.success && <OutputStatusBadge hasOutput={!!result.output} className="ml-2" />}
5563
{result?.success && processStatus && processStatus !== "running" && (

src/browser/stories/App.bash.stories.tsx

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,89 @@ export const Mixed: AppStory = {
348348
await expandAllBashTools(canvasElement);
349349
},
350350
};
351+
352+
/**
353+
* Story: Grouped Bash Output
354+
* Demonstrates the collapsing of consecutive bash_output calls to the same process.
355+
* Grouping is computed at render-time (not as a message transformation).
356+
* Shows:
357+
* - 5 consecutive output calls to same process: first, collapsed indicator, last
358+
* - Group position labels (🔗 start/end) on first and last items
359+
* - Non-grouped bash_output calls for comparison (groups of 1-2)
360+
* - Mixed process IDs are not grouped together
361+
*/
362+
export const GroupedOutput: AppStory = {
363+
render: () => (
364+
<AppWithMocks
365+
setup={() =>
366+
setupSimpleChatStory({
367+
workspaceId: "ws-grouped-output",
368+
messages: [
369+
// Background process started
370+
createUserMessage("msg-1", "Start a dev server and monitor it", {
371+
historySequence: 1,
372+
timestamp: STABLE_TIMESTAMP - 800000,
373+
}),
374+
createAssistantMessage("msg-2", "Starting dev server:", {
375+
historySequence: 2,
376+
timestamp: STABLE_TIMESTAMP - 790000,
377+
toolCalls: [
378+
createBackgroundBashTool("call-1", "npm run dev", "bash_1", "Dev Server"),
379+
],
380+
}),
381+
// Multiple consecutive output checks (will be grouped)
382+
createUserMessage("msg-3", "Keep checking the server output", {
383+
historySequence: 3,
384+
timestamp: STABLE_TIMESTAMP - 700000,
385+
}),
386+
createAssistantMessage("msg-4", "Monitoring server output:", {
387+
historySequence: 4,
388+
timestamp: STABLE_TIMESTAMP - 690000,
389+
toolCalls: [
390+
// These 5 consecutive calls will be collapsed to 3 items
391+
createBashOutputTool("call-2", "bash_1", "Starting compilation...", "running"),
392+
createBashOutputTool("call-3", "bash_1", "Compiling src/index.ts...", "running"),
393+
createBashOutputTool("call-4", "bash_1", "Compiling src/utils.ts...", "running"),
394+
createBashOutputTool("call-5", "bash_1", "Compiling src/components/...", "running"),
395+
createBashOutputTool(
396+
"call-6",
397+
"bash_1",
398+
" VITE v5.0.0 ready in 320 ms\n\n ➜ Local: http://localhost:5173/",
399+
"running"
400+
),
401+
],
402+
}),
403+
// Non-grouped output (only 2 consecutive calls - no grouping)
404+
createUserMessage("msg-5", "Check both servers briefly", {
405+
historySequence: 5,
406+
timestamp: STABLE_TIMESTAMP - 500000,
407+
}),
408+
createAssistantMessage("msg-6", "Checking servers:", {
409+
historySequence: 6,
410+
timestamp: STABLE_TIMESTAMP - 490000,
411+
toolCalls: [
412+
// Only 2 calls - no grouping
413+
createBashOutputTool("call-7", "bash_1", "Server healthy", "running"),
414+
createBashOutputTool("call-8", "bash_1", "", "running"),
415+
],
416+
}),
417+
// Mixed: different process IDs (no grouping across processes)
418+
createUserMessage("msg-7", "Check dev server and build process", {
419+
historySequence: 7,
420+
timestamp: STABLE_TIMESTAMP - 300000,
421+
}),
422+
createAssistantMessage("msg-8", "Status of both processes:", {
423+
historySequence: 8,
424+
timestamp: STABLE_TIMESTAMP - 290000,
425+
toolCalls: [
426+
createBashOutputTool("call-9", "bash_1", "Server running", "running"),
427+
createBashOutputTool("call-10", "bash_2", "Build in progress", "running"),
428+
createBashOutputTool("call-11", "bash_1", "New request received", "running"),
429+
],
430+
}),
431+
],
432+
})
433+
}
434+
/>
435+
),
436+
};

0 commit comments

Comments
 (0)