From f7f351cc53ec9f19d40d26c5779d2ca35d8db81b Mon Sep 17 00:00:00 2001 From: bobleer Date: Tue, 28 Jul 2026 22:45:52 +0800 Subject: [PATCH 1/2] fix(web-ui): smooth FlowChat auto-collapse without pane flash Animate shared collapse motion for thinking/explore/tool/terminal cards, keep collapse-intent protection for the full animation window, and stop explore projection key swaps that remount the virtualized list. --- ...-28-flowchat-collapse-smoothness-design.md | 157 ++++++++++++++++++ .../modern/ExploreGroupRenderer.tsx | 10 +- .../components/modern/ExploreRegion.scss | 7 - .../modern/FLOWCHAT_SCROLL_STABILITY.md | 30 ++-- .../modern/SmoothHeightCollapse.scss | 5 +- .../modern/SmoothHeightCollapse.test.tsx | 25 +++ .../modern/SmoothHeightCollapse.tsx | 11 +- .../components/modern/VirtualMessageList.tsx | 78 ++++++--- .../modern/flowChatCollapseMotion.test.ts | 15 ++ .../modern/flowChatCollapseMotion.ts | 16 ++ .../modern/modelRoundItemGrouping.test.ts | 27 +++ .../modern/modelRoundItemGrouping.ts | 18 +- .../subagent/SubagentProjectionView.tsx | 7 +- .../store/modernFlowChatStore.test.ts | 67 ++++++++ .../flow_chat/store/modernFlowChatStore.ts | 19 +-- .../src/flow_chat/tool-cards/BaseToolCard.tsx | 8 +- .../tool-cards/ExecProcessToolCardView.tsx | 58 ++----- .../tool-cards/FileOperationToolCard.tsx | 6 - .../flow_chat/tool-cards/GitToolDisplay.tsx | 66 +------- .../tool-cards/ModelThinkingDisplay.scss | 19 +-- .../tool-cards/ModelThinkingDisplay.tsx | 8 - .../flow_chat/tool-cards/TerminalToolCard.tsx | 59 ++----- 22 files changed, 455 insertions(+), 261 deletions(-) create mode 100644 docs/superpowers/specs/2026-07-28-flowchat-collapse-smoothness-design.md create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.test.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.ts diff --git a/docs/superpowers/specs/2026-07-28-flowchat-collapse-smoothness-design.md b/docs/superpowers/specs/2026-07-28-flowchat-collapse-smoothness-design.md new file mode 100644 index 0000000000..82ca7b2ed0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-28-flowchat-collapse-smoothness-design.md @@ -0,0 +1,157 @@ +# FlowChat Collapse Smoothness & Flash Fix + +**Date:** 2026-07-28 +**Status:** Approved for implementation (user authorized full design + implement; approach C) + +## Problem + +During FlowChat streaming, collapsible UI (thinking, explore groups, tool cards, +subagent, terminal, etc.) often expands then collapses. Two user-visible defects: + +1. Collapse feels abrupt — content vanishes instead of easing away. +2. The whole pane can jump / flash as if reloaded (also mid-stream). + +## Root Causes + +1. **Rule Zero instant auto-collapse.** Automatic expand/collapse was forced to + 0ms (`--instant` / `disableAnimation`) so scroll compensation would not chase + a multi-frame height change. That removed jitter at the cost of abrupt UX. +2. **Opacity leads height.** `SmoothHeightCollapse` animates height ~260ms but + opacity ~180ms, so content fades out before the box finishes closing. +3. **Hard shell swaps.** Terminal / ExecProcess / Git toggle between + `BaseToolCard` and `CompactToolCard`, unmounting expanded UI with no height + transition. +4. **Intent settles too early for animation.** Auto collapse-intent finalizes + after ~4 rAF frames (~64ms), far shorter than a real height transition. +5. **Projection identity churn.** `hasActiveStreamingNarrative` defers + explore-group projection until the narrative settles, swapping Virtuoso keys + (`model-round` → `explore-group`) and remounting visible content. + +## Goals + +- Auto-collapse uses a single smooth height animation (~300ms) with opacity and + transform on the same duration / easing. +- Scroll compensation tracks the full animation window; no drop-then-snap. +- Thinking / Explore / FileOp / Task+Subagent / Terminal / ExecProcess / Git + share one collapse contract. +- Live explore projection identity stays stable from first explore-capable + render through completion. +- Prefer-reduced-motion still disables animation. + +## Non-Goals + +- No framer-motion. +- No change to when content *should* collapse (`isLastItem`, `wasCutByCritical`, etc.). +- No mount / `--streaming`→`--complete` enter animations (virtualization remount risk). +- No Rust / mobile-web changes. + +## Solution + +### 1. Shared collapse timing contract + +New module `src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.ts`: + +| Constant | Value | Role | +|---|---|---| +| `FLOWCHAT_COLLAPSE_DURATION_MS` | `300` | Height / opacity / transform duration | +| `FLOWCHAT_COLLAPSE_EASING` | `cubic-bezier(0.4, 0, 0.2, 1)` | Shared easing | +| `FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES` | `4` | Extra rAF after animation before intent finalize | + +### 2. `SmoothHeightCollapse` + +- Default `durationMs = FLOWCHAT_COLLAPSE_DURATION_MS`. +- Inline + SCSS transition durations: height, opacity, and transform all use + `durationMs` (no shorter opacity channel). +- Keep reverse-from-current-height behavior and `--instant` for reduced-motion / + explicit `disableAnimation`. + +### 3. Enable animated auto-collapse (revise Rule Zero §4) + +Update `FLOWCHAT_SCROLL_STABILITY.md`: + +- Automatic collapse **may** animate when a collapse-intent is active for the + full `FLOWCHAT_COLLAPSE_DURATION_MS` (+ settle frames). +- Instant collapse remains only for `prefers-reduced-motion` or explicit + `disableAnimation` during live open growth where needed. +- Remove the “auto = one frame only” requirement from Thinking / Explore / + FileOperation / BaseToolCard call sites. + +Concrete call-site changes: + +- `ModelThinkingDisplay`: stop applying `--instant` on auto toggles; use the + shared 300ms grid transition. +- `ExploreGroupRenderer`: animate auto cut; do not gate on `animateToggle`; + only skip animation while the group is open and still streaming content growth + if measurement requires it — collapse itself always animates. +- `FileOperationToolCard`: stop setting `disableExpandAnimation` for auto. +- Task / Subagent already animate; align `durationMs` to the shared constant. + +### 4. Collapse-intent lifetime tracks animation + +In `VirtualMessageList.scheduleCollapseIntentFinalization`: + +- For `reason === 'auto'`, wait `FLOWCHAT_COLLAPSE_DURATION_MS`, then run the + existing settle-frame finalizer (not settle-only). +- Keep TTL (1000ms) as hard backup. +- When a new intent arrives while one is active: **coalesce** — extend TTL, + add provisional shrink, update/preserve semantic anchor — instead of + finalizing the previous intent (which can briefly drop protection). + +### 5. Stable explore projection identity + +Remove `hasActiveStreamingNarrative` deferral from: + +- `sessionToVirtualItems` / `isExploreOnlyRound` +- `buildModelRoundItemGroups` (`deferExploreGrouping` only from + `disableExploreGrouping`) + +Keep `isActiveToolItem` so *running* explore tools remain critical / visible +until they complete, then merge without a virtual-item type swap for the parent +round. Typewriter remount risk remains covered by `replayOnMount: false`. + +### 6. Eliminate hard shell swaps + +`TerminalToolCard`, `ExecProcessToolCardView`, and `GitToolDisplay` always render +`BaseToolCard` and collapse body via `SmoothHeightCollapse` (already inside +`BaseToolCard`). Do not conditional-mount `CompactToolCard` for expand/collapse +transitions. Compact visual cues may remain as CSS modifiers on the same shell. + +### 7. Tests + +- `SmoothHeightCollapse`: opacity/height share duration; auto path animates. +- Store / grouping: streaming narrative + explore tools keep explore-group + identity (no mid-settle type flip). +- Collapse-intent scheduling helpers / scroll stability: auto intent protects + for at least `FLOWCHAT_COLLAPSE_DURATION_MS`. +- Existing session-boundary and store projection tests updated if expectations + change. + +## Verification + +```bash +pnpm run type-check:web +pnpm --dir src/web-ui run test:run \ + src/flow_chat/components/modern/SmoothHeightCollapse.test.tsx \ + src/flow_chat/store/modernFlowChatStore.test.ts \ + src/flow_chat/components/modern/modelRoundItemGrouping.test.ts \ + src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx \ + src/flow_chat/tool-cards/useToolCardHeightContract.test.tsx +``` + +Manual: stream a turn with thinking → explore tools → write/edit → task/subagent +→ terminal; confirm smooth auto-collapse and no whole-pane flash/jump. + +## Related files + +- `src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md` +- `src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.{tsx,scss}` +- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx` +- `src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx` +- `src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts` +- `src/web-ui/src/flow_chat/store/modernFlowChatStore.ts` +- `src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.{tsx,scss}` +- `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx` +- `src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx` +- `src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx` +- `src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx` +- `src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx` 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 53e58e3c66..71d688c882 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -18,6 +18,7 @@ import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; import { useToolCardHeightContract } from '../../tool-cards/useToolCardHeightContract'; import { useFlowChatContext, useFlowChatVolatileContext } from './FlowChatContext'; import { SmoothHeightCollapse } from './SmoothHeightCollapse'; +import { FLOWCHAT_COLLAPSE_DURATION_MS } from './flowChatCollapseMotion'; import './ExploreRegion.scss'; export interface ExploreGroupRendererProps { @@ -69,10 +70,6 @@ export const ExploreGroupRenderer: React.FC = React.m wasCutByCritical, } = data; const prevWasCutRef = useRef(wasCutByCritical); - // Only a user-initiated toggle animates. An automatic collapse that animates - // spreads the height loss over many frames, which the list's scroll anchor - // then has to chase frame by frame — that chase is the visible jitter. - const [animateToggle, setAnimateToggle] = useState(false); const { cardRootRef, applyExpandedState, @@ -153,7 +150,6 @@ export const ExploreGroupRenderer: React.FC = React.m log.debug('explore group cut by critical', { groupId }); - setAnimateToggle(false); applyExpandedState(true, false, () => { onCollapseGroup?.(groupId); }, { @@ -234,7 +230,6 @@ export const ExploreGroupRenderer: React.FC = React.m }, [stats, allItems.length, t]); const handleToggle = useCallback(() => { - setAnimateToggle(true); if (isCollapsed) { applyExpandedState(false, true, () => { onExploreGroupToggle?.(groupId); @@ -288,8 +283,7 @@ export const ExploreGroupRenderer: React.FC = React.m isOpen={isExpanded} className="explore-region__content-wrapper" innerClassName="explore-region__content-inner" - durationMs={320} - disableAnimation={isGroupStreaming || !animateToggle} + durationMs={FLOWCHAT_COLLAPSE_DURATION_MS} >
{ }); expect(container.querySelector('.smooth-height-collapse')?.style.height).toBe('17px'); }); + + it('keeps height opacity and transform on the same collapse duration', () => { + measuredHeight = 80; + act(() => { + root.render( + +
content
+
, + ); + }); + + act(() => { + root.render( + +
content
+
, + ); + }); + + const collapse = container.querySelector('.smooth-height-collapse'); + const expected = `${FLOWCHAT_COLLAPSE_DURATION_MS}ms, ${FLOWCHAT_COLLAPSE_DURATION_MS}ms, ${FLOWCHAT_COLLAPSE_DURATION_MS}ms`; + expect(collapse?.style.transitionDuration).toBe(expected); + expect(collapse?.classList.contains('smooth-height-collapse--closing')).toBe(true); + }); }); 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 8db30957af..16737d58a2 100644 --- a/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx +++ b/src/web-ui/src/flow_chat/components/modern/SmoothHeightCollapse.tsx @@ -1,4 +1,8 @@ import React, { ReactNode, useLayoutEffect, useRef, useState } from 'react'; +import { + FLOWCHAT_COLLAPSE_DURATION_MS, + FLOWCHAT_COLLAPSE_EASING, +} from './flowChatCollapseMotion'; interface SmoothHeightCollapseProps { isOpen: boolean; @@ -16,7 +20,7 @@ export const SmoothHeightCollapse: React.FC = ({ children, className = '', innerClassName = '', - durationMs = 260, + durationMs = FLOWCHAT_COLLAPSE_DURATION_MS, disableAnimation = false, }) => { const outerRef = useRef(null); @@ -105,6 +109,8 @@ export const SmoothHeightCollapse: React.FC = ({ return () => observer.disconnect(); }, [phase, shouldAnimate]); + const transitionDuration = `${durationMs}ms`; + return (
= ({ ].filter(Boolean).join(' ')} style={{ height, - transitionDuration: `${durationMs}ms, 180ms, ${durationMs}ms`, + transitionDuration: `${transitionDuration}, ${transitionDuration}, ${transitionDuration}`, + transitionTimingFunction: `${FLOWCHAT_COLLAPSE_EASING}, ${FLOWCHAT_COLLAPSE_EASING}, ${FLOWCHAT_COLLAPSE_EASING}`, }} aria-hidden={!isOpen} > diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx index e4c3d37177..620bfb6609 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -86,6 +86,11 @@ import { FlowChatViewportCoordinator, type FlowChatViewportRangeHost, } from './FlowChatViewportCoordinator'; +import { + FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES, + FLOWCHAT_COLLAPSE_DURATION_MS, + FLOWCHAT_COLLAPSE_INTENT_TTL_MS, +} from './flowChatCollapseMotion'; import './VirtualMessageList.scss'; const PINNED_TURN_VIEWPORT_OFFSET_PX = 57; // Keep in sync with `.message-list-header`. @@ -104,8 +109,8 @@ const HISTORY_PROJECTION_HANDOFF_MAX_DURATION_MS = 5000; const SESSION_OPEN_HANDOFF_ITEM_BUDGET = 24; const PREVIOUS_HISTORY_BOUNDARY_STATUS_DURATION_MS = 2500; const SEARCH_NAVIGATION_MAX_ATTEMPTS = 24; -const COLLAPSE_INTENT_TTL_MS = 1000; -const AUTO_COLLAPSE_SETTLE_FRAMES = 4; +const COLLAPSE_INTENT_TTL_MS = FLOWCHAT_COLLAPSE_INTENT_TTL_MS; +const AUTO_COLLAPSE_SETTLE_FRAMES = FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES; const STICKY_PIN_GROWTH_SETTLE_MS = 300; type FlowChatVirtuosoContext = { @@ -458,6 +463,7 @@ const VirtualMessageListSession = forwardRef(null); + const collapseIntentAnimationTimerRef = useRef(null); const collapseIntentSettleFrameRef = useRef(null); const pendingStickyPinGrowthRef = useRef<{ targetTurnId: string | null; @@ -2120,6 +2126,10 @@ const VirtualMessageListSession = forwardRef { collapseIntentSettleFrameRef.current = requestAnimationFrame(() => { if (remainingFrames > 1) { @@ -2203,7 +2216,10 @@ const VirtualMessageListSession = forwardRef { + collapseIntentAnimationTimerRef.current = null; + settle(AUTO_COLLAPSE_SETTLE_FRAMES); + }, FLOWCHAT_COLLAPSE_DURATION_MS); }, [clearCollapseIntentScheduling]); useEffect(() => clearCollapseIntentScheduling, [clearCollapseIntentScheduling]); @@ -2831,44 +2847,58 @@ const VirtualMessageListSession = forwardRef).detail; - if (pendingCollapseIntentRef.current.active) { - finalizeCollapseIntent('collapse-intent-superseded', { - expectedExpiresAtMs: pendingCollapseIntentRef.current.expiresAtMs, - suppressHandoff: true, - }); + const previousIntent = pendingCollapseIntentRef.current; + // Coalesce overlapping collapses instead of finalizing the previous + // intent. Finalizing mid-animation can briefly drop footer protection and + // flash the pane when several cards compact in the same stream burst. + if (detail?.anchorElement) { + viewportCoordinatorRef.current.preserveElement(detail.anchorElement); + } else if (!previousIntent.active) { + viewportCoordinatorRef.current.preserveElement(null); } - viewportCoordinatorRef.current.preserveElement(detail?.anchorElement); - - const baseTotalCompensationPx = getTotalBottomCompensationPx(); + const baseTotalCompensationPx = previousIntent.active + ? previousIntent.baseTotalCompensationPx + : getTotalBottomCompensationPx(); const currentScrollTop = scrollerElement.scrollTop; const previousStableScrollTop = previousScrollTopRef.current; - const anchorScrollTop = resolveAutoCollapseAnchorScrollTop({ - currentScrollTop, - previousStableScrollTop, - reason: detail?.reason, - isFollowingOutput: isFollowingOutputRef.current, - isStreamingOutput: isStreamingOutputRef.current, - }); + const anchorScrollTop = previousIntent.active + ? previousIntent.anchorScrollTop + : resolveAutoCollapseAnchorScrollTop({ + currentScrollTop, + previousStableScrollTop, + reason: detail?.reason, + isFollowingOutput: isFollowingOutputRef.current, + isStreamingOutput: isStreamingOutputRef.current, + }); const distanceFromBottom = Math.max( 0, scrollerElement.scrollHeight - scrollerElement.clientHeight - currentScrollTop ); - const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - baseTotalCompensationPx); + const currentTotalCompensationPx = getTotalBottomCompensationPx(); + const effectiveDistanceFromBottom = previousIntent.active + ? previousIntent.distanceFromBottomBeforeCollapse + : Math.max(0, distanceFromBottom - currentTotalCompensationPx); const estimatedShrink = Math.max(0, detail?.cardHeight ?? 0); + const cumulativeShrinkPx = previousIntent.active + ? previousIntent.cumulativeShrinkPx + : 0; const provisionalTotalCompensationPx = Math.max( - 0, - baseTotalCompensationPx + Math.max(0, estimatedShrink - effectiveDistanceFromBottom) + currentTotalCompensationPx, + baseTotalCompensationPx + Math.max( + 0, + cumulativeShrinkPx + estimatedShrink - effectiveDistanceFromBottom, + ), ); const nextIntent: PendingCollapseIntentState = { active: true, anchorScrollTop, - toolId: detail?.toolId ?? null, - toolName: detail?.toolName ?? null, + toolId: detail?.toolId ?? previousIntent.toolId ?? null, + toolName: detail?.toolName ?? previousIntent.toolName ?? null, expiresAtMs: performance.now() + COLLAPSE_INTENT_TTL_MS, distanceFromBottomBeforeCollapse: effectiveDistanceFromBottom, baseTotalCompensationPx, - cumulativeShrinkPx: 0, + cumulativeShrinkPx, }; pendingCollapseIntentRef.current = nextIntent; if (provisionalTotalCompensationPx - baseTotalCompensationPx > COMPENSATION_EPSILON_PX) { diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.test.ts b/src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.test.ts new file mode 100644 index 0000000000..4e0a7c1a10 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; + +import { + FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES, + FLOWCHAT_COLLAPSE_DURATION_MS, + FLOWCHAT_COLLAPSE_INTENT_TTL_MS, +} from './flowChatCollapseMotion'; + +describe('flowChatCollapseMotion', () => { + it('keeps intent TTL above the animated collapse window', () => { + expect(FLOWCHAT_COLLAPSE_DURATION_MS).toBe(300); + expect(FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES).toBeGreaterThan(0); + expect(FLOWCHAT_COLLAPSE_INTENT_TTL_MS).toBeGreaterThan(FLOWCHAT_COLLAPSE_DURATION_MS); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.ts b/src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.ts new file mode 100644 index 0000000000..64a640dadf --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/flowChatCollapseMotion.ts @@ -0,0 +1,16 @@ +/** + * Shared FlowChat collapse / expand motion contract. + * + * Auto-collapse and manual toggles share one duration so VirtualMessageList + * collapse-intent protection can cover the full animated height change. + */ + +export const FLOWCHAT_COLLAPSE_DURATION_MS = 300; + +export const FLOWCHAT_COLLAPSE_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)'; + +/** Extra rAF frames after the CSS duration before finalizing an auto collapse intent. */ +export const FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES = 4; + +/** Hard backup TTL; must stay above duration + settle margin. */ +export const FLOWCHAT_COLLAPSE_INTENT_TTL_MS = 1000; diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.test.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.test.ts index 160c7cd18b..f8bced537b 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.test.ts @@ -177,4 +177,31 @@ describe('buildModelRoundItemGroups', () => { expect(streaming).toEqual(settled); }); + + it('groups completed explore tools with streaming thinking instead of deferring', () => { + const thinkingItem = { + id: 'thinking-1', + type: 'thinking' as const, + content: 'Inspecting', + isStreaming: true, + timestamp: 999, + status: 'streaming' as const, + }; + const toolItem = makeReadTool('tool-1'); + + const groups = buildModelRoundItemGroups({ + items: [thinkingItem, toolItem], + isStreaming: true, + disableExploreGrouping: false, + isCollapsibleTool: toolName => toolName === 'Read', + }); + + expect(groups).toEqual([ + { + type: 'explore', + items: [thinkingItem, toolItem], + isLast: true, + }, + ]); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts index e89a474bb3..09bd51cf0e 100644 --- a/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts +++ b/src/web-ui/src/flow_chat/components/modern/modelRoundItemGrouping.ts @@ -12,15 +12,6 @@ interface BuildModelRoundItemGroupsInput { isCollapsibleTool: (toolName: string) => boolean; } -function hasActiveStreamingNarrative(items: FlowItem[]): boolean { - return items.some(item => { - if (item.type !== 'text' && item.type !== 'thinking') return false; - const maybeStreaming = item as { isStreaming?: boolean; status?: string }; - return maybeStreaming.isStreaming === true && - (maybeStreaming.status === 'streaming' || maybeStreaming.status === 'running'); - }); -} - function isActiveToolItem(item: FlowItem): boolean { if (item.type !== 'tool') return false; return item.status !== 'completed' && item.status !== 'cancelled' && item.status !== 'rejected' && item.status !== 'error'; @@ -31,14 +22,19 @@ function isActiveToolItem(item: FlowItem): boolean { * depend on wall-clock time: a time-dependent grouping re-runs on a timer, * restructures the round, and remounts cards long after the data settled — * which reads as the chat pane spontaneously refreshing itself. + * + * Streaming narrative must not defer explore grouping either: that would keep + * explore tools as critical model-round content and later flip them into an + * explore region, remounting visible cards. */ export function buildModelRoundItemGroups({ items, - isStreaming, + isStreaming: _isStreaming, // retained for call-site API stability; unused disableExploreGrouping, isCollapsibleTool, }: BuildModelRoundItemGroupsInput): ModelRoundItemGroup[] { - const deferExploreGrouping = disableExploreGrouping || (isStreaming && hasActiveStreamingNarrative(items)); + void _isStreaming; + const deferExploreGrouping = disableExploreGrouping; const intermediateGroups: Array<{ type: 'normal'; item: FlowItem }> = items.map(item => ({ type: 'normal', item, 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 5da59877d9..f659428612 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -6,6 +6,7 @@ import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; import { FlowToolCard } from '../FlowToolCard'; import { taskCollapseStateManager } from '../../store/TaskCollapseStateManager'; import { SmoothHeightCollapse } from '../modern/SmoothHeightCollapse'; +import { FLOWCHAT_COLLAPSE_DURATION_MS } from '../modern/flowChatCollapseMotion'; import { FlowChatStore } from '../../store/FlowChatStore'; import { getSubagentProjectionState } from '../../utils/subagentProjection'; import { ensureBtwSessionAvailable } from '../../services/btwSessionPane'; @@ -328,7 +329,11 @@ export const SubagentProjectionView: React.FC = ({ className={`subagent-projection-wrapper ${isCollapsed ? 'subagent-projection-wrapper--collapsed' : 'subagent-projection-wrapper--expanded'} ${className}`.trim()} data-subagent-session-id={resolvedSubagentSessionId} > - +
{ expect(notStreaming.map(item => item.type)).toEqual(justFinished.map(item => item.type)); }); + it('keeps explore-group identity while thinking narrative is still streaming', () => { + const streamingThinking = { + id: 'thinking-1', + type: 'thinking' as const, + content: 'Inspecting the codebase', + isStreaming: true, + timestamp: 1000, + status: 'streaming' as const, + }; + const session = makeSession({ + sessionId: 'streaming-thinking-explore-session', + dialogTurns: [{ + id: 'turn-1', + sessionId: 'streaming-thinking-explore-session', + userMessage: { + id: 'user-1', + content: 'Help', + timestamp: 900, + }, + modelRounds: [ + makeRound({ + id: 'round-1', + items: [streamingThinking, makeReadTool('tool-1')], + isStreaming: true, + isComplete: false, + status: 'streaming', + }), + ], + status: 'processing', + startTime: 900, + }], + }); + + const streamingItems = sessionToVirtualItems(session); + expect(streamingItems.map(item => item.type)).toEqual(['user-message', 'explore-group']); + expect(streamingItems[1]).toMatchObject({ + type: 'explore-group', + data: { groupId: 'round-1' }, + }); + + const settledSession = makeSession({ + sessionId: 'streaming-thinking-explore-session', + dialogTurns: [{ + ...session.dialogTurns[0], + modelRounds: [ + makeRound({ + id: 'round-1', + items: [ + { ...streamingThinking, isStreaming: false, status: 'completed' }, + makeReadTool('tool-1'), + ], + isStreaming: false, + isComplete: true, + status: 'completed', + }), + ], + status: 'completed', + }], + }); + const settledItems = sessionToVirtualItems(settledSession); + expect(settledItems.map(item => item.type)).toEqual(streamingItems.map(item => item.type)); + expect(settledItems[1]).toMatchObject({ + type: 'explore-group', + data: { groupId: 'round-1' }, + }); + }); + it('keeps the same explore group id when a completed trailing tool is merged in', () => { const baseTurn = { id: 'turn-1', diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index fac6f9eb3f..e929b43fc8 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -115,19 +115,10 @@ interface ModernFlowChatState { * Pure thinking rounds (thinking without critical tools) are merged into * adjacent explore groups to reduce visual noise from standalone "thinking N chars" lines. * Pure text rounds (like final replies) should not be collapsed. - * Keep streaming narrative visible in-place until the stream settles; otherwise - * a mid-stream switch to explore-group remounts the text block and replays the - * typewriter animation from the beginning. + * Explore-capable rounds keep explore-group identity from the first render so + * settling a streaming narrative cannot swap Virtuoso keys and remount the pane. + * Typewriter remount risk is covered by useTypewriter(replayOnMount: false). */ -function hasActiveStreamingNarrative(round: ModelRound): boolean { - return round.items.some(item => { - if (item.type !== 'text' && item.type !== 'thinking') return false; - const maybeStreaming = item as { isStreaming?: boolean; status?: string }; - return maybeStreaming.isStreaming === true && - (maybeStreaming.status === 'streaming' || maybeStreaming.status === 'running'); - }); -} - function hasTrailingVisibleText(round: ModelRound): boolean { for (let index = round.items.length - 1; index >= 0; index -= 1) { const item = round.items[index]; @@ -152,10 +143,6 @@ function isExploreOnlyRound(round: ModelRound): boolean { return false; } - if (round.isStreaming && hasActiveStreamingNarrative(round)) { - return false; - } - if (hasTrailingVisibleText(round)) { return false; } diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx index 2b24537f8e..bdb4830105 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.tsx @@ -5,6 +5,7 @@ import React, { ReactNode } from 'react'; import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { SmoothHeightCollapse } from '../components/modern/SmoothHeightCollapse'; +import { FLOWCHAT_COLLAPSE_DURATION_MS } from '../components/modern/flowChatCollapseMotion'; import { ToolCardHeaderLayoutContext, useToolCardHeaderLayout, @@ -133,6 +134,7 @@ export const BaseToolCard: React.FC = ({
@@ -140,7 +142,11 @@ export const BaseToolCard: React.FC = ({
- +
{errorContent}
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 4624e739de..f57272cf7e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx @@ -7,8 +7,6 @@ import { type TerminalOutputRendererHandle, } from '@/tools/terminal/components/LazyTerminalOutputRenderer'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; -import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; -import { ToolCardStatusSlot } from './ToolCardStatusSlot'; import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; import { ToolCommandPreview } from './ToolCommandPreview'; import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; @@ -495,53 +493,19 @@ export const ExecProcessToolCardView: React.FC = ( /> ); - const renderCompactHeader = () => ( - } - action={model.actionLabel} - content={ - - {renderPrimaryText('compact')} - - {renderTimeoutIndicator()} - {rejectedOrCancelled && ( - - {t(cancelledStatusLabelKey)} - - )} - - {renderCopyButton()} - - - - } - /> - ); - return (
- {isExpanded ? ( - - ) : ( - - )} +
); }; 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 3e75be4d47..ada8fed3a9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -131,10 +131,6 @@ export const FileOperationToolCard: React.FC = ({ const [isErrorExpanded, setIsErrorExpanded] = useState(false); const [isContentExpanded, setIsContentExpanded] = useState(status !== 'completed'); - // Only a user click animates the expand/collapse. The status-driven collapse - // 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); @@ -387,7 +383,6 @@ export const FileOperationToolCard: React.FC = ({ userToggledContentRef.current = true; setRetainLiveCompletionPreview(false); } - setAnimateContentToggle(reason === 'manual'); applyHeightContractExpandedState( isContentExpanded, nextExpanded, @@ -1258,7 +1253,6 @@ export const FileOperationToolCard: React.FC = ({ toggleTestId="chat-file-change-toggle" headerExpandAffordance={hasExpandableContent} headerAffordanceKind="expand" - disableExpandAnimation={!animateContentToggle} />
); 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 1676a319dc..cd70022fdf 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.tsx @@ -8,8 +8,6 @@ import { GitBranch, AlertTriangle } from 'lucide-react'; import { CubeLoading } 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'; @@ -201,41 +199,6 @@ export const GitToolDisplay: React.FC = ({ /> ); - const renderCompactHeader = () => ( - } 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')} - - )} - - - - - - } - rightStatusIcon={renderStatusIcon()} - /> - ); - const renderExpandedContent = () => { if (!resultData) return null; @@ -327,26 +290,15 @@ export const GitToolDisplay: React.FC = ({ return (
- {isExpanded ? ( - - ) : ( - - )} +
); }; diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index d1882f12e5..bece2b61f3 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -76,30 +76,27 @@ transform: rotate(90deg); } -/* Expand container with animation */ +/* Expand container with animation — duration matches FLOWCHAT_COLLAPSE_DURATION_MS. */ .thinking-expand-container { display: grid; grid-template-rows: 0fr; - transition: grid-template-rows 0.25s ease-out; + transition: grid-template-rows 300ms cubic-bezier(0.4, 0, 0.2, 1); &--open { grid-template-rows: 1fr; } - /* - * Automatic expand/collapse must land in a single frame. Animating it drags - * the height change across ~15 frames that the message list's scroll anchor - * has to chase, which the user sees as the chat jumping by itself. - */ - &--instant { - transition: none; - } - > .thinking-content-wrapper { overflow: hidden; } } +@media (prefers-reduced-motion: reduce) { + .thinking-expand-container { + transition: none; + } +} + .thinking-content { font-size: var(--flowchat-font-size-base); line-height: var(--flowchat-text-line-height); 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 8a99e0945b..e9a93aceea 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx @@ -48,11 +48,6 @@ export const ModelThinkingDisplay: React.FC = ({ const [isExpanded, setIsExpanded] = useState(shouldDefaultExpanded); const userToggledRef = useRef(false); - // Only a user click animates the grid-row transition. An automatic collapse - // that animates spreads its height loss over ~250ms, and the message list's - // scroll anchor has to chase it frame by frame — that chase is what reads as - // the conversation jumping on its own. - const [animateToggle, setAnimateToggle] = useState(false); const { applyExpandedState } = useToolCardHeightContract({ toolId: thinkingItem.id, toolName: 'thinking', @@ -67,7 +62,6 @@ export const ModelThinkingDisplay: React.FC = ({ useLayoutEffect(() => { if (userToggledRef.current) return; if (isExpanded !== shouldDefaultExpanded) { - setAnimateToggle(false); applyExpandedState(isExpanded, shouldDefaultExpanded, setIsExpanded, { reason: 'auto', }); @@ -199,7 +193,6 @@ export const ModelThinkingDisplay: React.FC = ({ const handleToggleClick = () => { const nextExpanded = !isExpanded; userToggledRef.current = true; - setAnimateToggle(true); applyExpandedState(isExpanded, nextExpanded, setIsExpanded); }; @@ -273,7 +266,6 @@ export const ModelThinkingDisplay: React.FC = ({ className={[ 'thinking-expand-container', isExpanded ? 'thinking-expand-container--open' : '', - animateToggle ? '' : 'thinking-expand-container--instant', ].filter(Boolean).join(' ')} >
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 d5be0a0111..09ded94f71 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -17,10 +17,8 @@ import React, { useState, useRef, useCallback, useEffect, useLayoutEffect, useMe import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { Terminal, 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'; import { DotMatrixLoader, IconButton } from '../../component-library'; import { LazyTerminalOutputRenderer } from '@/tools/terminal/components/LazyTerminalOutputRenderer'; import { createLogger } from '@/shared/utils/logger'; @@ -575,27 +573,6 @@ export const TerminalToolCard: React.FC = ({ /> ); - const renderCompactHeader = () => ( - } defaultIcon="tool" />} - action={t('toolCards.terminal.executeCommand')} - content={ - - {renderCommandContent('compact')} - {/* Hover-only inline actions — duration, status, copy, open panel */} - - {renderTimeoutIndicator()} - {viewState.hasHeaderExtra && renderStatusText()} - - {renderCopyCommandButton()} - {renderOpenInPanelButton()} - - - - } - extra={renderHeaderExtra(false)} - /> - ); const compactSettledPreview = isExpanded && isLastItem === true && @@ -624,30 +601,18 @@ export const TerminalToolCard: React.FC = ({ data-expanded={isExpanded ? 'true' : 'false'} data-terminal-session-id={terminalSessionId || ''} > - {isExpanded ? ( - - ) : ( - - )} +
); }; From f742b540c1abd2911ef8269cc1e3350caad11de2 Mon Sep 17 00:00:00 2001 From: bobleer Date: Tue, 28 Jul 2026 23:39:35 +0800 Subject: [PATCH 2/2] test(web-ui): update tool card expectations for animated collapse Align ExecProcess/Git/FileOperation tests with the stable BaseToolCard shell and SmoothHeightCollapse auto-collapse timing. --- .../flow_chat/tool-cards/ExecProcessToolCardView.test.tsx | 6 ++++-- .../flow_chat/tool-cards/FileOperationToolCard.test.tsx | 7 +++++++ .../src/flow_chat/tool-cards/GitToolDisplay.test.tsx | 4 +++- 3 files changed, 14 insertions(+), 3 deletions(-) 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 5728cb9d35..c6f93d9bd3 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 @@ -209,7 +209,9 @@ describe('ExecProcessToolCardView', () => { ); }); - expect(container.querySelector('.base-tool-card')).toBeNull(); - expect(container.querySelector('.compact-tool-card')).not.toBeNull(); + // Collapsed cards keep the BaseToolCard shell and animate height closed. + expect(container.querySelector('.base-tool-card')).not.toBeNull(); + expect(container.querySelector('.base-tool-card.expanded')).toBeNull(); + expect(container.querySelector('.compact-tool-card')).toBeNull(); }); }); 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 22b03ec0aa..35d26bf2fc 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 @@ -906,6 +906,13 @@ 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'); + await act(async () => { + await new Promise((resolve) => { + window.setTimeout(resolve, 350); + }); + }); expect(container.querySelector('[data-testid="chat-file-change-preview"]')).toBeNull(); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx index d17918a3f7..12233046da 100644 --- a/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/GitToolDisplay.test.tsx @@ -113,8 +113,10 @@ describe('GitToolDisplay', () => { root.render(); }); + expect(container.querySelector('.base-tool-card')).not.toBeNull(); + expect(container.querySelector('.compact-tool-card')).toBeNull(); + expect(container.textContent).toContain('git status --short'); expect(container.textContent).not.toContain('Gitgit status --short'); - expect(container.textContent?.trim().startsWith('git status --short')).toBe(true); const copyButton = container.querySelector( 'button[aria-label="Copy git command"]'