diff --git a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts index 96b699d5b1..546ce7ffb1 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputWorkspaceStrip.appearance.ts @@ -6,7 +6,8 @@ export const chatInputWorkspaceStripAppearanceDescriptor: AppearanceSurfaceDescr // `harness` / `runtime` / `actions` grouping went with the conditional grid. parts: [ { id: 'root' }, { id: 'context' }, { id: 'next' }, - { id: 'workspace' }, { id: 'branch' }, + { id: 'workspace' }, { id: 'workspaceMenu' }, + { id: 'workspaceOption' }, { id: 'branch' }, { id: 'divider' }, { id: 'permission' }, { id: 'permissionMenu' }, { id: 'permissionOptions' }, { id: 'permissionOptionRow' }, { id: 'permissionOption' }, { id: 'permissionOptionTrailing' }, @@ -15,6 +16,7 @@ export const chatInputWorkspaceStripAppearanceDescriptor: AppearanceSurfaceDescr states: [ { id: 'open', selector: { kind: 'self', suffix: '[data-bf-state~="open"]' } }, { id: 'selected', selector: { kind: 'self', suffix: '[data-bf-state~="selected"]' } }, + { id: 'active', selector: { kind: 'self', suffix: '[data-bf-state~="active"]' } }, { id: 'armed', selector: { kind: 'self', suffix: '[data-bf-state~="armed"]' } }, ], }; 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 35d26bf2fc..67e5be8b72 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 @@ -889,11 +889,12 @@ describe('FileOperationToolCard', () => { }); expect(container.querySelector('[data-testid="chat-file-change-preview"]')).not.toBeNull(); - expect(mocks.codePreviewProps).toHaveLength(1); - expect(mocks.codePreviewProps[0]).toMatchObject({ + expect(mocks.codePreviewProps).toHaveLength(2); + expect(mocks.codePreviewProps.at(-1)).toMatchObject({ isStreaming: false, maxHeight: 88, }); + expect(mocks.inlineDiffPreviewProps).toHaveLength(0); await act(async () => { root.render( @@ -908,6 +909,7 @@ describe('FileOperationToolCard', () => { // Auto-collapse animates closed; wait for SmoothHeightCollapse to unmount children. expect(container.querySelector('[data-testid="chat-file-change-card"]')?.getAttribute('data-expanded')).toBe('false'); + expect(mocks.inlineDiffPreviewProps).toHaveLength(0); await act(async () => { await new Promise((resolve) => { window.setTimeout(resolve, 350); 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 034f235963..f4584f33d8 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -509,16 +509,30 @@ export const FileOperationToolCard: React.FC = ({ }, [sessionId, toolCall?.id, status, isFailed]); const isLoading = status === 'preparing' || status === 'streaming' || status === 'running'; + /* + * Auto-managed completed cards must keep their compact streaming preview + * from the first completed render through the collapse commit. Waiting for + * the grace-period layout effect to set its state briefly renders the large + * diff preview, and follow-output can treat that transient height as output. + * A manually expanded card marks itself as user-owned and still gets the + * full diff preview. + */ + const keepAutoCompletionPreview = + status === 'completed' && + !isFailed && + !userToggledContentRef.current; + const keepCompactCompletionPreview = + retainLiveCompletionPreview || keepAutoCompletionPreview; const shouldUseExpandedDiffPreviewHeight = status === 'completed' && isContentExpanded && - !retainLiveCompletionPreview; + !keepCompactCompletionPreview; const keepLiveEditPreview = - retainLiveCompletionPreview && + keepCompactCompletionPreview && toolItem.toolName === 'Edit' && Boolean(newStringContent); const keepLiveWritePreview = - retainLiveCompletionPreview && + keepCompactCompletionPreview && toolItem.toolName === 'Write' && Boolean(contentPreview); const previewVariant = useMemo(() => { diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx index 7f44c366b8..fa409524f0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.test.tsx @@ -67,7 +67,7 @@ describe('createTodoRenderItems', () => { }); }); -describe('TodoWriteDisplay automatic collapse', () => { +describe('TodoWriteDisplay expansion', () => { let container: HTMLDivElement; let root: Root; @@ -98,19 +98,31 @@ describe('TodoWriteDisplay automatic collapse', () => { vi.restoreAllMocks(); }); - it('lets completed todo content collapse through normal layout state', () => { + it('stays collapsed during streaming until the user expands it', () => { vi.useFakeTimers(); act(() => { root.render(); }); - expect(container.querySelector('.todo-expanded-body')).not.toBeNull(); + expect(container.querySelector('.todo-expanded-body')).toBeNull(); act(() => { root.render(); }); + expect(container.querySelector('.todo-expanded-body')).toBeNull(); + + act(() => { + container.querySelector('[data-testid="todo-write-toggle"]')?.click(); + }); + expect(container.querySelector('.todo-expanded-body')).not.toBeNull(); + act(() => { + root.render(); + }); expect(container.querySelector('.todo-expanded-body')).not.toBeNull(); + act(() => { + container.querySelector('[data-testid="todo-write-toggle"]')?.click(); + }); act(() => { vi.advanceTimersByTime(FLOWCHAT_COLLAPSE_DURATION_MS); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx index 9b737aaf02..97747da2e3 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TodoWriteDisplay.tsx @@ -2,7 +2,7 @@ * Tool card for TodoWrite. */ -import React, { useState, useMemo, useCallback, useLayoutEffect } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { ListTodo, CheckCircle2, Circle, XCircle } from 'lucide-react'; import { TaskRunningIndicator } from '../../component-library'; import { useTranslation } from 'react-i18next'; @@ -23,7 +23,7 @@ export const TodoWriteDisplay: React.FC = ({ const { t } = useTranslation('flow-chat'); const { status, toolResult, partialParams, isParamsStreaming } = toolItem; - const [expandedState, setExpandedState] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); const toolId = toolItem.id; const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ toolId, @@ -73,32 +73,6 @@ export const TodoWriteDisplay: React.FC = ({ ? { status: 'completed' as const, defaultIcon: 'status' as const } : { status, defaultIcon: 'tool' as const }; - const desiredAutomaticExpanded = useMemo(() => { - return inProgressTasks.length === 0 && todosToDisplay.length > 0 && !isAllCompleted; - }, [inProgressTasks.length, todosToDisplay.length, isAllCompleted]); - const [automaticExpanded, setAutomaticExpanded] = useState(desiredAutomaticExpanded); - - // Keep the currently rendered automatic state for one layout commit. This - // lets the shared height contract publish the collapse intent before the - // second synchronous commit removes the expanded body. - useLayoutEffect(() => { - if (expandedState !== null || automaticExpanded === desiredAutomaticExpanded) { - return; - } - applyExpandedState( - automaticExpanded, - desiredAutomaticExpanded, - setAutomaticExpanded, - ); - }, [ - applyExpandedState, - automaticExpanded, - desiredAutomaticExpanded, - expandedState, - ]); - - const isExpanded = expandedState ?? automaticExpanded; - const isLoading = status === 'preparing' || status === 'streaming' || status === 'running'; const displayMode = config?.displayMode || 'compact'; @@ -110,9 +84,7 @@ export const TodoWriteDisplay: React.FC = ({ const handleToggleExpanded = useCallback(() => { if (todosToDisplay.length === 0) return; - applyExpandedState(isExpanded, !isExpanded, (nextExpanded) => { - setExpandedState(nextExpanded); - }); + applyExpandedState(isExpanded, !isExpanded, setIsExpanded); }, [applyExpandedState, isExpanded, todosToDisplay.length]); const renderTodoItem = (todo: TodoLike, key: string) => ( @@ -246,6 +218,7 @@ export const TodoWriteDisplay: React.FC = ({ isExpanded={isExpanded && hasTodos} onClick={hasTodos ? handleToggleExpanded : undefined} clickable={hasTodos} + toggleTestId="todo-write-toggle" className="todo-write-card" header={