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
26 changes: 25 additions & 1 deletion src/web-ui/src/flow_chat/components/FlowToolCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@ vi.mock('react-i18next', () => ({
vi.mock('../tool-cards', async () => {
const ReactModule = await import('react');
return {
getToolCardComponent: (toolName: string) => ({ toolItem }: { toolItem: FlowToolItem }) =>
getToolCardComponent: (toolName: string) => ({
toolItem,
isLastItem,
}: {
toolItem: FlowToolItem;
isLastItem?: boolean;
}) =>
ReactModule.createElement('div', {
'data-selected-card': toolName,
'data-card-tool-name': toolItem.toolName,
'data-is-last-item': String(isLastItem === true),
}),
};
});
Expand Down Expand Up @@ -152,4 +159,21 @@ describe('FlowToolCard deferred identity', () => {
act(() => root.render(<FlowToolCard toolItem={tool('pending-call')} />));
expect(container.querySelector('.flow-tool-card-wrapper--permission-pending')).not.toBeNull();
});

it('updates the card when it becomes or stops being the visual tail', () => {
const tool: FlowToolItem = {
id: 'tail-tool',
type: 'tool',
toolName: 'ExecCommand',
toolCall: { id: 'tail-tool', input: { cmd: 'cargo check' } },
status: 'completed',
timestamp: 1,
};

act(() => root.render(<FlowToolCard toolItem={tool} isLastItem />));
expect(container.querySelector('[data-is-last-item="true"]')).not.toBeNull();

act(() => root.render(<FlowToolCard toolItem={tool} isLastItem={false} />));
expect(container.querySelector('[data-is-last-item="false"]')).not.toBeNull();
});
});
4 changes: 4 additions & 0 deletions src/web-ui/src/flow_chat/components/FlowToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface FlowToolCardProps {
turnId?: string;
className?: string;
displayContext?: ToolCardDisplayContext;
isLastItem?: boolean;
}

export const FlowToolCard: React.FC<FlowToolCardProps> = React.memo(({
Expand All @@ -40,6 +41,7 @@ export const FlowToolCard: React.FC<FlowToolCardProps> = React.memo(({
sessionId,
className = '',
displayContext = 'default',
isLastItem,
}) => {
const { t } = useTranslation('flow-chat');
const effectiveToolItem = projectEffectiveToolItem(toolItem);
Expand Down Expand Up @@ -97,6 +99,7 @@ export const FlowToolCard: React.FC<FlowToolCardProps> = React.memo(({
onExpand={handleExpand}
sessionId={sessionId}
displayContext={displayContext}
isLastItem={isLastItem}
/>
</FlowToolCardErrorBoundary>
<ToolApprovalBar
Expand Down Expand Up @@ -134,6 +137,7 @@ export const FlowToolCard: React.FC<FlowToolCardProps> = React.memo(({
prevProps.toolItem.subagentModelId === nextProps.toolItem.subagentModelId &&
prevProps.toolItem.subagentModelDisplayName === nextProps.toolItem.subagentModelDisplayName &&
prevProps.displayContext === nextProps.displayContext &&
prevProps.isLastItem === nextProps.isLastItem &&
prevProgress === nextProgress &&
prevProgressLogs === nextProgressLogs &&
prevProps.toolItem.partialParams === nextProps.toolItem.partialParams &&
Expand Down
33 changes: 14 additions & 19 deletions src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Renders merged explore-only rounds as a collapsible region.
*/

import React, { useRef, useMemo, useCallback, useEffect, useState } from 'react';
import React, { useRef, useMemo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { ChevronRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { FlowItem, FlowToolItem, FlowTextItem, FlowThinkingItem, ToolRejectOptions } from '../../types/flow-chat';
Expand Down Expand Up @@ -142,31 +142,25 @@ export const ExploreGroupRenderer: React.FC<ExploreGroupRendererProps> = React.m
// defaultExpanded = !wasCutByCritical). So `justGotCut && isExpanded` would
// always be false and the collapse-intent would never fire.
//
// Instead, reason about the state *before* the cut:
// - No explicit state → group was expanded by default (it was tail).
// - Explicit state = true → user had it open.
// Both cases mean the group WAS visually expanded before this render; we need
// to dispatch the height-contract event so Virtuoso can anchor-lock.
useEffect(() => {
// No explicit state means the group was expanded by the live-tail default,
// so dispatch the height-contract event before compacting it. An explicit
// state is user intent and must not be overwritten by a later auto event.
useLayoutEffect(() => {
const justGotCut = wasCutByCritical && !prevWasCutRef.current;
prevWasCutRef.current = wasCutByCritical;

if (!justGotCut) return;
if (!justGotCut || hasExplicitState) return;

const wasExpanded = !hasExplicitState || explicitExpanded;
log.debug('explore group cut by critical', { groupId, wasExpanded, hasExplicitState });
log.debug('explore group cut by critical', { groupId });

if (wasExpanded) {
setAnimateToggle(false);
applyExpandedState(true, false, () => {
onCollapseGroup?.(groupId);
}, {
reason: 'auto',
});
}
setAnimateToggle(false);
applyExpandedState(true, false, () => {
onCollapseGroup?.(groupId);
}, {
reason: 'auto',
});
}, [
applyExpandedState,
explicitExpanded,
groupId,
hasExplicitState,
wasCutByCritical,
Expand Down Expand Up @@ -382,6 +376,7 @@ const ExploreItemRenderer = React.memo<ExploreItemRendererProps>(({ item, turnId
<div className="flowchat-flow-item" data-flow-item-id={item.id} data-flow-item-type="tool">
<FlowToolCard
toolItem={item as FlowToolItem}
isLastItem={isLastItem}
onConfirm={handleConfirm}
onReject={handleReject}
onOpenInEditor={handleOpenInEditor}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,29 +6,38 @@ This document explains the scroll-stability mechanism used by `VirtualMessageLis

Every rule below is compensation for content that changes size on its own. The
cheapest way to keep the pane stable is to not generate the movement in the
first place. Three invariants hold across the message list, and breaking any of
first place. Four invariants hold across the message list, and breaking any of
them reintroduces the "the chat keeps refreshing itself" report:

1. **No mount-triggered animation on anything the list renders.** The list is
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.
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.
2. **No wall-clock input to projection or grouping.** `sessionToVirtualItems`
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.
3. **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.

A fourth, related rule lives in `useTypewriter`: `replayOnMount` defaults to
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.

A fifth, related rule lives in `useTypewriter`: `replayOnMount` defaults to
false, so a still-streaming block that remounts continues from its current text
instead of resetting to an empty string and re-growing.

Expand Down Expand Up @@ -303,9 +312,9 @@ If a future collapsible component shows the same "header drops" or "flash on col

Use this checklist:

1. Verify bottom collapse at the end of a conversation.
1. Verify the live tail stays expanded when a conversation ends with an action.
2. Verify manual collapse of a completed `Write` / `Edit` tool card.
3. Verify auto-collapse of file tool cards after streaming finishes.
3. Verify automatic compaction only after newer content supersedes the action.
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 Down
10 changes: 0 additions & 10 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -368,16 +368,6 @@

// ==================== Explore region adjustments ====================

/*
* A finished explore tool is removed from flow outright. There is no enter or
* exit animation: animating an item to zero height spreads the reflow over many
* frames, which the list's scroll-anchor machinery then has to chase — the
* visible result was a chat pane that jittered after every tool call.
*/
.flowchat-flow-item--tool-settled {
display: none;
}

/* Focus highlight for programmatic "jump to marker" navigation. */
.flowchat-flow-item.flowchat-flow-item--focused {
outline: 2px solid var(--border-accent);
Expand Down
18 changes: 2 additions & 16 deletions src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,7 +452,6 @@ export const ModelRoundItem = React.memo<ModelRoundItemProps>(
turnId={turnId}
roundId={options.roundId}
isLastItem={isLast && itemIdx === group.items.length - 1}
hideSettledExploreTool
/>
));

Expand Down Expand Up @@ -800,8 +799,6 @@ interface FlowItemRendererProps {
turnId: string;
roundId?: string;
isLastItem?: boolean;
/** Hide finished explore tools instead of leaving them inline. */
hideSettledExploreTool?: boolean;
}

// Do not memoize: streaming content updates frequently.
Expand All @@ -810,7 +807,6 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({
turnId,
roundId,
isLastItem,
hideSettledExploreTool = false,
}) => {
const {
onToolConfirm,
Expand Down Expand Up @@ -846,22 +842,12 @@ const FlowItemRenderer: React.FC<FlowItemRendererProps> = ({

case 'tool': {
const toolItem = item as FlowToolItem;
// Explore tools that already finished are hidden outright. The hidden
// state is derived from the item status alone — no wall-clock window and
// no enter/exit animation — so the round never reflows on a timer.
const isSettledExploreTool =
hideSettledExploreTool &&
toolItem.status === 'completed' &&
isCollapsibleTool(getEffectiveToolName(toolItem));
const toolClassName = [
'flowchat-flow-item',
isSettledExploreTool ? 'flowchat-flow-item--tool-settled' : null,
].filter(Boolean).join(' ');

return (
<div className={toolClassName} data-flow-item-id={item.id} data-flow-item-type="tool">
<div className="flowchat-flow-item" data-flow-item-id={item.id} data-flow-item-type="tool">
<FlowToolCard
toolItem={toolItem}
isLastItem={isLastItem}
onConfirm={async (toolId: string, permissionOptionId?: string, approve?: boolean) => {
if (onToolConfirm) {
await onToolConfirm(toolId, permissionOptionId, approve);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,18 +35,18 @@
padding: 0.5rem 0;
}

// Hint text — same breathe rhythm as tool cards (tool-card-text-fade: 1.6s 0%→25%→0%)
// Hint text — use the same low-contrast breathe rhythm as active tool cards.
.processing-indicator__hint {
font-size: 0.8125rem;
color: var(--color-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
// fade-in entry (0.4s) then breathe loop identical to card shimmer
// Fade in once, then breathe gently without making the whole row blink.
animation:
hint-fade-in 0.4s ease forwards,
hint-breathe 1.6s ease-in-out 0.4s infinite;
hint-breathe 2.2s ease-in-out 0.4s infinite;

@media (max-width: 768px) {
max-width: 200px;
Expand All @@ -64,12 +64,12 @@
}
}

// Mirrors BaseToolCard's tool-card-text-fade exactly (1→0.25→1)
// Mirrors BaseToolCard's tool-card-text-fade.
@keyframes hint-breathe {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.25;
opacity: 0.68;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
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);
will-change: auto;
}

.smooth-height-collapse--opening,
.smooth-height-collapse--closing {
will-change: height, opacity, transform;
}

Expand All @@ -18,7 +23,6 @@
.smooth-height-collapse--open {
height: auto;
overflow: visible;
will-change: auto;
}

.smooth-height-collapse--closing {
Expand All @@ -30,6 +34,12 @@
min-height: 0;
}

.smooth-height-collapse--instant {
transition: none;
transform: none;
will-change: auto;
}

@media (prefers-reduced-motion: reduce) {
.smooth-height-collapse {
transition: none;
Expand Down
Loading