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