Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 55 additions & 14 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import { useResizableSidebar } from "@/browser/hooks/useResizableSidebar";
import {
shouldShowInterruptedBarrier,
mergeConsecutiveStreamErrors,
computeBashOutputGroupInfo,
} from "@/browser/utils/messages/messageUtils";
import { BashOutputCollapsedIndicator } from "./tools/BashOutputCollapsedIndicator";
import { hasInterruptedStream } from "@/browser/utils/messages/retryEligibility";
import { ThinkingProvider } from "@/browser/contexts/ThinkingContext";
import { ModeProvider } from "@/browser/contexts/ModeContext";
Expand Down Expand Up @@ -173,23 +175,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
undefined
);

// Track which bash_output groups are expanded (keyed by first message ID)
const [expandedBashGroups, setExpandedBashGroups] = useState<Set<string>>(new Set());

// Extract state from workspace state
const { messages, canInterrupt, isCompacting, loading, currentModel } = workspaceState;

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

// CRITICAL: When message count changes (new message sent/received), show immediately.
// Only defer content changes within existing messages (streaming deltas).
// This ensures user messages appear instantly while keeping streaming performant.
const deferredMessages =
mergedMessages.length !== deferredMergedMessages.length
? mergedMessages
: deferredMergedMessages;
transformedMessages.length !== deferredTransformedMessages.length
? transformedMessages
: deferredTransformedMessages;

// Get active stream message ID for token counting
const activeStreamMessageId = aggregator?.getActiveStreamMessageId();
Expand Down Expand Up @@ -309,8 +316,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
}

