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 @@ -63,7 +63,7 @@ pub async fn handle_anthropic_stream(
}
};

trace!(target: AI_STREAM_RESPONSE_TARGET, "Anthropic SSE event: {}", sse.event);
trace!(target: AI_STREAM_RESPONSE_TARGET, "Anthropic SSE: {:?}", sse);
let event_type = sse.event;
let data = sse.data;
stats.record_sse_event(&event_type);
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import './BaseToolCard.scss';
const LOADING_SHIMMER_STATUSES = new Set([
'preparing',
'streaming',
'receiving',
'running',
'analyzing',
]);
Expand All @@ -25,7 +26,7 @@ function statusUsesLoadingShimmer(status: string): boolean {

export interface BaseToolCardProps {
/** Tool status */
status: 'pending' | 'preparing' | 'streaming' | 'running' | 'completed' | 'error' | 'cancelled' | 'analyzing' | 'pending_confirmation' | 'confirmed';
status: 'pending' | 'preparing' | 'streaming' | 'receiving' | 'running' | 'completed' | 'error' | 'cancelled' | 'analyzing' | 'pending_confirmation' | 'confirmed';
/** Whether expanded */
isExpanded?: boolean;
/** Card click callback */
Expand Down
2 changes: 2 additions & 0 deletions src/web-ui/src/flow_chat/tool-cards/CompactToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CompactToolCardProps {
| 'pending'
| 'preparing'
| 'streaming'
| 'receiving'
| 'running'
| 'completed'
| 'error'
Expand Down Expand Up @@ -56,6 +57,7 @@ export const CompactToolCard: React.FC<CompactToolCardProps> = ({
const loadingShimmer =
status === 'preparing' ||
status === 'streaming' ||
status === 'receiving' ||
status === 'running' ||
status === 'analyzing';

Expand Down
84 changes: 17 additions & 67 deletions src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
/**
* Terminal tool card component
* Displays command execution output (streaming progress + final result)
* Displays command execution lifecycle:
* - receive tool parameters
* - wait for terminal output after launch
* - stream real output and final result
*
* Design notes:
* - Final lifecycle always comes from backend tool status
Expand All @@ -12,7 +15,6 @@

import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import type { ToolCardProps } from '../types/flow-chat';
import { Terminal, Play, X, ExternalLink, Square } from 'lucide-react';
import { createTerminalTab } from '@/shared/utils/tabUtils';
Expand All @@ -21,6 +23,7 @@ import { CubeLoading, IconButton, Tooltip } from '../../component-library';
import { TerminalOutputRenderer } from '@/tools/terminal/components';
import { createLogger } from '@/shared/utils/logger';
import { useToolCardHeightContract, type ToolCardCollapseReason } from './useToolCardHeightContract';
import { getTerminalViewState, type TerminalViewState } from './terminalToolCardState';
import './TerminalToolCard.scss';

const log = createLogger('TerminalToolCard');
Expand All @@ -44,19 +47,6 @@ interface ParsedTerminalResult {
terminalSessionId?: string;
}

interface TerminalViewState {
isLoading: boolean;
isFailed: boolean;
showInterruptButton: boolean;
showLiveOutput: boolean;
showWaiting: boolean;
showCompletedResult: boolean;
showCancelledResult: boolean;
hasHeaderExtra: boolean;
statusLabel: string | null;
statusClassName: string | null;
}

function normalizeTerminalSessionId(value: unknown): string | undefined {
if (typeof value !== 'string' || value.startsWith('FlowChat-')) {
return undefined;
Expand Down Expand Up @@ -85,60 +75,18 @@ function getAutoExpandedStateForTerminalStatus(status: string): boolean | null {
return null;
}

function getTerminalViewState(params: {
status: string;
liveOutput: string;
interruptRequested: boolean;
showConfirmButtons: boolean;
wasInterrupted: boolean;
t: TFunction<'flow-chat'>;
}): TerminalViewState {
const { status, liveOutput, interruptRequested, showConfirmButtons, wasInterrupted, t } = params;
const isRunning = status === 'running';
const isStreaming = status === 'streaming';
const isActive = isRunning || isStreaming;
const isLoading = status === 'preparing' || isActive;
const showInterruptButton = isRunning && !interruptRequested;

let statusLabel: string | null = null;
let statusClassName: string | null = null;

if (status === 'rejected') {
statusLabel = t('toolCards.terminal.rejected');
statusClassName = 'status-rejected';
} else if ((interruptRequested && isRunning) || wasInterrupted || status === 'cancelled') {
statusLabel = t('toolCards.terminal.cancelled');
statusClassName = 'status-cancelled';
} else if (status === 'error') {
statusLabel = t('toolCards.terminal.failed');
statusClassName = 'status-error';
}

return {
isLoading,
isFailed: status === 'error',
showInterruptButton,
showLiveOutput: isActive && liveOutput.length > 0,
showWaiting: isActive && liveOutput.length === 0,
showCompletedResult: status === 'completed',
showCancelledResult: status === 'cancelled' && liveOutput.length > 0,
hasHeaderExtra: Boolean(statusLabel || showConfirmButtons || showInterruptButton),
statusLabel,
statusClassName,
};
}

function renderTerminalExpandedContent(params: {
viewState: TerminalViewState;
liveOutput: string;
parsedResult: ParsedTerminalResult;
t: TFunction<'flow-chat'>;
waitingMessage: string | null;
t: (key: string, options?: Record<string, unknown>) => string;
}): React.ReactNode {
const { viewState, liveOutput, parsedResult, t } = params;
const { viewState, liveOutput, parsedResult, waitingMessage, t } = params;

return (
<>
{viewState.showLiveOutput && (
{viewState.displayPhase === 'live_output' && (
<div className="terminal-execution-output">
<TerminalOutputRenderer
content={liveOutput}
Expand All @@ -148,9 +96,9 @@ function renderTerminalExpandedContent(params: {
</div>
)}

{viewState.showWaiting && (
{(viewState.displayPhase === 'receiving_params' || viewState.displayPhase === 'executing') && waitingMessage && (
<div className="terminal-execution-output terminal-waiting">
<span className="waiting-text">{t('toolCards.terminal.executingCommand')}</span>
<span className="waiting-text">{waitingMessage}</span>
</div>
)}

Expand Down Expand Up @@ -267,6 +215,7 @@ export const TerminalToolCard: React.FC<TerminalToolCardProps> = ({
const toolResult = toolItem.toolResult;
const command = toolCall?.input?.command;
const status = toolItem.status || 'pending';
const isParamsStreaming = Boolean(toolItem.isParamsStreaming);
const progressMessage = typeof (toolItem as any)._progressMessage === 'string'
? (toolItem as any)._progressMessage
: '';
Expand Down Expand Up @@ -398,19 +347,20 @@ export const TerminalToolCard: React.FC<TerminalToolCardProps> = ({
return getTerminalViewState({
status,
liveOutput,
isParamsStreaming,
interruptRequested,
showConfirmButtons,
wasInterrupted: parsedResult.wasInterrupted,
t,
});
}, [
isParamsStreaming,
interruptRequested,
liveOutput,
parsedResult.wasInterrupted,
showConfirmButtons,
status,
t,
]);
const waitingMessage = viewState.waitingMessageKey ? t(viewState.waitingMessageKey) : null;

const handleExecute = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
Expand Down Expand Up @@ -524,7 +474,7 @@ export const TerminalToolCard: React.FC<TerminalToolCardProps> = ({

return (
<span className={`terminal-status-text ${viewState.statusClassName}`}>
{viewState.statusLabel}
{t(`toolCards.terminal.${viewState.statusLabel}`)}
</span>
);
};
Expand Down Expand Up @@ -584,7 +534,7 @@ export const TerminalToolCard: React.FC<TerminalToolCardProps> = ({
/>
);
const expandedContent = isExpanded
? renderTerminalExpandedContent({ viewState, liveOutput, parsedResult, t })
? renderTerminalExpandedContent({ viewState, liveOutput, parsedResult, waitingMessage, t })
: null;
const errorContent = viewState.isFailed
? renderTerminalErrorContent(toolResult?.error || t('toolCards.terminal.executionFailed'))
Expand Down
61 changes: 61 additions & 0 deletions src/web-ui/src/flow_chat/tool-cards/terminalToolCardState.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';

import { getTerminalViewState } from './terminalToolCardState';

describe('terminalToolCardState', () => {
it('shows receiving params while bash input is still streaming', () => {
const state = getTerminalViewState({
status: 'streaming',
liveOutput: '',
isParamsStreaming: true,
interruptRequested: false,
showConfirmButtons: false,
wasInterrupted: false,
});

expect(state.displayPhase).toBe('receiving_params');
expect(state.waitingMessageKey).toBe('toolCards.terminal.receivingParams');
});

it('shows executing after params finish but before command output arrives', () => {
const state = getTerminalViewState({
status: 'running',
liveOutput: '',
isParamsStreaming: false,
interruptRequested: false,
showConfirmButtons: false,
wasInterrupted: false,
});

expect(state.displayPhase).toBe('executing');
expect(state.waitingMessageKey).toBe('toolCards.terminal.executingCommand');
});

it('prefers real terminal output even if params streaming flag lags behind', () => {
const state = getTerminalViewState({
status: 'streaming',
liveOutput: 'npm test\n',
isParamsStreaming: true,
interruptRequested: false,
showConfirmButtons: false,
wasInterrupted: false,
});

expect(state.displayPhase).toBe('live_output');
expect(state.waitingMessageKey).toBeNull();
});

it('switches to completed result once the tool finishes', () => {
const state = getTerminalViewState({
status: 'completed',
liveOutput: 'partial output',
isParamsStreaming: false,
interruptRequested: false,
showConfirmButtons: false,
wasInterrupted: false,
});

expect(state.displayPhase).toBe('completed');
expect(state.showCompletedResult).toBe(true);
});
});
Loading
Loading