Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
8ec50ed
🤖 feat: add bash_output tool for incremental background process output
ammar-agent Dec 8, 2025
7e4fffe
fix: format backgroundProcessManager.ts
ammar-agent Dec 8, 2025
45c7ecf
feat: add background processes banner UI
ammar-agent Dec 8, 2025
0cbef0e
feat: add Storybook story for background processes banner
ammar-agent Dec 8, 2025
fea7298
🤖 fix: show only pid in background processes banner
ammar-agent Dec 8, 2025
cfb5cb1
🤖 refactor: use shared formatDuration helper in banner
ammar-agent Dec 8, 2025
c620d80
🤖 fix: opaque background and flip command/pid hierarchy in banner
ammar-agent Dec 8, 2025
ddc62f9
🤖 refactor: decouple background bash from Runtime interface
ammar-agent Dec 8, 2025
d13bd5d
🤖 refactor: remove stdout_path/stderr_path from bash API
ammar-agent Dec 8, 2025
9e292dc
ci: retry CI
ammar-agent Dec 8, 2025
c2d99c7
🤖 refactor: remove unused BackgroundedError and markAsBackground
ammar-agent Dec 8, 2025
f2c43cd
🤖 refactor: rename backgroundProcesses to backgroundBashes
ammar-agent Dec 8, 2025
cc81fe8
🤖 refactor: rename useBackgroundProcesses to useBackgroundBashes
ammar-agent Dec 8, 2025
002435c
🤖 fix: address review feedback
ammar-agent Dec 8, 2025
9edfd20
🤖 feat: add send-to-background button for bash tool calls
ammar-agent Dec 8, 2025
f645f7a
🤖 fix: position banner above reviews and align with content
ammar-agent Dec 8, 2025
3c827a1
🤖 test: add direct integration tests for background bash processes
ammar-agent Dec 9, 2025
3e8a17f
🤖 test: reproduce SSH runtime bug - getOutput reads local fs instead …
ammar-agent Dec 9, 2025
ba93b98
🤖 fix: getOutput reads via handle for SSH runtime support
ammar-agent Dec 9, 2025
bf6c200
🤖 feat: graceful foreground→background migration with process tracking
ammar-agent Dec 9, 2025
f7b2578
🤖 feat: improve background bash UX per review feedback
ammar-agent Dec 9, 2025
7f32390
🤖 refactor: decompose background bash logic and track by toolCallId
ammar-agent Dec 9, 2025
c6e11e1
🤖 refactor: unify banner aesthetics and fix hover spacing
ammar-agent Dec 9, 2025
1ae34ea
🤖 test: add background bash output capture tests
ammar-agent Dec 9, 2025
95d70a8
🤖 refactor: unify bash_output to use single output field
ammar-agent Dec 9, 2025
d4daeaa
🤖 feat: add BashOutputToolCall component with unified styling
ammar-agent Dec 9, 2025
e36b0c2
🤖 feat: add background bash storybook and fix output display
ammar-agent Dec 9, 2025
1edd7a8
feat: add note for empty bash_output and improve UI badges
ammar-agent Dec 9, 2025
2e5f93a
feat: require display_name for all bash invocations
ammar-agent Dec 9, 2025
a97d88b
refactor: support multiple parallel foreground bashes
ammar-agent Dec 9, 2025
d4f54b3
refactor: simplify background badge - just show icon + display name
ammar-agent Dec 9, 2025
47744fa
refactor: replace polling with event-based subscription for backgroun…
ammar-agent Dec 9, 2025
d833c50
refactor: use unified output.log and runtime.tempDir() for background…
ammar-agent Dec 9, 2025
c1a7d36
refactor: simplify subscription pattern and improve DRY in background…
ammar-agent Dec 9, 2025
b60373a
fix: add afterEach cleanup to prevent test pollution in background to…
ammar-agent Dec 9, 2025
c1bf078
fix: include spawn error in test failure message for better debugging
ammar-agent Dec 9, 2025
db8ad31
fix: properly quote paths with spaces in background process executor
ammar-agent Dec 9, 2025
f792d33
fix: update storybook mock to use subscribe instead of list for backg…
ammar-agent Dec 9, 2025
a9b8ff8
fix: update integration test regex for display_name process IDs
ammar-agent Dec 9, 2025
d29be9f
fix: increase wait time in incremental reads test for CI reliability
ammar-agent Dec 9, 2025
e50d6ae
fix: increase wait times in background process tests for CI reliability
ammar-agent Dec 9, 2025
d296ea9
refactor: remove sleep command blocking validation
ammar-agent Dec 9, 2025
e0fcf92
fix: increase timeouts for storybook play tests and background proces…
ammar-agent Dec 9, 2025
62fc02d
fix: use unique process IDs in integration tests to avoid stale read …
ammar-agent Dec 9, 2025
287bd9f
refactor: consolidate bash stories into App/Bash
ammar-agent Dec 9, 2025
07452bb
feat: add blocking timeout to bash_output tool
ammar-agent Dec 9, 2025
e5210de
feat: reject background bash on Windows with clear error
ammar-agent Dec 9, 2025
24f7b8e
fix: handle spaces in process names for background bash trap
ammar-agent Dec 9, 2025
15237bf
feat: restore blocking validation for commands starting with sleep
ammar-agent Dec 9, 2025
918575d
refactor: consolidate Bash stories from 12 to 3
ammar-agent Dec 9, 2025
3891713
test: add foreground→background migration integration tests
ammar-agent Dec 9, 2025
6ad8626
fix: prevent abort signal from killing backgrounded processes
ammar-agent Dec 9, 2025
f925cdf
🤖 feat: add 'backgrounded' status for migrated processes, expand bash…
ammar-agent Dec 9, 2025
6411453
🤖 fix: show timeout_secs in bash_output tool UI
ammar-agent Dec 9, 2025
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
27 changes: 27 additions & 0 deletions .storybook/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ export interface MockORPCClientOptions {
providersList?: string[];
/** Mock for projects.remove - return error string to simulate failure */
onProjectRemove?: (projectPath: string) => { success: true } | { success: false; error: string };
/** Background processes per workspace */
backgroundProcesses?: Map<
string,
Array<{
id: string;
pid: number;
script: string;
displayName?: string;
startTime: number;
status: "running" | "exited" | "killed" | "failed";
exitCode?: number;
}>
>;
}