// Otherwise, edit last user message
const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const lastUserMessage = [...mergedMessages]
const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const lastUserMessage = [...transformedMessages]
.reverse()
.find((msg): msg is Extract<DisplayedMessage, { type: "user" }> => msg.type === "user");
if (lastUserMessage) {
Expand Down Expand Up @@ -424,8 +431,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
useEffect(() => {
if (!workspaceState || !editingMessage) return;

const mergedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const editCutoffHistoryId = mergedMessages.find(
const transformedMessages = mergeConsecutiveStreamErrors(workspaceState.messages);
const editCutoffHistoryId = transformedMessages.find(
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
Expand Down Expand Up @@ -461,7 +468,7 @@ const AIViewInner: React.FC<AIViewProps> = ({

// When editing, find the cutoff point
const editCutoffHistoryId = editingMessage
? mergedMessages.find(
? transformedMessages.find(
(msg): msg is Exclude<DisplayedMessage, { type: "history-hidden" | "workspace-init" }> =>
msg.type !== "history-hidden" &&
msg.type !== "workspace-init" &&
Expand All @@ -472,8 +479,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
// Find the ID of the latest propose_plan tool call for external edit detection
// Only the latest plan should fetch fresh content from disk
let latestProposePlanId: string | null = null;
for (let i = mergedMessages.length - 1; i >= 0; i--) {
const msg = mergedMessages[i];
for (let i = transformedMessages.length - 1; i >= 0; i--) {
const msg = transformedMessages[i];
if (msg.type === "tool" && msg.toolName === "propose_plan") {
latestProposePlanId = msg.id;
break;
Expand Down Expand Up @@ -569,7 +576,21 @@ const AIViewInner: React.FC<AIViewProps> = ({
</div>
) : (
<>
{deferredMessages.map((msg) => {
{deferredMessages.map((msg, index) => {
// Compute bash_output grouping at render-time
const bashOutputGroup = computeBashOutputGroupInfo(deferredMessages, index);

// For bash_output groups, use first message ID as expansion key
const groupKey = bashOutputGroup
? deferredMessages[bashOutputGroup.firstIndex]?.id
: undefined;
const isGroupExpanded = groupKey ? expandedBashGroups.has(groupKey) : false;

// Skip rendering middle items in a bash_output group (unless expanded)
if (bashOutputGroup?.position === "middle" && !isGroupExpanded) {
return null;
}

const isAtCutoff =
editCutoffHistoryId !== undefined &&
msg.type !== "history-hidden" &&
Expand Down Expand Up @@ -599,8 +620,28 @@ const AIViewInner: React.FC<AIViewProps> = ({
}
foregroundBashToolCallIds={foregroundToolCallIds}
onSendBashToBackground={handleSendBashToBackground}
bashOutputGroup={bashOutputGroup}
/>
</div>
{/* Show collapsed indicator after the first item in a bash_output group */}
{bashOutputGroup?.position === "first" && groupKey && (
<BashOutputCollapsedIndicator
processId={bashOutputGroup.processId}
collapsedCount={bashOutputGroup.collapsedCount}
isExpanded={isGroupExpanded}
onToggle={() => {
setExpandedBashGroups((prev) => {
const next = new Set(prev);
if (next.has(groupKey)) {
next.delete(groupKey);
} else {
next.add(groupKey);
}
return next;
});
}}
/>
)}
{isAtCutoff && (
<div className="edit-cutoff-divider text-edit-mode bg-edit-mode/10 my-5 px-[15px] py-3 text-center text-xs font-medium">
⚠️ Messages below this line will be removed when you submit the edit
Expand Down
5 changes: 5 additions & 0 deletions src/browser/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import type { DisplayedMessage } from "@/common/types/message";
import type { BashOutputGroupInfo } from "@/browser/utils/messages/messageUtils";
import type { ReviewNoteData } from "@/common/types/review";
import { UserMessage } from "./UserMessage";
import { AssistantMessage } from "./AssistantMessage";
Expand All @@ -26,6 +27,8 @@ interface MessageRendererProps {
foregroundBashToolCallIds?: Set<string>;
/** Callback to send a foreground bash to background */
onSendBashToBackground?: (toolCallId: string) => void;
/** Optional bash_output grouping info (computed at render-time) */
bashOutputGroup?: BashOutputGroupInfo;
}

// Memoized to prevent unnecessary re-renders when parent (AIView) updates
Expand All @@ -40,6 +43,7 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
isLatestProposePlan,
foregroundBashToolCallIds,
onSendBashToBackground,
bashOutputGroup,
}) => {
// Route based on message type
switch (message.type) {
Expand Down Expand Up @@ -71,6 +75,7 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
isLatestProposePlan={isLatestProposePlan}
foregroundBashToolCallIds={foregroundBashToolCallIds}
onSendBashToBackground={onSendBashToBackground}
bashOutputGroup={bashOutputGroup}
/>
);
case "reasoning":
Expand Down
12 changes: 12 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
WebFetchToolResult,
} from "@/common/types/tools";
import type { ReviewNoteData } from "@/common/types/review";
import type { BashOutputGroupInfo } from "@/browser/utils/messages/messageUtils";

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

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

if (isBashOutputTool(message.toolName, message.args)) {
// Note: "middle" position items are filtered out in AIView.tsx render loop,
// and the collapsed indicator is rendered there. ToolMessage only sees first/last.
const groupPosition =
bashOutputGroup?.position === "first" || bashOutputGroup?.position === "last"
? bashOutputGroup.position
: undefined;

return (
<div className={className}>
<BashOutputToolCall
args={message.args}
result={message.result as BashOutputToolResult | undefined}
status={message.status}
groupPosition={groupPosition}
/>
</div>
);
Expand Down
52 changes: 52 additions & 0 deletions src/browser/components/tools/BashOutputCollapsedIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React from "react";

interface BashOutputCollapsedIndicatorProps {
processId: string;
collapsedCount: number;
isExpanded: boolean;
onToggle: () => void;
}

/**
* Visual indicator showing collapsed bash_output calls.
* Renders as a squiggly line with count badge between the first and last calls.
* Clickable to expand/collapse the hidden calls.
*/
export const BashOutputCollapsedIndicator: React.FC<BashOutputCollapsedIndicatorProps> = ({
processId,
collapsedCount,
isExpanded,
onToggle,
}) => {
return (
<div className="px-3 py-1">
<button
onClick={onToggle}
className="text-muted hover:bg-background-highlight inline-flex cursor-pointer items-center gap-2 rounded px-2 py-0.5 transition-colors"
>
{/* Squiggly line SVG - rotates when expanded */}
<svg
className={`text-border shrink-0 transition-transform ${isExpanded ? "rotate-90" : ""}`}
width="16"
height="24"
viewBox="0 0 16 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 0 Q12 4, 8 8 Q4 12, 8 16 Q12 20, 8 24"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
fill="none"
/>
</svg>
<span className="text-[10px] font-medium">
{isExpanded ? "Hide" : "Show"} {collapsedCount} more output check
{collapsedCount === 1 ? "" : "s"} for{" "}
<code className="font-monospace text-text-muted">{processId}</code>
</span>
</button>
</div>
);
};
10 changes: 9 additions & 1 deletion src/browser/components/tools/BashOutputToolCall.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from "react";
import { Layers } from "lucide-react";
import { Layers, Link } from "lucide-react";
import type { BashOutputToolArgs, BashOutputToolResult } from "@/common/types/tools";
import {
ToolContainer,
Expand All @@ -23,6 +23,8 @@ interface BashOutputToolCallProps {
args: BashOutputToolArgs;
result?: BashOutputToolResult;
status?: ToolStatus;
/** Position in a group of consecutive bash_output calls (undefined if not grouped) */
groupPosition?: "first" | "last";
}

/**
Expand All @@ -33,6 +35,7 @@ export const BashOutputToolCall: React.FC<BashOutputToolCallProps> = ({
args,
result,
status = "pending",
groupPosition,
}) => {
const { expanded, toggleExpanded } = useToolExpansion();

Expand All @@ -50,6 +53,11 @@ export const BashOutputToolCall: React.FC<BashOutputToolCallProps> = ({
output
{args.timeout_secs > 0 && ` • wait ${args.timeout_secs}s`}
{args.filter && ` • filter: ${args.filter}`}
{groupPosition && (
<span className="text-muted ml-1 flex items-center gap-0.5">
• <Link size={8} /> {groupPosition === "first" ? "start" : "end"}
</span>
)}
</span>
{result?.success && <OutputStatusBadge hasOutput={!!result.output} className="ml-2" />}
{result?.success && processStatus && processStatus !== "running" && (
Expand Down
86 changes: 86 additions & 0 deletions src/browser/stories/App.bash.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -348,3 +348,89 @@ export const Mixed: AppStory = {
await expandAllBashTools(canvasElement);
},
};

/**
* Story: Grouped Bash Output
* Demonstrates the collapsing of consecutive bash_output calls to the same process.
* Grouping is computed at render-time (not as a message transformation).
* Shows:
* - 5 consecutive output calls to same process: first, collapsed indicator, last
* - Group position labels (🔗 start/end) on first and last items
* - Non-grouped bash_output calls for comparison (groups of 1-2)
* - Mixed process IDs are not grouped together
*/
export const GroupedOutput: AppStory = {
render: () => (
<AppWithMocks
setup={() =>
setupSimpleChatStory({
workspaceId: "ws-grouped-output",
messages: [
// Background process started
createUserMessage("msg-1", "Start a dev server and monitor it", {
historySequence: 1,
timestamp: STABLE_TIMESTAMP - 800000,
}),
createAssistantMessage("msg-2", "Starting dev server:", {
historySequence: 2,
timestamp: STABLE_TIMESTAMP - 790000,
toolCalls: [
createBackgroundBashTool("call-1", "npm run dev", "bash_1", "Dev Server"),
],
}),
// Multiple consecutive output checks (will be grouped)
createUserMessage("msg-3", "Keep checking the server output", {
historySequence: 3,
timestamp: STABLE_TIMESTAMP - 700000,
}),
createAssistantMessage("msg-4", "Monitoring server output:", {
historySequence: 4,
timestamp: STABLE_TIMESTAMP - 690000,
toolCalls: [
// These 5 consecutive calls will be collapsed to 3 items
createBashOutputTool("call-2", "bash_1", "Starting compilation...", "running"),
createBashOutputTool("call-3", "bash_1", "Compiling src/index.ts...", "running"),
createBashOutputTool("call-4", "bash_1", "Compiling src/utils.ts...", "running"),
createBashOutputTool("call-5", "bash_1", "Compiling src/components/...", "running"),
createBashOutputTool(
"call-6",
"bash_1",
" VITE v5.0.0 ready in 320 ms\n\n ➜ Local: http://localhost:5173/",
"running"
),
],
}),
// Non-grouped output (only 2 consecutive calls - no grouping)
createUserMessage("msg-5", "Check both servers briefly", {
historySequence: 5,
timestamp: STABLE_TIMESTAMP - 500000,
}),
createAssistantMessage("msg-6", "Checking servers:", {
historySequence: 6,
timestamp: STABLE_TIMESTAMP - 490000,
toolCalls: [
// Only 2 calls - no grouping
createBashOutputTool("call-7", "bash_1", "Server healthy", "running"),
createBashOutputTool("call-8", "bash_1", "", "running"),
],
}),
// Mixed: different process IDs (no grouping across processes)
createUserMessage("msg-7", "Check dev server and build process", {
historySequence: 7,
timestamp: STABLE_TIMESTAMP - 300000,
}),
createAssistantMessage("msg-8", "Status of both processes:", {
historySequence: 8,
timestamp: STABLE_TIMESTAMP - 290000,
toolCalls: [
createBashOutputTool("call-9", "bash_1", "Server running", "running"),
createBashOutputTool("call-10", "bash_2", "Build in progress", "running"),
createBashOutputTool("call-11", "bash_1", "New request received", "running"),
],
}),
],
})
}
/>
),
};
Loading