diff --git a/src/web-ui/src/component-library/components/FlowChatCards/BaseToolCard/BaseToolCard.tsx b/src/web-ui/src/component-library/components/FlowChatCards/BaseToolCard/BaseToolCard.tsx index baa4b7ec1..a17176f40 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/BaseToolCard/BaseToolCard.tsx +++ b/src/web-ui/src/component-library/components/FlowChatCards/BaseToolCard/BaseToolCard.tsx @@ -4,8 +4,9 @@ */ import React from 'react'; -import { Loader2, CheckCircle, XCircle, Clock } from 'lucide-react'; +import { Loader2, CheckCircle, XCircle } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; +import { ToolProcessingDots } from '../ToolProcessingDots'; import './BaseToolCard.scss'; export interface BaseToolCardProps { @@ -63,7 +64,7 @@ export const BaseToolCard: React.FC = ({ case 'error': return ; default: - return ; + return ; } }; diff --git a/src/web-ui/src/component-library/components/FlowChatCards/SearchCard/SearchCard.tsx b/src/web-ui/src/component-library/components/FlowChatCards/SearchCard/SearchCard.tsx index e75734d38..edc5dd063 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/SearchCard/SearchCard.tsx +++ b/src/web-ui/src/component-library/components/FlowChatCards/SearchCard/SearchCard.tsx @@ -4,9 +4,10 @@ */ import React, { useState, useMemo } from 'react'; -import { Search, File, FolderOpen, ChevronDown, ChevronUp, Loader2, CheckCircle, XCircle, Clock } from 'lucide-react'; +import { Search, File, FolderOpen, ChevronDown, ChevronUp, Loader2, CheckCircle, XCircle } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { BaseToolCard, BaseToolCardProps } from '../BaseToolCard'; +import { ToolProcessingDots } from '../ToolProcessingDots'; import './SearchCard.scss'; export interface SearchCardProps extends Omit { @@ -98,7 +99,7 @@ export const SearchCard: React.FC = ({ case 'error': return ; default: - return ; + return ; } }; diff --git a/src/web-ui/src/component-library/components/FlowChatCards/SnapshotCard/SnapshotCard.tsx b/src/web-ui/src/component-library/components/FlowChatCards/SnapshotCard/SnapshotCard.tsx index e355d61c0..7310c94eb 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/SnapshotCard/SnapshotCard.tsx +++ b/src/web-ui/src/component-library/components/FlowChatCards/SnapshotCard/SnapshotCard.tsx @@ -4,9 +4,10 @@ */ import React from 'react'; -import { CheckCircle, XCircle, Maximize2, FileText, Loader2, Clock } from 'lucide-react'; +import { CheckCircle, XCircle, Maximize2, FileText, Loader2 } from 'lucide-react'; import { useI18n } from '@/infrastructure/i18n'; import { BaseToolCard, BaseToolCardProps } from '../BaseToolCard'; +import { ToolProcessingDots } from '../ToolProcessingDots'; import './SnapshotCard.scss'; export interface SnapshotCardProps extends Omit { @@ -71,7 +72,7 @@ export const SnapshotCard: React.FC = ({ case 'error': return ; default: - return ; + return ; } }; diff --git a/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/ToolProcessingDots.scss b/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/ToolProcessingDots.scss new file mode 100644 index 000000000..bf9bb2eb1 --- /dev/null +++ b/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/ToolProcessingDots.scss @@ -0,0 +1,75 @@ +/** + * Three-dot "processing" indicator for tool cards + */ +.bitfun-tool-processing-dots { + display: inline-flex; + align-items: center; + justify-content: center; + color: inherit; + vertical-align: middle; +} + +.bitfun-tool-processing-dots__dot { + flex-shrink: 0; + border-radius: 50%; + background: currentColor; + animation: bitfun-tool-processing-dots-bounce 1.05s ease-in-out infinite; + + &:nth-child(2) { + animation-delay: 0.15s; + } + + &:nth-child(3) { + animation-delay: 0.3s; + } +} + +.bitfun-tool-processing-dots--s10 { + gap: 1px; + + .bitfun-tool-processing-dots__dot { + width: 2px; + height: 2px; + } +} + +.bitfun-tool-processing-dots--s12 { + gap: 2px; + + .bitfun-tool-processing-dots__dot { + width: 2px; + height: 2px; + } +} + +.bitfun-tool-processing-dots--s14 { + gap: 2px; + + .bitfun-tool-processing-dots__dot { + width: 2.5px; + height: 2.5px; + } +} + +.bitfun-tool-processing-dots--s16 { + gap: 3px; + + .bitfun-tool-processing-dots__dot { + width: 3px; + height: 3px; + } +} + +@keyframes bitfun-tool-processing-dots-bounce { + 0%, + 60%, + 100% { + transform: translateY(0); + opacity: 0.55; + } + + 30% { + transform: translateY(-2px); + opacity: 1; + } +} diff --git a/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/ToolProcessingDots.tsx b/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/ToolProcessingDots.tsx new file mode 100644 index 000000000..60c1e4479 --- /dev/null +++ b/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/ToolProcessingDots.tsx @@ -0,0 +1,29 @@ +/** + * Compact three-dot pulse for tool "pending / parsing" states (replaces clock icon). + */ + +import React from 'react'; +import './ToolProcessingDots.scss'; + +export type ToolProcessingDotsSize = 10 | 12 | 14 | 16; + +export interface ToolProcessingDotsProps { + /** Visual scale aligned with common lucide-react icon sizes in tool headers */ + size?: ToolProcessingDotsSize; + className?: string; +} + +export const ToolProcessingDots: React.FC = ({ + size = 14, + className = '', +}) => ( + + + + + +); diff --git a/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/index.ts b/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/index.ts new file mode 100644 index 000000000..e8931ecef --- /dev/null +++ b/src/web-ui/src/component-library/components/FlowChatCards/ToolProcessingDots/index.ts @@ -0,0 +1,2 @@ +export { ToolProcessingDots } from './ToolProcessingDots'; +export type { ToolProcessingDotsProps, ToolProcessingDotsSize } from './ToolProcessingDots'; diff --git a/src/web-ui/src/component-library/components/FlowChatCards/index.ts b/src/web-ui/src/component-library/components/FlowChatCards/index.ts index af7abc907..ae8eaaa4e 100644 --- a/src/web-ui/src/component-library/components/FlowChatCards/index.ts +++ b/src/web-ui/src/component-library/components/FlowChatCards/index.ts @@ -8,6 +8,9 @@ import { i18nService } from '@/infrastructure/i18n'; export { BaseToolCard } from './BaseToolCard'; export type { BaseToolCardProps } from './BaseToolCard'; +export { ToolProcessingDots } from './ToolProcessingDots'; +export type { ToolProcessingDotsProps, ToolProcessingDotsSize } from './ToolProcessingDots'; + export { SnapshotCard } from './SnapshotCard'; export type { SnapshotCardProps } from './SnapshotCard'; diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index cb0ff8d73..b4d92ddf1 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -30,6 +30,26 @@ import './Markdown.scss'; const log = createLogger('Markdown'); const COMPUTER_LINK_PREFIX = 'computer://'; +// Module-level cache so that all simultaneously-mounting Markdown instances +// (e.g. dozens of history blocks after a workspace switch) share a single +// IPC round-trip for the workspace path. The in-flight deduplication in +// GlobalAPI already coalesces concurrent calls into one; this cache avoids +// even triggering a new IPC call while the result is still fresh. +let _cachedWorkspacePathResult: string | undefined; +let _cachedWorkspacePathAt = 0; +const WORKSPACE_PATH_CACHE_MS = 5000; + +async function getWorkspacePathCached(): Promise { + const now = Date.now(); + if (_cachedWorkspacePathResult !== undefined && now - _cachedWorkspacePathAt < WORKSPACE_PATH_CACHE_MS) { + return _cachedWorkspacePathResult; + } + const result = await globalAPI.getCurrentWorkspacePath(); + _cachedWorkspacePathResult = result; + _cachedWorkspacePathAt = Date.now(); + return result; +} + /** Catches render errors from react-markdown/remark-gfm (e.g. RegExp in transformGfmAutolinkLiterals) and shows plain text fallback. */ class MarkdownErrorBoundary extends Component< { children: ReactNode; fallbackContent: string }, @@ -577,7 +597,7 @@ export const Markdown = React.memo(({ useEffect(() => { let cancelled = false; - void globalAPI.getCurrentWorkspacePath() + void getWorkspacePathCached() .then((workspacePath) => { if (!cancelled && workspacePath) { setCurrentWorkspacePath(workspacePath); diff --git a/src/web-ui/src/flow_chat/components/ToolStatusIndicator.tsx b/src/web-ui/src/flow_chat/components/ToolStatusIndicator.tsx index d52ce62c6..c0192b1c1 100644 --- a/src/web-ui/src/flow_chat/components/ToolStatusIndicator.tsx +++ b/src/web-ui/src/flow_chat/components/ToolStatusIndicator.tsx @@ -3,7 +3,8 @@ */ import React from 'react'; -import { Loader2, CheckCircle, XCircle, Clock, AlertCircle } from 'lucide-react'; +import { Loader2, CheckCircle, XCircle, AlertCircle, type LucideIcon } from 'lucide-react'; +import { ToolProcessingDots } from '@/component-library'; import type { ToolExecutionStatus } from '../../shared/types/tool-events'; interface ToolStatusIndicatorProps { @@ -13,13 +14,24 @@ interface ToolStatusIndicatorProps { showLabel?: boolean; } -const STATUS_CONFIG = { +const STATUS_CONFIG: Record< + ToolExecutionStatus, + { + icon: LucideIcon | null; + color: string; + bgColor: string; + label: string; + animate: boolean; + useDots?: boolean; + } +> = { pending: { - icon: Clock, + icon: null, color: 'text-gray-500', bgColor: 'bg-gray-100', label: 'Waiting', - animate: false + animate: false, + useDots: true, }, receiving: { icon: Loader2, @@ -29,11 +41,12 @@ const STATUS_CONFIG = { animate: true }, starting: { - icon: Clock, - color: 'text-blue-500', + icon: null, + color: 'text-blue-500', bgColor: 'bg-blue-100', label: 'Starting', - animate: true + animate: false, + useDots: true, }, running: { icon: Loader2, @@ -72,7 +85,6 @@ export const ToolStatusIndicator: React.FC = ({ showLabel = true }) => { const config = STATUS_CONFIG[status]; - const Icon = config.icon; const formatDuration = (ms: number) => { if (ms < 1000) return `${ms}ms`; @@ -83,9 +95,13 @@ export const ToolStatusIndicator: React.FC = ({ return (
- + {config.useDots ? ( + + ) : config.icon ? ( + + ) : null}
{showLabel && ( diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx index 2cb9cb03a..8ef9acb76 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx @@ -10,12 +10,11 @@ import { AlertTriangle, AlertCircle, Info, - Clock, ChevronDown, ChevronUp, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Tooltip } from '@/component-library'; +import { Tooltip, ToolProcessingDots } from '@/component-library'; import type { ToolCardProps } from '../types/flow-chat'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; import { createLogger } from '@/shared/utils/logger'; @@ -168,7 +167,7 @@ export const CodeReviewToolCard: React.FC = React.memo(({ return null; case 'pending': default: - return ; + return ; } }; diff --git a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss index f01b39041..146686a10 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss @@ -215,6 +215,27 @@ } } +// Utility: child elements hidden by default, revealed on card hover. +// Uses max-width + overflow to collapse layout space when hidden so no gap +// appears before the hover target is revealed. +.compact-tool-card .compact-extra-on-hover { + max-width: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + flex-shrink: 0; + transition: max-width 0.18s ease, opacity 0.15s ease; +} + +.compact-tool-card:hover .compact-extra-on-hover { + max-width: 240px; + opacity: 1; + pointer-events: auto; + border-left: 1px solid var(--border-base); + padding-left: 6px; + margin-left: 6px; +} + /* ========== Expanded compact tools use the shared BaseToolCard shell ========== */ .base-tool-card-wrapper.compact-tool-card-wrapper--expanded-card { width: 100%; diff --git a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx index f4cea5088..728a9c6af 100644 --- a/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/DefaultToolCard.tsx @@ -4,10 +4,11 @@ */ import React, { useMemo, useState, useCallback } from 'react'; -import { Loader2, XCircle, Clock, Check, ChevronDown, ChevronRight } from 'lucide-react'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import './DefaultToolCard.scss'; @@ -134,21 +135,6 @@ export const DefaultToolCard: React.FC = ({ }); }, [applyExpandedState, canExpand, isExpanded, onExpand]); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'cancelled': - case 'error': - return ; - default: - return ; - } - }; - const getStatusText = () => { if (requiresConfirmation && !userConfirmed) { return t('toolCards.default.waitingConfirm'); @@ -228,12 +214,11 @@ export const DefaultToolCard: React.FC = ({ onClick={handleToggleExpand} className={`default-tool-card ${showConfirmationHighlight ? 'requires-confirmation' : ''}`} clickable={canExpand} - header={ + header={ } action={config.displayName} content={getSummaryText()} - extra={config.icon ? {config.icon} : undefined} rightStatusIcon={canExpand ? (isExpanded ? : ) : undefined} /> } diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx index 22a6765c3..d3240e4e0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -24,11 +24,10 @@ import { FileX2, ChevronRight, Loader2, - Clock, Check, X, } from 'lucide-react'; -import { CubeLoading, IconButton } from '../../component-library'; +import { CubeLoading, IconButton, ToolProcessingDots } from '../../component-library'; import type { ToolCardProps } from '../types/flow-chat'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; import { useSnapshotState } from '../../tools/snapshot_system/hooks/useSnapshotState'; @@ -737,7 +736,7 @@ export const FileOperationToolCard: React.FC = ({ case 'pending_confirmation': case 'analyzing': default: - return ; + return ; } }; diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss index 233129d91..68fcb8051 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.scss @@ -11,6 +11,10 @@ .git-card-icon { color: var(--color-text-secondary); flex-shrink: 0; + width: 14px; + height: 14px; + display: block; + transform: translateY(1.5px); } } @@ -96,6 +100,28 @@ flex-wrap: nowrap; min-width: 0; flex: 1; + gap: 0; + + // Prevent command preview from consuming all flex space so the inline + // summary can sit right next to the command text instead of being + // pushed to the far right. + .tool-command-preview { + flex: 0 1 auto; + max-width: 50%; + } + } + + .git-output-summary-inline { + font-size: 11px; + color: var(--tool-card-text-secondary, var(--color-text-muted)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1 1 0; + min-width: 0; + margin-left: 6px; + font-family: var(--tool-card-font-mono); + opacity: 0.7; } /* Collapsed row only — expanded uses `.terminal-command` from TerminalToolCard.scss. */ @@ -111,6 +137,18 @@ min-width: 0; } + .git-hover-actions { + display: inline-flex; + align-items: center; + gap: 4px; + } + + .git-confirm-actions { + display: inline-flex; + align-items: center; + gap: 4px; + } + .output-summary { font-size: 11px; color: var(--tool-card-text-secondary); diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx index 84b4b0fba..5a9a82dff 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx @@ -9,6 +9,7 @@ import { CubeLoading, IconButton } from '../../component-library'; import type { ToolCardProps } from '../types/flow-chat'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; import { ToolCommandPreview } from './ToolCommandPreview'; import { createLogger } from '@/shared/utils/logger'; @@ -167,23 +168,20 @@ export const GitToolDisplay: React.FC = ({ /> ); - const headerExtra = (useTerminalLayout: boolean) => ( - + // Used only for the expanded header (BaseToolCard layout) + const expandedHeaderExtra = () => ( + {!isFailed && outputSummary && status === 'completed' && ( - - {outputSummary} - + {outputSummary} )} - {isFailed && ( {t('toolCards.git.failed')} )} - - + = ({ failureMessage={t('toolCards.git.copyCommandFailed', { defaultValue: 'Failed to copy git command' })} ariaLabel={t('toolCards.git.copyCommand', { defaultValue: 'Copy git command' })} /> - {requiresConfirmation && !userConfirmed && status !== 'completed' && ( <> { - e.stopPropagation(); - onConfirm?.(toolCall?.input); - }} + onClick={(e) => { e.stopPropagation(); onConfirm?.(toolCall?.input); }} disabled={status === 'streaming'} tooltip={t('toolCards.git.confirmExecute')} > @@ -211,10 +205,7 @@ export const GitToolDisplay: React.FC = ({ className="tool-card-header-action git-reject-btn" variant="danger" size="xs" - onClick={(e) => { - e.stopPropagation(); - onReject?.(); - }} + onClick={(e) => { e.stopPropagation(); onReject?.(); }} disabled={status === 'streaming'} tooltip={t('toolCards.git.cancel')} > @@ -231,21 +222,68 @@ export const GitToolDisplay: React.FC = ({ icon={} action={isFailed ? t('toolCards.git.commandFailed') : `${t('toolCards.git.title')}:`} content={renderCommandPreview('expanded')} - extra={headerExtra(true)} + extra={expandedHeaderExtra()} statusIcon={renderStatusIcon()} /> ); const renderCompactHeader = () => ( } + icon={} defaultIcon="tool" />} action={isFailed ? t('toolCards.git.commandFailed') : undefined} content={ {renderCommandPreview('compact')} + {!isFailed && outputSummary && status === 'completed' && ( + {outputSummary} + )} + {/* Hover-only: error label + copy — inline after the command text */} + + {isFailed && ( + + {t('toolCards.git.failed')} + + )} + + + + } - extra={headerExtra(false)} + extra={ + requiresConfirmation && !userConfirmed && status !== 'completed' ? ( + + { e.stopPropagation(); onConfirm?.(toolCall?.input); }} + disabled={status === 'streaming'} + tooltip={t('toolCards.git.confirmExecute')} + > + + + { e.stopPropagation(); onReject?.(); }} + disabled={status === 'streaming'} + tooltip={t('toolCards.git.cancel')} + > + + + + ) : undefined + } rightStatusIcon={renderStatusIcon()} /> ); diff --git a/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx index c58ac469f..561d0647e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GlobSearchDisplay.tsx @@ -3,10 +3,11 @@ */ import React, { useState, useMemo, useCallback } from 'react'; -import { FolderSearch, Loader2, Clock, File, Folder, Check } from 'lucide-react'; +import { FolderSearch, File, Folder } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; export const GlobSearchDisplay: React.FC = ({ toolItem, @@ -21,18 +22,6 @@ export const GlobSearchDisplay: React.FC = ({ toolName: toolItem.toolName, }); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - default: - return ; - } - }; - const getSearchPattern = (): string => { const pattern = toolCall?.input?.pattern || toolCall?.input?.glob_pattern || @@ -196,9 +185,8 @@ export const GlobSearchDisplay: React.FC = ({ clickable={hasDetails} header={ } + icon={} />} content={renderContent()} - rightStatusIcon={getStatusIcon()} /> } expandedContent={hasDetails ? renderExpandedContent() : undefined} diff --git a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx index 6c2e44ae9..dc3d04717 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GrepSearchDisplay.tsx @@ -3,10 +3,11 @@ */ import React, { useState, useMemo, useCallback } from 'react'; -import { Search, Loader2, Clock, Check } from 'lucide-react'; +import { Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; export const GrepSearchDisplay: React.FC = ({ toolItem, @@ -21,18 +22,6 @@ export const GrepSearchDisplay: React.FC = ({ toolName: toolItem.toolName, }); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - default: - return ; - } - }; - const getSearchPattern = (): string => { const pattern = toolCall?.input?.pattern || toolCall?.input?.search_pattern || @@ -151,9 +140,8 @@ export const GrepSearchDisplay: React.FC = ({ clickable={hasDetails} header={ } + icon={} />} content={renderContent()} - rightStatusIcon={getStatusIcon()} /> } expandedContent={hasDetails ? renderExpandedContent() : undefined} diff --git a/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx index 0175fd2db..29f14eabf 100644 --- a/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/LSDisplay.tsx @@ -3,10 +3,11 @@ */ import React, { useState, useMemo, useCallback } from 'react'; -import { FolderOpen, Loader2, Clock, File, Folder, Check } from 'lucide-react'; +import { FolderOpen, File, Folder } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; interface LSEntry { name: string; @@ -28,18 +29,6 @@ export const LSDisplay: React.FC = ({ toolName: toolItem.toolName, }); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - default: - return ; - } - }; - const getDirectoryPath = (): string => { const path = toolCall?.input?.path; @@ -192,9 +181,8 @@ export const LSDisplay: React.FC = ({ clickable={hasDetails} header={ } + icon={} />} content={renderContent()} - rightStatusIcon={getStatusIcon()} /> } expandedContent={hasDetails ? renderExpandedContent() : undefined} diff --git a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx index 8ffd99cbe..e15e6c5d0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ReadFileDisplay.tsx @@ -3,10 +3,11 @@ */ import React, { useMemo } from 'react'; -import { FileText, Loader2, Clock, Check } from 'lucide-react'; +import { FileText } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; export const ReadFileDisplay: React.FC = React.memo(({ toolItem, @@ -15,19 +16,6 @@ export const ReadFileDisplay: React.FC = React.memo(({ const { t } = useTranslation('flow-chat'); const { toolCall, toolResult, status } = toolItem; - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'pending': - default: - return ; - } - }; - const filePath = useMemo(() => { const path = toolCall?.input?.file_path || toolCall?.input?.target_file || toolCall?.input?.path; @@ -134,9 +122,8 @@ export const ReadFileDisplay: React.FC = React.memo(({ clickable={canOpenFile} header={ } + icon={} />} content={renderContent()} - rightStatusIcon={getStatusIcon()} /> } /> diff --git a/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx index 2fc0c1419..a95a95ede 100644 --- a/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/SessionControlToolCard.tsx @@ -1,8 +1,9 @@ import React, { useMemo, useState } from 'react'; -import { Check, Clock, Loader2, X } from 'lucide-react'; +import { Layers } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; interface SessionSummary { @@ -87,23 +88,6 @@ export const SessionControlToolCard: React.FC = React.memo(({ toolResult?.error ); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'error': - case 'cancelled': - return ; - case 'pending': - case 'preparing': - default: - return ; - } - }; - const getActionLabel = () => { switch (action) { case 'create': @@ -286,10 +270,9 @@ export const SessionControlToolCard: React.FC = React.memo(({ clickable={hasDetails} header={( } />} action={`${t('toolCards.sessionControl.title')}:`} content={renderContent()} - extra={action === 'list' && status === 'completed' ? `${sessionCount}` : undefined} /> )} expandedContent={expandedContent} diff --git a/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx index f53393167..21ee458a5 100644 --- a/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/SessionMessageToolCard.tsx @@ -1,8 +1,9 @@ import React, { useMemo, useState } from 'react'; -import { Check, Clock, Loader2, X } from 'lucide-react'; +import { MessageSquare } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { useToolCardHeightContract } from './useToolCardHeightContract'; interface SessionMessageInput { @@ -57,23 +58,6 @@ export const SessionMessageToolCard: React.FC = React.memo(({ const message = inputData.message ?? ''; const hasDetails = Boolean(targetSessionId || workspace || agentType || message || toolResult?.error); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'error': - case 'cancelled': - return ; - case 'pending': - case 'preparing': - default: - return ; - } - }; - const targetLabel = targetSessionId || t('toolCards.sessionMessage.unknownSession'); const renderContent = () => { @@ -156,10 +140,9 @@ export const SessionMessageToolCard: React.FC = React.memo(({ clickable={hasDetails} header={( } />} action={`${t('toolCards.sessionMessage.title')}:`} content={renderContent()} - extra={agentType ? agentType : undefined} /> )} expandedContent={expandedContent} diff --git a/src/web-ui/src/flow_chat/tool-cards/SkillDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/SkillDisplay.tsx index 66eee4d4d..577fe4a0e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/SkillDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/SkillDisplay.tsx @@ -3,11 +3,12 @@ */ import React, { useMemo } from 'react'; -import { Loader2, Clock, Check, X } from 'lucide-react'; +import { Zap } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import type { CompactToolCardProps } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; export const SkillDisplay: React.FC = React.memo(({ toolItem }) => { const { t } = useTranslation('flow-chat'); @@ -35,22 +36,6 @@ export const SkillDisplay: React.FC = React.memo(({ toolItem }) = return t('toolCards.skill.loadSkillFailed'); }; - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - case 'preparing': - return ; - case 'completed': - return ; - case 'error': - return ; - case 'pending': - default: - return ; - } - }; - const renderContent = () => { if (status === 'error') { return ( @@ -96,7 +81,7 @@ export const SkillDisplay: React.FC = React.memo(({ toolItem }) = clickable={false} header={ } />} content={renderContent()} /> } diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx index cc7cc6d04..5edd13492 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalControlDisplay.tsx @@ -3,11 +3,12 @@ */ import React, { useMemo } from 'react'; -import { Loader2, Clock, Check, X } from 'lucide-react'; +import { Terminal } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import type { CompactToolCardProps } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; export const TerminalControlDisplay: React.FC = React.memo(({ toolItem, @@ -15,21 +16,6 @@ export const TerminalControlDisplay: React.FC = React.memo(({ const { t } = useTranslation('flow-chat'); const { toolCall, status } = toolItem; - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - return ; - case 'completed': - return ; - case 'error': - return ; - case 'pending': - default: - return ; - } - }; - const terminalSessionId = useMemo(() => { return toolCall?.input?.terminal_session_id as string | undefined; }, [toolCall?.input?.terminal_session_id]); @@ -97,7 +83,7 @@ export const TerminalControlDisplay: React.FC = React.memo(({ clickable={false} header={ } />} content={renderContent()} /> } diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss index e8be478dd..179a8fa4c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -261,6 +261,35 @@ margin-right: 0; } +/* ========== Compact content row (command + inline hover actions) ========== */ +.terminal-compact-content { + display: flex; + align-items: center; + min-width: 0; + flex: 1; + gap: 0; + + .tool-command-preview { + flex: 0 1 auto; + max-width: 60%; + } +} + +/* ========== Hover-only actions wrapper (compact collapsed state) ========== */ +.terminal-hover-actions { + display: inline-flex; + align-items: center; + gap: 4px; +} + +/* ========== Always-visible critical actions (compact collapsed state) ========== */ +.terminal-critical-actions { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + /* ========== Confirmation action button group ========== */ .terminal-confirm-actions { display: inline-flex; diff --git a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx index 3d2cb7b08..a2cebfd1a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -17,6 +17,7 @@ import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { Terminal, Play, X, ExternalLink, Square } from 'lucide-react'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { createTerminalTab } from '@/shared/utils/tabUtils'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; @@ -506,58 +507,63 @@ export const TerminalToolCard: React.FC = ({ ); }; - const renderHeaderActions = (includeInterrupt: boolean) => ( - - {renderCopyCommandButton()} - {renderOpenInPanelButton()} - - {showConfirmButtons && ( - e.stopPropagation()}> - - - - - - + const renderHeaderExtra = (includeInterrupt: boolean) => ( + + {/* Always visible: confirmation actions + interrupt */} + {(showConfirmButtons || (includeInterrupt && viewState.showInterruptButton)) && ( + + {showConfirmButtons && ( + e.stopPropagation()}> + + + + + + + + )} + {includeInterrupt && viewState.showInterruptButton && ( + + + + )} )} - {includeInterrupt && viewState.showInterruptButton && ( - - - + {/* Expanded header: duration + status text always visible */} + {includeInterrupt && ( + <> + {renderTimeoutIndicator()} + {viewState.hasHeaderExtra && renderStatusText()} + + {renderCopyCommandButton()} + {renderOpenInPanelButton()} + + )} - - ); - - const renderHeaderExtra = (includeInterrupt: boolean) => ( - - {renderTimeoutIndicator()} - {viewState.hasHeaderExtra && renderStatusText()} - {renderHeaderActions(includeInterrupt)} ); @@ -593,11 +599,23 @@ export const TerminalToolCard: React.FC = ({ const renderCompactHeader = () => ( } + icon={} defaultIcon="tool" />} action={t('toolCards.terminal.executeCommand')} - content={renderCommandContent('compact')} + content={ + + {renderCommandContent('compact')} + {/* Hover-only inline actions — duration, status, copy, open panel */} + + {renderTimeoutIndicator()} + {viewState.hasHeaderExtra && renderStatusText()} + + {renderCopyCommandButton()} + {renderOpenInPanelButton()} + + + + } extra={renderHeaderExtra(false)} - rightStatusIcon={renderLoadingStatusIcon()} /> ); const expandedContent = isExpanded diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.scss b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.scss new file mode 100644 index 000000000..b0796b7e5 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.scss @@ -0,0 +1,72 @@ +/** + * ToolCardStatusSlot styles. + * + * The slot is placed inside the ToolCardIconSlot container (34 px wide rail). + * It fills the inner 24×24 marks area and manages the crossfade between the + * status icon and the tool icon. + * + * Parent-level hover is triggered by .compact-tool-card:hover and + * .base-tool-card:hover — see rules at the bottom of this file. + */ + +.tool-card-status-slot { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + &__status-layer, + &__icon-layer { + display: flex; + align-items: center; + justify-content: center; + } + + &__status-layer { + opacity: 1; + + svg { + width: var(--tool-card-icon-size, 16px); + height: var(--tool-card-icon-size, 16px); + } + } + + &__icon-layer { + position: absolute; + inset: 0; + opacity: 0; + line-height: 1; + + svg { + display: block; + } + } +} + +// Status icon colors +.tcss-check { + color: var(--color-success, #4ade80); +} + +.tcss-error { + color: var(--color-error, #f87171); + filter: drop-shadow(0 0 3px rgba(248, 113, 113, 0.35)); +} + +.tcss-cancelled { + color: var(--color-text-muted); + opacity: 0.6; +} + +// ── tool-first: always show tool icon, status layer hidden ────────────────── +.tool-card-status-slot--tool-first { + .tool-card-status-slot__status-layer { + opacity: 0; + } + + .tool-card-status-slot__icon-layer { + opacity: 1; + } +} diff --git a/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx new file mode 100644 index 000000000..22960f0b2 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ToolCardStatusSlot.tsx @@ -0,0 +1,78 @@ +/** + * Unified left-slot component for compact tool card headers. + * + * Two display modes (controlled by `defaultIcon`): + * + * "status" (default) — most tools: + * - Default: status icon (dots / check / X) + * - Hover: tool-specific icon + * + * "tool" — identity-first tools (Git, Shell): + * - Default: tool-specific icon + * - Hover: status icon + * + * Usage: pass as the `icon` prop of CompactToolCardHeader. + */ + +import React, { ReactNode } from 'react'; +import { Check, X } from 'lucide-react'; +import { ToolProcessingDots } from '@/component-library'; +import type { ToolProcessingDotsSize } from '@/component-library'; +import type { BaseToolCardProps } from './BaseToolCard'; +import './ToolCardStatusSlot.scss'; + +export type ToolCardStatusSlotStatus = BaseToolCardProps['status']; + +export interface ToolCardStatusSlotProps { + status: ToolCardStatusSlotStatus; + toolIcon?: ReactNode; + /** + * Which icon is shown by default (non-hovered). + * - `"status"` (default): dots/check/X default; tool icon on hover. + * - `"tool"`: tool icon default; dots/check/X on hover. + */ + defaultIcon?: 'status' | 'tool'; + size?: ToolProcessingDotsSize; +} + +function StatusIcon({ status, size }: { status: ToolCardStatusSlotStatus; size: ToolProcessingDotsSize }) { + switch (status) { + case 'completed': + return ; + case 'error': + return ; + case 'cancelled': + return ; + default: + return ; + } +} + +export const ToolCardStatusSlot: React.FC = ({ + status, + toolIcon, + defaultIcon = 'status', + size = 16, +}) => { + const hasIcon = toolIcon != null; + const toolFirst = defaultIcon === 'tool' && hasIcon; + + return ( +
+
+ +
+ {hasIcon && ( +
+ {toolIcon} +
+ )} +
+ ); +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx b/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx index 761cf5294..ed92410cd 100644 --- a/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/WebSearchCard.tsx @@ -3,12 +3,13 @@ */ import React, { useState, useMemo, useCallback } from 'react'; -import { Globe, Loader2, Link, Clock, Check } from 'lucide-react'; +import { Globe, Link } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { systemAPI } from '../../infrastructure/api'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { Tooltip } from '@/component-library'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { createLogger } from '@/shared/utils/logger'; import { useToolCardHeightContract } from './useToolCardHeightContract'; @@ -27,19 +28,6 @@ export const WebSearchCard: React.FC = ({ toolName: toolItem.toolName, }); - const getStatusIcon = () => { - switch (status) { - case 'running': - case 'streaming': - case 'preparing': - return ; - case 'completed': - return ; - default: - return ; - } - }; - const getSearchTerm = () => { const searchTerm = toolCall?.input?.search_term || toolCall?.input?.query; @@ -179,10 +167,8 @@ export const WebSearchCard: React.FC = ({ clickable={isExpandable} header={ } - content={renderContent()} - rightStatusIcon={getStatusIcon()} - rightStatusIconWithDivider + icon={} />} + content={renderContent()} /> } expandedContent={isExpandable ? renderExpandedContent() : undefined} diff --git a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts index 95b3f7603..9073c0e7b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GlobalAPI.ts @@ -235,14 +235,27 @@ export class GlobalAPI { } + // In-flight deduplicator: if many components call getCurrentWorkspace at the + // same time (e.g. 20+ Markdown blocks mounting after a workspace switch) only + // one Tauri IPC round-trip is made; all callers share the same Promise. + private _getCurrentWorkspaceInFlight: Promise | null = null; + async getCurrentWorkspace(): Promise { - try { - return await api.invoke('get_current_workspace', { - request: {} - }); - } catch (error) { - throw createTauriCommandError('get_current_workspace', error); + if (this._getCurrentWorkspaceInFlight) { + return this._getCurrentWorkspaceInFlight; } + this._getCurrentWorkspaceInFlight = (async () => { + try { + return await api.invoke('get_current_workspace', { + request: {} + }); + } catch (error) { + throw createTauriCommandError('get_current_workspace', error); + } finally { + this._getCurrentWorkspaceInFlight = null; + } + })(); + return this._getCurrentWorkspaceInFlight; } diff --git a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx index 6c4056db7..e69a930ee 100644 --- a/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/McpToolsConfig.tsx @@ -13,13 +13,12 @@ import { Play, Square, CheckCircle, - Clock, AlertTriangle, MinusCircle, KeyRound, Trash2, } from 'lucide-react'; -import { Button, Textarea, IconButton, Modal } from '@/component-library'; +import { Button, Textarea, IconButton, Modal, ToolProcessingDots } from '@/component-library'; import { ConfigPageHeader, ConfigPageLayout, @@ -783,7 +782,7 @@ const McpToolsConfig: React.FC = () => { const getStatusIcon = (status: string): React.ReactNode => { const s = status.toLowerCase(); if (s.includes('healthy') || s.includes('connected')) return ; - if (s.includes('starting') || s.includes('reconnecting')) return ; + if (s.includes('starting') || s.includes('reconnecting')) return ; if (s.includes('failed') || s.includes('stopped') || s.includes('auth')) return ; return ;