From 3eb9e09fa7bbd63f67da319e1f7ff34ea259f4e0 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 26 Jul 2026 20:05:32 -0700 Subject: [PATCH] fix(flow-chat): smooth action lifecycle transitions --- .../components/FlowToolCard.test.tsx | 26 +++- .../src/flow_chat/components/FlowToolCard.tsx | 4 + .../modern/ExploreGroupRenderer.tsx | 33 ++--- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 37 +++-- .../components/modern/ModelRoundItem.scss | 10 -- .../components/modern/ModelRoundItem.tsx | 18 +-- .../modern/ProcessingIndicator.scss | 10 +- .../modern/SmoothHeightCollapse.scss | 12 +- .../modern/SmoothHeightCollapse.test.tsx | 115 +++++++++++++++ .../modern/SmoothHeightCollapse.tsx | 48 +++++-- .../components/modern/SubagentItems.scss | 3 - .../subagent/SubagentProjectionView.tsx | 27 ++-- .../store/modernFlowChatStore.test.ts | 102 ++++++++++++-- .../flow_chat/store/modernFlowChatStore.ts | 71 ++++++---- .../tool-cards/AskUserQuestionCard.scss | 23 +-- .../tool-cards/AskUserQuestionCard.test.tsx | 133 ++++++++++++++++++ .../tool-cards/AskUserQuestionCard.tsx | 22 +-- .../flow_chat/tool-cards/BaseToolCard.scss | 19 ++- .../flow_chat/tool-cards/CompactToolCard.scss | 23 ++- .../tool-cards/CreatePlanDisplay.scss | 9 +- .../tool-cards/ExecCommandToolCard.tsx | 2 + .../tool-cards/ExecControlToolCard.tsx | 2 + .../ExecProcessToolCardView.test.tsx | 47 +++++++ .../tool-cards/ExecProcessToolCardView.tsx | 30 ++-- .../tool-cards/FileOperationToolCard.scss | 2 +- .../tool-cards/FileOperationToolCard.test.tsx | 28 +++- .../tool-cards/FileOperationToolCard.tsx | 78 +++++++--- .../flow_chat/tool-cards/MCPToolDisplay.tsx | 2 +- .../tool-cards/ModelThinkingDisplay.tsx | 4 +- .../flow_chat/tool-cards/TaskToolDisplay.scss | 10 +- .../tool-cards/TaskToolDisplay.test.tsx | 49 +++++++ .../flow_chat/tool-cards/TaskToolDisplay.tsx | 26 +++- .../tool-cards/TerminalToolCard.scss | 10 +- .../flow_chat/tool-cards/TerminalToolCard.tsx | 53 +++++-- .../tool-cards/ViewImageToolCard.tsx | 4 +- .../tool-cards/WriteStdinToolCard.tsx | 2 + .../tool-cards/_tool-card-common.scss | 2 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 6 + 38 files changed, 857 insertions(+), 245 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.test.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx index 27e81396c8..841297be58 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx @@ -13,10 +13,17 @@ vi.mock('react-i18next', () => ({ vi.mock('../tool-cards', async () => { const ReactModule = await import('react'); return { - getToolCardComponent: (toolName: string) => ({ toolItem }: { toolItem: FlowToolItem }) => + getToolCardComponent: (toolName: string) => ({ + toolItem, + isLastItem, + }: { + toolItem: FlowToolItem; + isLastItem?: boolean; + }) => ReactModule.createElement('div', { 'data-selected-card': toolName, 'data-card-tool-name': toolItem.toolName, + 'data-is-last-item': String(isLastItem === true), }), }; }); @@ -152,4 +159,21 @@ describe('FlowToolCard deferred identity', () => { act(() => root.render()); expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).not.toBeNull(); }); + + it('updates the card when it becomes or stops being the visual tail', () => { + const tool: FlowToolItem = { + id: 'tail-tool', + type: 'tool', + toolName: 'ExecCommand', + toolCall: { id: 'tail-tool', input: { cmd: 'cargo check' } }, + status: 'completed', + timestamp: 1, + }; + + act(() => root.render()); + expect(container.querySelector('[data-is-last-item="true"]')).not.toBeNull(); + + act(() => root.render()); + expect(container.querySelector('[data-is-last-item="false"]')).not.toBeNull(); + }); }); diff --git a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx index 91152fcacb..f11075cd48 100644 --- a/src/web-ui/src/flow_chat/components/FlowToolCard.tsx +++ b/src/web-ui/src/flow_chat/components/FlowToolCard.tsx @@ -28,6 +28,7 @@ interface FlowToolCardProps { turnId?: string; className?: string; displayContext?: ToolCardDisplayContext; + isLastItem?: boolean; } export const FlowToolCard: React.FC = React.memo(({ @@ -40,6 +41,7 @@ export const FlowToolCard: React.FC = React.memo(({ sessionId, className = '', displayContext = 'default', + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const effectiveToolItem = projectEffectiveToolItem(toolItem); @@ -97,6 +99,7 @@ export const FlowToolCard: React.FC = React.memo(({ onExpand={handleExpand} sessionId={sessionId} displayContext={displayContext} + isLastItem={isLastItem} /> = React.memo(({ prevProps.toolItem.subagentModelId === nextProps.toolItem.subagentModelId && prevProps.toolItem.subagentModelDisplayName === nextProps.toolItem.subagentModelDisplayName && prevProps.displayContext === nextProps.displayContext && + prevProps.isLastItem === nextProps.isLastItem && prevProgress === nextProgress && prevProgressLogs === nextProgressLogs && prevProps.toolItem.partialParams === nextProps.toolItem.partialParams && diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index 0625fbdce5..53e58e3c66 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -4,7 +4,7 @@ * Renders merged explore-only rounds as a collapsible region. */ -import React, { useRef, useMemo, useCallback, useEffect, useState } from 'react'; +import React, { useRef, useMemo, useCallback, useEffect, useLayoutEffect, useState } from 'react'; import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { FlowItem, FlowToolItem, FlowTextItem, FlowThinkingItem, ToolRejectOptions } from '../../types/flow-chat'; @@ -142,31 +142,25 @@ export const ExploreGroupRenderer: React.FC = React.m // defaultExpanded = !wasCutByCritical). So `justGotCut && isExpanded` would // always be false and the collapse-intent would never fire. // - // Instead, reason about the state *before* the cut: - // - No explicit state → group was expanded by default (it was tail). - // - Explicit state = true → user had it open. - // Both cases mean the group WAS visually expanded before this render; we need - // to dispatch the height-contract event so Virtuoso can anchor-lock. - useEffect(() => { + // No explicit state means the group was expanded by the live-tail default, + // so dispatch the height-contract event before compacting it. An explicit + // state is user intent and must not be overwritten by a later auto event. + useLayoutEffect(() => { const justGotCut = wasCutByCritical && !prevWasCutRef.current; prevWasCutRef.current = wasCutByCritical; - if (!justGotCut) return; + if (!justGotCut || hasExplicitState) return; - const wasExpanded = !hasExplicitState || explicitExpanded; - log.debug('explore group cut by critical', { groupId, wasExpanded, hasExplicitState }); + log.debug('explore group cut by critical', { groupId }); - if (wasExpanded) { - setAnimateToggle(false); - applyExpandedState(true, false, () => { - onCollapseGroup?.(groupId); - }, { - reason: 'auto', - }); - } + setAnimateToggle(false); + applyExpandedState(true, false, () => { + onCollapseGroup?.(groupId); + }, { + reason: 'auto', + }); }, [ applyExpandedState, - explicitExpanded, groupId, hasExplicitState, wasCutByCritical, @@ -382,6 +376,7 @@ const ExploreItemRenderer = React.memo(({ item, turnId
( turnId={turnId} roundId={options.roundId} isLastItem={isLast && itemIdx === group.items.length - 1} - hideSettledExploreTool /> )); @@ -800,8 +799,6 @@ interface FlowItemRendererProps { turnId: string; roundId?: string; isLastItem?: boolean; - /** Hide finished explore tools instead of leaving them inline. */ - hideSettledExploreTool?: boolean; } // Do not memoize: streaming content updates frequently. @@ -810,7 +807,6 @@ const FlowItemRenderer: React.FC = ({ turnId, roundId, isLastItem, - hideSettledExploreTool = false, }) => { const { onToolConfirm, @@ -846,22 +842,12 @@ const FlowItemRenderer: React.FC = ({ case 'tool': { const toolItem = item as FlowToolItem; - // Explore tools that already finished are hidden outright. The hidden - // state is derived from the item status alone — no wall-clock window and - // no enter/exit animation — so the round never reflows on a timer. - const isSettledExploreTool = - hideSettledExploreTool && - toolItem.status === 'completed' && - isCollapsibleTool(getEffectiveToolName(toolItem)); - const toolClassName = [ - 'flowchat-flow-item', - isSettledExploreTool ? 'flowchat-flow-item--tool-settled' : null, - ].filter(Boolean).join(' '); return ( -
+
{ if (onToolConfirm) { await onToolConfirm(toolId, permissionOptionId, approve); diff --git a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss b/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss index 09d384bdb3..27b4600ca6 100644 --- a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss +++ b/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss @@ -35,7 +35,7 @@ padding: 0.5rem 0; } -// Hint text — same breathe rhythm as tool cards (tool-card-text-fade: 1.6s 0%→25%→0%) +// Hint text — use the same low-contrast breathe rhythm as active tool cards. .processing-indicator__hint { font-size: 0.8125rem; color: var(--color-text-secondary); @@ -43,10 +43,10 @@ overflow: hidden; text-overflow: ellipsis; max-width: 320px; - // fade-in entry (0.4s) then breathe loop identical to card shimmer + // Fade in once, then breathe gently without making the whole row blink. animation: hint-fade-in 0.4s ease forwards, - hint-breathe 1.6s ease-in-out 0.4s infinite; + hint-breathe 2.2s ease-in-out 0.4s infinite; @media (max-width: 768px) { max-width: 200px; @@ -64,12 +64,12 @@ } } -// Mirrors BaseToolCard's tool-card-text-fade exactly (1→0.25→1) +// Mirrors BaseToolCard's tool-card-text-fade. @keyframes hint-breathe { 0%, 100% { opacity: 1; } 50% { - opacity: 0.25; + opacity: 0.68; } } diff --git a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss index 12841fcdc6..5f790db60d 100644 --- a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss +++ b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.scss @@ -6,6 +6,11 @@ transition-property: height, opacity, transform; transition-duration: 260ms, 180ms, 260ms; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1), ease, cubic-bezier(0.4, 0, 0.2, 1); + will-change: auto; +} + +.smooth-height-collapse--opening, +.smooth-height-collapse--closing { will-change: height, opacity, transform; } @@ -18,7 +23,6 @@ .smooth-height-collapse--open { height: auto; overflow: visible; - will-change: auto; } .smooth-height-collapse--closing { @@ -30,6 +34,12 @@ min-height: 0; } +.smooth-height-collapse--instant { + transition: none; + transform: none; + will-change: auto; +} + @media (prefers-reduced-motion: reduce) { .smooth-height-collapse { transition: none; diff --git a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.test.tsx b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.test.tsx new file mode 100644 index 0000000000..eb0d9c6f03 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SmoothHeightCollapse } from './SmoothHeightCollapse'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +describe('SmoothHeightCollapse', () => { + let container: HTMLDivElement; + let root: Root; + let measuredHeight: number; + let requestAnimationFrameSpy: ReturnType; + + beforeEach(() => { + measuredHeight = 0; + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + vi.stubGlobal('ResizeObserver', class { + observe = vi.fn(); + disconnect = vi.fn(); + }); + vi.stubGlobal('matchMedia', vi.fn(() => ({ + matches: false, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }))); + requestAnimationFrameSpy = vi + .spyOn(window, 'requestAnimationFrame') + .mockImplementation(() => 1); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => undefined); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ + x: 0, + y: 0, + top: 0, + right: 0, + bottom: measuredHeight, + left: 0, + width: 0, + height: measuredHeight, + toJSON: () => ({}), + })); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('does not replay an opening animation when only animation mode changes', () => { + act(() => { + root.render( + +
content
+
, + ); + }); + + act(() => { + root.render( + +
content
+
, + ); + }); + + const collapse = container.querySelector('.smooth-height-collapse'); + expect(collapse?.classList.contains('smooth-height-collapse--open')).toBe(true); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); + }); + + it('reverses an in-flight toggle from its rendered height', () => { + act(() => { + root.render( + +
content
+
, + ); + }); + + act(() => { + root.render( + +
content
+
, + ); + }); + + measuredHeight = 42; + act(() => { + root.render( + +
content
+
, + ); + }); + expect(container.querySelector('.smooth-height-collapse')?.style.height).toBe('42px'); + + measuredHeight = 17; + act(() => { + root.render( + +
content
+
, + ); + }); + expect(container.querySelector('.smooth-height-collapse')?.style.height).toBe('17px'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx index d04913edcb..8db30957af 100644 --- a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx @@ -19,13 +19,27 @@ export const SmoothHeightCollapse: React.FC = ({ durationMs = 260, disableAnimation = false, }) => { + const outerRef = useRef(null); const innerRef = useRef(null); + const hasMountedRef = useRef(false); + const previousIsOpenRef = useRef(isOpen); const [phase, setPhase] = useState(() => (isOpen ? 'open' : 'closed')); const [height, setHeight] = useState(() => (isOpen ? 'auto' : '0px')); const shouldRender = isOpen || phase !== 'closed'; - const shouldAnimate = !disableAnimation && !(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false); + const prefersReducedMotion = + typeof window !== 'undefined' && + (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false); + const shouldAnimate = !disableAnimation && !prefersReducedMotion; useLayoutEffect(() => { + const previousIsOpen = previousIsOpenRef.current; + previousIsOpenRef.current = isOpen; + + if (!hasMountedRef.current) { + hasMountedRef.current = true; + return; + } + const inner = innerRef.current; if (!inner) { return; @@ -34,15 +48,18 @@ export const SmoothHeightCollapse: React.FC = ({ let frameId = 0; let timeoutId = 0; - if (!shouldAnimate) { + if (!shouldAnimate || previousIsOpen === isOpen) { setPhase(isOpen ? 'open' : 'closed'); setHeight(isOpen ? 'auto' : '0px'); return; } if (isOpen) { + // Preserve the rendered height when a rapid click reverses an in-flight + // close. Restarting from zero makes the body visibly snap. + const startHeight = outerRef.current?.getBoundingClientRect().height ?? 0; setPhase('opening'); - setHeight('0px'); + setHeight(`${startHeight}px`); frameId = window.requestAnimationFrame(() => { setHeight(`${inner.scrollHeight}px`); }); @@ -51,7 +68,9 @@ export const SmoothHeightCollapse: React.FC = ({ setHeight('auto'); }, durationMs); } else { - const startHeight = inner.getBoundingClientRect().height; + const startHeight = + outerRef.current?.getBoundingClientRect().height ?? + inner.getBoundingClientRect().height; setPhase('closing'); setHeight(`${startHeight}px`); frameId = window.requestAnimationFrame(() => { @@ -70,25 +89,36 @@ export const SmoothHeightCollapse: React.FC = ({ useLayoutEffect(() => { const inner = innerRef.current; - if (!inner || phase !== 'open' || !shouldAnimate) { + if ( + !inner || + (phase !== 'opening' && phase !== 'open') || + !shouldAnimate || + typeof ResizeObserver === 'undefined' + ) { return; } const observer = new ResizeObserver(() => { - setHeight('auto'); + setHeight(phase === 'opening' ? `${inner.scrollHeight}px` : 'auto'); }); observer.observe(inner); return () => observer.disconnect(); - }, [phase, shouldAnimate, children]); + }, [phase, shouldAnimate]); return (
{shouldRender && (
diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index 71e152be67..85411fc2b8 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -37,9 +37,6 @@ border-top: none; // Top corners are square to merge with the header card above. border-radius: 0 0 8px 8px; - // Match the BaseToolCard backdrop-filter for seamless visual merge. - backdrop-filter: blur(10px); - // Keep concurrent subagents scannable; overflow keeps details reachable. // The detail panel remains the place for reading long subagent output. max-height: clamp(78px, 12vh, 118px); diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx index db5cf26b91..5da59877d9 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -86,7 +86,7 @@ function renderProjectedItem( sessionId: string | undefined, turnId: string | undefined, compactText: boolean, - isLastActiveItem: boolean, + isLastVisibleItem: boolean, ): React.ReactNode { switch (item.type) { case 'text': @@ -102,7 +102,7 @@ function renderProjectedItem( ); @@ -114,6 +114,7 @@ function renderProjectedItem( sessionId={sessionId} turnId={turnId} displayContext="subagent-projection" + isLastItem={isLastVisibleItem} />
); @@ -259,22 +260,10 @@ export const SubagentProjectionView: React.FC = ({ }); }, [items.length, itemsProp, parentSessionId, parentToolIds, resolvedSubagentSessionId, sessionId]); - const lastActiveItemId = useMemo(() => { - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'rejected' && item.status !== 'error') { - return item.id; - } - if (item.type === 'thinking' && (item as FlowThinkingItem).isStreaming) { - return item.id; - } - if (item.type === 'text' && (item as FlowTextItem).isStreaming) { - return item.id; - } - } - - return items.length > 0 ? items[items.length - 1]?.id ?? null : null; - }, [items]); + // Tail position, not active status, controls live completion retention. + // Otherwise a newer settled action can collapse while an older item still + // carries a stale active status. + const lastVisibleItemId = items[items.length - 1]?.id; useEffect(() => { const container = containerRef.current; @@ -351,7 +340,7 @@ export const SubagentProjectionView: React.FC = ({ sessionId ?? resolvedSubagentSessionId, turnId, compactText, - item.id === lastActiveItemId, + item.id === lastVisibleItemId, ))}
diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts index 129664a9c1..9462edb785 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.test.ts @@ -395,7 +395,7 @@ describe('sessionToVirtualItems explore grouping', () => { }); }); - it('keeps the active collapsible tool visible after a collapsed explore group', () => { + it('keeps an active collapsible tool in the same trailing explore group', () => { const session = makeSession({ sessionId: 'active-tool-session', dialogTurns: [{ @@ -411,7 +411,9 @@ describe('sessionToVirtualItems explore grouping', () => { makeRound({ id: 'round-2', items: [makeTool('tool-2', 'Read', 'running')], - isStreaming: true, + // Item status is the durable source of truth even if the round's + // streaming bit arrives one store update late. + isStreaming: false, isComplete: false, status: 'streaming', }), @@ -423,18 +425,19 @@ describe('sessionToVirtualItems explore grouping', () => { const items = sessionToVirtualItems(session); - expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group', 'model-round']); + expect(items.map(item => item.type)).toEqual(['user-message', 'explore-group']); expect(items[1]).toMatchObject({ type: 'explore-group', data: { groupId: 'round-1', - wasCutByCritical: true, - }, - }); - expect(items[2]).toMatchObject({ - type: 'model-round', - data: { - id: 'round-2', + isGroupStreaming: true, + isLastGroupInTurn: true, + wasCutByCritical: false, + allItems: [ + expect.objectContaining({ id: 'text-1' }), + expect.objectContaining({ id: 'tool-1' }), + expect.objectContaining({ id: 'tool-2', status: 'running' }), + ], }, }); }); @@ -545,10 +548,17 @@ describe('sessionToVirtualItems explore grouping', () => { const activeItems = sessionToVirtualItems(activeSession); const completedItems = sessionToVirtualItems(completedSession); + expect(activeItems.map(item => item.type)).toEqual(['user-message', 'explore-group']); + expect(completedItems.map(item => item.type)).toEqual(activeItems.map(item => item.type)); expect(activeItems[1]).toMatchObject({ type: 'explore-group', data: { groupId: 'round-1', + allItems: [ + expect.objectContaining({ id: 'text-1' }), + expect.objectContaining({ id: 'tool-1' }), + expect.objectContaining({ id: 'tool-2', status: 'running' }), + ], }, }); expect(completedItems[1]).toMatchObject({ @@ -564,7 +574,7 @@ describe('sessionToVirtualItems explore grouping', () => { }); }); - it('auto-collapses completed trailing explore groups', () => { + it('keeps the latest completed trailing explore group expanded', () => { const session = makeSession(); const items = sessionToVirtualItems(session); @@ -572,9 +582,77 @@ describe('sessionToVirtualItems explore grouping', () => { expect(items[1]).toMatchObject({ type: 'explore-group', data: { + isGroupStreaming: false, + isLastGroupInTurn: true, + wasCutByCritical: false, + }, + }); + }); + + it('collapses a completed trailing explore group once a newer turn exists', () => { + const firstTurn: Session['dialogTurns'][number] = { + id: 'turn-1', + sessionId: 'session-1', + userMessage: { + id: 'user-1', + content: 'Inspect the file', + timestamp: 900, + }, + modelRounds: [makeRound({ id: 'round-1' })], + status: 'completed', + startTime: 900, + }; + const secondTurn: Session['dialogTurns'][number] = { + id: 'turn-2', + sessionId: 'session-1', + userMessage: { + id: 'user-2', + content: 'Continue', + timestamp: 1100, + }, + modelRounds: [makeRound({ id: 'round-2' })], + status: 'processing', + startTime: 1100, + }; + + // Populate the stable-turn projection cache while this is still the tail. + const initialItems = sessionToVirtualItems(makeSession({ + dialogTurns: [firstTurn], + })); + expect(initialItems[1]).toMatchObject({ + type: 'explore-group', + data: { + wasCutByCritical: false, + }, + }); + + // Reuse the same immutable turn object, matching the real append path. The + // cache must account for its new non-tail position. + const session = makeSession({ + dialogTurns: [ + firstTurn, + secondTurn, + ], + }); + + const items = sessionToVirtualItems(session); + const exploreGroups = items.filter(item => item.type === 'explore-group'); + + expect(exploreGroups).toHaveLength(2); + expect(exploreGroups[0]).toMatchObject({ + turnId: 'turn-1', + data: { + isLastGroupInTurn: false, wasCutByCritical: true, }, }); + expect(exploreGroups[1]).toMatchObject({ + turnId: 'turn-2', + data: { + isLastGroupInTurn: true, + wasCutByCritical: false, + }, + }); }); it('auto-collapses non-trailing explore groups during an active turn', () => { @@ -611,11 +689,13 @@ describe('sessionToVirtualItems explore grouping', () => { expect(exploreGroups).toHaveLength(2); expect(exploreGroups[0]).toMatchObject({ data: { + isLastGroupInTurn: false, wasCutByCritical: true, }, }); expect(exploreGroups[1]).toMatchObject({ data: { + isLastGroupInTurn: true, wasCutByCritical: false, }, }); diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index d08ac961fc..fac6f9eb3f 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -43,9 +43,9 @@ export interface ExploreGroupData { isLastGroupInTurn: boolean; /** * True when this group is no longer the tail of the turn — a non-explore - * (critical) round or turn completion has ended the group. The renderer uses - * this to trigger a one-shot auto-collapse instead of continuously watching - * isGroupStreaming. + * (critical) round, a top-level notice, or a newer dialog turn has superseded + * the group. Turn completion alone does not cut the live tail. The renderer + * uses this to trigger one-shot auto-collapse. */ wasCutByCritical: boolean; } @@ -128,13 +128,6 @@ function hasActiveStreamingNarrative(round: ModelRound): boolean { }); } -function hasActiveTool(round: ModelRound): boolean { - return round.items.some(item => { - if (item.type !== 'tool') return false; - return item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'rejected' && item.status !== 'error'; - }); -} - function hasTrailingVisibleText(round: ModelRound): boolean { for (let index = round.items.length - 1; index >= 0; index -= 1) { const item = round.items[index]; @@ -163,10 +156,6 @@ function isExploreOnlyRound(round: ModelRound): boolean { return false; } - if (hasActiveTool(round)) { - return false; - } - if (hasTrailingVisibleText(round)) { return false; } @@ -279,7 +268,10 @@ function isStableTurnProjection(turn: DialogTurn): boolean { let cachedSession: Session | null = null; let cachedDialogTurnsRef: DialogTurn[] | null = null; let cachedVirtualItems: VirtualItem[] = []; -let cachedTurnItems = new WeakMap(); +let cachedTurnItems = new WeakMap< + DialogTurn, + { items: VirtualItem[]; hasNewerDialogTurn: boolean } +>(); /** * Convert Session to virtualized render items @@ -313,10 +305,15 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { const items: VirtualItem[] = []; - session.dialogTurns.forEach(turn => { + session.dialogTurns.forEach((turn, turnIndex) => { + const hasNewerDialogTurn = turnIndex < session.dialogTurns.length - 1; const cachedItems = cachedTurnItems.get(turn); - if (cachedItems && isStableTurnProjection(turn)) { - items.push(...cachedItems); + if ( + cachedItems && + cachedItems.hasNewerDialogTurn === hasNewerDialogTurn && + isStableTurnProjection(turn) + ) { + items.push(...cachedItems.items); return; } const turnItemStart = items.length; @@ -372,7 +369,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { const flushRoundEntries = ( rounds: ModelRound[], - _options: { collapseTrailingExploreGroup: boolean }, + options: { collapseTrailingExploreGroup: boolean }, ) => { if (rounds.length === 0) return; @@ -434,8 +431,12 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { const group = tempGroups[groupIndex]; if (group && group.startIndex === roundIndex) { - const isLastGroup = groupIndex === tempGroups.length - 1; - const isGroupStreaming = group.rounds.some(r => r.isStreaming); + const isLastGroupInTurn = + group.endIndex === rounds.length - 1 && + !options.collapseTrailingExploreGroup; + const isGroupStreaming = group.rounds.some( + r => r.isStreaming || r.items.some(isActiveFlowItem), + ); // A group is "cut by critical" when it is no longer the tail of the // turn. Two conditions cover all cases: // 1. group.endIndex < rounds.length - 1: there are rounds after @@ -446,10 +447,15 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { // tempGroups only contains explore-only groups; a following // critical round (e.g. TodoWrite) is invisible to tempGroups // yet still sits after this group in the rounds array. - // 2. turn is complete and no round in this group is still streaming. + // 2. the caller knows another top-level item follows this segment + // (user steering, a completion/failure notice, or a newer turn). + // + // Turn completion by itself is deliberately not a cut. The live tail + // keeps its final action visible; a later conversation item is what + // makes the group compact. const wasCutByCritical = group.endIndex < rounds.length - 1 || - (isTurnComplete && !isGroupStreaming); + options.collapseTrailingExploreGroup; const groupId = group.rounds[0]?.id ?? `explore-group-${turn.id}-${group.startIndex}`; @@ -466,7 +472,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { commandCount: group.commandCount, }, isGroupStreaming, - isLastGroupInTurn: isLastGroup, + isLastGroupInTurn, wasCutByCritical, }, }); @@ -495,6 +501,8 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { } }; + const completionNotice = getTurnCompletionNotice(turn); + const hasFailureNotice = turn.status === 'error' && Boolean(turn.error || turn.errorDetail); let pendingRounds: ModelRound[] = []; renderEntries.forEach(entry => { @@ -515,9 +523,13 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { }); }); - flushRoundEntries(pendingRounds, { collapseTrailingExploreGroup: true }); + flushRoundEntries(pendingRounds, { + collapseTrailingExploreGroup: + hasNewerDialogTurn || + completionNotice !== null || + hasFailureNotice, + }); - const completionNotice = getTurnCompletionNotice(turn); if (completionNotice) { items.push({ type: 'turn-completion-notice', @@ -526,7 +538,7 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { }); } - if (turn.status === 'error' && (turn.error || turn.errorDetail)) { + if (hasFailureNotice) { items.push({ type: 'turn-failure-notice', turnId: turn.id, @@ -538,7 +550,10 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { } if (isStableTurnProjection(turn)) { - cachedTurnItems.set(turn, items.slice(turnItemStart)); + cachedTurnItems.set(turn, { + items: items.slice(turnItemStart), + hasNewerDialogTurn, + }); } }); diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss index 72d4e7b0e8..392a51917e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss @@ -5,6 +5,7 @@ */ @use './_tool-card-common.scss'; +@use '../components/modern/SmoothHeightCollapse.scss'; /* ========== Card wrapper ========== */ .ask-user-question-card { @@ -14,8 +15,11 @@ border-radius: var(--size-radius-md); border: 1px solid var(--border-base); background: var(--color-bg-scene); - backdrop-filter: blur(10px); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease, + opacity 180ms ease; overflow: hidden; position: relative; @@ -484,7 +488,7 @@ gap: 12px; cursor: pointer; padding: 10px 14px; - transition: all 0.15s ease; + transition: background-color 150ms ease; &:hover { background: var(--element-bg-subtle); @@ -545,7 +549,6 @@ margin-top: 0; padding-top: 12px; border-top: 1px solid var(--border-base); - animation: expandDown 0.2s ease-out; .option-label { pointer-events: none; @@ -582,18 +585,6 @@ padding: 12px; } -/* ========== Animation ========== */ -@keyframes expandDown { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - .animate-spin { animation: spin 1s linear infinite; } diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx new file mode 100644 index 0000000000..3bf934a0f1 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: Record) => ( + options?.count === undefined ? key : `${key}:${String(options.count)}` + ), + }), +})); + +vi.mock('@/component-library', () => ({ + Button: ({ + children, + isLoading: _isLoading, + ...props + }: React.ButtonHTMLAttributes & { isLoading?: boolean }) => ( + + ), + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +vi.mock('@/infrastructure/api/service-api/ToolAPI', () => ({ + toolAPI: { + submitUserAnswers: vi.fn(), + }, +})); + +import { AskUserQuestionCard } from './AskUserQuestionCard'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const config: ToolCardConfig = { + toolName: 'AskUserQuestion', + displayName: 'Ask User', + icon: 'Q', + requiresConfirmation: false, + resultDisplayType: 'detailed', +}; + +function questionTool(status: FlowToolItem['status']): FlowToolItem { + return { + id: 'question-tool-1', + type: 'tool', + toolName: 'AskUserQuestion', + timestamp: 1, + status, + toolCall: { + id: 'question-call-1', + input: { + questions: [{ + header: 'Database', + question: 'Which database?', + multiSelect: false, + options: [{ + label: 'PostgreSQL', + description: 'Use PostgreSQL', + }], + }], + }, + }, + ...(status === 'completed' + ? { + toolResult: { + success: true, + result: { + answers: { + 0: 'PostgreSQL', + }, + }, + }, + } + : {}), + }; +} + +describe('AskUserQuestionCard', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps a just-completed tail question visible until newer content arrives', () => { + act(() => { + root.render( + , + ); + }); + expect(container.querySelector('.questions-container')).not.toBeNull(); + expect(container.querySelector('.completed-summary')).toBeNull(); + + act(() => { + root.render( + , + ); + }); + expect(container.querySelector('.questions-container')).not.toBeNull(); + expect(container.querySelector('.completed-summary')).toBeNull(); + + act(() => { + root.render( + , + ); + }); + expect(container.querySelector('.completed-summary')).not.toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 901b95d6c7..20b09126ea 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -11,6 +11,7 @@ import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI'; import { createLogger } from '@/shared/utils/logger'; import { Button, Tooltip } from '@/component-library'; import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { SmoothHeightCollapse } from '../components/modern/SmoothHeightCollapse'; import './AskUserQuestionCard.scss'; const log = createLogger('AskUserQuestionCard'); @@ -85,7 +86,8 @@ function isAwaitingQuestionPayload( } export const AskUserQuestionCard: React.FC = ({ - toolItem + toolItem, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const { status, toolCall, toolResult, isParamsStreaming, partialParams } = toolItem; @@ -113,13 +115,14 @@ export const AskUserQuestionCard: React.FC = ({ toolId, toolName: toolItem.toolName, }); - const previousStatusRef = useRef(status); useLayoutEffect(() => { - const previousStatus = previousStatusRef.current; - previousStatusRef.current = status; + const shouldCompactCompleted = + status === 'completed' && + isLastItem !== true && + !showCompletedSummary; - if (previousStatus !== 'completed' && status === 'completed' && !showCompletedSummary) { + if (shouldCompactCompleted) { applyExpandedState(true, false, (nextExpanded) => { setShowCompletedSummary(!nextExpanded); }, { @@ -131,7 +134,7 @@ export const AskUserQuestionCard: React.FC = ({ if (status !== 'completed' && showCompletedSummary) { setShowCompletedSummary(false); } - }, [applyExpandedState, showCompletedSummary, status]); + }, [applyExpandedState, isLastItem, showCompletedSummary, status]); const isAllAnswered = useCallback(() => { if (questions.length === 0) return false; @@ -493,11 +496,14 @@ export const AskUserQuestionCard: React.FC = ({
- {isExpanded && ( +
{questions.map((q, idx) => renderQuestion(q, idx))}
- )} +
{renderResult()} diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss index 8b166f7ba7..b22e3ed979 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss @@ -15,8 +15,11 @@ border-radius: var(--flowchat-card-radius); border: 1px solid var(--border-base); background: var(--color-bg-scene); - backdrop-filter: blur(10px); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: + border-color 180ms ease, + box-shadow 180ms ease, + background-color 180ms ease, + opacity 180ms ease; overflow: hidden; width: 100%; box-sizing: border-box; @@ -130,7 +133,9 @@ width: 100%; box-sizing: border-box; background: transparent; - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + transition: + background-color 180ms ease, + opacity 180ms ease; overflow: hidden; &::before { @@ -166,7 +171,7 @@ font-size: var(--tool-card-action-font-size); font-weight: 500; color: var(--color-text-primary); - transition: color 0.2s ease; + transition: color 180ms ease, opacity 180ms ease; letter-spacing: -0.01em; flex-shrink: 0; line-height: var(--tool-card-action-line-height); @@ -182,7 +187,7 @@ font-size: var(--tool-card-action-font-size); font-weight: 500; color: var(--color-text-primary); - transition: color 0.2s ease; + transition: color 180ms ease, opacity 180ms ease; letter-spacing: -0.01em; line-height: var(--tool-card-action-line-height); overflow: hidden; @@ -332,7 +337,7 @@ .base-tool-card-wrapper--loading-shimmer .tool-card-icon-marks, .base-tool-card-wrapper--loading-shimmer .tool-card-action, .base-tool-card-wrapper--loading-shimmer .tool-card-content { - animation: tool-card-text-fade 1.6s ease-in-out infinite; + animation: tool-card-text-fade 2.2s ease-in-out infinite; } @keyframes tool-card-text-fade { @@ -340,6 +345,6 @@ opacity: 1; } 50% { - opacity: 0.25; + opacity: 0.68; } } 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 b5e557dc75..f5d53f5817 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CompactToolCard.scss @@ -95,13 +95,13 @@ /* E — action + content breathe; keep tool-identifier-icon steady */ .compact-tool-card-wrapper--loading-shimmer .compact-card-action, .compact-tool-card-wrapper--loading-shimmer .compact-card-content { - animation: tool-card-text-fade 1.6s ease-in-out infinite; + animation: tool-card-text-fade 2.2s ease-in-out infinite; } /* Legacy compact cards that still use status icon as left icon (no .tool-identifier-icon child): fade the icon-marks too for the original shimmer effect. */ .compact-tool-card-wrapper--loading-shimmer .tool-card-icon-marks:not(:has(.tool-identifier-icon)) { - animation: tool-card-text-fade 1.6s ease-in-out infinite; + animation: tool-card-text-fade 2.2s ease-in-out infinite; } /* ========== Card body - transparent, no border ========== */ @@ -116,7 +116,7 @@ gap: 8px; width: 100%; box-sizing: border-box; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: opacity 180ms ease; position: relative; &.clickable:hover { @@ -179,7 +179,7 @@ line-height: var(--tool-card-action-line-height); font-weight: var(--font-weight-medium); color: var(--color-text-muted); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 180ms ease, opacity 180ms ease; flex-shrink: 0; } @@ -189,7 +189,7 @@ line-height: var(--tool-card-action-line-height); color: var(--color-text-muted); min-width: 0; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 180ms ease, opacity 180ms ease; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -197,7 +197,7 @@ .read-file-meta { color: inherit; opacity: 0.85; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: opacity 180ms ease; } } @@ -207,7 +207,7 @@ color: var(--color-text-muted); display: flex; align-items: center; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 180ms ease, opacity 180ms ease; flex-shrink: 0; > svg { @@ -308,7 +308,6 @@ box-shadow: 0 2px 8px var(--color-overlay-black-15), inset 0 1px 0 var(--color-overlay-white-04); - backdrop-filter: blur(8px); } @@ -358,7 +357,7 @@ .compact-expanded-result-item { padding: 8px; border-radius: 4px; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + transition: background-color 180ms ease; &:hover { background: var(--element-bg-soft); @@ -374,7 +373,7 @@ font-weight: 600; cursor: pointer; margin-bottom: 4px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 180ms ease; &:hover { color: var(--color-text-primary); @@ -389,7 +388,7 @@ .inline-icon { flex-shrink: 0; color: var(--color-text-muted); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: color 180ms ease, transform 180ms ease; } } @@ -449,6 +448,6 @@ opacity: 1; } 50% { - opacity: 0.25; + opacity: 0.68; } } diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss index 07dbdba489..da532f23d2 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.scss @@ -12,7 +12,10 @@ border-radius: 8px; margin: var(--flowchat-card-gap) 0; overflow: hidden; - transition: all 0.2s ease; + transition: + border-color 180ms ease, + box-shadow 180ms ease, + transform 180ms ease; box-sizing: border-box; &:hover { @@ -175,7 +178,7 @@ &--loading-shimmer { .file-name { - animation: tool-card-text-fade 1.6s ease-in-out infinite; + animation: tool-card-text-fade 2.2s ease-in-out infinite; } } } @@ -466,6 +469,6 @@ opacity: 1; } 50% { - opacity: 0.25; + opacity: 0.68; } } diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx index 1057b3e2a7..d7ee7dc1b4 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecCommandToolCard.tsx @@ -7,6 +7,7 @@ import { buildExecCommandCardModel } from './execProcessToolCardModel'; export const ExecCommandToolCard: React.FC = ({ toolItem, onExpand, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const model = useMemo( @@ -19,6 +20,7 @@ export const ExecCommandToolCard: React.FC = ({ toolItem={toolItem} model={model} onExpand={onExpand} + isLastItem={isLastItem} /> ); }; diff --git a/src/web-ui/src/flow_chat/tool-cards/ExecControlToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ExecControlToolCard.tsx index b8157347ee..0bd1b6fc14 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecControlToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecControlToolCard.tsx @@ -7,6 +7,7 @@ import { buildExecControlCardModel } from './execProcessToolCardModel'; export const ExecControlToolCard: React.FC = ({ toolItem, onExpand, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const model = useMemo( @@ -19,6 +20,7 @@ export const ExecControlToolCard: React.FC = ({ toolItem={toolItem} model={model} onExpand={onExpand} + isLastItem={isLastItem} /> ); }; 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 7098c3d570..5728cb9d35 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 @@ -165,4 +165,51 @@ describe('ExecProcessToolCardView', () => { expect(container.textContent).toContain('Waiting for confirmation'); expect(container.textContent).not.toContain('Receiving parameters...'); }); + + it('retains a just-completed tail result until newer content supersedes it', () => { + const resultModel: ExecProcessCardModel = { + ...model, + resultOutput: 'All tests passed', + }; + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('.base-tool-card')).not.toBeNull(); + expect(container.querySelector('.compact-tool-card')).toBeNull(); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('.base-tool-card')).not.toBeNull(); + expect(container.querySelector('.compact-tool-card')).toBeNull(); + expect(container.textContent).toContain('All tests passed'); + + act(() => { + root.render( + , + ); + }); + + expect(container.querySelector('.base-tool-card')).toBeNull(); + expect(container.querySelector('.compact-tool-card')).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 a54880dec7..4624e739de 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx @@ -44,6 +44,7 @@ interface ExecProcessToolCardViewProps { toolItem: FlowToolItem; model: ExecProcessCardModel; onExpand?: () => void; + isLastItem?: boolean; } function isCollapsedStatus(status: string): boolean { @@ -54,9 +55,12 @@ function getInitialExpandedState(status: string): boolean { return !isCollapsedStatus(status); } -function getAutoExpandedStateForStatus(status: string): boolean | null { +function getAutoExpandedStateForStatus( + status: string, + isLastItem: boolean | undefined, +): boolean | null { if (isCollapsedStatus(status)) { - return false; + return isLastItem === true ? null : false; } if (status === 'preparing' || status === 'streaming' || status === 'running' || status === 'receiving') { @@ -161,6 +165,7 @@ export const ExecProcessToolCardView: React.FC = ( toolItem, model, onExpand, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const status = toolItem.status || 'pending'; @@ -181,12 +186,11 @@ export const ExecProcessToolCardView: React.FC = ( const cancelledStatusClassName = isUserRejectedTool(toolItem) ? 'status-rejected' : 'status-cancelled'; - const maxRows = isRunning ? EXEC_OUTPUT_STREAMING_MAX_ROWS : EXEC_OUTPUT_EXPANDED_MAX_ROWS; const toolId = toolItem.id ?? toolItem.toolCall?.id; const icon = ; const [isExpanded, setIsExpandedState] = useState(() => getInitialExpandedState(status)); - const previousStatusRef = useRef(status); + const userToggledRef = useRef(false); const commandRef = useRef(null); const outputRendererRef = useRef(null); const [isPrimaryTextTruncated, setIsPrimaryTextTruncated] = useState(false); @@ -210,21 +214,29 @@ export const ExecProcessToolCardView: React.FC = ( }, [applyExpandedState, isExpanded, onExpand]); const toggleExpanded = useCallback(() => { + userToggledRef.current = true; applyExecExpandedState(!isExpanded, { reason: 'manual' }); }, [applyExecExpandedState, isExpanded]); useLayoutEffect(() => { - const prevStatus = previousStatusRef.current; - previousStatusRef.current = status; - if (prevStatus === status) { + if (userToggledRef.current) { return; } - const nextExpanded = getAutoExpandedStateForStatus(status); + const nextExpanded = getAutoExpandedStateForStatus(status, isLastItem); if (nextExpanded !== null) { applyExecExpandedState(nextExpanded, { reason: 'auto' }); } - }, [applyExecExpandedState, status]); + }, [applyExecExpandedState, isLastItem, status]); + + const compactSettledPreview = + isExpanded && + isLastItem === true && + isCollapsedStatus(status) && + !userToggledRef.current; + const maxRows = isRunning || compactSettledPreview + ? EXEC_OUTPUT_STREAMING_MAX_ROWS + : EXEC_OUTPUT_EXPANDED_MAX_ROWS; const updatePrimaryTextTruncation = useCallback(() => { const element = commandRef.current; diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss index 7122b6b1d6..9bef6d4b1f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.scss @@ -273,7 +273,7 @@ align-items: center; gap: 4px; color: var(--color-text-muted); - transition: all 0.2s ease; + transition: color 180ms ease, opacity 180ms ease, transform 180ms ease; flex-shrink: 0; cursor: pointer; diff --git a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx index 9e63b1902d..22b03ec0aa 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.test.tsx @@ -119,6 +119,7 @@ vi.mock('../../shared/services/FileTabManager', () => ({ vi.mock('../../infrastructure/api', () => ({ snapshotAPI: { getOperationDiff: mocks.getOperationDiff, + getOperationSummary: vi.fn(async () => null), }, })); @@ -821,7 +822,7 @@ describe('FileOperationToolCard', () => { expect(container.textContent).toContain(`${fullContent.length} chars received`); }); - it('keeps completed write preview compact while auto-collapsing from streaming', async () => { + it('retains a compact completed write preview until newer content supersedes it', async () => { const config: ToolCardConfig = { toolName: 'Write', displayName: 'Write', @@ -868,10 +869,12 @@ describe('FileOperationToolCard', () => { toolItem={streamingToolItem} config={config} sessionId="session-1" + isLastItem /> ); }); + mocks.codePreviewProps = []; mocks.inlineDiffPreviewProps = []; await act(async () => { @@ -880,13 +883,30 @@ describe('FileOperationToolCard', () => { toolItem={completedToolItem} config={config} sessionId="session-1" + isLastItem + /> + ); + }); + + expect(container.querySelector('[data-testid="chat-file-change-preview"]')).not.toBeNull(); + expect(mocks.codePreviewProps).toHaveLength(1); + expect(mocks.codePreviewProps[0]).toMatchObject({ + isStreaming: false, + maxHeight: 88, + }); + + await act(async () => { + root.render( + ); }); - expect(mocks.inlineDiffPreviewProps.length).toBeGreaterThan(0); - expect(mocks.inlineDiffPreviewProps.map(props => props.maxHeight)).not.toContain(330); - expect(mocks.inlineDiffPreviewProps.map(props => props.maxHeight)).toContain(88); + expect(container.querySelector('[data-testid="chat-file-change-preview"]')).toBeNull(); }); it('uses the larger diff preview height after a completed write card is manually expanded', async () => { 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 89e2f2589b..ff8c97159c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -115,6 +115,7 @@ export const FileOperationToolCard: React.FC = ({ sessionId, onOpenInEditor, displayContext, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const { @@ -134,11 +135,14 @@ export const FileOperationToolCard: React.FC = ({ // on completion must land in a single frame, otherwise the message list's // scroll anchor spends ~260ms chasing the shrink and the pane visibly jumps. const [animateContentToggle, setAnimateContentToggle] = useState(false); + const [retainLiveCompletionPreview, setRetainLiveCompletionPreview] = useState(false); const [operationDiffStats, setOperationDiffStats] = useState<{ additions: number; deletions: number } | null>(null); const hasInitializedCompletionEffectRef = useRef(false); const previousCompletionEndTimeRef = useRef(toolItem.endTime ?? null); - const previousStatusRef = useRef(status); + const previousExpansionStatusRef = useRef(status); + const previousFailureStatusRef = useRef(status); + const userToggledContentRef = useRef(false); const lastStableExpandedHeightRef = useRef(0); const { cardRootRef, @@ -378,6 +382,10 @@ export const FileOperationToolCard: React.FC = ({ nextExpanded: boolean, reason: 'manual' | 'auto', ) => { + if (reason === 'manual') { + userToggledContentRef.current = true; + setRetainLiveCompletionPreview(false); + } setAnimateContentToggle(reason === 'manual'); applyHeightContractExpandedState( isContentExpanded, @@ -406,26 +414,35 @@ export const FileOperationToolCard: React.FC = ({ } }, [error, clearError, currentFilePath]); - useEffect(() => { - if (previousStatusRef.current !== status) { - if (status === 'completed' && !isFailed) { - applyContentExpandedState(false, 'auto'); - } else if (status !== 'completed') { - applyContentExpandedState(true, 'auto'); + useLayoutEffect(() => { + const previousStatus = previousExpansionStatusRef.current; + previousExpansionStatusRef.current = status; + + if (userToggledContentRef.current) { + return; + } + + if (status === 'completed' && !isFailed) { + if (isLastItem === true && isContentExpanded) { + if (previousStatus !== 'completed') { + setRetainLiveCompletionPreview(true); + } + return; } - previousStatusRef.current = status; + + setRetainLiveCompletionPreview(false); + applyContentExpandedState(false, 'auto'); + return; } + + setRetainLiveCompletionPreview(false); + applyContentExpandedState(true, 'auto'); }, [ applyContentExpandedState, - cardRootRef, - contentPreview, - currentFilePath, isContentExpanded, isFailed, - oldStringContent, + isLastItem, status, - toolId, - toolItem.toolName, ]); const localDiffStats = useMemo(() => { @@ -500,18 +517,27 @@ export const FileOperationToolCard: React.FC = ({ const shouldUseExpandedDiffPreviewHeight = status === 'completed' && isContentExpanded && - previousStatusRef.current === status; + !retainLiveCompletionPreview; + const keepLiveEditPreview = + retainLiveCompletionPreview && + toolItem.toolName === 'Edit' && + Boolean(newStringContent); + const keepLiveWritePreview = + retainLiveCompletionPreview && + toolItem.toolName === 'Write' && + Boolean(contentPreview); const previewVariant = useMemo(() => { if (toolItem.toolName === 'Edit') { // Keep streaming-code until typewriter drains so completion does not snap // the remaining characters into the diff view. - if ((status !== 'completed' || editTypewriter.isRevealing) && newStringContent) { + if ((status !== 'completed' || editTypewriter.isRevealing || keepLiveEditPreview) && newStringContent) { return 'streaming-code'; } if ( status === 'completed' && !isParamsStreaming && !editTypewriter.isRevealing + && !keepLiveEditPreview && (oldStringContent || newStringContent) ) { return 'completed-diff'; @@ -519,13 +545,14 @@ export const FileOperationToolCard: React.FC = ({ } if (toolItem.toolName === 'Write') { - if ((status !== 'completed' || writeTypewriter.isRevealing) && contentPreview) { + if ((status !== 'completed' || writeTypewriter.isRevealing || keepLiveWritePreview) && contentPreview) { return 'streaming-code'; } if ( status === 'completed' && !isParamsStreaming && !writeTypewriter.isRevealing + && !keepLiveWritePreview && contentPreview ) { return 'completed-diff'; @@ -537,6 +564,8 @@ export const FileOperationToolCard: React.FC = ({ contentPreview, editTypewriter.isRevealing, isParamsStreaming, + keepLiveEditPreview, + keepLiveWritePreview, newStringContent, oldStringContent, status, @@ -552,7 +581,8 @@ export const FileOperationToolCard: React.FC = ({ }, [cardRootRef, isContentExpanded, isFailed, previewVariant, status]); useLayoutEffect(() => { - const previousStatus = previousStatusRef.current; + const previousStatus = previousFailureStatusRef.current; + previousFailureStatusRef.current = status; const isNewFailure = previousStatus !== status && status === 'error'; if (!isNewFailure || !isContentExpanded) { return; @@ -901,7 +931,10 @@ export const FileOperationToolCard: React.FC = ({ : FILE_OPERATION_STREAMING_MAX_HEIGHT; if (toolItem.toolName === 'Edit') { - if ((status !== 'completed' || editTypewriter.isRevealing) && newStringContent) { + if ( + (status !== 'completed' || editTypewriter.isRevealing || keepLiveEditPreview) + && newStringContent + ) { return (
@@ -923,6 +956,7 @@ export const FileOperationToolCard: React.FC = ({ status === 'completed' && !isParamsStreaming && !editTypewriter.isRevealing + && !keepLiveEditPreview && (oldStringContent || newStringContent) ) { return ( @@ -945,7 +979,10 @@ export const FileOperationToolCard: React.FC = ({ } if (toolItem.toolName === 'Write') { - if ((status !== 'completed' || writeTypewriter.isRevealing) && contentPreview) { + if ( + (status !== 'completed' || writeTypewriter.isRevealing || keepLiveWritePreview) + && contentPreview + ) { return (
@@ -967,6 +1004,7 @@ export const FileOperationToolCard: React.FC = ({ status === 'completed' && !isParamsStreaming && !writeTypewriter.isRevealing + && !keepLiveWritePreview && contentPreview ) { return ( diff --git a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx index a1c01d6e1a..eddb02b229 100644 --- a/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/MCPToolDisplay.tsx @@ -250,7 +250,7 @@ export const MCPToolDisplay: React.FC = ({ }, [config.toolName, uiResourceUriFromResult, status, isFailed, toolId]); // Auto-expand when MCP App UI is ready so user sees the interactive UI immediately - useEffect(() => { + useLayoutEffect(() => { if (mcpAppState?.html && !isExpanded) { applyExpandedState(isExpanded, true, setIsExpanded, { reason: 'auto', diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx index acc0a2f8b5..adba6a6b35 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -7,7 +7,7 @@ * Applies typewriter effect during streaming. */ -import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; import { ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { FlowThinkingItem } from '../types/flow-chat'; @@ -63,7 +63,7 @@ export const ModelThinkingDisplay: React.FC = ({ }, }); - useEffect(() => { + useLayoutEffect(() => { if (userToggledRef.current) return; if (isExpanded !== shouldDefaultExpanded) { setAnimateToggle(false); diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index 70774c71a3..9c9ff7f8b2 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -546,10 +546,9 @@ .flow-tool-card-wrapper .base-tool-card-wrapper.task-tool-display { margin: 0; /* - * The global BaseToolCard uses transition: all. During task/subagent merge - * that animates border/background/radius removal and briefly exposes a - * separate rounded card behind the prompt. Only keep shadow/border hover - * feedback in this grouped shell. + * Keep only shadow/border hover feedback in this grouped shell. Layout + * properties must not interpolate while the task and subagent surfaces + * merge into one card. */ transition: box-shadow 0.18s ease, @@ -568,7 +567,6 @@ // The wrapper itself provides the unified card appearance. background: var(--color-bg-scene); border: 1px solid var(--border-base); - backdrop-filter: blur(10px); overflow: hidden; box-shadow: 0 2px 6px var(--color-overlay-black-15), @@ -649,7 +647,7 @@ /* E — task description text fades with the card */ .task-tool-display.base-tool-card-wrapper--loading-shimmer .task-action { - animation: tool-card-text-fade 1.6s ease-in-out infinite; + animation: tool-card-text-fade 2.2s ease-in-out infinite; } /* Reviewer context block — replaces raw prompt for review-team members. */ diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx index 0f62e7d386..49c8d5877a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.test.tsx @@ -381,6 +381,55 @@ describeWithJsdom('TaskToolDisplay', () => { expect(taskCollapseStateManager.isCollapsed('task-tool-1')).toBe(true); }); + it('retains a completed tail task until newer content supersedes it', async () => { + await act(async () => { + root.render( + , + ); + }); + + await act(async () => { + root.render( + , + ); + }); + expect(taskCollapseStateManager.isCollapsed('task-tool-1')).toBe(false); + + await act(async () => { + root.render( + , + ); + }); + expect(taskCollapseStateManager.isCollapsed('task-tool-1')).toBe(false); + + await act(async () => { + root.render( + , + ); + }); + expect(taskCollapseStateManager.isCollapsed('task-tool-1')).toBe(true); + }); + it('keeps Deep Review reviewer task cards collapsed when they start running', async () => { await act(async () => { root.render( diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index b8698cb9a0..fde44bc18f 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -255,6 +255,7 @@ export const TaskToolDisplay: React.FC = ({ interruptionNote, onOpenInPanel, sessionId, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const defaultTimeoutDisabled = useSessionGoalModeActive(sessionId); @@ -285,11 +286,15 @@ export const TaskToolDisplay: React.FC = ({ }); const prevStatusRef = useRef(status); + const userToggledRef = useRef(false); const updateCardExpandedState = useCallback(( nextExpanded: boolean, reason: 'manual' | 'auto' = 'manual', ) => { + if (reason === 'manual') { + userToggledRef.current = true; + } if (nextExpanded !== isExpanded) { /* Sync before the next commit paints so subagent wrapper + task card merge in one frame. */ taskCollapseStateManager.setCollapsed(toolItem.id, !nextExpanded); @@ -307,17 +312,34 @@ export const TaskToolDisplay: React.FC = ({ prevStatusRef.current = status; return; } + + if (userToggledRef.current) { + prevStatusRef.current = status; + return; + } if (prevStatus !== status) { prevStatusRef.current = status; if (status === 'completed') { - updateCardExpandedState(false, 'auto'); + if (isLastItem !== true) { + updateCardExpandedState(false, 'auto'); + } } else if (isRunning && !keepCollapsedWhileRunning) { updateCardExpandedState(true, 'auto'); } + } else if (status === 'completed' && isLastItem === false && isExpanded) { + updateCardExpandedState(false, 'auto'); } - }, [isCancelAction, isExpanded, isRunning, keepCollapsedWhileRunning, status, updateCardExpandedState]); + }, [ + isCancelAction, + isExpanded, + isLastItem, + isRunning, + keepCollapsedWhileRunning, + status, + updateCardExpandedState, + ]); useLayoutEffect(() => { taskCollapseStateManager.setCollapsed(toolItem.id, isCancelAction ? true : !isExpanded); 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 736dd48a7d..963c540e26 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.scss @@ -134,7 +134,7 @@ &.editable { cursor: pointer; - transition: all 0.2s ease; + transition: color 180ms ease; &:hover { color: var(--color-accent-500); @@ -160,7 +160,7 @@ border: none !important; outline: none !important; box-shadow: none !important; - transition: all 0.2s ease; + transition: color 180ms ease; line-height: var(--flowchat-compact-line-height); box-sizing: border-box; @@ -299,7 +299,11 @@ color: var(--color-text-muted); cursor: pointer; font-size: var(--flowchat-font-size-sm); - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + transition: + color 180ms ease, + background-color 180ms ease, + transform 180ms ease, + opacity 180ms ease; padding: 0; position: relative; overflow: hidden; 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 499d3d2890..d5be0a0111 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -66,8 +66,18 @@ function getInitialTerminalExpandedState(status: string): boolean { return !(isCollapsedTerminalStatus(status) || status === 'pending_confirmation'); } -function getAutoExpandedStateForTerminalStatus(status: string): boolean | null { - if (isCollapsedTerminalStatus(status) || status === 'pending_confirmation') { +function getAutoExpandedStateForTerminalStatus( + status: string, + isLastItem: boolean | undefined, +): boolean | null { + if (isCollapsedTerminalStatus(status)) { + // A card that was already mounted while live keeps its compact output + // visible at the tail. It collapses when a newer conversation item takes + // over, so completion itself never looks like the card blinked away. + return isLastItem === true ? null : false; + } + + if (status === 'pending_confirmation') { return false; } @@ -83,16 +93,24 @@ function renderTerminalExpandedContent(params: { liveOutput: string; parsedResult: ParsedTerminalResult; waitingMessage: string | null; + compactSettledPreview: boolean; t: (key: string, options?: Record) => string; }): React.ReactNode { - const { viewState, liveOutput, parsedResult, waitingMessage, t } = params; + const { + viewState, + liveOutput, + parsedResult, + waitingMessage, + compactSettledPreview, + t, + } = params; const isStreamingPhase = viewState.displayPhase === 'live_output' || viewState.displayPhase === 'receiving_params' || viewState.displayPhase === 'executing'; - const maxRows = isStreamingPhase + const maxRows = isStreamingPhase || compactSettledPreview ? TERMINAL_OUTPUT_STREAMING_MAX_ROWS : TERMINAL_OUTPUT_EXPANDED_MAX_ROWS; @@ -224,6 +242,7 @@ export const TerminalToolCard: React.FC = ({ toolItem, onExpand, terminalSessionId: propTerminalSessionId, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const toolCall = toolItem.toolCall; @@ -266,7 +285,7 @@ export const TerminalToolCard: React.FC = ({ const toolId = toolItem.id ?? toolCall?.id; const [isExpanded, setIsExpandedState] = useState(() => getInitialTerminalExpandedState(status)); - const previousStatusRef = useRef(status); + const userToggledRef = useRef(false); const { cardRootRef, applyExpandedState, @@ -289,6 +308,7 @@ export const TerminalToolCard: React.FC = ({ }, [applyExpandedState, isExpanded, onExpand]); const toggleExpanded = useCallback(() => { + userToggledRef.current = true; applyTerminalExpandedState(!isExpanded, { reason: 'manual' }); }, [applyTerminalExpandedState, isExpanded]); @@ -303,18 +323,15 @@ export const TerminalToolCard: React.FC = ({ }, [status]); useLayoutEffect(() => { - const prevStatus = previousStatusRef.current; - previousStatusRef.current = status; - - if (prevStatus === status) { + if (userToggledRef.current) { return; } - const nextExpanded = getAutoExpandedStateForTerminalStatus(status); + const nextExpanded = getAutoExpandedStateForTerminalStatus(status, isLastItem); if (nextExpanded !== null) { applyTerminalExpandedState(nextExpanded, { reason: 'auto' }); } - }, [applyTerminalExpandedState, status]); + }, [applyTerminalExpandedState, isLastItem, status]); const updateCommandTruncation = useCallback(() => { const element = commandRef.current; @@ -579,8 +596,20 @@ export const TerminalToolCard: React.FC = ({ extra={renderHeaderExtra(false)} /> ); + const compactSettledPreview = + isExpanded && + isLastItem === true && + isCollapsedTerminalStatus(status) && + !userToggledRef.current; const expandedContent = isExpanded - ? renderTerminalExpandedContent({ viewState, liveOutput, parsedResult, waitingMessage, t }) + ? renderTerminalExpandedContent({ + viewState, + liveOutput, + parsedResult, + waitingMessage, + compactSettledPreview, + t, + }) : null; const errorContent = viewState.isFailed ? renderTerminalErrorContent(toolResult?.error || t('toolCards.terminal.executionFailed')) diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx index bc92c72ca8..f4f89874f0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, Image as ImageIcon } from 'lucide-react'; import { Modal } from '@/component-library'; @@ -66,7 +66,7 @@ export const ViewImageToolCard: React.FC = ({ toolItem, onExpand toolName: toolItem.toolName, }); - useEffect(() => { + useLayoutEffect(() => { if (!source || didAutoExpand.current) return; didAutoExpand.current = true; applyExpandedState(isExpanded, true, setIsExpanded, { reason: 'auto' }); diff --git a/src/web-ui/src/flow_chat/tool-cards/WriteStdinToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/WriteStdinToolCard.tsx index 1acdbce690..fd502fcc0c 100644 --- a/src/web-ui/src/flow_chat/tool-cards/WriteStdinToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/WriteStdinToolCard.tsx @@ -7,6 +7,7 @@ import { buildWriteStdinCardModel } from './execProcessToolCardModel'; export const WriteStdinToolCard: React.FC = ({ toolItem, onExpand, + isLastItem, }) => { const { t } = useTranslation('flow-chat'); const model = useMemo( @@ -19,6 +20,7 @@ export const WriteStdinToolCard: React.FC = ({ toolItem={toolItem} model={model} onExpand={onExpand} + isLastItem={isLastItem} /> ); }; diff --git a/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss b/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss index d40abfd8ec..71837d3341 100644 --- a/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss +++ b/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss @@ -170,7 +170,7 @@ flex-shrink: 0; margin-left: 4px; padding-left: 4px; - transition: all 0.2s ease; + transition: color 180ms ease, opacity 180ms ease, transform 180ms ease; &.tool-card-status-icon--with-divider { border-left: 1px solid var(--border-base); diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index c3d3e6c4b7..5d4f80a563 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -574,6 +574,12 @@ export interface ToolCardProps { sessionId?: string; turnId?: string; displayContext?: ToolCardDisplayContext; + /** + * Whether this card is the current visual tail of the conversation. + * Live cards use this to keep their final result visible until a newer + * action arrives, instead of collapsing in the same frame as completion. + */ + isLastItem?: boolean; /** Callback for MCP App ui/message requests. Returns whether the message was handled successfully. */ onMcpAppMessage?: (params: import('@/infrastructure/api/service-api/MCPAPI').McpUiMessageParams) => Promise; }