/**
Expand Down Expand Up @@ -55,6 +68,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
providersConfig = {},
providersList = [],
onProjectRemove,
backgroundProcesses = new Map(),
} = options;

const workspaceMap = new Map(workspaces.map((w) => [w.id, w]));
Expand Down Expand Up @@ -178,6 +192,19 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
await new Promise(() => {}); // Never resolves
},
},
backgroundBashes: {
subscribe: async function* (input: { workspaceId: string }) {
// Yield initial state
yield {
processes: backgroundProcesses.get(input.workspaceId) ?? [],
foregroundToolCallIds: [],
};
// Then hang forever (like a real subscription)
await new Promise(() => {});
},
terminate: async () => ({ success: true, data: undefined }),
sendToBackground: async () => ({ success: true, data: undefined }),
},
},
window: {
setTitle: async () => undefined,
Expand Down
30 changes: 29 additions & 1 deletion src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ import { evictModelFromLRU } from "@/browser/hooks/useModelLRU";
import { QueuedMessage } from "./Messages/QueuedMessage";
import { CompactionWarning } from "./CompactionWarning";
import { ConcurrentLocalWarning } from "./ConcurrentLocalWarning";
import { BackgroundProcessesBanner } from "./BackgroundProcessesBanner";
import { useBackgroundBashHandlers } from "@/browser/hooks/useBackgroundBashHandlers";
import { checkAutoCompaction } from "@/browser/utils/compaction/autoCompactionCheck";
import { executeCompaction } from "@/browser/utils/chatCommands";
import { useProviderOptions } from "@/browser/hooks/useProviderOptions";
Expand All @@ -61,6 +63,7 @@ import { useAPI } from "@/browser/contexts/API";
import { useReviews } from "@/browser/hooks/useReviews";
import { ReviewsBanner } from "./ReviewsBanner";
import type { ReviewNoteData } from "@/common/types/review";
import { PopoverError } from "./PopoverError";

interface AIViewProps {
workspaceId: string;
Expand Down Expand Up @@ -119,6 +122,15 @@ const AIViewInner: React.FC<AIViewProps> = ({

// Reviews state
const reviews = useReviews(workspaceId);

const {
processes: backgroundBashes,
handleTerminate: handleTerminateBackgroundBash,
foregroundToolCallIds,
handleSendToBackground: handleSendBashToBackground,
handleMessageSentBackground,
error: backgroundBashError,
} = useBackgroundBashHandlers(api, workspaceId);
const { options } = useProviderOptions();
const use1M = options.anthropic?.use1MContext ?? false;
// Get pending model for auto-compaction settings (threshold is per-model)
Expand Down Expand Up @@ -312,13 +324,17 @@ const AIViewInner: React.FC<AIViewProps> = ({
}, []);

const handleMessageSent = useCallback(() => {
// Auto-background any running foreground bash when user sends a new message
// This prevents the user from waiting for the bash to complete before their message is processed
handleMessageSentBackground();

// Enable auto-scroll when user sends a message
setAutoScroll(true);

// Reset autoRetry when user sends a message
// User action = clear intent: "I'm actively using this workspace"
setAutoRetry(true);
}, [setAutoScroll, setAutoRetry]);
}, [setAutoScroll, setAutoRetry, handleMessageSentBackground]);

const handleClearHistory = useCallback(
async (percentage = 1.0) => {
Expand Down Expand Up @@ -573,6 +589,8 @@ const AIViewInner: React.FC<AIViewProps> = ({
msg.toolName === "propose_plan" &&
msg.id === latestProposePlanId
}
foregroundBashToolCallIds={foregroundToolCallIds}
onSendBashToBackground={handleSendBashToBackground}
/>
</div>
{isAtCutoff && (
Expand Down Expand Up @@ -647,6 +665,10 @@ const AIViewInner: React.FC<AIViewProps> = ({
onCompactClick={handleCompactClick}
/>
)}
<BackgroundProcessesBanner
processes={backgroundBashes}
onTerminate={handleTerminateBackgroundBash}
/>
<ReviewsBanner workspaceId={workspaceId} />
<ChatInput
variant="workspace"
Expand Down Expand Up @@ -683,6 +705,12 @@ const AIViewInner: React.FC<AIViewProps> = ({
onReviewNote={handleReviewNote} // Pass review note handler to append to chat
isCreating={status === "creating"} // Workspace still being set up
/>

<PopoverError
error={backgroundBashError.error}
prefix="Failed to terminate:"
onDismiss={backgroundBashError.clearError}
/>
</div>
);
};
Expand Down
125 changes: 125 additions & 0 deletions src/browser/components/BackgroundProcessesBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import React, { useState, useCallback, useEffect } from "react";
import { Terminal, X, ChevronDown, ChevronRight } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip";
import type { BackgroundProcessInfo } from "@/common/orpc/schemas/api";
import { cn } from "@/common/lib/utils";
import { formatDuration } from "./tools/shared/toolUtils";

/**
* Truncate script to reasonable display length.
*/
function truncateScript(script: string, maxLength = 60): string {
// First line only, truncated
const firstLine = script.split("\n")[0] ?? script;
if (firstLine.length <= maxLength) {
return firstLine;
}
return firstLine.slice(0, maxLength - 3) + "...";
}

