From dcbfbcc510709da7bc6f7367ad7242ecf6778770 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 31 Jul 2026 01:16:45 +0800 Subject: [PATCH 1/2] fix(web-ui): auto-collapse completed tail tool cards - Explain why the change is needed: completed ExecCommand and Write cards could remain expanded while the next model response was delayed, making users think the command was still running. - Add a shared 800 ms completion-preview grace period with one-shot timers. - Apply the behavior to ExecProcess, Terminal, and file-operation cards. - Preserve typewriter reveals and add timer-based regression coverage. --- .../ExecProcessToolCardView.test.tsx | 42 +++++++- .../tool-cards/ExecProcessToolCardView.tsx | 25 ++++- .../tool-cards/FileOperationToolCard.tsx | 23 +++-- .../flow_chat/tool-cards/TerminalToolCard.tsx | 29 +++++- .../useToolCardCompletionGracePeriod.ts | 99 +++++++++++++++++++ 5 files changed, 203 insertions(+), 15 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/useToolCardCompletionGracePeriod.ts 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 c6f93d9bd..22788d9fc 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 @@ -113,6 +113,7 @@ describe('ExecProcessToolCardView', () => { act(() => { root.unmount(); }); + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -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', @@ -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( + , + ); + }); + + act(() => { + root.render( + , + ); + }); + 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(); + }); }); 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 f57272cf7..14aa312de 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ExecProcessToolCardView.tsx @@ -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'; @@ -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') { @@ -196,6 +198,16 @@ export const ExecProcessToolCardView: React.FC = ( toolId, toolName: toolItem.toolName, }); + const { + begin: beginCompletionPreview, + isActive: isCompletionPreviewActive, + } = useToolCardCompletionGracePeriod({ + eligible: + isCollapsedStatus(status) && + isLastItem === true && + isExpanded && + !userToggledRef.current, + }); const applyExecExpandedState = useCallback(( nextExpanded: boolean, @@ -221,11 +233,18 @@ export const ExecProcessToolCardView: React.FC = ( 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 && 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 ada8fed3a..9215d9df2 100644 --- a/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/FileOperationToolCard.tsx @@ -43,6 +43,7 @@ import { diffLines } from 'diff'; import { createLogger } from '@/shared/utils/logger'; import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { useToolCardCompletionGracePeriod } from './useToolCardCompletionGracePeriod'; import { useTypewriter } from '../hooks/useTypewriter'; import { useReportTypewriterReveal } from '../hooks/typewriterRevealGateContext'; import { hasNonFileUriScheme } from '@/shared/utils/pathUtils'; @@ -136,7 +137,6 @@ export const FileOperationToolCard: React.FC = ({ const hasInitializedCompletionEffectRef = useRef(false); const previousCompletionEndTimeRef = useRef(toolItem.endTime ?? null); - const previousExpansionStatusRef = useRef(status); const previousFailureStatusRef = useRef(status); const userToggledContentRef = useRef(false); const { @@ -298,6 +298,18 @@ export const FileOperationToolCard: React.FC = ({ }, [status, toolItem.toolName, writeContentCharCount]); const isFailed = status === 'error' || (toolResult && 'success' in toolResult && !toolResult.success); + const { + begin: beginCompletionPreview, + isActive: isCompletionPreviewActive, + } = useToolCardCompletionGracePeriod({ + eligible: + status === 'completed' && + !isFailed && + isLastItem === true && + isContentExpanded && + !userToggledContentRef.current, + isRevealing: writeTypewriter.isRevealing || editTypewriter.isRevealing, + }); const rawErrorMessage = (() => { if (toolResult && 'error' in toolResult) { return toolResult.error; @@ -411,19 +423,16 @@ export const FileOperationToolCard: React.FC = ({ }, [error, clearError, currentFilePath]); useLayoutEffect(() => { - const previousStatus = previousExpansionStatusRef.current; - previousExpansionStatusRef.current = status; - if (userToggledContentRef.current) { return; } if (status === 'completed' && !isFailed) { if (isLastItem === true && isContentExpanded) { - if (previousStatus !== 'completed') { + if (beginCompletionPreview()) { setRetainLiveCompletionPreview(true); + return; } - return; } setRetainLiveCompletionPreview(false); @@ -435,6 +444,8 @@ export const FileOperationToolCard: React.FC = ({ applyContentExpandedState(true, 'auto'); }, [ applyContentExpandedState, + beginCompletionPreview, + isCompletionPreviewActive, isContentExpanded, isFailed, isLastItem, 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 09ded94f7..646e28ebc 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx @@ -23,6 +23,7 @@ import { DotMatrixLoader, IconButton } from '../../component-library'; import { LazyTerminalOutputRenderer } from '@/tools/terminal/components/LazyTerminalOutputRenderer'; import { createLogger } from '@/shared/utils/logger'; import { useToolCardHeightContract, type ToolCardCollapseReason } from './useToolCardHeightContract'; +import { useToolCardCompletionGracePeriod } from './useToolCardCompletionGracePeriod'; import { getTerminalViewState, type TerminalViewState } from './terminalToolCardState'; import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; @@ -67,12 +68,13 @@ function getInitialTerminalExpandedState(status: string): boolean { function getAutoExpandedStateForTerminalStatus( status: string, isLastItem: boolean | undefined, + keepTailPreview: boolean, ): 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; + // visible briefly at the tail. It collapses when a newer conversation + // item takes over or when the completion preview grace period expires. + return isLastItem === true && keepTailPreview ? null : false; } if (status === 'pending_confirmation') { @@ -291,6 +293,16 @@ export const TerminalToolCard: React.FC = ({ toolId, toolName: toolItem.toolName, }); + const { + begin: beginCompletionPreview, + isActive: isCompletionPreviewActive, + } = useToolCardCompletionGracePeriod({ + eligible: + isCollapsedTerminalStatus(status) && + isLastItem === true && + isExpanded && + !userToggledRef.current, + }); const applyTerminalExpandedState = useCallback(( nextExpanded: boolean, options?: { reason?: ToolCardCollapseReason }, @@ -325,11 +337,18 @@ export const TerminalToolCard: React.FC = ({ return; } - const nextExpanded = getAutoExpandedStateForTerminalStatus(status, isLastItem); + const keepTailPreview = isCollapsedTerminalStatus(status) && beginCompletionPreview(); + const nextExpanded = getAutoExpandedStateForTerminalStatus(status, isLastItem, keepTailPreview); if (nextExpanded !== null) { applyTerminalExpandedState(nextExpanded, { reason: 'auto' }); } - }, [applyTerminalExpandedState, isLastItem, status]); + }, [ + applyTerminalExpandedState, + beginCompletionPreview, + isCompletionPreviewActive, + isLastItem, + status, + ]); const updateCommandTruncation = useCallback(() => { const element = commandRef.current; diff --git a/src/web-ui/src/flow_chat/tool-cards/useToolCardCompletionGracePeriod.ts b/src/web-ui/src/flow_chat/tool-cards/useToolCardCompletionGracePeriod.ts new file mode 100644 index 000000000..ee01340df --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/useToolCardCompletionGracePeriod.ts @@ -0,0 +1,99 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +/** Keep a newly completed tail card visible briefly before compacting it. */ +export const TOOL_CARD_COMPLETION_PREVIEW_GRACE_MS = 800; + +interface UseToolCardCompletionGracePeriodOptions { + eligible: boolean; + isRevealing?: boolean; + durationMs?: number; +} + +/** + * Starts at most one completion grace period for the current tail state. + * The caller explicitly starts it from its existing auto-expansion decision so + * the first completed render cannot collapse before the active state is set. + */ +export function useToolCardCompletionGracePeriod({ + eligible, + isRevealing = false, + durationMs = TOOL_CARD_COMPLETION_PREVIEW_GRACE_MS, +}: UseToolCardCompletionGracePeriodOptions) { + const [isActive, setIsActive] = useState(false); + const isActiveRef = useRef(false); + const hasStartedRef = useRef(false); + const timerRef = useRef | null>(null); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }, []); + + const reset = useCallback(() => { + clearTimer(); + hasStartedRef.current = false; + isActiveRef.current = false; + setIsActive(previous => (previous ? false : previous)); + }, [clearTimer]); + + const expire = useCallback(() => { + timerRef.current = null; + isActiveRef.current = false; + setIsActive(false); + }, []); + + const schedule = useCallback(() => { + if (!isActiveRef.current || isRevealing || timerRef.current !== null) { + return; + } + + timerRef.current = setTimeout(expire, Math.max(0, durationMs)); + }, [durationMs, expire, isRevealing]); + + const begin = useCallback(() => { + if (!eligible) { + reset(); + return false; + } + + if (hasStartedRef.current) { + if (isActiveRef.current && !isRevealing) { + schedule(); + } + return isActiveRef.current; + } + + hasStartedRef.current = true; + isActiveRef.current = true; + setIsActive(true); + if (!isRevealing) { + schedule(); + } + return true; + }, [eligible, isRevealing, reset, schedule]); + + useLayoutEffect(() => { + if (!eligible) { + reset(); + return; + } + + if (isActiveRef.current) { + if (isRevealing) { + clearTimer(); + } else { + schedule(); + } + } + }, [clearTimer, eligible, isRevealing, reset, schedule]); + + useEffect(() => () => { + clearTimer(); + isActiveRef.current = false; + hasStartedRef.current = false; + }, [clearTimer]); + + return { begin, isActive }; +} From 33ef688673deb427651e9681f06d2266e825db70 Mon Sep 17 00:00:00 2001 From: wsp Date: Fri, 31 Jul 2026 01:44:45 +0800 Subject: [PATCH 2/2] docs(flowchat): document scroll stability contracts and verification - Update the scroll-stability document to match the current coordinator, follow-output, collapse, and completion-preview implementations. - Add local FlowChat instructions requiring stability documentation review before changes and focused automated verification afterward. - Document the motivation: prevent competing scroll writers and stale guidance from reintroducing flashes, header drops, tail whitespace, and ownership races. - Require users to perform the complex manual viewport regression scenarios, since agents cannot reliably validate timing-sensitive interactive behavior. --- .../src/flow_chat/components/modern/AGENTS.md | 127 ++++++++++++++++++ .../modern/FLOWCHAT_SCROLL_STABILITY.md | 72 +++++++--- 2 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/AGENTS.md diff --git a/src/web-ui/src/flow_chat/components/modern/AGENTS.md b/src/web-ui/src/flow_chat/components/modern/AGENTS.md new file mode 100644 index 000000000..d96a429bb --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/AGENTS.md @@ -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 +``` + +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. diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 06b836b03..8e23b2cc6 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -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 @@ -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 @@ -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` @@ -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. @@ -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`