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
127 changes: 127 additions & 0 deletions src/web-ui/src/flow_chat/components/modern/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# FlowChat Scroll Stability Instructions

This file applies to the modern FlowChat viewport implementation under this
directory.

## Required Reading

Before changing FlowChat rendering, projection, virtualization, scrolling,
tool-card collapse behavior, footer reservations, runtime-status slots, or
typewriter/reveal behavior, read:

- `FLOWCHAT_SCROLL_STABILITY.md`

Also follow the repository and Web UI instructions in the parent `AGENTS.md`
files.

The stability document describes the current contracts and the failure modes
that have caused visible flashes, header drops, stale tail whitespace, and
scroll ownership races. Treat it as part of the implementation contract, not
as background documentation.

## Stability Model

The central invariant is single ownership of viewport motion:

- `FlowChatViewportCoordinator` owns semantic viewport modes and anchors:
`pinned-item`, `following-tail`, and `preserving-element`.
- `useFlowChatFollowOutput` owns the continuous streaming tail-follow RAF loop.
It is the only continuous writer that advances the viewport toward the
streaming tail and it must yield while an element anchor or collapse
transaction owns the viewport.
- `VirtualMessageList` keeps Virtuoso's `followOutput={false}`. Do not enable
Virtuoso's autonomous follow behavior or introduce another effect that writes
the outer FlowChat viewport tail position. Independent writers are what
produce the drop-then-restore flash. Local scroll surfaces inside a thinking,
explore, terminal, or subagent card have their own narrowly scoped behavior.
- Tool cards must not calculate `scrollTop`, `scrollBy`, compensation pixels,
or anchor offsets. They dispatch
`flowchat:tool-card-collapse-intent` before a known height reduction and use
`useToolCardHeightContract` for the state transition.
- Direct `scrollTop` / `scrollTo` writes are not categorically forbidden, but
outer-viewport writes are restricted to the coordinator, the follow
controller, and the narrowly scoped `VirtualMessageList` navigation,
physical-bottom recovery, and reservation transactions. Every new write
needs an explicit owner, a user-intent guard, and a reason why the existing
coordinator or follow controller cannot perform it.
- Footer `collapse` and `pin` reservations provide physical range while the
DOM or Virtuoso measurements settle. Apply footer compensation synchronously
before restoring an anchor; do not replace this with React-state-only footer
rendering.

Stable virtual-item keys and projection identity are equally important. Do not
split one `ModelRound` into multiple `model-round` virtual items, add
mount-triggered animations, or use a timer to reclassify projection/grouping.
Card-local completion preview timers are allowed only when they leave the
virtual projection unchanged and use the existing height contract when they
eventually collapse a card.

## Required Verification

Choose focused tests for the code you changed, then run the normal Web UI
checks:

```text
pnpm run type-check:web
pnpm --dir src/web-ui run lint
pnpm --dir src/web-ui run test:run <focused-test-files>
```

Relevant stability tests include:

- `src/flow_chat/components/modern/FlowChatViewportCoordinator.test.ts`
- `src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx`
- `src/flow_chat/components/modern/VirtualMessageList.layout.test.ts`
- `src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx`
- `src/flow_chat/components/modern/flowChatCollapseMotion.test.ts`

For tool-card or collapse-contract changes, also run the nearest focused card
tests, for example the `FileOperationToolCard`, `ExecProcessToolCardView`,
`TaskToolDisplay`, `useToolCardHeightContract`, or `SmoothHeightCollapse` tests.
Do not claim the stability behavior is verified from type-check/lint alone.

## Manual Verification

For changes that affect viewport ownership, tail follow, pinning, collapse
height, session handoff, or scroll event handling, **require the user to perform
the following manual verification before considering the change complete**.
These interactions are too stateful and timing-sensitive for an Agent to verify
reliably; an Agent may run static checks and automated tests, but must not claim
these manual results without explicit user confirmation. Use a real streaming
conversation and check all of the following:

1. Start a new round and confirm the new user message is pinned to the intended
top position without a visible jump.
2. Do not touch the viewport while output streams. When the output reaches the
bottom, confirm it transitions naturally into follow-output mode instead of
stopping short or waiting for the round to finish.
3. While output is streaming and following, scroll upward and downward by hand.
Confirm the user's scroll intent immediately wins: auto-follow must not pull
the viewport back or re-pin it unexpectedly.
4. Switch to another conversation during an active round, then return to the
original conversation. Confirm the viewport resumes the correct follow mode
when appropriate, without a black/empty tail, excessive synthetic footer
space, or a delayed recovery that depends on another user scroll.
5. Exercise completed `Write`, `Edit`, `ExecCommand`, and terminal/tool-card
collapses both at the tail and in the middle of the transcript. Confirm the
header stays at the same viewport position while the body contracts.
6. Repeat the flow around thinking, explore groups, runtime-status/footer
visibility, and at least one multi-tool round. Confirm there is no visible
flash, drop-then-snap-back, permanent fall, accumulating tail whitespace, or
one-frame loss of the pinned header.

