@@ -37,7 +40,7 @@ const toolResultMsg = (
): AcpToolResultUiMessage =>
({
id: 'story-msg',
- ts: 0,
+ ts: Date.now(),
role: 'assistant',
partial: false,
sessionId: null,
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx
index adeb906c1..373164020 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpCommandOutputMessage.tsx
@@ -1,5 +1,10 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+
import { sanitizeSandboxPathString } from '@/lib';
import { cn } from '@/lib/utils';
+import { Button, SquareIcon } from '@/components/system';
import {
CodeBlock,
@@ -20,13 +25,43 @@ interface AcpCommandOutputMessageProps {
msg: AcpToolCallUiMessage | AcpToolResultUiMessage;
ts?: number;
status?: AcpCommandStatus;
+ connected?: boolean;
+ connectionWasEstablished?: boolean;
+ canAbort?: boolean;
+ abortPending?: boolean;
+ onAbort?: () => void;
+ showOutput?: boolean;
+}
+
+function formatCommandDuration(durationMs: number): string {
+ const totalSeconds = Math.max(1, Math.floor(Math.max(0, durationMs) / 1000));
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ if (hours > 0) {
+ return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
+ }
+
+ if (minutes > 0) {
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
+ }
+
+ return `${totalSeconds}s`;
}
export const AcpCommandOutputMessage = ({
msg,
ts,
status = null,
+ connected = true,
+ connectionWasEstablished = false,
+ canAbort = false,
+ abortPending = false,
+ onAbort,
+ showOutput = false,
}: AcpCommandOutputMessageProps) => {
+ const [now, setNow] = useState(() => Date.now());
const cmd =
msg.kind === 'tool_result'
? {
@@ -45,22 +80,50 @@ export const AcpCommandOutputMessage = ({
: undefined;
const output = sanitizeSandboxPathString(cmd.text);
+ const hasOutput = output.trim().length > 0;
+ const outputVisible = showOutput && hasOutput;
const isExitCodePresent = cmd.exitCode !== undefined;
const isPending =
status === 'in_progress' || (status === null && !isExitCodePresent);
const isFailed =
status === 'failed' || (isExitCodePresent && cmd.exitCode !== 0);
+ const isDisconnected = isPending && connectionWasEstablished && !connected;
+ const startedAt = msg.startedAt ?? msg.ts;
+ const duration =
+ isPending || msg.startedAt !== undefined
+ ? formatCommandDuration((isPending ? now : msg.ts) - startedAt)
+ : null;
+ const quietDuration = formatCommandDuration(now - msg.ts);
+
+ useEffect(() => {
+ if (!isPending || isDisconnected) {
+ return;
+ }
+
+ const intervalId = window.setInterval(() => setNow(Date.now()), 1000);
+ return () => window.clearInterval(intervalId);
+ }, [isDisconnected, isPending]);
- const statusText = isPending
- ? null
- : isExitCodePresent
- ? cmd.exitCode === 0
- ? null
- : `exit ${cmd.exitCode}`
- : status === 'failed'
- ? 'failed'
- : null;
+ const statusText = isDisconnected
+ ? 'last known running · connection lost'
+ : isPending
+ ? now - msg.ts >= 15_000
+ ? `running ${duration} · last update ${quietDuration} ago`
+ : `running ${duration}`
+ : isExitCodePresent
+ ? cmd.exitCode === 0
+ ? duration
+ ? `completed in ${duration}`
+ : null
+ : duration
+ ? `exit ${cmd.exitCode} · ${duration}`
+ : `exit ${cmd.exitCode}`
+ : status === 'failed'
+ ? duration
+ ? `failed · ${duration}`
+ : 'failed'
+ : null;
return (
@@ -69,19 +132,19 @@ export const AcpCommandOutputMessage = ({
code={output}
language="bash"
variant="compact"
- collapsible={false}
- defaultCollapsed={false}
- forceDark={true}
- renderContent={false}
- maxHeight={undefined}
+ collapsible={outputVisible}
+ defaultCollapsed={!isPending}
+ renderContent={outputVisible}
+ maxHeight={240}
+ highlight={false}
command={command ?? ''}
showCommandCopy
- showOutputCopy={false}
+ showOutputCopy={outputVisible}
>
@@ -94,6 +157,23 @@ export const AcpCommandOutputMessage = ({
→ {statusText}
)}
+ {isPending && canAbort && onAbort && (
+
+ )}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx
new file mode 100644
index 000000000..b05bc8570
--- /dev/null
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.stories.tsx
@@ -0,0 +1,102 @@
+'use client';
+
+import type { Meta, StoryObj } from '@storybook/nextjs-vite';
+
+import { AcpGroupedToolMessage } from './AcpGroupedToolMessage';
+import type { GroupedToolCallRenderBlock } from './render-blocks';
+import type { AcpToolResultUiMessage } from './types';
+
+const startedAt = Date.now();
+
+function commandResult(
+ id: string,
+ command: string,
+ output: string,
+ status: 'completed' | 'in_progress',
+): AcpToolResultUiMessage {
+ return {
+ id,
+ ts: startedAt,
+ startedAt,
+ role: 'tool',
+ partial: status === 'in_progress',
+ sessionId: 'storybook-session',
+ updateType: 'roomote_runtime.tool_result',
+ kind: 'tool_result',
+ text: output,
+ data: {
+ toolCallId: id,
+ kind: 'execute_command',
+ title: command,
+ isExecute: true,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command,
+ exitCode: status === 'completed' ? 0 : null,
+ output,
+ status,
+ },
+ };
+}
+
+const group: GroupedToolCallRenderBlock = {
+ kind: 'tool_group',
+ id: 'grouped-commands',
+ ts: startedAt,
+ action: 'Running',
+ objectSummary: '2 commands',
+ groupKey: 'execute:storybook',
+ displayKind: 'execute',
+ items: [
+ {
+ objectLabel: 'pnpm install',
+ groupKey: 'execute:storybook',
+ displayKind: 'execute',
+ stepKind: null,
+ msg: commandResult(
+ 'install-command',
+ 'pnpm install',
+ 'Packages: +1842\nDone in 12.4s',
+ 'completed',
+ ),
+ },
+ {
+ objectLabel: 'pnpm check-types',
+ groupKey: 'execute:storybook',
+ displayKind: 'execute',
+ stepKind: null,
+ msg: commandResult(
+ 'typecheck-command',
+ 'pnpm check-types',
+ 'Packages in scope: 29\nRunning check-types in 29 packages...',
+ 'in_progress',
+ ),
+ },
+ ],
+};
+
+const meta: Meta = {
+ title: 'Surfaces/Task Workspace/ACP/GroupedToolMessage',
+ component: AcpGroupedToolMessage,
+ parameters: {
+ layout: 'padded',
+ },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export default meta;
+type Story = StoryObj;
+
+export const CommandOutputEnabled: Story = {
+ args: {
+ group,
+ showCommandOutput: true,
+ },
+};
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx
index 607db3dba..db03f7b3e 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpGroupedToolMessage.tsx
@@ -31,6 +31,7 @@ import type {
interface AcpGroupedToolMessageProps {
group: GroupedToolCallRenderBlock;
showSubagentPayload?: boolean;
+ showCommandOutput?: boolean;
}
const GROUPED_TOOL_ITEM_MAX_HEIGHT = 200;
@@ -38,6 +39,7 @@ const GROUPED_TOOL_ITEM_MAX_HEIGHT = 200;
export function AcpGroupedToolMessage({
group,
showSubagentPayload = false,
+ showCommandOutput = false,
}: AcpGroupedToolMessageProps) {
const anchorId = messageAnchorId(group.ts);
const objectSummary = sanitizeSandboxPathString(group.objectSummary);
@@ -94,6 +96,7 @@ export function AcpGroupedToolMessage({
);
const showItemDetails = !hidesExpandedToolResult(item.msg, {
showSubagentPayload,
+ showCommandOutput,
});
return (
@@ -110,6 +113,7 @@ export function AcpGroupedToolMessage({
msg={item.msg}
maxHeight={GROUPED_TOOL_ITEM_MAX_HEIGHT}
showSubagentPayload={showSubagentPayload}
+ showCommandOutput={showCommandOutput}
/>
) : null}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx
index ddc9bdcf1..82104ffea 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpMessageItem.tsx
@@ -12,6 +12,12 @@ import { AcpUnknownMessage } from './AcpUnknownMessage';
interface AcpMessageItemProps {
msg: AcpUiMessage;
onSuppress?: (messageId: string) => void;
+ commandConnected?: boolean;
+ commandConnectionWasEstablished?: boolean;
+ canAbortCommand?: boolean;
+ commandAbortPending?: boolean;
+ onAbortCommand?: () => void;
+ commandOutputVisible?: boolean;
showSubagentPayload?: boolean;
children?: ReactNode;
}
@@ -19,6 +25,12 @@ interface AcpMessageItemProps {
function AcpMessageItemBase({
msg,
onSuppress,
+ commandConnected,
+ commandConnectionWasEstablished,
+ canAbortCommand,
+ commandAbortPending,
+ onAbortCommand,
+ commandOutputVisible,
showSubagentPayload = false,
children,
}: AcpMessageItemProps) {
@@ -31,11 +43,17 @@ function AcpMessageItemBase({
return ;
case 'tool_call':
case 'tool_result': {
- return msg.data.kind === 'execute' ? (
+ return msg.data.isExecute ? (
) : (
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx
index 62416be0a..9ec65a864 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpToolDetails.tsx
@@ -27,21 +27,33 @@ interface AcpToolDetailsProps {
msg: AcpToolCallUiMessage | AcpToolResultUiMessage;
maxHeight?: number;
showSubagentPayload?: boolean;
+ showCommandOutput?: boolean;
}
export function AcpToolDetails({
msg,
maxHeight = 400,
showSubagentPayload = false,
+ showCommandOutput = false,
}: AcpToolDetailsProps) {
- if (hidesExpandedToolResult(msg, { showSubagentPayload })) {
+ if (
+ hidesExpandedToolResult(msg, {
+ showSubagentPayload,
+ showCommandOutput,
+ })
+ ) {
return null;
}
const sanitizedToolData = sanitizeSandboxPathsForDisplay(msg.data);
- const sanitizedText = msg.text
- ? sanitizeSandboxPathString(msg.text)
- : msg.text;
+ const displayText =
+ msg.text ||
+ (msg.kind === 'tool_result' && msg.data.isExecute
+ ? msg.data.output
+ : undefined);
+ const sanitizedText = displayText
+ ? sanitizeSandboxPathString(displayText)
+ : displayText;
const isSubagent = isSubagentToolPayload(msg.data);
const subagentPrompt = getSubagentPrompt(msg);
const subagentLastMessage = getSubagentLastMessage(msg);
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx
index 4f6f9bd35..e0ce0b191 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpCommandOutputMessage.client.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { fireEvent, render, screen } from '@testing-library/react';
import type { ReactNode } from 'react';
import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from '../types';
@@ -50,7 +50,11 @@ describe('AcpCommandOutputMessage', () => {
codeBlockCommandSpy.mockClear();
});
- it('renders command headers without expandable raw output', () => {
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders completed command output in a bounded collapsed viewer', () => {
const msg: AcpToolResultUiMessage = {
...baseMsg,
text: '$ pnpm test\nPASS src/example.test.ts',
@@ -70,19 +74,21 @@ describe('AcpCommandOutputMessage', () => {
},
};
- render();
+ render();
expect(codeBlockSpy).toHaveBeenCalledTimes(1);
expect(codeBlockSpy).toHaveBeenCalledWith(
expect.objectContaining({
- collapsible: false,
- defaultCollapsed: false,
- renderContent: false,
- maxHeight: undefined,
+ collapsible: true,
+ defaultCollapsed: true,
+ renderContent: true,
+ maxHeight: 240,
+ highlight: false,
showCommandCopy: true,
- showOutputCopy: false,
+ showOutputCopy: true,
}),
);
+ expect(codeBlockSpy.mock.calls[0]?.[0]).not.toHaveProperty('forceDark');
});
it('keeps command output blocks non-collapsible even when there is no output to show', () => {
@@ -104,7 +110,9 @@ describe('AcpCommandOutputMessage', () => {
},
};
- render();
+ render(
+ ,
+ );
expect(codeBlockSpy).toHaveBeenCalledTimes(1);
expect(codeBlockSpy).toHaveBeenCalledWith(
@@ -112,7 +120,7 @@ describe('AcpCommandOutputMessage', () => {
collapsible: false,
defaultCollapsed: false,
renderContent: false,
- maxHeight: undefined,
+ maxHeight: 240,
showOutputCopy: false,
}),
);
@@ -201,4 +209,225 @@ describe('AcpCommandOutputMessage', () => {
}),
);
});
+
+ it('shows elapsed and quiet time while a command is running', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(31_000);
+ const msg: AcpToolCallUiMessage = {
+ ...baseMsg,
+ ts: 11_000,
+ startedAt: 1_000,
+ kind: 'tool_call',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isRead: false,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ status: 'in_progress',
+ },
+ };
+
+ render();
+
+ expect(
+ screen.getByText('→ running 30s · last update 20s ago'),
+ ).toBeInTheDocument();
+ });
+
+ it('opens live command output as soon as content arrives', () => {
+ const msg: AcpToolResultUiMessage = {
+ ...baseMsg,
+ text: 'Counting objects: 42%\nCompressing objects: 12%',
+ kind: 'tool_result',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ exitCode: null,
+ output: 'Counting objects: 42%\nCompressing objects: 12%',
+ status: 'in_progress',
+ },
+ };
+
+ render(
+ ,
+ );
+
+ expect(codeBlockSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ code: 'Counting objects: 42%\nCompressing objects: 12%',
+ collapsible: true,
+ defaultCollapsed: false,
+ renderContent: true,
+ maxHeight: 240,
+ highlight: false,
+ showOutputCopy: true,
+ }),
+ );
+ });
+
+ it('keeps command output hidden when the preference is disabled', () => {
+ const msg: AcpToolResultUiMessage = {
+ ...baseMsg,
+ text: 'Counting objects: 42%',
+ kind: 'tool_result',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ exitCode: null,
+ output: 'Counting objects: 42%',
+ status: 'in_progress',
+ },
+ };
+
+ render();
+
+ expect(codeBlockSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ collapsible: false,
+ renderContent: false,
+ showOutputCopy: false,
+ }),
+ );
+ });
+
+ it('marks a pending command as last-known when the live connection is lost', () => {
+ const msg: AcpToolCallUiMessage = {
+ ...baseMsg,
+ kind: 'tool_call',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isRead: false,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ status: 'in_progress',
+ },
+ };
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.getByText('→ last known running · connection lost'),
+ ).toBeInTheDocument();
+ expect(codeBlockCommandSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ spinner: false }),
+ );
+ });
+
+ it('shows the final command duration', () => {
+ const msg: AcpToolResultUiMessage = {
+ ...baseMsg,
+ ts: 6_000,
+ startedAt: 1_000,
+ kind: 'tool_result',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ exitCode: 0,
+ output: '',
+ status: 'completed',
+ },
+ };
+
+ render();
+
+ expect(screen.getByText('→ completed in 5s')).toBeInTheDocument();
+ });
+
+ it('offers an abort action for the active command', () => {
+ const onAbort = vi.fn();
+ const msg: AcpToolCallUiMessage = {
+ ...baseMsg,
+ kind: 'tool_call',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isRead: false,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ status: 'in_progress',
+ },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Abort' }));
+ expect(onAbort).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps the abort action disabled while the turn is stopping', () => {
+ const msg: AcpToolCallUiMessage = {
+ ...baseMsg,
+ kind: 'tool_call',
+ data: {
+ toolCallId: 'tool-call-1',
+ kind: 'execute',
+ title: null,
+ isExecute: true,
+ isRead: false,
+ isMcp: false,
+ mcpServerName: null,
+ mcpToolName: null,
+ command: 'git push',
+ status: 'in_progress',
+ },
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'Stopping...' })).toBeDisabled();
+ });
});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx
index 6985d2161..b3eb9b220 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
import { AcpGroupedToolMessage } from '../AcpGroupedToolMessage';
import type { GroupedToolCallRenderBlock } from '../render-blocks';
+import type { AcpToolResultUiMessage } from '../types';
const codeBlockSpy = vi.fn();
@@ -115,6 +116,34 @@ function buildGroup(): GroupedToolCallRenderBlock {
};
}
+function buildExecuteGroup(): GroupedToolCallRenderBlock {
+ const group = buildGroup();
+
+ return {
+ ...group,
+ action: 'Running',
+ objectSummary: '2 commands',
+ displayKind: 'execute',
+ items: group.items.map((item, index) => ({
+ ...item,
+ objectLabel: `command ${index + 1}`,
+ displayKind: 'execute',
+ stepKind: null,
+ msg: {
+ ...item.msg,
+ text: `output ${index + 1}`,
+ data: {
+ ...item.msg.data,
+ kind: 'execute_command',
+ isExecute: true,
+ command: `echo ${index + 1}`,
+ output: `output ${index + 1}`,
+ },
+ } as AcpToolResultUiMessage,
+ })),
+ };
+}
+
describe('AcpGroupedToolMessage', () => {
beforeEach(() => {
codeBlockSpy.mockClear();
@@ -131,4 +160,23 @@ describe('AcpGroupedToolMessage', () => {
expect(codeBlockSpy).not.toHaveBeenCalled();
});
+
+ it('shows grouped command output only when the preference is enabled', () => {
+ const group = buildExecuteGroup();
+ const { rerender } = render();
+
+ expect(codeBlockSpy).not.toHaveBeenCalled();
+
+ rerender();
+
+ expect(codeBlockSpy).toHaveBeenCalledTimes(2);
+ expect(codeBlockSpy).toHaveBeenNthCalledWith(
+ 1,
+ expect.objectContaining({ code: 'output 1' }),
+ );
+ expect(codeBlockSpy).toHaveBeenNthCalledWith(
+ 2,
+ expect.objectContaining({ code: 'output 2' }),
+ );
+ });
});
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx
index 05a310a41..0aa73e239 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpMessageItem.client.test.tsx
@@ -92,6 +92,22 @@ describe('AcpMessageItem tool routing', () => {
expect(toolMessageSpy).not.toHaveBeenCalled();
});
+ it('renders alternate execute command kinds as command output', () => {
+ const msg = buildToolResult('execute_command', {
+ isExecute: true,
+ command: 'pnpm test',
+ exitCode: 0,
+ });
+
+ render();
+
+ expect(screen.getByText('command output')).toBeInTheDocument();
+ expect(commandOutputSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ msg }),
+ );
+ expect(toolMessageSpy).not.toHaveBeenCalled();
+ });
+
it.each(['read', 'search', 'mcp', 'subagent'])(
'renders %s tools as standard tool rows',
(kind) => {
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx
index 56f18cf1f..07255ce4b 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx
@@ -218,6 +218,42 @@ describe('AcpToolDetails', () => {
);
});
+ it('shows execute output when command output visibility is enabled', () => {
+ const msg = {
+ ...buildMessage({
+ kind: 'execute_command',
+ title: 'Run command',
+ isExecute: true,
+ command: 'pnpm test',
+ output: 'Tests passed',
+ }),
+ text: '',
+ };
+
+ render();
+
+ expect(codeBlockSpy).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'Tests passed' }),
+ );
+ expect(toolInputSpy).not.toHaveBeenCalled();
+ });
+
+ it('hides execute output when command output visibility is disabled', () => {
+ const msg = buildMessage({
+ kind: 'execute',
+ title: 'Run command',
+ isExecute: true,
+ command: 'pnpm test',
+ output: 'Tests passed',
+ });
+
+ const { container } = render();
+
+ expect(container).toBeEmptyDOMElement();
+ expect(codeBlockSpy).not.toHaveBeenCalled();
+ expect(toolInputSpy).not.toHaveBeenCalled();
+ });
+
it('hides expanded details for Roomote Slack lifecycle tools', () => {
const { container } = render(
;
+ if (isInternalDebugToolCallMessage(msg)) {
+ return true;
+ }
+
+ if (
+ msg.data.kind === 'execute' ||
+ msg.data.kind === 'execute_command' ||
+ data.isExecute === true
+ ) {
+ return options?.showCommandOutput !== true;
+ }
+
if (isSubagentToolPayload(msg.data)) {
if (options?.showSubagentPayload === true) {
return false;
@@ -81,12 +94,5 @@ export function hidesExpandedToolResult(
);
}
- return (
- isInternalDebugToolCallMessage(msg) ||
- msg.data.kind === 'read' ||
- msg.data.kind === 'execute' ||
- msg.data.kind === 'execute_command' ||
- data.isRead === true ||
- data.isExecute === true
- );
+ return msg.data.kind === 'read' || data.isRead === true;
}
diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts
index 37d77218b..2351fd494 100644
--- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts
+++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/types.ts
@@ -10,6 +10,8 @@ import type {
interface AcpUiMessageBase {
id: string;
ts: number;
+ /** First event timestamp for a tool call; `ts` continues to track its latest update. */
+ startedAt?: number;
role: Exclude;
partial: boolean;
isTurnCompletion?: boolean;
diff --git a/apps/web/src/components/ai-elements/code-block.client.test.tsx b/apps/web/src/components/ai-elements/code-block.client.test.tsx
index 737ce59e7..d09985f1b 100644
--- a/apps/web/src/components/ai-elements/code-block.client.test.tsx
+++ b/apps/web/src/components/ai-elements/code-block.client.test.tsx
@@ -11,6 +11,17 @@ const LONG_COMMAND =
'gh pr checks 1219 --repo Roomote/example-app --watch --required --interval 5 --json state,name,link';
describe('CodeBlock', () => {
+ it('uses theme colors for compact output', () => {
+ const { container } = render(
+ ,
+ );
+
+ const output = container.querySelector('[data-language="bash"] > div');
+
+ expect(output).toHaveClass('bg-muted/50', 'text-foreground');
+ expect(output).not.toHaveClass('bg-zinc-800', 'dark');
+ });
+
it('does not reserve collapsed header space for an invisible copy button', () => {
render(
div]:overflow-visible',
)}
style={{
diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx
index 6d0a893fa..8a879b781 100644
--- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx
+++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx
@@ -3,20 +3,28 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
type PersonalColorTheme = 'light' | 'dark' | 'system';
-const { colorThemeState, narrationModeState } = vi.hoisted(() => ({
- colorThemeState: {
- colorTheme: 'system' as PersonalColorTheme,
- isLoading: false,
- isUpdating: false,
- setColorTheme: vi.fn(),
- },
- narrationModeState: {
- enabled: false,
- isLoading: false,
- isUpdating: false,
- setEnabled: vi.fn(),
- },
-}));
+const { colorThemeState, narrationModeState, commandOutputState } = vi.hoisted(
+ () => ({
+ colorThemeState: {
+ colorTheme: 'system' as PersonalColorTheme,
+ isLoading: false,
+ isUpdating: false,
+ setColorTheme: vi.fn(),
+ },
+ narrationModeState: {
+ enabled: false,
+ isLoading: false,
+ isUpdating: false,
+ setEnabled: vi.fn(),
+ },
+ commandOutputState: {
+ enabled: false,
+ isLoading: false,
+ isUpdating: false,
+ setEnabled: vi.fn(),
+ },
+ }),
+);
vi.mock('@/hooks/useColorTheme', () => ({
useColorTheme: () => colorThemeState,
@@ -26,6 +34,10 @@ vi.mock('@/hooks/useNarrationMode', () => ({
useNarrationMode: () => narrationModeState,
}));
+vi.mock('@/hooks/useShowCommandOutput', () => ({
+ useShowCommandOutput: () => commandOutputState,
+}));
+
vi.mock('@/components/system', () => ({
Label: ({
children,
@@ -108,11 +120,15 @@ describe('UserPreferencesSection', () => {
narrationModeState.enabled = false;
narrationModeState.isLoading = false;
narrationModeState.isUpdating = false;
+ commandOutputState.enabled = false;
+ commandOutputState.isLoading = false;
+ commandOutputState.isUpdating = false;
});
it('renders user preference controls with the current state', () => {
colorThemeState.colorTheme = 'dark' as PersonalColorTheme;
narrationModeState.enabled = true;
+ commandOutputState.enabled = true;
render();
@@ -126,16 +142,22 @@ describe('UserPreferencesSection', () => {
),
).toBeInTheDocument();
expect(screen.getByLabelText('Toggle narration mode')).toBeChecked();
+ expect(screen.getByText('Show command output')).toHaveClass(
+ 'font-semibold',
+ );
+ expect(screen.getByLabelText('Toggle command output')).toBeChecked();
});
it('disables controls while the corresponding preference is loading or updating', () => {
colorThemeState.isLoading = true;
narrationModeState.isUpdating = true;
+ commandOutputState.isLoading = true;
render();
expect(screen.getByLabelText('Color theme')).toBeDisabled();
expect(screen.getByLabelText('Toggle narration mode')).toBeDisabled();
+ expect(screen.getByLabelText('Toggle command output')).toBeDisabled();
});
it('updates the color theme immediately when a different option is selected', () => {
@@ -156,6 +178,14 @@ describe('UserPreferencesSection', () => {
expect(narrationModeState.setEnabled).toHaveBeenCalledWith(true);
});
+ it('updates command output visibility immediately when the switch changes', () => {
+ render();
+
+ fireEvent.click(screen.getByLabelText('Toggle command output'));
+
+ expect(commandOutputState.setEnabled).toHaveBeenCalledWith(true);
+ });
+
it('renders theme choices in a dropdown', () => {
render();
diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx
index a5b59edcd..d22cd8250 100644
--- a/apps/web/src/components/settings/UserPreferencesSection.tsx
+++ b/apps/web/src/components/settings/UserPreferencesSection.tsx
@@ -2,6 +2,7 @@
import { useColorTheme } from '@/hooks/useColorTheme';
import { useNarrationMode } from '@/hooks/useNarrationMode';
+import { useShowCommandOutput } from '@/hooks/useShowCommandOutput';
import type { PersonalColorTheme } from '@/types/preferences';
import {
@@ -34,6 +35,12 @@ export function UserPreferencesSection() {
setColorTheme,
} = useColorTheme();
const { enabled, isLoading, isUpdating, setEnabled } = useNarrationMode();
+ const {
+ enabled: commandOutputEnabled,
+ isLoading: isCommandOutputLoading,
+ isUpdating: isCommandOutputUpdating,
+ setEnabled: setCommandOutputEnabled,
+ } = useShowCommandOutput();
const isThemeDisabled = isThemeLoading || isThemeUpdating;
return (
@@ -84,6 +91,24 @@ export function UserPreferencesSection() {
+
+