interface BackgroundProcessesBannerProps {
processes: BackgroundProcessInfo[];
onTerminate: (processId: string) => void;
}

/**
* Banner showing running background processes.
* Displays "N running bashes" which expands on click to show details.
*/
export const BackgroundProcessesBanner: React.FC<BackgroundProcessesBannerProps> = (props) => {
const [isExpanded, setIsExpanded] = useState(false);
const [, setTick] = useState(0);

// Filter to only running processes
const runningProcesses = props.processes.filter((p) => p.status === "running");
const count = runningProcesses.length;

// Update duration display every second when expanded
useEffect(() => {
if (!isExpanded || count === 0) return;
const interval = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(interval);
}, [isExpanded, count]);

const { onTerminate } = props;
const handleTerminate = useCallback(
(processId: string, event: React.MouseEvent) => {
event.stopPropagation();
onTerminate(processId);
},
[onTerminate]
);

const handleToggle = useCallback(() => {
setIsExpanded((prev) => !prev);
}, []);

// Don't render if no running processes
if (count === 0) {
return null;
}

return (
<div className="border-border bg-dark border-t px-[15px]">
{/* Collapsed banner - thin stripe, content aligned with chat */}
<button
type="button"
onClick={handleToggle}
className="group mx-auto flex w-full max-w-4xl items-center gap-2 px-2 py-1 text-xs transition-colors"
>
<Terminal className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
<span className="text-muted group-hover:text-secondary transition-colors">
<span className="font-medium">{count}</span>
{" background bash"}
{count !== 1 && "es"}
</span>
<div className="ml-auto">
{isExpanded ? (
<ChevronDown className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
) : (
<ChevronRight className="text-muted group-hover:text-secondary size-3.5 transition-colors" />
)}
</div>
</button>

{/* Expanded view - content aligned with chat */}
{isExpanded && (
<div className="border-border mx-auto max-h-48 max-w-4xl space-y-1.5 overflow-y-auto border-t py-2">
{runningProcesses.map((proc) => (
<div
key={proc.id}
className={cn(
"hover:bg-hover flex items-center justify-between gap-3 rounded px-2 py-1.5",
"transition-colors"
)}
>
<div className="min-w-0 flex-1">
<div className="text-foreground truncate font-mono text-xs" title={proc.script}>
{proc.displayName ?? truncateScript(proc.script)}
</div>
<div className="text-muted font-mono text-[10px]">pid {proc.pid}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-muted text-[10px]">
{formatDuration(Date.now() - proc.startTime)}
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => handleTerminate(proc.id, e)}
className="text-muted hover:text-error rounded p-1 transition-colors"
>
<X size={14} />
</button>
</TooltipTrigger>
<TooltipContent>Terminate process</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
</div>
);
};
8 changes: 8 additions & 0 deletions src/browser/components/Messages/MessageRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ interface MessageRendererProps {
onReviewNote?: (data: ReviewNoteData) => void;
/** Whether this message is the latest propose_plan tool call (for external edit detection) */
isLatestProposePlan?: boolean;
/** Set of tool call IDs of foreground bashes */
foregroundBashToolCallIds?: Set<string>;
/** Callback to send a foreground bash to background */
onSendBashToBackground?: (toolCallId: string) => void;
}

// Memoized to prevent unnecessary re-renders when parent (AIView) updates
Expand All @@ -34,6 +38,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
isCompacting,
onReviewNote,
isLatestProposePlan,
foregroundBashToolCallIds,
onSendBashToBackground,
}) => {
// Route based on message type
switch (message.type) {
Expand Down Expand Up @@ -63,6 +69,8 @@ export const MessageRenderer = React.memo<MessageRendererProps>(
workspaceId={workspaceId}
onReviewNote={onReviewNote}
isLatestProposePlan={isLatestProposePlan}
foregroundBashToolCallIds={foregroundBashToolCallIds}
onSendBashToBackground={onSendBashToBackground}
/>
);
case "reasoning":
Expand Down
33 changes: 33 additions & 0 deletions src/browser/components/Messages/ToolMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ import { StatusSetToolCall } from "../tools/StatusSetToolCall";
import { WebFetchToolCall } from "../tools/WebFetchToolCall";
import { BashBackgroundListToolCall } from "../tools/BashBackgroundListToolCall";
import { BashBackgroundTerminateToolCall } from "../tools/BashBackgroundTerminateToolCall";
import { BashOutputToolCall } from "../tools/BashOutputToolCall";
import type {
BashToolArgs,
BashToolResult,
BashBackgroundListArgs,
BashBackgroundListResult,
BashBackgroundTerminateArgs,
BashBackgroundTerminateResult,
BashOutputToolArgs,
BashOutputToolResult,
FileReadToolArgs,
FileReadToolResult,
FileEditReplaceStringToolArgs,
Expand Down Expand Up @@ -45,6 +48,10 @@ interface ToolMessageProps {
onReviewNote?: (data: ReviewNoteData) => void;
/** Whether this is the latest propose_plan in the conversation */
isLatestProposePlan?: boolean;
/** Set of tool call IDs of foreground bashes */
foregroundBashToolCallIds?: Set<string>;
/** Callback to send a foreground bash to background */
onSendBashToBackground?: (toolCallId: string) => void;
}

// Type guards using Zod schemas for single source of truth
Expand Down Expand Up @@ -113,22 +120,36 @@ function isBashBackgroundTerminateTool(
return TOOL_DEFINITIONS.bash_background_terminate.schema.safeParse(args).success;
}

function isBashOutputTool(toolName: string, args: unknown): args is BashOutputToolArgs {
if (toolName !== "bash_output") return false;
return TOOL_DEFINITIONS.bash_output.schema.safeParse(args).success;
}

export const ToolMessage: React.FC<ToolMessageProps> = ({
message,
className,
workspaceId,
onReviewNote,
isLatestProposePlan,
foregroundBashToolCallIds,
onSendBashToBackground,
}) => {
// Route to specialized components based on tool name
if (isBashTool(message.toolName, message.args)) {
// Only show "Background" button if this specific tool call is a foreground process
const canSendToBackground = foregroundBashToolCallIds?.has(message.toolCallId) ?? false;
const toolCallId = message.toolCallId;
return (
<div className={className}>
<BashToolCall
args={message.args}
result={message.result as BashToolResult | undefined}
status={message.status}
startedAt={message.timestamp}
canSendToBackground={canSendToBackground}
onSendToBackground={
onSendBashToBackground ? () => onSendBashToBackground(toolCallId) : undefined
}
/>
</div>
);
Expand Down Expand Up @@ -262,6 +283,18 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
);
}

if (isBashOutputTool(message.toolName, message.args)) {
return (
<div className={className}>
<BashOutputToolCall
args={message.args}
result={message.result as BashOutputToolResult | undefined}
status={message.status}
/>
</div>
);
}

// Fallback to generic tool call
return (
<div className={className}>
Expand Down
Loading