When a manual check fails, enable the FlowChat diagnostics setting and inspect
the session `flowchat.log` before changing ownership rules. Keep diagnostic
payloads bounded and free of message content, tool arguments, and file data.

## Change Discipline

- Do not add a competing scroll writer, persistent scroll lock, or ad hoc
`scrollBy`/`scrollTo` call from a card or renderer.
- Do not bypass `flowchat:tool-card-collapse-intent` for a known shrink.
- Do not clear footer reservations or semantic anchors merely to make a test
pass; determine which viewport owner still needs them.
- Preserve user scroll intent, session/generation cancellation, and cleanup of
pending RAF/timer work.
- Update `FLOWCHAT_SCROLL_STABILITY.md` whenever the ownership model,
reservation contract, collapse lifecycle, or required verification changes.
Original file line number Diff line number Diff line change
Expand Up @@ -9,27 +9,35 @@ cheapest way to keep the pane stable is to not generate the movement in the
first place. Five invariants hold across the message list, and breaking any of
them reintroduces the "the chat keeps refreshing itself" report:

1. **Keep a live action's projection identity stable.** A collapsible tool
belongs to its trailing `explore-group` from the first active render through
completion. Do not render it as a standalone `model-round` while active and
move it into a group when it settles; that swaps the Virtuoso key, unmounts
the card, and looks like a flash. Likewise, never hide the old location with
`display: none` as a handoff mechanism.
1. **Keep a live action's top-level projection identity stable.** A rendered
`ModelRound` remains one `model-round` virtual item, and an explore-only
round remains one `explore-group` virtual item with a stable key. Within a
`ModelRound`, an active collapsible tool is intentionally kept as a critical
item; after it settles it may join the surrounding explore grouping. That
inner grouping transition must not split the round into multiple virtual
items or replace the item/round keys, which would unmount the card and look
like a flash. Likewise, never hide the old location with `display: none` as
a handoff mechanism.
2. **No mount-triggered animation on anything the list renders.** The list is
virtualized: an item that scrolls out of view unmounts and remounts, so a
`fadeIn` / `slideInUp` keyed off mount replays on every pass. Same for an
animation keyed off `--streaming` → `--complete`: it replays when the
typewriter drains. `getModelRoundItemClassName` deliberately has no `--enter`
modifier, and `.user-message-item` deliberately has no enter animation.
3. **No wall-clock input to projection or grouping.** `sessionToVirtualItems`
and `buildModelRoundItemGroups` are pure functions of the session data. A
time-dependent classification needs a timer to re-run it, and that timer
restructures and remounts cards seconds after the data settled. There is no
"transient window" for recently-completed tools any more.
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 **may animate** for
3. **Keep wall-clock state out of projection and grouping.**
`sessionToVirtualItems` and `buildModelRoundItemGroups` remain pure
functions of session data. A timer must not reclassify a round, change a
`VirtualItem` key, or create a recently-completed projection. The card layer
does have a bounded completion-preview timer (documented below), but it only
changes local expanded state after the card is already rendered; it does not
restructure the virtual list.
4. **Do not compact a live tail in the same completion commit.** The execution
and file-operation cards use a short completion-preview grace period while
they remain the expanded tail. A newer item still collapses them immediately;
if no newer item arrives, they compact after the grace period. Task,
question, thinking, and explore-group components retain their own
status/last-item policies and are not implicitly covered by this timer. When
an automatic collapse starts, it **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
Expand Down Expand Up @@ -279,6 +287,29 @@ fixed-height slot inside their local scroll surface.

If the list waits until `ResizeObserver` sees the shrink, the browser may already have clamped `scrollTop`.

### Completion-preview grace period

`useToolCardCompletionGracePeriod.ts` provides the bounded tail-preview window
used by `ExecProcessToolCardView`, `TerminalToolCard`, and
`FileOperationToolCard`. Its default is
`TOOL_CARD_COMPLETION_PREVIEW_GRACE_MS = 800`.

The timer starts only when a card that was expanded during execution is still
the last rendered item and has not been manually toggled. A newer item, user
interaction, unmount, or loss of tail ownership cancels the pending preview.
For ExecProcess/Terminal cards this covers terminal completion, cancellation,
errors, and rejections. For successful Write/Edit cards, the timer starts after
the typewriter reveal finishes so the completed content is not truncated. The
timer does not change `isLastItem`; an empty next round can still leave the
previous card as the rendered tail, but the grace period bounds that wait. The
timer expiry calls the existing height-contract collapse path, so footer
pre-compensation and semantic-anchor handling remain the same as for a
successor-driven collapse.

This is deliberately separate from the VirtualMessageList collapse-intent TTL
and settlement timers: the former controls when a card may compact, while the
latter protects the viewport while its height changes.

## Runtime Flow

## A. Known Tool Card Collapse
Expand Down Expand Up @@ -439,7 +470,8 @@ If you remove `overflow-anchor: none`, the browser may apply its own anchor corr

Current producer:

- `useToolCardHeightContract.ts`
- `useToolCardHeightContract.ts` (used by most tool cards, including
`ExecProcessToolCardView`, `FileOperationToolCard`, and `TerminalToolCard`)
- `ModelThinkingDisplay.tsx`
- `ExploreGroupRenderer.tsx`

