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
Original file line number Diff line number Diff line change
Expand Up @@ -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"] {
Expand Down
113 changes: 103 additions & 10 deletions src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ExecProcessToolCardView toolItem={toolItem(status)} model={model} />);
});
expect(container.querySelector(expandedSurface)).toBeNull();
}

act(() => {
root.render(<ExecProcessToolCardView toolItem={{ ...toolItem('running'), [field]: field === '_progressLogs' ? [''] : '' } as FlowToolItem} model={model} />);
});
expect(container.querySelector(expandedSurface)).toBeNull();

act(() => {
root.render(<ExecProcessToolCardView toolItem={{ ...toolItem('running'), [field]: field === '_progressLogs' ? ['hello'] : 'hello' } as FlowToolItem} model={model} />);
});
expect(container.querySelector(expandedSurface)).not.toBeNull();
expect(container.textContent).toContain('hello');
act(() => { vi.advanceTimersByTime(1000); });
expect(container.querySelector(expandedSurface)).not.toBeNull();

act(() => {
root.render(<ExecProcessToolCardView toolItem={toolItem('completed')} model={{ ...model, resultOutput: 'hello' }} />);
});
expect(container.querySelector(expandedSurface)).toBeNull();
});

it('stays collapsed when buffered output arrives only on completion', () => {
act(() => {
root.render(<ExecProcessToolCardView toolItem={toolItem('running')} model={model} isLastItem />);
});
expect(container.querySelector(expandedSurface)).toBeNull();
act(() => {
root.render(<ExecProcessToolCardView toolItem={toolItem('completed')} model={{ ...model, resultOutput: 'buffered output' }} isLastItem />);
});
expect(container.querySelector(expandedSurface)).toBeNull();
});

it('respects manual collapse when more output arrives', () => {
act(() => {
root.render(<ExecProcessToolCardView toolItem={{ ...toolItem('running'), _progressLogs: ['hello'] } as FlowToolItem} model={model} />);
});
act(() => {
container.querySelector<HTMLElement>(expandedSurface)!.click();
});
act(() => {
root.render(<ExecProcessToolCardView toolItem={{ ...toolItem('running'), _progressLogs: ['hello', 'world'] } as FlowToolItem} model={model} />);
});
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(<ExecProcessToolCardView
toolItem={{ ...toolItem(status), _progressLogs: [output] } as FlowToolItem}
model={{ ...model, resultOutput: output }}
isLastItem={tail}
/>);
};
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(<ExecProcessToolCardView toolItem={{ ...toolItem('running'), _progressLogs: ['hello'] } as FlowToolItem} model={model} />);
});
act(() => { vi.advanceTimersByTime(100); });
act(() => {
root.render(<ExecProcessToolCardView toolItem={toolItem('completed')} model={{ ...model, resultOutput: 'hello' }} />);
});
act(() => { container.querySelector<HTMLElement>(expandedSurface)!.click(); });
expect(container.querySelector(expandedSurface)).toBeNull();
act(() => {
container.querySelector<HTMLElement>('[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(<ExecProcessToolCardView toolItem={toolItem('running', true)} model={model} />);
Expand Down Expand Up @@ -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(
<ExecProcessToolCardView
toolItem={toolItem('running')}
toolItem={{ ...toolItem('running'), _progressLogs: ['All tests passed'] } as FlowToolItem}
model={resultModel}
isLastItem
/>,
Expand Down Expand Up @@ -328,17 +420,17 @@ describe('ExecProcessToolCardView', () => {
};

act(() => {
root.render(<ExecProcessToolCardView toolItem={toolItem('running')} model={model} />);
root.render(<ExecProcessToolCardView toolItem={streamingItem} model={model} />);
});
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(<ExecProcessToolCardView toolItem={streamingItem} model={model} />);
root.render(<ExecProcessToolCardView toolItem={{ ...streamingItem, _progressLogs: ['more output'] } as FlowToolItem} model={model} />);
});
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);
Expand All @@ -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,
Expand All @@ -369,26 +461,27 @@ describe('ExecProcessToolCardView', () => {
act(() => {
root.render(
<ExecProcessToolCardView
toolItem={toolItem('running')}
toolItem={{ ...toolItem('running'), _progressLogs: ['All tests passed'] } as FlowToolItem}
model={resultModel}
isLastItem
isLastItem={isLastItem}
/>,
);
});

act(() => { vi.advanceTimersByTime(100); });
act(() => {
root.render(
<ExecProcessToolCardView
toolItem={toolItem('completed')}
model={resultModel}
isLastItem
isLastItem={isLastItem}
/>,
);
});
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();

Expand Down
47 changes: 41 additions & 6 deletions src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -129,9 +132,12 @@ export const ExecProcessToolCardView: React.FC<ExecProcessToolCardViewProps> = (
? '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<number | null>(null);
const outputRendererRef = useRef<TerminalOutputRendererHandle | null>(null);
const { cardRootRef, applyExpandedState } = useToolCardHeightContract({
toolId,
Expand All @@ -142,6 +148,7 @@ export const ExecProcessToolCardView: React.FC<ExecProcessToolCardViewProps> = (
isActive: isCompletionPreviewActive,
} = useToolCardCompletionGracePeriod({
eligible:
!waitForOutput &&
isCollapsedStatus(status) &&
isLastItem === true &&
isExpanded &&
Expand All @@ -166,22 +173,50 @@ export const ExecProcessToolCardView: React.FC<ExecProcessToolCardViewProps> = (
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
Expand Down
Loading