diff --git a/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.module.css b/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.module.css
index 694c26f7e0..f7b70f845e 100644
--- a/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.module.css
+++ b/design-system/packages/ui/src/flow-chat/tool-cards/CommandToolCard.module.css
@@ -68,7 +68,7 @@
overflow: hidden;
min-block-size: 0;
border-radius: var(--openbitfun-radius-sm);
- background: var(--openbitfun-color-surface-canvas);
+ background: var(--openbitfun-color-surface-scene);
}
.outputFrame[data-density="compact"] {
diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx
index 326ec2a51d..80c88b109f 100644
--- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx
+++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx
@@ -99,6 +99,97 @@ describe('ExecProcessToolCardView', () => {
vi.unstubAllGlobals();
});
+ const expandedSurface = '[data-openbitfun-part="surface"][data-openbitfun-state~="expanded"]';
+
+ it.each(['_progressLogs', '_progressMessage'])('expands only when live output arrives through %s, then collapses on completion', (field) => {
+ vi.useFakeTimers();
+ for (const status of ['preparing', 'streaming', 'running', 'receiving'] as const) {
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ }
+
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).not.toBeNull();
+ expect(container.textContent).toContain('hello');
+ act(() => { vi.advanceTimersByTime(1000); });
+ expect(container.querySelector(expandedSurface)).not.toBeNull();
+
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ });
+
+ it('stays collapsed when buffered output arrives only on completion', () => {
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ });
+
+ it('respects manual collapse when more output arrives', () => {
+ act(() => {
+ root.render();
+ });
+ act(() => {
+ container.querySelector(expandedSurface)!.click();
+ });
+ act(() => {
+ root.render();
+ });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ });
+
+ it('does not restart the minimum duration for more output or a tail change', () => {
+ vi.useFakeTimers();
+ const render = (status: FlowToolItem['status'], output: string, tail: boolean) => {
+ root.render();
+ };
+ act(() => { render('running', 'first', true); });
+ act(() => { vi.advanceTimersByTime(100); });
+ act(() => { render('running', 'first\nsecond', false); });
+ act(() => { render('completed', 'first\nsecond', false); });
+ act(() => { vi.advanceTimersByTime(899); });
+ expect(container.querySelector(expandedSurface)).not.toBeNull();
+ act(() => { vi.advanceTimersByTime(1); });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ });
+
+ it('lets manual toggles override a pending automatic collapse', () => {
+ vi.useFakeTimers();
+ act(() => {
+ root.render();
+ });
+ act(() => { vi.advanceTimersByTime(100); });
+ act(() => {
+ root.render();
+ });
+ act(() => { container.querySelector(expandedSurface)!.click(); });
+ expect(container.querySelector(expandedSurface)).toBeNull();
+ act(() => {
+ container.querySelector('[data-openbitfun-part="surface"][data-openbitfun-attention="prominent"]')!.click();
+ });
+ act(() => { vi.advanceTimersByTime(1000); });
+ expect(container.querySelector(expandedSurface)).not.toBeNull();
+ });
+
it('shows cancelled state instead of receiving params when a stale streaming flag remains', () => {
act(() => {
root.render();
@@ -182,16 +273,17 @@ describe('ExecProcessToolCardView', () => {
expect(container.querySelector('[data-openbitfun-component="command-tool-card"] [data-openbitfun-part="output"] pre')).toBeNull();
});
- it('retains a just-completed tail result during the grace period', () => {
+ it('retains a just-completed WriteStdin tail result during the grace period', () => {
const resultModel: ExecProcessCardModel = {
...model,
+ kind: 'stdin',
resultOutput: 'All tests passed',
};
act(() => {
root.render(
,
@@ -328,17 +420,17 @@ describe('ExecProcessToolCardView', () => {
};
act(() => {
- root.render();
+ root.render();
});
const frameBeforeOutput = container.querySelector('[data-openbitfun-component="command-tool-card"] [data-openbitfun-part="outputFrame"]');
const footerBeforeOutput = container.querySelector('[data-openbitfun-component="command-tool-card"] [data-openbitfun-part="footer"]');
expect(frameBeforeOutput?.getAttribute('data-density')).toBe('compact');
expect(frameBeforeOutput?.getAttribute('data-sizing')).toBe('fixed');
expect(footerBeforeOutput?.textContent).toBe('');
- expect(container.querySelector('[data-openbitfun-part="output"] pre')).toBeNull();
+ expect(container.querySelector('[data-openbitfun-part="output"] pre')).not.toBeNull();
act(() => {
- root.render();
+ root.render();
});
expect(container.querySelector('[data-openbitfun-component="command-tool-card"] [data-openbitfun-part="outputFrame"]')).toBe(frameBeforeOutput);
expect(container.querySelector('[data-openbitfun-component="command-tool-card"] [data-openbitfun-part="footer"]')).toBe(footerBeforeOutput);
@@ -359,7 +451,7 @@ describe('ExecProcessToolCardView', () => {
expect(container.querySelector('[data-openbitfun-component="command-tool-card"] [data-openbitfun-part="outputFrame"]')?.getAttribute('data-density')).toBe('compact');
});
- it('collapses a completed tail result when the grace period expires', () => {
+ it.each([true, false])('keeps a command expanded for 1000ms from first output (tail=%s)', (isLastItem) => {
vi.useFakeTimers();
const resultModel: ExecProcessCardModel = {
...model,
@@ -369,26 +461,27 @@ describe('ExecProcessToolCardView', () => {
act(() => {
root.render(
,
);
});
+ act(() => { vi.advanceTimersByTime(100); });
act(() => {
root.render(
,
);
});
expect(vi.getTimerCount()).toBeGreaterThan(0);
act(() => {
- vi.advanceTimersByTime(799);
+ vi.advanceTimersByTime(899);
});
expect(container.querySelector('[data-openbitfun-part="surface"][data-openbitfun-attention="prominent"][data-openbitfun-state~="expanded"]')).not.toBeNull();
diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx
index 4ad3c00544..47514503f3 100644
--- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx
+++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx
@@ -19,6 +19,7 @@ import { formatSessionViewPreviewText } from '../utils/sessionViewPreview';
const EXEC_COLLAPSED_STATUSES = new Set(['completed', 'cancelled', 'error', 'rejected']);
const EXEC_OUTPUT_STREAMING_MAX_ROWS = 4;
const EXEC_OUTPUT_EXPANDED_MAX_ROWS = 15;
+const EXEC_MINIMUM_EXPANDED_MS = 1000;
export interface ExecProcessCardModel {
kind: 'command' | 'stdin' | 'control';
@@ -50,21 +51,23 @@ function isCollapsedStatus(status: string): boolean {
return EXEC_COLLAPSED_STATUSES.has(status);
}
-function getInitialExpandedState(status: string): boolean {
- return !isCollapsedStatus(status);
+function getInitialExpandedState(status: string, waitForOutput: boolean, hasLiveOutput: boolean): boolean {
+ return !isCollapsedStatus(status) && (!waitForOutput || hasLiveOutput || status === 'pending_confirmation');
}
function getAutoExpandedStateForStatus(
status: string,
isLastItem: boolean | undefined,
keepTailPreview: boolean,
+ hasLiveOutput: boolean,
+ waitForOutput: boolean,
): boolean | null {
if (isCollapsedStatus(status)) {
return isLastItem === true && keepTailPreview ? null : false;
}
if (status === 'preparing' || status === 'streaming' || status === 'running' || status === 'receiving') {
- return true;
+ return !waitForOutput || hasLiveOutput;
}
return null;
@@ -129,9 +132,12 @@ export const ExecProcessToolCardView: React.FC = (
? 'toolCards.terminal.rejected'
: 'toolCards.terminal.cancelled';
const toolId = toolItem.id ?? toolItem.toolCall?.id;
+ const waitForOutput = model.kind === 'command';
+ const hasLiveOutput = liveOutput.length > 0;
- const [isExpanded, setIsExpandedState] = useState(() => getInitialExpandedState(status));
+ const [isExpanded, setIsExpandedState] = useState(() => getInitialExpandedState(status, waitForOutput, hasLiveOutput));
const userToggledRef = useRef(false);
+ const autoExpandedAtRef = useRef(null);
const outputRendererRef = useRef(null);
const { cardRootRef, applyExpandedState } = useToolCardHeightContract({
toolId,
@@ -142,6 +148,7 @@ export const ExecProcessToolCardView: React.FC = (
isActive: isCompletionPreviewActive,
} = useToolCardCompletionGracePeriod({
eligible:
+ !waitForOutput &&
isCollapsedStatus(status) &&
isLastItem === true &&
isExpanded &&
@@ -166,22 +173,50 @@ export const ExecProcessToolCardView: React.FC = (
return;
}
+ if (waitForOutput) {
+ if (isRunning && hasLiveOutput) {
+ // Start once, including when mounting with output already available.
+ autoExpandedAtRef.current ??= Date.now();
+ applyExecExpandedState(true);
+ } else if (isCollapsedStatus(status)) {
+ const remainingMs = autoExpandedAtRef.current === null
+ ? 0
+ : EXEC_MINIMUM_EXPANDED_MS - (Date.now() - autoExpandedAtRef.current);
+ if (isExpanded && remainingMs > 0) {
+ const timer = setTimeout(() => {
+ if (!userToggledRef.current) {
+ applyExecExpandedState(false);
+ }
+ }, remainingMs);
+ return () => clearTimeout(timer);
+ }
+ applyExecExpandedState(false);
+ } else if (isRunning && autoExpandedAtRef.current === null) {
+ applyExecExpandedState(false);
+ }
+ return;
+ }
+
const keepTailPreview = isCollapsedStatus(status) && beginCompletionPreview();
- const nextExpanded = getAutoExpandedStateForStatus(status, isLastItem, keepTailPreview);
+ const nextExpanded = getAutoExpandedStateForStatus(status, isLastItem, keepTailPreview, hasLiveOutput, waitForOutput);
if (nextExpanded !== null) {
applyExecExpandedState(nextExpanded);
}
}, [
applyExecExpandedState,
beginCompletionPreview,
+ hasLiveOutput,
isCompletionPreviewActive,
+ isExpanded,
+ isRunning,
isLastItem,
status,
+ waitForOutput,
]);
const compactSettledPreview =
isExpanded &&
- isLastItem === true &&
+ (waitForOutput || isLastItem === true) &&
isCollapsedStatus(status) &&
!userToggledRef.current;
// Keep auto-managed completed cards on the compact preview through the