Expand Down Expand Up @@ -540,9 +572,11 @@ with `flowChatDiagnostics.isEnabled()` before allocating probe objects.

Use this checklist:

1. Verify the live tail stays expanded when a conversation ends with an action.
1. Verify a just-completed ExecCommand/Write tail keeps its preview during the
short grace period, then compacts if no follow-on item arrives.
2. Verify manual collapse of a completed `Write` / `Edit` tool card.
3. Verify automatic compaction only after newer content supersedes the action.
3. Verify a newer item still causes immediate automatic compaction before the
grace period expires.
4. Verify repeated expand/collapse near the bottom.
5. Verify thinking / explore / other collapsible sections still schedule measurements correctly.
6. Verify there is no visible "drop then snap back" flash.
Expand All @@ -554,6 +588,8 @@ Use this checklist:
- `src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts`
- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss`
- `src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts`
- `src/web-ui/src/flow_chat/tool-cards/useToolCardCompletionGracePeriod.ts`
- `src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx`
- `src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx`
- `src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx`
- `src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ describe('ExecProcessToolCardView', () => {
act(() => {
root.unmount();
});
vi.useRealTimers();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -166,7 +167,7 @@ describe('ExecProcessToolCardView', () => {
expect(container.textContent).not.toContain('Receiving parameters...');
});

it('retains a just-completed tail result until newer content supersedes it', () => {
it('retains a just-completed tail result during the grace period', () => {
const resultModel: ExecProcessCardModel = {
...model,
resultOutput: 'All tests passed',
Expand Down Expand Up @@ -214,4 +215,43 @@ describe('ExecProcessToolCardView', () => {
expect(container.querySelector('.base-tool-card.expanded')).toBeNull();
expect(container.querySelector('.compact-tool-card')).toBeNull();
});

it('collapses a completed tail result when the grace period expires', () => {
vi.useFakeTimers();
const resultModel: ExecProcessCardModel = {
...model,
resultOutput: 'All tests passed',
};

act(() => {
root.render(
<ExecProcessToolCardView
toolItem={toolItem('running')}
model={resultModel}
isLastItem
/>,
);
});

act(() => {
root.render(
<ExecProcessToolCardView
toolItem={toolItem('completed')}
model={resultModel}
isLastItem
/>,
);
});
expect(vi.getTimerCount()).toBeGreaterThan(0);

act(() => {
vi.advanceTimersByTime(799);
});
expect(container.querySelector('.base-tool-card.expanded')).not.toBeNull();

act(() => {
vi.advanceTimersByTime(1);
});
expect(container.querySelector('.base-tool-card.expanded')).toBeNull();
});
});
25 changes: 22 additions & 3 deletions src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { ToolCommandPreview } from './ToolCommandPreview';
import { ToolTimeoutIndicator } from './ToolTimeoutIndicator';
import { DotMatrixLoader } from '../../component-library';
import { useToolCardHeightContract, type ToolCardCollapseReason } from './useToolCardHeightContract';
import { useToolCardCompletionGracePeriod } from './useToolCardCompletionGracePeriod';
import { formatSessionViewPreviewText } from '../utils/sessionViewPreview';
import './ExecProcessToolCard.scss';

Expand Down Expand Up @@ -56,9 +57,10 @@ function getInitialExpandedState(status: string): boolean {
function getAutoExpandedStateForStatus(
status: string,
isLastItem: boolean | undefined,
keepTailPreview: boolean,
): boolean | null {
if (isCollapsedStatus(status)) {
return isLastItem === true ? null : false;
return isLastItem === true && keepTailPreview ? null : false;
}

if (status === 'preparing' || status === 'streaming' || status === 'running' || status === 'receiving') {
Expand Down Expand Up @@ -196,6 +198,16 @@ export const ExecProcessToolCardView: React.FC<ExecProcessToolCardViewProps> = (
toolId,
toolName: toolItem.toolName,
});
const {
begin: beginCompletionPreview,
isActive: isCompletionPreviewActive,
} = useToolCardCompletionGracePeriod({
eligible:
isCollapsedStatus(status) &&
isLastItem === true &&
isExpanded &&
!userToggledRef.current,
});

const applyExecExpandedState = useCallback((
nextExpanded: boolean,
Expand All @@ -221,11 +233,18 @@ export const ExecProcessToolCardView: React.FC<ExecProcessToolCardViewProps> = (
return;
}

const nextExpanded = getAutoExpandedStateForStatus(status, isLastItem);
const keepTailPreview = isCollapsedStatus(status) && beginCompletionPreview();
const nextExpanded = getAutoExpandedStateForStatus(status, isLastItem, keepTailPreview);
if (nextExpanded !== null) {
applyExecExpandedState(nextExpanded, { reason: 'auto' });
}
}, [applyExecExpandedState, isLastItem, status]);
}, [
applyExecExpandedState,
beginCompletionPreview,
isCompletionPreviewActive,
isLastItem,
status,
]);

const compactSettledPreview =
isExpanded &&
Expand Down
Loading