Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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`
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -69,10 +70,6 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = 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,
Expand Down Expand Up @@ -153,7 +150,6 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = React.m

log.debug('explore group cut by critical', { groupId });

setAnimateToggle(false);
applyExpandedState(true, false, () => {
onCollapseGroup?.(groupId);
}, {
Expand Down Expand Up @@ -234,7 +230,6 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = React.m
}, [stats, allItems.length, t]);

const handleToggle = useCallback(() => {
setAnimateToggle(true);
if (isCollapsed) {
applyExpandedState(false, true, () => {
onExploreGroupToggle?.(groupId);
Expand Down Expand Up @@ -288,8 +283,7 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = React.m
isOpen={isExpanded}
className="explore-region__content-wrapper"
innerClassName="explore-region__content-inner"
durationMs={320}
disableAnimation={isGroupStreaming || !animateToggle}
durationMs={FLOWCHAT_COLLAPSE_DURATION_MS}
>
<div
ref={containerRef}
Expand Down
7 changes: 0 additions & 7 deletions src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,6 @@
opacity: 0.8;
}

.explore-region__content-wrapper {
transition:
height 0.32s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.18s ease,
transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}

// Inner div must have min-height: 0 for height animation clipping to work.
.explore-region__content-inner {
overflow: hidden;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ them reintroduces the "the chat keeps refreshing itself" report:
4. **Do not compact the live tail merely because its status completed.** A
terminal, process, file, task, question, or thinking card that was visible
while running keeps a compact result preview until newer content supersedes
it. When superseded, automatic expand/collapse lands in one frame; only user
clicks animate. An automatic collapse that animates over 250–320 ms forces
the compensation path below to track a moving target frame by frame — that
tracking is the visible jitter. `ModelThinkingDisplay`,
`FileOperationToolCard` (via `BaseToolCard disableExpandAnimation`) and
`ExploreGroupRenderer` (via `SmoothHeightCollapse disableAnimation`) all
animate only when the change came from a user click.
it. When superseded, automatic expand/collapse **may animate** for
`FLOWCHAT_COLLAPSE_DURATION_MS` (300ms) as long as
`flowchat:tool-card-collapse-intent` stays active for that full window plus
settle frames. Instant collapse is reserved for `prefers-reduced-motion` or
an explicit `disableAnimation` opt-out. Height, opacity, and transform must
share one duration (see `flowChatCollapseMotion.ts` /
`SmoothHeightCollapse`). Do not hard-swap `BaseToolCard` ↔ `CompactToolCard`
for expand/collapse — that remounts the body with no height transition.

A fifth, related rule lives in `useTypewriter`: `replayOnMount` defaults to
false, so a still-streaming block that remounts continues from its current text
Expand Down Expand Up @@ -241,15 +242,15 @@ User-initiated expand/collapse still uses animated layout properties such as:
- `height`
- `max-height`

(Automatic collapses no longer animate — see Rule Zero — so this path now only
covers deliberate user toggles.)
Automatic and manual collapses both animate through the shared motion contract
unless animation is explicitly disabled.

During those transitions, the DOM may report intermediate sizes for multiple frames.

The collapse intent carries a hard TTL (`expiresAtMs`, currently 1000 ms), but
its settlement is autonomous rather than scroll-driven. Automatic collapses are
finalized after a short settle-frame window; manual or otherwise unsignaled
intents use the TTL timer. The scroll handler keeps only a throttled-background
finalized after `FLOWCHAT_COLLAPSE_DURATION_MS` plus a short settle-frame window;
manual or otherwise unsignaled intents use the TTL timer. The scroll handler keeps only a throttled-background
timer fallback for browsers that delay timers. While the intent is alive, the
grow branch of `measureHeightChange` protects the collapse reservation, but it
may still consume measured content growth from the sticky pin reservation.
Expand Down Expand Up @@ -348,15 +349,18 @@ If a future collapsible component shows the same "header drops" or "flash on col

## Common Ways To Break This

- Adding a mount-triggered CSS animation to a virtualized list item, or making an
automatic collapse animated again (see Rule Zero).
- Adding a mount-triggered CSS animation to a virtualized list item, or animating
an automatic collapse without keeping collapse-intent protection alive for the
full `FLOWCHAT_COLLAPSE_DURATION_MS` window (see Rule Zero).
- Feeding `Date.now()` back into `sessionToVirtualItems` /
`buildModelRoundItemGroups`, or splitting one `ModelRound` into several
`model-round` virtual items — both swap stable Virtuoso keys for new ones and
remount visible content.
- Replacing `applyFooterCompensationNow()` with state-only rendering.
- Measuring raw `scrollHeight` deltas without subtracting existing compensation.
- Removing `flowchat:tool-card-collapse-intent` from a helper-backed collapsible component.
- Finalizing an active collapse intent when a new one arrives mid-burst instead of
coalescing TTL / provisional shrink (drops footer protection for a frame).
- Dispatching collapse intent after `setState` instead of before it.
- Removing `overflow-anchor: none`.
- Removing the intent TTL, settle-frame finalizer, or the throttled scroll
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
opacity: 0;
transform: translateY(-2px);
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);
/* Duration/easing are applied inline so callers share FLOWCHAT_COLLAPSE_DURATION_MS. */
transition-duration: 300ms, 300ms, 300ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1), cubic-bezier(0.4, 0, 0.2, 1), cubic-bezier(0.4, 0, 0.2, 1);
will-change: auto;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { FLOWCHAT_COLLAPSE_DURATION_MS } from './flowChatCollapseMotion';
import { SmoothHeightCollapse } from './SmoothHeightCollapse';

globalThis.IS_REACT_ACT_ENVIRONMENT = true;
Expand Down Expand Up @@ -112,4 +113,28 @@ describe('SmoothHeightCollapse', () => {
});
expect(container.querySelector<HTMLElement>('.smooth-height-collapse')?.style.height).toBe('17px');
});

it('keeps height opacity and transform on the same collapse duration', () => {
measuredHeight = 80;
act(() => {
root.render(
<SmoothHeightCollapse isOpen>
<div>content</div>
</SmoothHeightCollapse>,
);
});

act(() => {
root.render(
<SmoothHeightCollapse isOpen={false}>
<div>content</div>
</SmoothHeightCollapse>,
);
});

const collapse = container.querySelector<HTMLElement>('.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);
});
});
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,7 +20,7 @@ export const SmoothHeightCollapse: React.FC<SmoothHeightCollapseProps> = ({
children,
className = '',
innerClassName = '',
durationMs = 260,
durationMs = FLOWCHAT_COLLAPSE_DURATION_MS,
disableAnimation = false,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -105,6 +109,8 @@ export const SmoothHeightCollapse: React.FC<SmoothHeightCollapseProps> = ({
return () => observer.disconnect();
}, [phase, shouldAnimate]);

const transitionDuration = `${durationMs}ms`;

return (
<div
ref={outerRef}
Expand All @@ -116,7 +122,8 @@ export const SmoothHeightCollapse: React.FC<SmoothHeightCollapseProps> = ({
].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}
>
Expand Down
Loading