From abd674bf7affdd76cc9bc9c15205d5c0c560b24b Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 18 Mar 2026 18:00:00 +0800 Subject: [PATCH 1/7] fix(flow-chat): avoid text replay when tool cards appear Prevent streaming thinking/text from being regrouped mid-stream so tool startup no longer remounts the text block and replays the typewriter animation. --- .../modern/ExploreGroupRenderer.tsx | 5 ----- .../components/modern/ModelRoundItem.tsx | 21 +++++++++++++++++-- .../flow_chat/store/modernFlowChatStore.ts | 19 ++++++++++++++++- 3 files changed, 37 insertions(+), 8 deletions(-) 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 da3838c2..f2ae53fc 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -212,11 +212,6 @@ const ExploreItemRenderer = React.memo(({ item, isLast case 'thinking': { const thinkingItem = item as FlowThinkingItem; - // Hide completed thinking inside explore groups — it adds no value - // when collapsed (the explore group summary already shows thinking count). - if (thinkingItem.status === 'completed' && !isLastItem) { - return null; - } return ( ); diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index b918121b..c95e8386 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -32,6 +32,15 @@ interface ModelRoundItemProps { isLastRound?: 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'); + }); +} + export const ModelRoundItem = React.memo( ({ round, turnId, isLastRound = false }) => { const { t } = useTranslation('flow-chat'); @@ -76,6 +85,7 @@ export const ModelRoundItem = React.memo( // 1) group subagent items // 2) group normal items into explore/critical via anchor tool const groupedItems = useMemo(() => { + const deferExploreGrouping = round.isStreaming && hasActiveStreamingNarrative(sortedItems); const intermediateGroups: Array<{ type: 'normal', item: FlowItem } | { type: 'subagent', parentTaskToolId: string, items: FlowItem[] }> = []; let currentSubagentGroup: { parentTaskToolId: string, items: FlowItem[] } | null = null; @@ -161,6 +171,13 @@ export const ModelRoundItem = React.memo( const isExploreTool = isCollapsibleTool(toolName); if (isExploreTool) { + if (deferExploreGrouping) { + flushExploreBuffer(false); + flushPendingAsCritical(); + finalGroups.push({ type: 'critical', item }); + normalItemIndex++; + continue; + } exploreBuffer.push(...pendingBuffer, item); pendingBuffer = []; @@ -182,8 +199,8 @@ export const ModelRoundItem = React.memo( flushPendingAsCritical(); return finalGroups; - }, [sortedItems]); - + }, [round.isStreaming, sortedItems]); + const extractDialogTurnContent = useCallback(() => { const flowChatStore = FlowChatStore.getInstance(); const state = flowChatStore.getState(); diff --git a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts index 7c0faa38..903fea1f 100644 --- a/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/modernFlowChatStore.ts @@ -71,9 +71,25 @@ 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. */ +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 isExploreOnlyRound(round: ModelRound): boolean { if (!round.items || round.items.length === 0) return false; + + if (round.isStreaming && hasActiveStreamingNarrative(round)) { + return false; + } const hasCollapsibleTool = round.items.some(item => item.type === 'tool' && isCollapsibleTool((item as FlowToolItem).toolName) @@ -181,7 +197,8 @@ export function sessionToVirtualItems(session: Session | null): VirtualItem[] { let currentGroup: TempExploreGroup | null = null; nonEmptyRounds.forEach((round, index) => { - if (isExploreOnlyRound(round)) { + const exploreOnly = isExploreOnlyRound(round); + if (exploreOnly) { const stats = computeRoundStats(round); if (currentGroup) { currentGroup.rounds.push(round); From 8a3451b5c8539b7e24451a06c317df7a17e65d83 Mon Sep 17 00:00:00 2001 From: wsp Date: Wed, 18 Mar 2026 18:00:00 +0800 Subject: [PATCH 2/7] fix(flow-chat): stabilize scroll anchoring and turn pinning Trace and eliminate multiple sources of FlowChat viewport jitter, including explore-group top margin drift, thinking-card collapse transitions, missing scrollbar gutter reservation, and overly aggressive follow-output behavior. Introduce pre-collapse compensation with post-layout reconciliation so card headers keep their visual position during collapse. Also pin new turns to the top, add turn navigation controls to the FlowChat header, and route turn navigation through top-pinning instead of scrolling to a transient position. --- .../components/btw/BtwSessionPanel.tsx | 15 +- .../modern/ExploreGroupRenderer.tsx | 96 +- .../components/modern/ExploreRegion.scss | 16 +- .../modern/FLOWCHAT_SCROLL_STABILITY.md | 241 ++++ .../components/modern/FlowChatHeader.scss | 99 ++ .../components/modern/FlowChatHeader.tsx | 177 ++- .../modern/ModernFlowChatContainer.tsx | 555 ++------ .../components/modern/ScrollAnchor.tsx | 22 +- .../components/modern/VirtualMessageList.scss | 3 + .../components/modern/VirtualMessageList.tsx | 1219 ++++++++++++++++- .../components/modern/useExploreGroupState.ts | 58 + .../modern/useFlowChatCopyDialog.ts | 38 + .../modern/useFlowChatFileActions.ts | 68 + .../modern/useFlowChatNavigation.ts | 218 +++ .../modern/useFlowChatSessionRelationship.ts | 61 + .../components/modern/useFlowChatSync.ts | 31 + .../modern/useFlowChatToolActions.ts | 129 ++ .../flow_chat/events/flowchatNavigation.ts | 25 + .../flow-chat-manager/MessageModule.ts | 13 + .../tool-cards/AskUserQuestionCard.tsx | 38 +- .../tool-cards/CodeReviewToolCard.tsx | 38 +- .../tool-cards/CreatePlanDisplay.tsx | 18 +- .../flow_chat/tool-cards/DefaultToolCard.tsx | 74 +- .../tool-cards/FileOperationToolCard.scss | 11 - .../tool-cards/FileOperationToolCard.tsx | 98 +- .../tool-cards/GetFileDiffDisplay.tsx | 38 +- .../flow_chat/tool-cards/GitToolDisplay.tsx | 40 +- .../tool-cards/GlobSearchDisplay.tsx | 45 +- .../tool-cards/GrepSearchDisplay.tsx | 47 +- .../tool-cards/ImageAnalysisCard.tsx | 51 +- .../src/flow_chat/tool-cards/LSDisplay.tsx | 47 +- .../flow_chat/tool-cards/MCPToolDisplay.tsx | 46 +- .../tool-cards/ModelThinkingDisplay.tsx | 33 +- src/web-ui/src/flow_chat/tool-cards/README.md | 127 ++ .../tool-cards/SessionControlToolCard.tsx | 48 +- .../tool-cards/SessionMessageToolCard.tsx | 48 +- .../flow_chat/tool-cards/TaskToolDisplay.tsx | 58 +- .../flow_chat/tool-cards/TerminalToolCard.tsx | 82 +- .../flow_chat/tool-cards/TodoWriteDisplay.tsx | 26 +- .../flow_chat/tool-cards/WebSearchCard.tsx | 45 +- .../tool-cards/useToolCardHeightContract.ts | 72 + src/web-ui/src/locales/en-US/flow-chat.json | 2 + src/web-ui/src/locales/zh-CN/flow-chat.json | 2 + 43 files changed, 3329 insertions(+), 889 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md create mode 100644 src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatFileActions.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatSessionRelationship.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatSync.ts create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts create mode 100644 src/web-ui/src/flow_chat/events/flowchatNavigation.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/README.md create mode 100644 src/web-ui/src/flow_chat/tool-cards/useToolCardHeightContract.ts diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index 91b27cbf..e3685725 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -8,6 +8,7 @@ import { ProcessingIndicator } from '../modern/ProcessingIndicator'; import { flowChatStore } from '../../store/FlowChatStore'; import type { FlowChatConfig, FlowChatState, Session } from '../../types/flow-chat'; import { sessionToVirtualItems } from '../../store/modernFlowChatStore'; +import { FLOWCHAT_FOCUS_ITEM_EVENT, type FlowChatFocusItemRequest } from '../../events/flowchatNavigation'; import { fileTabManager } from '@/shared/services/FileTabManager'; import { createTab } from '@/shared/utils/tabUtils'; import { IconButton, type LineRange } from '@/component-library'; @@ -210,14 +211,16 @@ export const BtwSessionPanel: React.FC = ({ const requestId = btwOrigin?.requestId; const itemId = requestId ? `btw_marker_${requestId}` : undefined; + const request: FlowChatFocusItemRequest = { + sessionId: resolvedParentSessionId, + turnIndex: btwOrigin?.parentTurnIndex, + itemId, + source: 'btw-back', + }; globalEventBus.emit( - 'flowchat:focus-item', - { - sessionId: resolvedParentSessionId, - turnIndex: btwOrigin?.parentTurnIndex, - itemId, - }, + FLOWCHAT_FOCUS_ITEM_EVENT, + request, 'BtwSessionPanel' ); }, [btwOrigin, parentSessionId]); 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 f2ae53fc..f6a7f1d0 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -3,7 +3,7 @@ * Renders merged explore-only rounds as a collapsible region. */ -import React, { useRef, useMemo, useCallback, useEffect } 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 } from '../../types/flow-chat'; @@ -11,6 +11,7 @@ import type { ExploreGroupData } from '../../store/modernFlowChatStore'; import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; +import { useToolCardHeightContract } from '../../tool-cards/useToolCardHeightContract'; import { useFlowChatContext } from './FlowChatContext'; import './ExploreRegion.scss'; @@ -40,27 +41,58 @@ export const ExploreGroupRenderer: React.FC = ({ isFollowedByCritical, isLastGroupInTurn } = data; + const previousGroupIdRef = useRef(groupId); - // Track auto-collapse once to prevent flicker. - const hasAutoCollapsed = useRef(false); - // Reset collapse state when the merged group changes. - const prevGroupId = useRef(groupId); - - if (prevGroupId.current !== groupId) { - prevGroupId.current = groupId; - hasAutoCollapsed.current = false; - } - - // Auto-collapse once critical content follows, without waiting for streaming to end. - if (isFollowedByCritical && !hasAutoCollapsed.current) { - hasAutoCollapsed.current = true; - } - - const shouldAutoCollapse = hasAutoCollapsed.current; + const [hasAutoCollapsed, setHasAutoCollapsed] = useState(isFollowedByCritical); + const { + cardRootRef, + applyExpandedState, + dispatchToolCardToggle, + } = useToolCardHeightContract({ + toolId: groupId, + toolName: 'explore-group', + getCardHeight: () => ( + containerRef.current?.scrollHeight + ?? containerRef.current?.getBoundingClientRect().height + ?? null + ), + }); const userExpanded = exploreGroupStates?.get(groupId) ?? false; + const shouldAutoCollapse = hasAutoCollapsed; const isCollapsed = shouldAutoCollapse && !userExpanded; + + useLayoutEffect(() => { + if (previousGroupIdRef.current !== groupId) { + previousGroupIdRef.current = groupId; + setHasAutoCollapsed(isFollowedByCritical); + return; + } + + if (!isFollowedByCritical || hasAutoCollapsed) { + return; + } + + if (!userExpanded) { + applyExpandedState(true, false, () => { + setHasAutoCollapsed(true); + }, { + reason: 'auto', + }); + return; + } + + setHasAutoCollapsed(true); + dispatchToolCardToggle(); + }, [ + applyExpandedState, + dispatchToolCardToggle, + groupId, + hasAutoCollapsed, + isFollowedByCritical, + userExpanded, + ]); // Auto-scroll to bottom during streaming. useEffect(() => { @@ -96,17 +128,17 @@ export const ExploreGroupRenderer: React.FC = ({ }, [stats, allItems.length, t]); const handleToggle = useCallback(() => { - // Notify VirtualMessageList to avoid auto-scrolling on user action. - window.dispatchEvent(new CustomEvent('tool-card-toggle')); - if (isCollapsed) { - // Expand only the clicked group. - onExploreGroupToggle?.(groupId); - } else { - // Collapse only the current group. - onCollapseGroup?.(groupId); + applyExpandedState(false, true, () => { + onExploreGroupToggle?.(groupId); + }); + return; } - }, [isCollapsed, groupId, onExploreGroupToggle, onCollapseGroup]); + + applyExpandedState(true, false, () => { + onCollapseGroup?.(groupId); + }); + }, [applyExpandedState, groupId, isCollapsed, onCollapseGroup, onExploreGroupToggle]); // Build class list. const className = [ @@ -119,7 +151,11 @@ export const ExploreGroupRenderer: React.FC = ({ // Non-collapsible: just render content without header (streaming, no auto-collapse yet). if (!shouldAutoCollapse) { return ( -
+
{allItems.map((item, idx) => ( = ({ // Collapsible: unified header + animated content wrapper. return ( -
+
{displaySummary} diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index 6d70ac9e..c5dafa03 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -16,7 +16,21 @@ .explore-region__content { position: relative; - padding: 4px 0; + // Match the pre-grouped model-round layout. A non-collapsible explore group + // should not push the first summary line downward when the round is + // re-wrapped from `model-round` into `explore-group`. + padding: 0; + + // In the plain model-round layout, the first/last flow item margins can + // collapse with the parent. Once wrapped by explore-group, that collapse no + // longer happens consistently, which leaves a residual 4px vertical drift. + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } &::-webkit-scrollbar { width: 4px; 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 new file mode 100644 index 00000000..ba1529a7 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -0,0 +1,241 @@ +# FlowChat Scroll Stability + +This document explains the scroll-stability mechanism used by `VirtualMessageList.tsx`. + +Read this before changing any of the following: + +- footer height / footer rendering in `VirtualMessageList.tsx` +- scroll compensation state or refs +- anchor-lock timing +- `ResizeObserver` / `MutationObserver` / transition listeners +- `flowchat:tool-card-collapse-intent` +- `tool-card-toggle` +- `overflow-anchor` styles in `VirtualMessageList.scss` + +## Problem + +FlowChat uses `react-virtuoso` for virtualization. When the user is already at or near the bottom, collapsing content near the end of the list can shrink total content height. + +Without compensation, the browser clamps `scrollTop` downward immediately because the previous bottom position no longer exists. That causes the visible header/content above to drop. + +If we compensate too late, the user sees a flash: + +1. browser clamps `scrollTop` +2. code restores `scrollTop` +3. header appears to drop and jump back + +If we restore without enough compensation, the final position is still wrong. + +The goal of this mechanism is: + +- keep the visible header/content vertically stable +- allow temporary invisible blank space at the bottom +- avoid the collapse flash + +## High-Level Strategy + +The fix is a two-stage approach: + +1. Pre-compensate before a known collapse starts. +2. Reconcile with the real measured height delta after layout updates. + +This prevents the "drop first, restore later" behavior while still using the actual measured shrink amount to settle on the correct final compensation. + +## Core Building Blocks + +## 1. Bottom Reservations + +The footer uses a unified bottom-reservation model. Each reservation contributes +temporary tail space, but keeps its own semantics: + +- `collapse`: shrink protection for height loss near the bottom +- `pin`: viewport positioning space for "pin turn to top" navigation + +The rendered footer height is the sum of all active reservations. + +Important details: + +- the real footer height is `MESSAGE_LIST_FOOTER_HEIGHT + totalBottomReservationPx` +- reservation space is not real content height +- reservations may define a `floorPx` +- only reservation space above the floor is consumable +- all measurements that compare old vs new content height must use: + +```ts +effectiveScrollHeight = scroller.scrollHeight - getTotalBottomCompensationPx() +``` + +If you forget to subtract reservation space, future shrink/growth calculations become wrong. + +`pin` reservations use this extra metadata: + +- `targetTurnId`: which user turn the viewport should align to +- `mode: 'transient' | 'sticky-latest'` +- `floorPx`: the minimum tail space needed to keep the pinned target stable + +`sticky-latest` is used for the "latest turn should stay pinned to top" behavior. +Its floor can be reconciled from live DOM measurements as content grows or shrinks. + +## 2. Synchronous Footer DOM Apply + +React state alone is not enough here. + +`applyFooterCompensationNow()` writes footer height directly to the DOM and forces layout reads: + +- `footer.style.height` +- `footer.style.minHeight` +- `footer.offsetHeight` +- `scroller.scrollHeight` + +This is intentional. It ensures the browser uses the new footer height in the same turn, before we restore the anchor. + +If you move compensation back to "React render only", the flash can return because the DOM may still be one frame behind when `scrollTop` is restored. + +## 3. Anchor Lock + +`anchorLockRef` temporarily remembers the desired `scrollTop`. + +It exists for two reasons: + +- immediate restore right after compensation is applied +- follow-up enforcement during scroll events while the layout is still settling + +The immediate restore handles the critical path. The scroll listener is the safety net. + +## 4. Collapse Intent + +Some collapses are predictable before layout actually shrinks. + +`flowchat:tool-card-collapse-intent` is emitted before a known collapsible UI +shrinks. `VirtualMessageList` uses that event to: + +- capture the pre-collapse anchor `scrollTop` +- capture the bottom distance before collapse +- estimate required compensation from current card height +- apply provisional compensation immediately + +This pre-compensation is what avoids the flash. + +If the list waits until `ResizeObserver` sees the shrink, the browser may already have clamped `scrollTop`. + +## Runtime Flow + +## A. Known Tool Card Collapse + +When a helper-backed card or region is about to collapse: + +1. it dispatches `flowchat:tool-card-collapse-intent` before the collapse state is applied +2. `VirtualMessageList` estimates the upcoming shrink using `cardHeight` +3. `VirtualMessageList` adds provisional footer compensation immediately +4. `VirtualMessageList` activates anchor lock using the current `scrollTop` +5. actual layout shrink happens +6. `ResizeObserver` / `MutationObserver` / transition listeners trigger `measureHeightChange()` +7. measured shrink reconciles the compensation to the real final value +8. anchor lock restores / enforces the final `scrollTop` + +Common examples: + +- `FileOperationToolCard` +- `ModelThinkingDisplay` +- `TerminalToolCard` +- `ExploreGroupRenderer` + +## B. Unknown or Unsignaled Shrink + +If a shrink happens without a collapse intent: + +1. `measureHeightChange()` detects the negative height delta +2. compensation falls back to `shrinkAmount - distanceFromBottom` +3. anchor lock uses the previously known scroll position + +This path is safer than doing nothing, but it is more likely to show visible movement than the pre-compensation path. + +## Why Transition Tracking Exists + +Some collapsible UI uses animated layout properties such as: + +- `grid-template-rows` +- `height` +- `max-height` + +During those transitions, the DOM may report intermediate sizes for multiple frames. + +`layoutTransitionCountRef` prevents us from consuming compensation too early while the layout is still animating. If you remove this guard, compensation can disappear mid-transition and reintroduce vertical drift. + +## Why `overflow-anchor: none` Must Stay + +`VirtualMessageList.scss` disables native browser scroll anchoring on: + +- `[data-virtuoso-scroller]` +- `.message-list-footer` + +This is required because the browser's built-in anchoring fights the manual compensation logic. + +If you remove `overflow-anchor: none`, the browser may apply its own anchor correction on top of our compensation and produce unstable or inconsistent results. + +## Required Event Contract + +`tool-card-toggle` + +- dispatch after a generic expand/collapse action that changes height +- purpose: schedule a follow-up measurement + +`flowchat:tool-card-collapse-intent` + +- dispatch before a collapse that can reduce list height near the bottom +- include `cardHeight` when possible +- purpose: pre-compensate before the browser clamps scroll position + +Current producer: + +- `useToolCardHeightContract.ts` +- `ModelThinkingDisplay.tsx` +- `ExploreGroupRenderer.tsx` + +Most tool cards now emit these events through `useToolCardHeightContract`. +Components that need more accurate collapse estimation can pass a custom +`getCardHeight` function to the helper. + +If a future collapsible component shows the same "header drops" or "flash on collapse" symptom, it should likely emit `flowchat:tool-card-collapse-intent` before collapsing. + +## Invariants To Preserve + +- Footer compensation must remain additive temporary space, not real content. +- Effective height comparisons must subtract current compensation. +- Footer DOM compensation must be applied synchronously before anchor restore. +- Anchor restore must clamp against current `maxScrollTop`. +- Pre-collapse intent must capture the anchor before the component shrinks. +- Compensation must not be consumed too early during active layout transitions. +- Session changes and empty-list resets must clear compensation and anchor state. + +## Common Ways To Break This + +- 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. +- Dispatching collapse intent after `setState` instead of before it. +- Removing `overflow-anchor: none`. +- Removing transition-aware delayed measurement. +- Simplifying anchor restore to a one-shot restore without the scroll listener fallback. + +## If You Need To Change This Logic + +Use this checklist: + +1. Verify bottom collapse at the end of a conversation. +2. Verify manual collapse of a completed `Write` / `Edit` tool card. +3. Verify auto-collapse of file tool cards after streaming finishes. +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. +7. Verify the final header position remains stable after collapse. + +## Related Files + +- `src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx` +- `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/FileOperationToolCard.tsx` +- `src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.tsx` +- `src/web-ui/src/flow_chat/tool-cards/TerminalToolCard.tsx` +- `src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx` diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss index 5d5ca732..b6f10e4d 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -63,6 +63,105 @@ } } + &__turn-nav { + position: relative; + display: flex; + align-items: center; + gap: $size-gap-1; + } + + &__turn-nav-button { + flex: 0 0 auto; + + &:not(:disabled):hover, + &--active { + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + } + } + + &__turn-list-panel { + position: absolute; + top: calc(100% + 8px); + right: 0; + width: min(360px, calc(100vw - 32px)); + max-height: min(420px, calc(100vh - 96px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-base); + border-radius: $size-radius-lg; + background: color-mix(in srgb, var(--color-bg-elevated) 94%, transparent); + box-shadow: var(--shadow-lg); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + z-index: 30; + } + + &__turn-list-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $size-gap-2; + padding: $size-gap-2 $size-gap-3; + border-bottom: 1px solid var(--border-base); + font-size: $font-size-xs; + color: var(--color-text-secondary); + } + + &__turn-list { + display: flex; + flex-direction: column; + overflow-y: auto; + padding: $size-gap-1; + max-height: calc(7 * 40px); + scrollbar-gutter: stable; + } + + &__turn-list-item { + display: flex; + align-items: center; + gap: $size-gap-2; + width: 100%; + padding: $size-gap-2; + border: 0; + border-radius: $size-radius-base; + background: transparent; + color: var(--color-text-primary); + min-height: 40px; + text-align: left; + cursor: pointer; + transition: background $motion-base $easing-standard; + + &:hover { + background: color-mix(in srgb, var(--element-bg-soft) 88%, transparent); + } + + &--active { + background: color-mix(in srgb, var(--accent-primary) 12%, var(--element-bg-soft)); + } + } + + &__turn-list-badge { + flex: 0 0 auto; + padding: 2px 8px; + border-radius: 999px; + background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); + color: var(--color-text-secondary); + font-size: $font-size-xs; + line-height: 1; + white-space: nowrap; + } + + &__turn-list-title { + min-width: 0; + flex: 1; + font-size: $font-size-sm; + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + // ==================== Center message ==================== &__message { flex: 1; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx index e35bae24..52d26a78 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -4,15 +4,22 @@ * Height matches side panel headers (40px). */ -import React from 'react'; -import { CornerUpLeft, MessageSquarePlus } from 'lucide-react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { ChevronDown, ChevronUp, CornerUpLeft, List, MessageSquarePlus } from 'lucide-react'; import { Tooltip, IconButton } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { globalEventBus } from '@/infrastructure/event-bus'; import { SessionFilesBadge } from './SessionFilesBadge'; import type { Session } from '../../types/flow-chat'; +import { FLOWCHAT_FOCUS_ITEM_EVENT, type FlowChatFocusItemRequest } from '../../events/flowchatNavigation'; import './FlowChatHeader.scss'; +export interface FlowChatHeaderTurnSummary { + turnId: string; + turnIndex: number; + title: string; +} + export interface FlowChatHeaderProps { /** Current turn index. */ currentTurn: number; @@ -30,6 +37,14 @@ export interface FlowChatHeaderProps { btwParentTitle?: string; /** Creates a new BTW thread from the current session. */ onCreateBtwSession?: () => void; + /** Ordered turn summaries used by header navigation. */ + turns?: FlowChatHeaderTurnSummary[]; + /** Jump to a specific turn. */ + onJumpToTurn?: (turnId: string) => void; + /** Jump to the previous turn. */ + onJumpToPreviousTurn?: () => void; + /** Jump to the next turn. */ + onJumpToNextTurn?: () => void; } export const FlowChatHeader: React.FC = ({ currentTurn, @@ -40,12 +55,15 @@ export const FlowChatHeader: React.FC = ({ btwOrigin, btwParentTitle = '', onCreateBtwSession, + turns = [], + onJumpToTurn, + onJumpToPreviousTurn, + onJumpToNextTurn, }) => { const { t } = useTranslation('flow-chat'); - - if (!visible || totalTurns === 0) { - return null; - } + const [isTurnListOpen, setIsTurnListOpen] = useState(false); + const turnListRef = useRef(null); + const activeTurnItemRef = useRef(null); // Truncate long messages. const truncatedMessage = currentUserMessage.length > 50 @@ -65,23 +83,98 @@ export const FlowChatHeader: React.FC = ({ const createBtwTooltip = t('flowChatHeader.btwCreateTooltip', { defaultValue: 'Start a quick side question', }); + const turnListTooltip = t('flowChatHeader.turnList', { + defaultValue: 'Turn list', + }); + const untitledTurnLabel = t('flowChatHeader.untitledTurn', { + defaultValue: 'Untitled turn', + }); const turnBadgeLabel = t('flowChatHeader.turnBadge', { current: currentTurn, defaultValue: `Turn ${currentTurn}`, }); + const previousTurnDisabled = currentTurn <= 1; + const nextTurnDisabled = currentTurn <= 0 || currentTurn >= totalTurns; + const hasTurnNavigation = turns.length > 0 && !!onJumpToTurn; + const displayTurns = useMemo(() => ( + turns.map(turn => ({ + ...turn, + title: turn.title.trim() || untitledTurnLabel, + })) + ), [turns, untitledTurnLabel]); + + useEffect(() => { + if (!isTurnListOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + if (!turnListRef.current?.contains(event.target as Node)) { + setIsTurnListOpen(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsTurnListOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [isTurnListOpen]); + + useEffect(() => { + setIsTurnListOpen(false); + }, [currentTurn]); + + useEffect(() => { + if (!isTurnListOpen) return; + + const frameId = requestAnimationFrame(() => { + activeTurnItemRef.current?.scrollIntoView({ + block: 'center', + inline: 'nearest', + }); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [currentTurn, displayTurns.length, isTurnListOpen]); const handleBackToParent = () => { const parentId = btwOrigin?.parentSessionId; if (!parentId) return; const requestId = btwOrigin?.requestId; const itemId = requestId ? `btw_marker_${requestId}` : undefined; - globalEventBus.emit('flowchat:focus-item', { + const request: FlowChatFocusItemRequest = { sessionId: parentId, turnIndex: btwOrigin?.parentTurnIndex, itemId, - }, 'FlowChatHeader'); + source: 'btw-back', + }; + globalEventBus.emit(FLOWCHAT_FOCUS_ITEM_EVENT, request, 'FlowChatHeader'); + }; + + const handleToggleTurnList = () => { + if (!hasTurnNavigation) return; + setIsTurnListOpen(prev => !prev); + }; + + const handleTurnSelect = (turnId: string) => { + if (!onJumpToTurn) return; + onJumpToTurn(turnId); + setIsTurnListOpen(false); }; + if (!visible || totalTurns === 0) { + return null; + } + return (
@@ -100,6 +193,74 @@ export const FlowChatHeader: React.FC = ({
+
+ + + + + + + + + + + {isTurnListOpen && hasTurnNavigation && ( +
+
+ {turnListTooltip} + {currentTurn}/{totalTurns} +
+
+ {displayTurns.map(turn => ( + + ))} +
+
+ )} +
{!!btwOrigin?.parentSessionId && ( ; - interface ModernFlowChatContainerProps { className?: string; config?: Partial; @@ -53,398 +43,31 @@ export const ModernFlowChatContainer: React.FC = ( const virtualItems = useVirtualItems(); const activeSession = useActiveSession(); const visibleTurnInfo = useVisibleTurnInfo(); + const [pendingHeaderTurnId, setPendingHeaderTurnId] = useState(null); + const autoPinnedSessionIdRef = useRef(null); const virtualListRef = useRef(null); const { workspacePath } = useWorkspaceContext(); - const isBtwSession = resolveSessionRelationship(activeSession).isBtw; - const [btwOrigin, setBtwOrigin] = useState(null); - const [btwParentTitle, setBtwParentTitle] = useState(''); - - // Explore group collapse state (key: groupId, true = user-expanded). - const [exploreGroupStates, setExploreGroupStates] = useState>(new Map()); - - const handleExploreGroupToggle = useCallback((groupId: string) => { - setExploreGroupStates(prev => { - const next = new Map(prev); - next.set(groupId, !prev.get(groupId)); - return next; - }); - }, []); - - const handleExpandAllInTurn = useCallback((turnId: string) => { - const groupIds = virtualItems - .filter((item): item is ExploreGroupVirtualItem => - item.type === 'explore-group' && - item.turnId === turnId - ) - .map(item => item.data.groupId); - - setExploreGroupStates(prev => { - const next = new Map(prev); - [...new Set(groupIds)].forEach(id => next.set(id, true)); - return next; - }); - }, [virtualItems]); - - const handleCollapseGroup = useCallback((groupId: string) => { - setExploreGroupStates(prev => { - const next = new Map(prev); - next.set(groupId, false); - return next; - }); - }, []); - - useEffect(() => { - const unsubscribe = startAutoSync(); - return () => { - unsubscribe(); - }; - }, []); - - useEffect(() => { - const syncBtwState = (state = flowChatStore.getState()) => { - const currentSessionId = activeSession?.sessionId; - if (!currentSessionId) { - setBtwOrigin(null); - setBtwParentTitle(''); - return; - } - - const session = state.sessions.get(currentSessionId); - if (!session) { - setBtwOrigin(null); - setBtwParentTitle(''); - return; - } - - const relationship = resolveSessionRelationship(session); - const nextOrigin = relationship.origin || null; - const parentId = relationship.parentSessionId; - const parent = parentId ? state.sessions.get(parentId) : undefined; - - setBtwOrigin(nextOrigin); - setBtwParentTitle(parent?.title || ''); - }; - - syncBtwState(); - const unsubscribe = flowChatStore.subscribe(syncBtwState); - return unsubscribe; - }, [activeSession?.sessionId]); - - useEffect(() => { - const unlisten = agentAPI.onSessionTitleGenerated((event) => { - flowChatStore.updateSessionTitle( - event.sessionId, - event.title, - 'generated' - ); - }); - - return () => { - unlisten(); - }; - }, []); - - useEffect(() => { - const unsubscribe = globalEventBus.on('flowchat:copy-dialog', ({ dialogTurn }) => { - if (!dialogTurn) { - log.warn('Copy failed: dialog element not provided'); - return; - } - - const dialogElement = dialogTurn as HTMLElement; - const fullText = getElementText(dialogElement); - - if (!fullText || fullText.trim().length === 0) { - notificationService.warning('Dialog is empty, nothing to copy'); - return; - } - - copyTextToClipboard(fullText).then(success => { - if (!success) { - notificationService.error('Copy failed. Please try again.'); - } - }); - }); - - return unsubscribe; - }, []); - - useEffect(() => { - const unsubscribe = globalEventBus.on<{ - sessionId: string; - turnIndex?: number; - itemId?: string; - }>('flowchat:focus-item', async ({ sessionId, turnIndex, itemId }) => { - if (!sessionId) return; - - const waitFor = async (predicate: () => boolean, timeoutMs: number): Promise => { - const start = performance.now(); - while (performance.now() - start < timeoutMs) { - if (predicate()) return true; - await new Promise(r => requestAnimationFrame(() => r())); - } - return predicate(); - }; - - // Switch session first (if needed). Note: Modern virtual list methods are bound to - // the current render; after switching sessions we must wait a frame or two so - // VirtualMessageList updates its imperative ref and item map before scrolling. - if (activeSession?.sessionId !== sessionId) { - try { - await flowChatManager.switchChatSession(sessionId); - } catch (e) { - log.warn('Failed to switch session for focus request', { sessionId, e }); - return; - } - } - - // Wait until the modern store and list ref have caught up to the target session. - // This avoids a common race where scrollToTurn no-ops against the previous session's item list. - await waitFor(() => { - const modernActive = useModernFlowChatStore.getState().activeSession?.sessionId; - return modernActive === sessionId && !!virtualListRef.current; - }, 1500); - - let resolvedVirtualIndex: number | undefined = undefined; - let resolvedTurnIndex = turnIndex; - if (itemId) { - const s = flowChatStore.getState().sessions.get(sessionId); - if (s) { - for (let i = 0; i < s.dialogTurns.length; i++) { - const t = s.dialogTurns[i]; - const found = t.modelRounds?.some(r => r.items?.some(it => it.id === itemId)); - if (found) { - resolvedTurnIndex = i + 1; - break; - } - } - } - - // Prefer a precise virtual scroll target so the marker is actually rendered. - // Scrolling to the turn's user message can leave the marker far outside the rendered range. - const currentVirtualItems = useModernFlowChatStore.getState().virtualItems; - for (let i = 0; i < currentVirtualItems.length; i++) { - const vi = currentVirtualItems[i]; - if (vi.type === 'model-round') { - const hit = vi.data?.items?.some((it: any) => it?.id === itemId); - if (hit) { - resolvedVirtualIndex = i; - break; - } - } else if (vi.type === 'explore-group') { - const hit = vi.data?.allItems?.some((it: any) => it?.id === itemId); - if (hit) { - resolvedVirtualIndex = i; - break; - } - } - } - } - - // Scroll to the most precise target we have. - if (resolvedVirtualIndex != null && virtualListRef.current) { - virtualListRef.current.scrollToIndex(resolvedVirtualIndex); - } else if (resolvedTurnIndex && virtualListRef.current) { - virtualListRef.current.scrollToTurn(resolvedTurnIndex); - } - - if (!itemId) return; - - // Wait two frames for Virtuoso to settle after instant scrollToIndex before - // searching the DOM. This avoids finding an element that Virtuoso is about - // to recycle when it processes the new scroll position. - await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r()))); - - // Then focus the specific flow item (marker) within the DOM. - // Retry a few times because virtualization/paint can lag behind the scroll. - const maxAttempts = 120; - let attempts = 0; - const tryFocus = () => { - attempts++; - const el = document.querySelector(`[data-flow-item-id="${CSS.escape(itemId)}"]`) as HTMLElement | null; - if (!el) { - // Keep nudging the list to the right neighborhood; scrollToTurn can be preempted by - // stick-to-bottom mode during session switches or streaming updates. - if (attempts % 12 === 0 && virtualListRef.current) { - if (resolvedVirtualIndex != null) { - virtualListRef.current.scrollToIndex(resolvedVirtualIndex); - } else if (resolvedTurnIndex) { - virtualListRef.current.scrollToTurn(resolvedTurnIndex); - } - } - if (attempts < maxAttempts) { - requestAnimationFrame(tryFocus); - } - return; - } - - el.classList.add('flowchat-flow-item--focused'); - window.setTimeout(() => el.classList.remove('flowchat-flow-item--focused'), 1600); - }; - - requestAnimationFrame(tryFocus); - }); - - return unsubscribe; - }, [activeSession?.sessionId]); - - const handleToolConfirm = useCallback(async (toolId: string, updatedInput?: any) => { - try { - const latestState = flowChatStore.getState(); - const dialogTurns = Array.from(latestState.sessions.values()).flatMap(session => - Object.values(session.dialogTurns) as DialogTurn[] - ); - - let toolItem: FlowToolItem | null = null; - let turnId: string | null = null; - - for (const turn of dialogTurns) { - for (const modelRound of Object.values(turn.modelRounds) as ModelRound[]) { - const item = modelRound.items.find((item: FlowItem) => - item.type === 'tool' && item.id === toolId - ) as FlowToolItem; - - if (item) { - toolItem = item; - turnId = turn.id; - break; - } - } - if (toolItem) break; - } - - if (!toolItem || !turnId) { - notificationService.error(`Tool confirmation failed: tool item ${toolId} not found in current session`); - return; - } - - const finalInput = updatedInput || toolItem.toolCall?.input; - - const activeSessionId = latestState.activeSessionId; - if (activeSessionId) { - flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { - userConfirmed: true, - status: 'confirmed', - toolCall: { - ...toolItem.toolCall, - input: finalInput - } - } as any); - } - - if (!activeSessionId) { - throw new Error('No active session ID'); - } - - const { agentService } = await import('../../../shared/services/agent-service'); - await agentService.confirmToolExecution( - activeSessionId, - toolId, - 'confirm', - finalInput - ); - } catch (error) { - log.error('Tool confirmation failed', error); - notificationService.error(`Tool confirmation failed: ${error}`); - } - }, []); - - const handleToolReject = useCallback(async (toolId: string) => { - try { - const latestState = flowChatStore.getState(); - const dialogTurns = Array.from(latestState.sessions.values()).flatMap(session => - Object.values(session.dialogTurns) as DialogTurn[] - ); - - let toolItem: FlowToolItem | null = null; - let turnId: string | null = null; - - for (const turn of dialogTurns) { - for (const modelRound of Object.values(turn.modelRounds) as ModelRound[]) { - const item = modelRound.items.find((item: FlowItem) => - item.type === 'tool' && item.id === toolId - ) as FlowToolItem; - - if (item) { - toolItem = item; - turnId = turn.id; - break; - } - } - if (toolItem) break; - } - - if (!toolItem || !turnId) { - log.warn('Tool rejection failed: tool item not found', { toolId }); - return; - } - - const activeSessionId = latestState.activeSessionId; - if (activeSessionId) { - flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { - userConfirmed: false, - status: 'rejected' - } as any); - } - - if (!activeSessionId) { - throw new Error('No active session ID'); - } - - const { agentService } = await import('../../../shared/services/agent-service'); - await agentService.confirmToolExecution( - activeSessionId, - toolId, - 'reject' - ); - } catch (error) { - log.error('Tool rejection failed', error); - notificationService.error(`Tool rejection failed: ${error}`); - } - }, []); - - const handleFileViewRequest = useCallback(async ( - filePath: string, - fileName: string, - lineRange?: LineRange - ) => { - log.debug('File view request', { - filePath, - fileName, - hasLineRange: !!lineRange, - hasExternalCallback: !!onFileViewRequest - }); - - if (onFileViewRequest) { - onFileViewRequest(filePath, fileName, lineRange); - return; - } - - let absoluteFilePath = filePath; - - const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(filePath); - - if (!isWindowsAbsolutePath && !path.isAbsolute(filePath) && workspacePath) { - absoluteFilePath = path.join(workspacePath, filePath); - log.debug('Converted relative path to absolute', { - relative: filePath, - absolute: absoluteFilePath - }); - } - - try { - fileTabManager.openFile({ - filePath: absoluteFilePath, - fileName, - workspacePath, - jumpToRange: lineRange, - mode: 'agent', - }); - } catch (error) { - log.error('File navigation failed', error); - notificationService.error(`Unable to open file: ${absoluteFilePath}`); - } - }, [onFileViewRequest, workspacePath]); + const { isBtwSession, btwOrigin, btwParentTitle } = useFlowChatSessionRelationship(activeSession); + const { + exploreGroupStates, + onExploreGroupToggle: handleExploreGroupToggle, + onExpandAllInTurn: handleExpandAllInTurn, + onCollapseGroup: handleCollapseGroup, + } = useExploreGroupState(virtualItems); + const { handleToolConfirm, handleToolReject } = useFlowChatToolActions(); + const { handleFileViewRequest } = useFlowChatFileActions({ + workspacePath, + onFileViewRequest, + }); + + useFlowChatSync(); + useFlowChatCopyDialog(); + + useFlowChatNavigation({ + activeSessionId: activeSession?.sessionId, + virtualItems, + virtualListRef, + }); const contextValue: FlowChatContextValue = useMemo(() => ({ onFileViewRequest: handleFileViewRequest, @@ -489,19 +112,123 @@ export const ModernFlowChatContainer: React.FC = ( detail: { message: '/btw ' } })); }, [activeSession?.sessionId]); + + const turnSummaries = useMemo(() => { + return (activeSession?.dialogTurns ?? []) + .filter(turn => !!turn.userMessage) + .map((turn, index) => ({ + turnId: turn.id, + turnIndex: index + 1, + title: turn.userMessage?.content ?? '', + })); + }, [activeSession?.dialogTurns]); + + const effectiveVisibleTurnInfo = useMemo(() => { + if (!pendingHeaderTurnId) { + return visibleTurnInfo; + } + + const targetTurn = turnSummaries.find(turn => turn.turnId === pendingHeaderTurnId); + if (!targetTurn) { + return visibleTurnInfo; + } + + return { + turnId: targetTurn.turnId, + turnIndex: targetTurn.turnIndex, + totalTurns: turnSummaries.length, + userMessage: targetTurn.title, + }; + }, [pendingHeaderTurnId, turnSummaries, visibleTurnInfo]); + + useEffect(() => { + if (!pendingHeaderTurnId) return; + + if (visibleTurnInfo?.turnId === pendingHeaderTurnId) { + setPendingHeaderTurnId(null); + return; + } + + const targetStillExists = turnSummaries.some(turn => turn.turnId === pendingHeaderTurnId); + if (!targetStillExists) { + setPendingHeaderTurnId(null); + } + }, [pendingHeaderTurnId, turnSummaries, visibleTurnInfo?.turnId]); + + useEffect(() => { + autoPinnedSessionIdRef.current = null; + setPendingHeaderTurnId(null); + }, [activeSession?.sessionId]); + + useEffect(() => { + const sessionId = activeSession?.sessionId; + const latestTurnId = turnSummaries[turnSummaries.length - 1]?.turnId; + if (!sessionId || !latestTurnId || autoPinnedSessionIdRef.current === sessionId) { + return; + } + + autoPinnedSessionIdRef.current = sessionId; + setPendingHeaderTurnId(latestTurnId); + + const frameId = requestAnimationFrame(() => { + const accepted = virtualListRef.current?.pinTurnToTop(latestTurnId, { + behavior: 'auto', + pinMode: 'sticky-latest', + }) ?? false; + + if (!accepted) { + autoPinnedSessionIdRef.current = null; + setPendingHeaderTurnId(null); + } + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [activeSession?.sessionId, turnSummaries]); + + const handleJumpToTurn = useCallback((turnId: string) => { + if (!turnId) return; + + const isLatestTurn = turnSummaries[turnSummaries.length - 1]?.turnId === turnId; + const accepted = virtualListRef.current?.pinTurnToTop(turnId, { + behavior: 'smooth', + pinMode: isLatestTurn ? 'sticky-latest' : 'transient', + }) ?? false; + + setPendingHeaderTurnId(accepted ? turnId : null); + }, [turnSummaries]); + + const handleJumpToPreviousTurn = useCallback(() => { + if (!effectiveVisibleTurnInfo || effectiveVisibleTurnInfo.turnIndex <= 1) return; + const previousTurn = turnSummaries[effectiveVisibleTurnInfo.turnIndex - 2]; + if (!previousTurn) return; + handleJumpToTurn(previousTurn.turnId); + }, [effectiveVisibleTurnInfo, handleJumpToTurn, turnSummaries]); + + const handleJumpToNextTurn = useCallback(() => { + if (!effectiveVisibleTurnInfo || effectiveVisibleTurnInfo.turnIndex >= turnSummaries.length) return; + const nextTurn = turnSummaries[effectiveVisibleTurnInfo.turnIndex]; + if (!nextTurn) return; + handleJumpToTurn(nextTurn.turnId); + }, [effectiveVisibleTurnInfo, handleJumpToTurn, turnSummaries]); return (
0} sessionId={activeSession?.sessionId} btwOrigin={btwOrigin} btwParentTitle={btwParentTitle} onCreateBtwSession={activeSession?.sessionId && !isBtwSession ? handleCreateBtwSession : undefined} + turns={turnSummaries} + onJumpToTurn={handleJumpToTurn} + onJumpToPreviousTurn={handleJumpToPreviousTurn} + onJumpToNextTurn={handleJumpToNextTurn} />
diff --git a/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx b/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx index 9b3dd914..50a1abb2 100644 --- a/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx @@ -4,13 +4,12 @@ */ import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; -import { VirtuosoHandle } from 'react-virtuoso'; import { useVirtualItems } from '../../store/modernFlowChatStore'; import { i18nService } from '@/infrastructure/i18n'; import './ScrollAnchor.scss'; interface ScrollAnchorProps { - virtuosoRef: React.RefObject; + onAnchorNavigate: (turnId: string) => void; scrollerRef?: React.RefObject; } @@ -26,7 +25,7 @@ interface AnchorPoint { export const ScrollAnchor: React.FC = ({ - virtuosoRef, + onAnchorNavigate, scrollerRef, }) => { const virtualItems = useVirtualItems(); @@ -104,23 +103,10 @@ export const ScrollAnchor: React.FC = ({ }, [virtualItems]); const handleAnchorClick = useCallback((anchor: AnchorPoint) => { - if (!virtuosoRef.current) return; - - if (anchor.index === 0) { - virtuosoRef.current.scrollTo({ - top: 0, - behavior: 'smooth', - }); - } else { - virtuosoRef.current.scrollToIndex({ - index: anchor.index, - behavior: 'smooth', - align: 'center', - }); - } + onAnchorNavigate(anchor.turnId); setHoveredAnchor(null); - }, [virtuosoRef]); + }, [onAnchorNavigate]); const handleAnchorMouseEnter = useCallback((anchor: AnchorPoint, event: React.MouseEvent) => { setHoveredAnchor(anchor); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss index 9fbe7ab6..7a9dfa8d 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -36,6 +36,8 @@ width: 100% !important; max-width: none !important; margin: 0 !important; + overflow-anchor: none; + scrollbar-gutter: stable; } &--empty { @@ -65,6 +67,7 @@ + extra space (18px) + header buffer (20px). */ height: 140px; min-height: 140px; /* Ensure footer minimum height. */ + overflow-anchor: none; } } 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 1413aefa..7e8196ed 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -3,29 +3,154 @@ * Renders a flattened DialogTurn stream (user messages + model rounds). * * Scroll policy (simplified): - * - While the agent is processing → always auto-scroll to bottom (smooth). - * - While idle → user scrolls freely; no auto-scroll interference. - * - "Scroll to latest" bar appears when not at bottom AND not processing. + * - The list never forces the user back to the bottom while new content streams in. + * - User scroll position is preserved unless they explicitly jump to a target. + * - "Scroll to latest" bar appears whenever the list is not at bottom. */ import React, { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'; -import { Virtuoso, VirtuosoHandle, ListRange } from 'react-virtuoso'; +import { Virtuoso, VirtuosoHandle } from 'react-virtuoso'; import { useActiveSessionState } from '../../hooks/useActiveSessionState'; import { VirtualItemRenderer } from './VirtualItemRenderer'; import { ScrollToLatestBar } from '../ScrollToLatestBar'; import { ProcessingIndicator } from './ProcessingIndicator'; import { ScrollAnchor } from './ScrollAnchor'; -import { useVirtualItems, useActiveSession, useModernFlowChatStore } from '../../store/modernFlowChatStore'; +import type { FlowChatPinTurnToTopMode } from '../../events/flowchatNavigation'; +import { useVirtualItems, useActiveSession, useModernFlowChatStore, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; import { useChatInputState } from '../../store/chatInputStateStore'; import './VirtualMessageList.scss'; +const MESSAGE_LIST_FOOTER_HEIGHT = 140; +const COMPENSATION_EPSILON_PX = 0.5; +const ANCHOR_LOCK_MIN_DEVIATION_PX = 0.5; +const ANCHOR_LOCK_DURATION_MS = 450; +const PINNED_TURN_VIEWPORT_OFFSET_PX = 57; // Keep in sync with `.message-list-header`. + +// Read `FLOWCHAT_SCROLL_STABILITY.md` before changing collapse compensation logic. + /** * Methods exposed by VirtualMessageList. */ export interface VirtualMessageListRef { scrollToTurn: (turnIndex: number) => void; scrollToIndex: (index: number) => void; - scrollToBottom: () => void; + // Clears pin reservation first, then scrolls to the physical bottom. + scrollToPhysicalBottomAndClearPin: () => void; + // Preserves any existing pin reservation and behaves like an End-key scroll. + scrollToLatestEndPosition: () => void; + // Aligns the target turn's user message to the viewport top. + pinTurnToTop: (turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => boolean; +} + +interface ScrollAnchorLockState { + active: boolean; + targetScrollTop: number; + reason: 'transition-shrink' | 'instant-shrink' | null; + lockUntilMs: number; +} + +interface PendingCollapseIntentState { + active: boolean; + anchorScrollTop: number; + toolId: string | null; + toolName: string | null; + expiresAtMs: number; + distanceFromBottomBeforeCollapse: number; + baseTotalCompensationPx: number; + cumulativeShrinkPx: number; +} + +type BottomReservationKind = 'collapse' | 'pin'; + +interface BottomReservationBase { + kind: BottomReservationKind; + px: number; + floorPx: number; +} + +interface CollapseBottomReservation extends BottomReservationBase { + kind: 'collapse'; +} + +interface PinBottomReservation extends BottomReservationBase { + kind: 'pin'; + mode: FlowChatPinTurnToTopMode; + targetTurnId: string | null; +} + +interface BottomReservationState { + collapse: CollapseBottomReservation; + pin: PinBottomReservation; +} + +interface PendingTurnPinState { + turnId: string; + behavior: ScrollBehavior; + pinMode: FlowChatPinTurnToTopMode; + expiresAtMs: number; + attempts: number; +} + +function createInitialBottomReservationState(): BottomReservationState { + return { + collapse: { + kind: 'collapse', + px: 0, + floorPx: 0, + }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }; +} + +function sanitizeReservationPx(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0; +} + +function sanitizeBottomReservationState(state: BottomReservationState): BottomReservationState { + const collapsePx = sanitizeReservationPx(state.collapse.px); + const collapseFloorPx = Math.min(collapsePx, sanitizeReservationPx(state.collapse.floorPx)); + const pinPx = sanitizeReservationPx(state.pin.px); + const pinFloorPx = Math.min(pinPx, sanitizeReservationPx(state.pin.floorPx)); + + return { + collapse: { + kind: 'collapse', + px: collapsePx, + floorPx: collapseFloorPx, + }, + pin: { + kind: 'pin', + px: pinPx, + floorPx: pinFloorPx, + mode: state.pin.mode ?? 'transient', + targetTurnId: state.pin.targetTurnId ?? null, + }, + }; +} + +function areBottomReservationStatesEqual(left: BottomReservationState, right: BottomReservationState): boolean { + return ( + Math.abs(left.collapse.px - right.collapse.px) <= COMPENSATION_EPSILON_PX && + Math.abs(left.collapse.floorPx - right.collapse.floorPx) <= COMPENSATION_EPSILON_PX && + Math.abs(left.pin.px - right.pin.px) <= COMPENSATION_EPSILON_PX && + Math.abs(left.pin.floorPx - right.pin.floorPx) <= COMPENSATION_EPSILON_PX && + left.pin.mode === right.pin.mode && + left.pin.targetTurnId === right.pin.targetTurnId + ); +} + +function getReservationTotalPx(reservation: BottomReservationBase): number { + return Math.max(0, reservation.px); +} + +function getReservationConsumablePx(reservation: BottomReservationBase): number { + return Math.max(0, reservation.px - reservation.floorPx); } export const VirtualMessageList = forwardRef((_, ref) => { @@ -34,8 +159,39 @@ export const VirtualMessageList = forwardRef((_, ref) => const activeSession = useActiveSession(); const [isAtBottom, setIsAtBottom] = useState(true); + const [scrollerElement, setScrollerElement] = useState(null); + const [bottomReservationState, setBottomReservationState] = useState( + () => createInitialBottomReservationState() + ); + const [pendingTurnPin, setPendingTurnPin] = useState(null); const scrollerElementRef = useRef(null); + const footerElementRef = useRef(null); + const bottomReservationStateRef = useRef(createInitialBottomReservationState()); + const previousMeasuredHeightRef = useRef(null); + const previousScrollTopRef = useRef(0); + const measureFrameRef = useRef(null); + const visibleTurnMeasureFrameRef = useRef(null); + const pinReservationReconcileFrameRef = useRef(null); + const resizeObserverRef = useRef(null); + const mutationObserverRef = useRef(null); + const layoutTransitionCountRef = useRef(0); + const anchorLockRef = useRef({ + active: false, + targetScrollTop: 0, + reason: null, + lockUntilMs: 0, + }); + const pendingCollapseIntentRef = useRef({ + active: false, + anchorScrollTop: 0, + toolId: null, + toolName: null, + expiresAtMs: 0, + distanceFromBottomBeforeCollapse: 0, + baseTotalCompensationPx: 0, + cumulativeShrinkPx: 0, + }); const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); @@ -44,70 +200,971 @@ export const VirtualMessageList = forwardRef((_, ref) => const isProcessing = activeSessionState.isProcessing; const processingPhase = activeSessionState.processingPhase; - const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { - if (el && el instanceof HTMLElement) { - scrollerElementRef.current = el; + const getFooterHeightPx = useCallback((compensationPx: number) => { + return MESSAGE_LIST_FOOTER_HEIGHT + compensationPx; + }, []); + + const getTotalBottomCompensationPx = useCallback((state: BottomReservationState = bottomReservationStateRef.current) => { + return getReservationTotalPx(state.collapse) + getReservationTotalPx(state.pin); + }, []); + + const updateBottomReservationState = useCallback(( + updater: BottomReservationState | ((prev: BottomReservationState) => BottomReservationState), + ) => { + setBottomReservationState(prev => { + const rawNext = typeof updater === 'function' ? updater(prev) : updater; + const next = sanitizeBottomReservationState(rawNext); + bottomReservationStateRef.current = next; + return areBottomReservationStatesEqual(next, prev) ? prev : next; + }); + }, []); + + const resetBottomReservations = useCallback(() => { + updateBottomReservationState(createInitialBottomReservationState()); + }, [updateBottomReservationState]); + + const consumeBottomCompensation = useCallback((amountPx: number) => { + if (amountPx <= COMPENSATION_EPSILON_PX) { + return bottomReservationStateRef.current; } + + let resolvedNextState = bottomReservationStateRef.current; + updateBottomReservationState(prev => { + let remaining = Math.max(0, amountPx); + + const collapseConsumablePx = getReservationConsumablePx(prev.collapse); + const collapseConsumed = Math.min(collapseConsumablePx, remaining); + remaining -= collapseConsumed; + + const pinConsumablePx = getReservationConsumablePx(prev.pin); + const pinConsumed = Math.min(pinConsumablePx, remaining); + + const nextState: BottomReservationState = { + collapse: { + ...prev.collapse, + px: Math.max(prev.collapse.floorPx, prev.collapse.px - collapseConsumed), + }, + pin: { + ...prev.pin, + px: Math.max(prev.pin.floorPx, prev.pin.px - pinConsumed), + }, + }; + resolvedNextState = nextState; + return nextState; + }); + return resolvedNextState; + }, [updateBottomReservationState]); + + const applyFooterCompensationNow = useCallback((compensation: number | BottomReservationState) => { + const footer = footerElementRef.current; + const scroller = scrollerElementRef.current; + if (!footer || !scroller) return; + + const compensationPx = typeof compensation === 'number' + ? compensation + : getTotalBottomCompensationPx(compensation); + const footerHeightPx = getFooterHeightPx(compensationPx); + footer.style.height = `${footerHeightPx}px`; + footer.style.minHeight = `${footerHeightPx}px`; + void footer.offsetHeight; + void scroller.scrollHeight; + }, [getFooterHeightPx, getTotalBottomCompensationPx]); + + const releaseAnchorLock = useCallback((_reason: string) => { + if (!anchorLockRef.current.active) return; + anchorLockRef.current = { + active: false, + targetScrollTop: 0, + reason: null, + lockUntilMs: 0, + }; + }, []); + + const activateAnchorLock = useCallback((targetScrollTop: number, reason: 'transition-shrink' | 'instant-shrink') => { + const nextTarget = Math.max(anchorLockRef.current.targetScrollTop, targetScrollTop); + anchorLockRef.current = { + active: true, + targetScrollTop: nextTarget, + reason, + lockUntilMs: performance.now() + ANCHOR_LOCK_DURATION_MS, + }; }, []); - // ── User-message index map (for turn navigation & range reporting) ─── + const restoreAnchorLockNow = useCallback((reason: string) => { + const scroller = scrollerElementRef.current; + const lockState = anchorLockRef.current; + if (!scroller || !lockState.active) return false; + + const now = performance.now(); + if (now > lockState.lockUntilMs && layoutTransitionCountRef.current === 0) { + releaseAnchorLock(`expired-before-${reason}`); + return false; + } + + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const targetScrollTop = Math.min(lockState.targetScrollTop, maxScrollTop); + const currentScrollTop = scroller.scrollTop; + const restoreDelta = targetScrollTop - currentScrollTop; + + if (Math.abs(restoreDelta) <= ANCHOR_LOCK_MIN_DEVIATION_PX) { + return false; + } + + scroller.scrollTop = targetScrollTop; + previousScrollTopRef.current = targetScrollTop; + return true; + }, [releaseAnchorLock]); + + const measureHeightChange = useCallback(() => { + const scroller = scrollerElementRef.current; + if (!scroller) return; + + const currentScrollTop = scroller.scrollTop; + const previousScrollTop = previousScrollTopRef.current; + const currentTotalCompensation = getTotalBottomCompensationPx(); + const effectiveScrollHeight = Math.max(0, scroller.scrollHeight - currentTotalCompensation); + const previousMeasuredHeight = previousMeasuredHeightRef.current; + previousMeasuredHeightRef.current = effectiveScrollHeight; + + if (previousMeasuredHeight === null) { + previousScrollTopRef.current = currentScrollTop; + return; + } + + const heightDelta = effectiveScrollHeight - previousMeasuredHeight; + if (Math.abs(heightDelta) <= COMPENSATION_EPSILON_PX) { + previousScrollTopRef.current = currentScrollTop; + return; + } + + const distanceFromBottom = Math.max( + 0, + scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + ); + + // Content grew: consume temporary footer padding first. + if (heightDelta > 0) { + if (currentTotalCompensation > COMPENSATION_EPSILON_PX && layoutTransitionCountRef.current > 0) { + previousScrollTopRef.current = currentScrollTop; + return; + } + + const nextReservationState = consumeBottomCompensation(heightDelta); + applyFooterCompensationNow(nextReservationState); + previousScrollTopRef.current = currentScrollTop; + return; + } + + // Content shrank: preserve the current visual anchor by extending the footer + // when the user does not already have enough distance from the bottom. + const shrinkAmount = -heightDelta; + const collapseIntent = pendingCollapseIntentRef.current; + const now = performance.now(); + const hasValidCollapseIntent = collapseIntent.active && collapseIntent.expiresAtMs >= now; + const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - currentTotalCompensation); + const fallbackAdditionalCompensation = Math.max(0, shrinkAmount - effectiveDistanceFromBottom); + const cumulativeShrinkPx = hasValidCollapseIntent + ? collapseIntent.cumulativeShrinkPx + shrinkAmount + : 0; + const resolvedIntentCompensation = hasValidCollapseIntent + ? collapseIntent.baseTotalCompensationPx + Math.max(0, cumulativeShrinkPx - collapseIntent.distanceFromBottomBeforeCollapse) + : 0; + const nextTotalCompensation = hasValidCollapseIntent + ? ( + layoutTransitionCountRef.current > 0 + ? Math.max(currentTotalCompensation, resolvedIntentCompensation) + : resolvedIntentCompensation + ) + : currentTotalCompensation + fallbackAdditionalCompensation; + if (hasValidCollapseIntent) { + pendingCollapseIntentRef.current = { + ...collapseIntent, + cumulativeShrinkPx, + }; + } + const nextReservationState: BottomReservationState = { + ...bottomReservationStateRef.current, + collapse: { + ...bottomReservationStateRef.current.collapse, + px: Math.max(0, nextTotalCompensation - getReservationTotalPx(bottomReservationStateRef.current.pin)), + floorPx: 0, + }, + }; + updateBottomReservationState(nextReservationState); + if (nextTotalCompensation > COMPENSATION_EPSILON_PX) { + const anchorTarget = + hasValidCollapseIntent + ? collapseIntent.anchorScrollTop + : previousScrollTop; + + activateAnchorLock( + anchorTarget, + layoutTransitionCountRef.current > 0 ? 'transition-shrink' : 'instant-shrink' + ); + applyFooterCompensationNow(nextReservationState); + restoreAnchorLockNow('measure-shrink'); + if (layoutTransitionCountRef.current === 0) { + pendingCollapseIntentRef.current = { + active: false, + anchorScrollTop: 0, + toolId: null, + toolName: null, + expiresAtMs: 0, + distanceFromBottomBeforeCollapse: 0, + baseTotalCompensationPx: 0, + cumulativeShrinkPx: 0, + }; + } + } + + previousScrollTopRef.current = currentScrollTop; + }, [ + activateAnchorLock, + applyFooterCompensationNow, + consumeBottomCompensation, + getTotalBottomCompensationPx, + restoreAnchorLockNow, + updateBottomReservationState, + ]); + + const scheduleHeightMeasure = useCallback((frames: number = 1) => { + if (measureFrameRef.current !== null) { + cancelAnimationFrame(measureFrameRef.current); + measureFrameRef.current = null; + } + + const run = (remainingFrames: number) => { + measureFrameRef.current = requestAnimationFrame(() => { + if (remainingFrames > 1) { + run(remainingFrames - 1); + return; + } + + measureFrameRef.current = null; + measureHeightChange(); + }); + }; + + run(Math.max(1, frames)); + }, [measureHeightChange]); + const userMessageItems = React.useMemo(() => { return virtualItems .map((item, index) => ({ item, index })) .filter(({ item }) => item.type === 'user-message'); }, [virtualItems]); - // ── Visible turn info (range-changed callback) ─────────────────────── - const handleRangeChanged = useCallback((range: ListRange) => { + const visibleTurnInfoByTurnId = React.useMemo(() => { + const infoMap = new Map(); + + userMessageItems.forEach(({ item }, index) => { + if (item.type !== 'user-message') return; + + infoMap.set(item.turnId, { + turnIndex: index + 1, + totalTurns: userMessageItems.length, + userMessage: item.data?.content || '', + turnId: item.turnId, + }); + }); + + return infoMap; + }, [userMessageItems]); + + const measureVisibleTurn = useCallback(() => { const setVisibleTurnInfo = useModernFlowChatStore.getState().setVisibleTurnInfo; + const currentVisibleTurnInfo = useModernFlowChatStore.getState().visibleTurnInfo; if (userMessageItems.length === 0) { - setVisibleTurnInfo(null); + if (currentVisibleTurnInfo !== null) { + setVisibleTurnInfo(null); + } return; } - const visibleUserMessage = userMessageItems.find(({ index }) => - index >= range.startIndex && index <= range.endIndex + const scroller = scrollerElementRef.current; + if (!scroller) { + const fallbackInfo = visibleTurnInfoByTurnId.get(userMessageItems[0]?.item.turnId ?? '') ?? null; + if ( + currentVisibleTurnInfo?.turnId !== fallbackInfo?.turnId || + currentVisibleTurnInfo?.turnIndex !== fallbackInfo?.turnIndex || + currentVisibleTurnInfo?.totalTurns !== fallbackInfo?.totalTurns || + currentVisibleTurnInfo?.userMessage !== fallbackInfo?.userMessage + ) { + setVisibleTurnInfo(fallbackInfo); + } + return; + } + + const scrollerRect = scroller.getBoundingClientRect(); + const viewportTop = scrollerRect.top + PINNED_TURN_VIEWPORT_OFFSET_PX; + const viewportBottom = scrollerRect.bottom; + const renderedItems = Array.from( + scroller.querySelectorAll('.virtual-item-wrapper[data-turn-id]') ); - const targetMessage = visibleUserMessage || - [...userMessageItems].reverse().find(({ index }) => index < range.startIndex); - if (targetMessage) { - const turnIndex = userMessageItems.indexOf(targetMessage) + 1; - const userMessage = targetMessage.item.type === 'user-message' - ? targetMessage.item.data - : null; + const topVisibleItem = renderedItems.find(node => { + const rect = node.getBoundingClientRect(); + return rect.bottom > viewportTop && rect.top < viewportBottom; + }); - setVisibleTurnInfo({ - turnIndex, - totalTurns: userMessageItems.length, - userMessage: userMessage?.content || '', - turnId: targetMessage.item.turnId, + const nextTurnId = topVisibleItem?.dataset.turnId ?? userMessageItems[0]?.item.turnId ?? null; + const nextInfo = nextTurnId ? (visibleTurnInfoByTurnId.get(nextTurnId) ?? null) : null; + + if ( + currentVisibleTurnInfo?.turnId === nextInfo?.turnId && + currentVisibleTurnInfo?.turnIndex === nextInfo?.turnIndex && + currentVisibleTurnInfo?.totalTurns === nextInfo?.totalTurns && + currentVisibleTurnInfo?.userMessage === nextInfo?.userMessage + ) { + return; + } + + setVisibleTurnInfo(nextInfo); + }, [userMessageItems, visibleTurnInfoByTurnId]); + + const scheduleVisibleTurnMeasure = useCallback((frames: number = 1) => { + if (visibleTurnMeasureFrameRef.current !== null) { + cancelAnimationFrame(visibleTurnMeasureFrameRef.current); + visibleTurnMeasureFrameRef.current = null; + } + + const run = (remainingFrames: number) => { + visibleTurnMeasureFrameRef.current = requestAnimationFrame(() => { + if (remainingFrames > 1) { + run(remainingFrames - 1); + return; + } + + visibleTurnMeasureFrameRef.current = null; + measureVisibleTurn(); }); + }; + + run(Math.max(1, frames)); + }, [measureVisibleTurn]); + + const getRenderedUserMessageElement = useCallback((turnId: string) => { + const scroller = scrollerElementRef.current; + if (!scroller) return null; + + return scroller.querySelector( + `.virtual-item-wrapper[data-item-type="user-message"][data-turn-id="${turnId}"]`, + ); + }, []); + + const buildPinReservation = useCallback(( + turnId: string, + pinMode: FlowChatPinTurnToTopMode, + requiredTailSpacePx: number, + currentPinReservation: PinBottomReservation = bottomReservationStateRef.current.pin, + ): PinBottomReservation => { + const resolvedRequiredTailSpacePx = sanitizeReservationPx(requiredTailSpacePx); + const nextFloorPx = pinMode === 'sticky-latest' + ? resolvedRequiredTailSpacePx + : 0; + const shouldPreserveCurrentPx = ( + currentPinReservation.mode === pinMode && + currentPinReservation.targetTurnId === turnId + ); + const preservedPx = shouldPreserveCurrentPx ? currentPinReservation.px : 0; + const additiveRetryPx = ( + shouldPreserveCurrentPx && + pinMode === 'transient' && + resolvedRequiredTailSpacePx > COMPENSATION_EPSILON_PX + ) + ? currentPinReservation.px + resolvedRequiredTailSpacePx + : 0; + const shouldRetainTarget = ( + pinMode === 'sticky-latest' || + resolvedRequiredTailSpacePx > COMPENSATION_EPSILON_PX || + shouldPreserveCurrentPx + ); + + return { + kind: 'pin', + px: Math.max(nextFloorPx, resolvedRequiredTailSpacePx, preservedPx, additiveRetryPx), + floorPx: nextFloorPx, + mode: pinMode, + targetTurnId: shouldRetainTarget ? turnId : null, + }; + }, []); + + const resolveTurnPinMetrics = useCallback((turnId: string, ignoredTailSpacePx: number = 0) => { + const scroller = scrollerElementRef.current; + if (!scroller) return null; + + const targetElement = getRenderedUserMessageElement(turnId); + if (!targetElement) return null; + + const scrollerRect = scroller.getBoundingClientRect(); + const targetRect = targetElement.getBoundingClientRect(); + const viewportTop = scrollerRect.top + PINNED_TURN_VIEWPORT_OFFSET_PX; + const desiredScrollTop = Math.max(0, scroller.scrollTop + (targetRect.top - viewportTop)); + const effectiveScrollHeight = Math.max(0, scroller.scrollHeight - Math.max(0, ignoredTailSpacePx)); + const maxScrollTop = Math.max(0, effectiveScrollHeight - scroller.clientHeight); + const missingTailSpace = Math.max(0, desiredScrollTop - maxScrollTop); + + return { + targetElement, + viewportTop, + desiredScrollTop, + maxScrollTop, + missingTailSpace, + }; + }, [getRenderedUserMessageElement]); + + const reconcileStickyPinReservation = useCallback(() => { + const scroller = scrollerElementRef.current; + const currentState = bottomReservationStateRef.current; + const pinReservation = currentState.pin; + if (!scroller || pinReservation.mode !== 'sticky-latest' || !pinReservation.targetTurnId) { + return false; } - }, [userMessageItems]); - useEffect(() => { - const setVisibleTurnInfo = useModernFlowChatStore.getState().setVisibleTurnInfo; + const collapseIntent = pendingCollapseIntentRef.current; + const hasActiveCollapseTransition = ( + layoutTransitionCountRef.current > 0 && + collapseIntent.active && + collapseIntent.expiresAtMs >= performance.now() + ); + // During a collapse animation, let collapse compensation own the footer space. + // Recomputing sticky pin floor from intermediate DOM heights causes the two + // reservations to fight each other and reintroduces visible vertical jitter. + if (hasActiveCollapseTransition) { + return false; + } + + const resolvedMetrics = resolveTurnPinMetrics( + pinReservation.targetTurnId, + pinReservation.px, + ); + if (!resolvedMetrics) { + return false; + } + + const requiredFloorPx = sanitizeReservationPx(resolvedMetrics.missingTailSpace); + const hadOnlyFloor = pinReservation.px <= pinReservation.floorPx + COMPENSATION_EPSILON_PX; + const nextPinPx = hadOnlyFloor + ? requiredFloorPx + : Math.max(requiredFloorPx, pinReservation.px); + const nextPinReservation: PinBottomReservation = { + ...pinReservation, + px: nextPinPx, + floorPx: requiredFloorPx, + }; + + if ( + Math.abs(nextPinReservation.px - pinReservation.px) <= COMPENSATION_EPSILON_PX && + Math.abs(nextPinReservation.floorPx - pinReservation.floorPx) <= COMPENSATION_EPSILON_PX + ) { + return false; + } + + const nextState: BottomReservationState = { + ...currentState, + pin: nextPinReservation, + }; + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + previousMeasuredHeightRef.current = Math.max( + 0, + scroller.scrollHeight - getTotalBottomCompensationPx(nextState), + ); + return true; + }, [ + applyFooterCompensationNow, + getTotalBottomCompensationPx, + resolveTurnPinMetrics, + updateBottomReservationState, + ]); + + const schedulePinReservationReconcile = useCallback((frames: number = 1) => { + if (pinReservationReconcileFrameRef.current !== null) { + cancelAnimationFrame(pinReservationReconcileFrameRef.current); + pinReservationReconcileFrameRef.current = null; + } + + const run = (remainingFrames: number) => { + pinReservationReconcileFrameRef.current = requestAnimationFrame(() => { + if (remainingFrames > 1) { + run(remainingFrames - 1); + return; + } + + pinReservationReconcileFrameRef.current = null; + reconcileStickyPinReservation(); + }); + }; + + run(Math.max(1, frames)); + }, [reconcileStickyPinReservation]); + + const tryResolvePendingTurnPin = useCallback((request: PendingTurnPinState) => { + const scroller = scrollerElementRef.current; + const virtuoso = virtuosoRef.current; + if (!scroller || !virtuoso) return false; + + const targetItem = userMessageItems.find(({ item }) => item.turnId === request.turnId); + if (!targetItem) return false; + + const currentPinReservation = bottomReservationStateRef.current.pin; + // Existing pin tail space is synthetic footer reservation, not real content. + // Ignore it when resolving a new pin target so maxScrollTop is computed against + // the effective content height instead of the previous pin reservation. + let ignoredTailSpacePx = 0; + if (currentPinReservation.px > COMPENSATION_EPSILON_PX) { + ignoredTailSpacePx = currentPinReservation.px; + } + const resolvedMetrics = resolveTurnPinMetrics(request.turnId, ignoredTailSpacePx); + if (!resolvedMetrics) { + virtuoso.scrollToIndex({ + index: targetItem.index, + align: 'start', + behavior: request.attempts === 0 && request.behavior === 'smooth' ? 'smooth' : 'auto', + }); + return false; + } - if (userMessageItems.length > 0) { - const firstMessage = userMessageItems[0]; - const userMessage = firstMessage.item.type === 'user-message' - ? firstMessage.item.data + const nextReservationState: BottomReservationState = { + ...bottomReservationStateRef.current, + pin: buildPinReservation( + request.turnId, + request.pinMode, + resolvedMetrics.missingTailSpace, + ), + }; + updateBottomReservationState(nextReservationState); + applyFooterCompensationNow(nextReservationState); + + const resolvedMaxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const targetScrollTop = Math.min(resolvedMetrics.desiredScrollTop, resolvedMaxScrollTop); + if (Math.abs(scroller.scrollTop - targetScrollTop) > COMPENSATION_EPSILON_PX) { + scroller.scrollTop = targetScrollTop; + } + + // Some turn jumps align correctly at first, then drift on the next frame as + // Virtuoso finishes layout stabilization. Re-check the live DOM before we + // decide the pin has truly settled. + const verifyPinAlignment = (frameLabel: string) => { + const liveTargetElement = getRenderedUserMessageElement(request.turnId); + const liveRect = liveTargetElement?.getBoundingClientRect(); + const viewportTop = liveTargetElement + ? scroller.getBoundingClientRect().top + PINNED_TURN_VIEWPORT_OFFSET_PX + : null; + const deltaToViewportTop = liveRect && viewportTop != null + ? liveRect.top - viewportTop : null; - setVisibleTurnInfo({ - turnIndex: 1, - totalTurns: userMessageItems.length, - userMessage: userMessage?.content || '', - turnId: firstMessage.item.turnId, + const stickyPinStillTargetsRequest = ( + bottomReservationStateRef.current.pin.mode === 'sticky-latest' && + bottomReservationStateRef.current.pin.targetTurnId === request.turnId + ); + // Only correct post-layout drift for the active jump target, and only when + // the user has not already moved away from the original scroll position. + const shouldRealign = ( + frameLabel !== 'immediate' && + deltaToViewportTop != null && + Math.abs(deltaToViewportTop) > 1.5 && + Math.abs(scroller.scrollTop - targetScrollTop) <= 2 && + ( + request.pinMode === 'transient' || + stickyPinStillTargetsRequest + ) + ); + if (!shouldRealign) { + return; + } + + const correctedMaxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const correctedScrollTop = Math.min( + correctedMaxScrollTop, + Math.max(0, scroller.scrollTop + deltaToViewportTop), + ); + if (Math.abs(correctedScrollTop - scroller.scrollTop) <= COMPENSATION_EPSILON_PX) { + return; + } + + scroller.scrollTop = correctedScrollTop; + previousScrollTopRef.current = correctedScrollTop; + previousMeasuredHeightRef.current = Math.max( + 0, + scroller.scrollHeight - getTotalBottomCompensationPx(bottomReservationStateRef.current), + ); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }; + verifyPinAlignment('immediate'); + // The observed drift lands after the initial alignment, so sample two + // follow-up frames and realign only if the target actually shifts. + requestAnimationFrame(() => { + verifyPinAlignment('raf-1'); + requestAnimationFrame(() => { + verifyPinAlignment('raf-2'); }); - } else { + }); + + previousScrollTopRef.current = targetScrollTop; + previousMeasuredHeightRef.current = Math.max( + 0, + scroller.scrollHeight - getTotalBottomCompensationPx(nextReservationState), + ); + + const alignedRect = resolvedMetrics.targetElement.getBoundingClientRect(); + return Math.abs(alignedRect.top - resolvedMetrics.viewportTop) <= 1.5; + }, [ + buildPinReservation, + applyFooterCompensationNow, + getTotalBottomCompensationPx, + resolveTurnPinMetrics, + schedulePinReservationReconcile, + scheduleVisibleTurnMeasure, + updateBottomReservationState, + userMessageItems, + ]); + + const handleScrollerRef = useCallback((el: HTMLElement | Window | null) => { + if (el && el instanceof HTMLElement) { + scrollerElementRef.current = el; + setScrollerElement(el); + return; + } + + scrollerElementRef.current = null; + setScrollerElement(null); + }, []); + + useEffect(() => { + previousMeasuredHeightRef.current = null; + previousScrollTopRef.current = 0; + setPendingTurnPin(null); + anchorLockRef.current = { + active: false, + targetScrollTop: 0, + reason: null, + lockUntilMs: 0, + }; + pendingCollapseIntentRef.current = { + active: false, + anchorScrollTop: 0, + toolId: null, + toolName: null, + expiresAtMs: 0, + distanceFromBottomBeforeCollapse: 0, + baseTotalCompensationPx: 0, + cumulativeShrinkPx: 0, + }; + resetBottomReservations(); + }, [activeSession?.sessionId, resetBottomReservations]); + + useEffect(() => { + if (virtualItems.length === 0) { + previousMeasuredHeightRef.current = null; + setPendingTurnPin(null); + resetBottomReservations(); + } + }, [virtualItems.length, resetBottomReservations]); + + useEffect(() => { + if (!scrollerElement) { + previousMeasuredHeightRef.current = null; + return; + } + + const resizeTarget = + scrollerElement.firstElementChild instanceof HTMLElement + ? scrollerElement.firstElementChild + : scrollerElement; + + previousMeasuredHeightRef.current = Math.max( + 0, + scrollerElement.scrollHeight - getTotalBottomCompensationPx() + ); + previousScrollTopRef.current = scrollerElement.scrollTop; + + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = new ResizeObserver(() => { + scheduleHeightMeasure(); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }); + resizeObserverRef.current.observe(resizeTarget); + + mutationObserverRef.current?.disconnect(); + mutationObserverRef.current = new MutationObserver(() => { + scheduleHeightMeasure(2); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }); + mutationObserverRef.current.observe(scrollerElement, { + subtree: true, + childList: true, + characterData: true, + }); + + const isLayoutTransitionProperty = (propertyName: string) => ( + propertyName === 'grid-template-rows' || + propertyName === 'height' || + propertyName === 'max-height' + ); + + const handleTransitionRun = (event: TransitionEvent) => { + if (!isLayoutTransitionProperty(event.propertyName)) return; + layoutTransitionCountRef.current += 1; + }; + + const handleTransitionFinish = (event: TransitionEvent) => { + if (!isLayoutTransitionProperty(event.propertyName)) return; + layoutTransitionCountRef.current = Math.max(0, layoutTransitionCountRef.current - 1); + scheduleHeightMeasure(2); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }; + scrollerElement.addEventListener('transitionrun', handleTransitionRun, true); + scrollerElement.addEventListener('transitionend', handleTransitionFinish, true); + scrollerElement.addEventListener('transitioncancel', handleTransitionFinish, true); + + const handleScroll = () => { + const now = performance.now(); + if (anchorLockRef.current.active && now > anchorLockRef.current.lockUntilMs && layoutTransitionCountRef.current === 0) { + releaseAnchorLock('expired-before-scroll'); + } + + const currentTotalCompensation = getTotalBottomCompensationPx(); + if ( + currentTotalCompensation > COMPENSATION_EPSILON_PX && + !anchorLockRef.current.active && + layoutTransitionCountRef.current === 0 + ) { + const nextScrollTop = scrollerElement.scrollTop; + const scrollDelta = nextScrollTop - previousScrollTopRef.current; + if (scrollDelta > COMPENSATION_EPSILON_PX) { + const nextCompensationState = consumeBottomCompensation(scrollDelta); + applyFooterCompensationNow(nextCompensationState); + previousMeasuredHeightRef.current = Math.max( + 0, + scrollerElement.scrollHeight - getTotalBottomCompensationPx(nextCompensationState), + ); + } + } + + if (getTotalBottomCompensationPx() > COMPENSATION_EPSILON_PX) { + const nextScrollTop = scrollerElement.scrollTop; + const maxScrollTop = Math.max(0, scrollerElement.scrollHeight - scrollerElement.clientHeight); + if (anchorLockRef.current.active && performance.now() <= anchorLockRef.current.lockUntilMs) { + const targetScrollTop = Math.min(anchorLockRef.current.targetScrollTop, maxScrollTop); + const restoreDelta = targetScrollTop - nextScrollTop; + if (Math.abs(restoreDelta) > ANCHOR_LOCK_MIN_DEVIATION_PX) { + scrollerElement.scrollTop = targetScrollTop; + previousScrollTopRef.current = targetScrollTop; + return; + } + } + } + previousScrollTopRef.current = scrollerElement.scrollTop; + scheduleVisibleTurnMeasure(); + + if (anchorLockRef.current.active && performance.now() > anchorLockRef.current.lockUntilMs && layoutTransitionCountRef.current === 0) { + releaseAnchorLock('expired-after-scroll'); + } + }; + scrollerElement.addEventListener('scroll', handleScroll, { passive: true }); + + const handleToolCardToggle = () => { + scheduleHeightMeasure(2); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }; + + const handleToolCardCollapseIntent = (event: Event) => { + const detail = (event as CustomEvent<{ + toolId?: string | null; + toolName?: string | null; + cardHeight?: number | null; + filePath?: string | null; + reason?: string | null; + }>).detail; + const baseTotalCompensationPx = getTotalBottomCompensationPx(); + const distanceFromBottom = Math.max( + 0, + scrollerElement.scrollHeight - scrollerElement.clientHeight - scrollerElement.scrollTop + ); + const effectiveDistanceFromBottom = Math.max(0, distanceFromBottom - baseTotalCompensationPx); + const estimatedShrink = Math.max(0, detail?.cardHeight ?? 0); + const provisionalTotalCompensationPx = Math.max( + 0, + baseTotalCompensationPx + Math.max(0, estimatedShrink - effectiveDistanceFromBottom) + ); + pendingCollapseIntentRef.current = { + active: true, + anchorScrollTop: scrollerElement.scrollTop, + toolId: detail?.toolId ?? null, + toolName: detail?.toolName ?? null, + expiresAtMs: performance.now() + 1000, + distanceFromBottomBeforeCollapse: effectiveDistanceFromBottom, + baseTotalCompensationPx, + cumulativeShrinkPx: 0, + }; + if (provisionalTotalCompensationPx - baseTotalCompensationPx > COMPENSATION_EPSILON_PX) { + const nextReservationState: BottomReservationState = { + ...bottomReservationStateRef.current, + collapse: { + ...bottomReservationStateRef.current.collapse, + px: Math.max(0, provisionalTotalCompensationPx - getReservationTotalPx(bottomReservationStateRef.current.pin)), + floorPx: 0, + }, + }; + updateBottomReservationState(nextReservationState); + applyFooterCompensationNow(nextReservationState); + activateAnchorLock(scrollerElement.scrollTop, 'instant-shrink'); + } + + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }; + + window.addEventListener('tool-card-toggle', handleToolCardToggle); + window.addEventListener('flowchat:tool-card-collapse-intent', handleToolCardCollapseIntent as EventListener); + scheduleVisibleTurnMeasure(2); + + return () => { + scrollerElement.removeEventListener('transitionrun', handleTransitionRun, true); + scrollerElement.removeEventListener('transitionend', handleTransitionFinish, true); + scrollerElement.removeEventListener('transitioncancel', handleTransitionFinish, true); + scrollerElement.removeEventListener('scroll', handleScroll); + window.removeEventListener('tool-card-toggle', handleToolCardToggle); + window.removeEventListener('flowchat:tool-card-collapse-intent', handleToolCardCollapseIntent as EventListener); + resizeObserverRef.current?.disconnect(); + resizeObserverRef.current = null; + mutationObserverRef.current?.disconnect(); + mutationObserverRef.current = null; + + if (measureFrameRef.current !== null) { + cancelAnimationFrame(measureFrameRef.current); + measureFrameRef.current = null; + } + + if (visibleTurnMeasureFrameRef.current !== null) { + cancelAnimationFrame(visibleTurnMeasureFrameRef.current); + visibleTurnMeasureFrameRef.current = null; + } + + if (pinReservationReconcileFrameRef.current !== null) { + cancelAnimationFrame(pinReservationReconcileFrameRef.current); + pinReservationReconcileFrameRef.current = null; + } + }; + }, [ + activateAnchorLock, + applyFooterCompensationNow, + consumeBottomCompensation, + getTotalBottomCompensationPx, + releaseAnchorLock, + restoreAnchorLockNow, + scheduleHeightMeasure, + schedulePinReservationReconcile, + scheduleVisibleTurnMeasure, + scrollerElement, + updateBottomReservationState, + ]); + + // `rangeChanged` is affected by overscan/increaseViewportBy, so treat it as a + // "rendered DOM changed" signal and derive the pinned turn from real DOM visibility. + const handleRangeChanged = useCallback(() => { + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }, [schedulePinReservationReconcile, scheduleVisibleTurnMeasure]); + + useEffect(() => { + if (userMessageItems.length === 0) { + const setVisibleTurnInfo = useModernFlowChatStore.getState().setVisibleTurnInfo; setVisibleTurnInfo(null); + return; } - }, [userMessageItems.length]); + + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }, [activeSession?.sessionId, schedulePinReservationReconcile, scheduleVisibleTurnMeasure, scrollerElement, userMessageItems, virtualItems.length]); + + useEffect(() => { + if (!pendingTurnPin) return; + + if (performance.now() > pendingTurnPin.expiresAtMs) { + setPendingTurnPin(null); + return; + } + + const frameId = requestAnimationFrame(() => { + const resolved = tryResolvePendingTurnPin(pendingTurnPin); + if (resolved) { + setPendingTurnPin(null); + scheduleVisibleTurnMeasure(2); + return; + } + + setPendingTurnPin(prev => { + if (!prev || prev.turnId !== pendingTurnPin.turnId) { + return prev; + } + + return { + ...prev, + attempts: prev.attempts + 1, + behavior: 'auto', + }; + }); + }); + + return () => { + cancelAnimationFrame(frameId); + }; + }, [pendingTurnPin, scheduleVisibleTurnMeasure, tryResolvePendingTurnPin]); // ── Navigation helpers ──────────────────────────────────────────────── + const clearPinReservationForUserNavigation = useCallback(() => { + const currentState = bottomReservationStateRef.current; + const scroller = scrollerElementRef.current; + const hasActivePin = ( + currentState.pin.px > COMPENSATION_EPSILON_PX || + currentState.pin.floorPx > COMPENSATION_EPSILON_PX || + currentState.pin.targetTurnId !== null || + currentState.pin.mode !== 'transient' + ); + + releaseAnchorLock('user-navigation'); + setPendingTurnPin(null); + + if (!hasActivePin) { + return; + } + + const nextReservationState: BottomReservationState = { + ...currentState, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }; + updateBottomReservationState(nextReservationState); + applyFooterCompensationNow(nextReservationState); + + if (scroller) { + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = Math.max( + 0, + scroller.scrollHeight - getTotalBottomCompensationPx(nextReservationState), + ); + } + }, [ + applyFooterCompensationNow, + getTotalBottomCompensationPx, + releaseAnchorLock, + updateBottomReservationState, + ]); + const scrollToTurn = useCallback((turnIndex: number) => { if (!virtuosoRef.current) return; if (turnIndex < 1 || turnIndex > userMessageItems.length) return; @@ -115,6 +1172,8 @@ export const VirtualMessageList = forwardRef((_, ref) => const targetItem = userMessageItems[turnIndex - 1]; if (!targetItem) return; + clearPinReservationForUserNavigation(); + if (targetItem.index === 0) { virtuosoRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } else { @@ -124,46 +1183,59 @@ export const VirtualMessageList = forwardRef((_, ref) => align: 'center', }); } - }, [userMessageItems]); + }, [clearPinReservationForUserNavigation, userMessageItems]); const scrollToIndex = useCallback((index: number) => { if (!virtuosoRef.current) return; if (index < 0 || index >= virtualItems.length) return; + clearPinReservationForUserNavigation(); + if (index === 0) { virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); } else { virtuosoRef.current.scrollToIndex({ index, align: 'center', behavior: 'auto' }); } - }, [virtualItems.length]); + }, [clearPinReservationForUserNavigation, virtualItems.length]); + + const pinTurnToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { + const targetItem = userMessageItems.find(({ item }) => item.turnId === turnId); + if (!targetItem || !virtuosoRef.current) { + return false; + } - const scrollToBottom = useCallback(() => { + setPendingTurnPin({ + turnId, + behavior: options?.behavior ?? 'auto', + pinMode: options?.pinMode ?? 'transient', + expiresAtMs: performance.now() + 1500, + attempts: 0, + }); + return true; + }, [userMessageItems]); + + const scrollToPhysicalBottomAndClearPin = useCallback(() => { if (virtuosoRef.current && virtualItems.length > 0) { + clearPinReservationForUserNavigation(); virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'smooth' }); } - }, [virtualItems.length]); + }, [clearPinReservationForUserNavigation, virtualItems.length]); + + const scrollToLatestEndPosition = useCallback(() => { + if (virtuosoRef.current && virtualItems.length > 0) { + releaseAnchorLock('scroll-to-latest'); + setPendingTurnPin(null); + virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'smooth' }); + } + }, [releaseAnchorLock, virtualItems.length]); useImperativeHandle(ref, () => ({ scrollToTurn, scrollToIndex, - scrollToBottom, - }), [scrollToTurn, scrollToIndex, scrollToBottom]); - - // ── Initial scroll to bottom when processing starts ────────────────── - // Note: followOutput handles continuous auto-scroll, so we only need - // an initial scroll here. The 300ms interval was removed because it - // conflicted with followOutput and caused visual jitter. - useEffect(() => { - if (!isProcessing) return; - - if (virtuosoRef.current) { - virtuosoRef.current.scrollTo({ top: 999999999, behavior: 'auto' }); - } - }, [isProcessing]); - - const handleFollowOutput = useCallback(() => { - return isProcessing ? 'smooth' as const : false; - }, [isProcessing]); + scrollToPhysicalBottomAndClearPin, + scrollToLatestEndPosition, + pinTurnToTop, + }), [pinTurnToTop, scrollToTurn, scrollToIndex, scrollToPhysicalBottomAndClearPin, scrollToLatestEndPosition]); const handleAtBottomStateChange = useCallback((atBottom: boolean) => { setIsAtBottom(atBottom); @@ -252,6 +1324,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return true; }, [lastItemInfo.isTurnProcessing, isProcessing, processingPhase]); + const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx(bottomReservationState)); + // ── Render ──────────────────────────────────────────────────────────── if (virtualItems.length === 0) { return ( @@ -277,7 +1351,7 @@ export const VirtualMessageList = forwardRef((_, ref) => index={index} /> )} - followOutput={handleFollowOutput} + followOutput={false} alignToBottom={false} initialTopMostItemIndex={0} @@ -300,20 +1374,29 @@ export const VirtualMessageList = forwardRef((_, ref) => Footer: () => ( <> -
+
), }} /> { + pinTurnToTop(turnId, { behavior: 'smooth' }); + }} scrollerRef={scrollerElementRef} /> 0} - onClick={scrollToBottom} + visible={!isAtBottom && virtualItems.length > 0} + onClick={scrollToLatestEndPosition} isInputActive={isInputActive} isInputExpanded={isInputExpanded} /> diff --git a/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts new file mode 100644 index 00000000..f429068b --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useExploreGroupState.ts @@ -0,0 +1,58 @@ +/** + * Explore-group expansion state for Modern FlowChat. + */ + +import { useCallback, useState } from 'react'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; + +type ExploreGroupVirtualItem = Extract; + +interface UseExploreGroupStateResult { + exploreGroupStates: Map; + onExploreGroupToggle: (groupId: string) => void; + onExpandAllInTurn: (turnId: string) => void; + onCollapseGroup: (groupId: string) => void; +} + +export function useExploreGroupState( + virtualItems: VirtualItem[], +): UseExploreGroupStateResult { + const [exploreGroupStates, setExploreGroupStates] = useState>(new Map()); + + const onExploreGroupToggle = useCallback((groupId: string) => { + setExploreGroupStates(prev => { + const next = new Map(prev); + next.set(groupId, !prev.get(groupId)); + return next; + }); + }, []); + + const onExpandAllInTurn = useCallback((turnId: string) => { + const groupIds = virtualItems + .filter((item): item is ExploreGroupVirtualItem => ( + item.type === 'explore-group' && item.turnId === turnId + )) + .map(item => item.data.groupId); + + setExploreGroupStates(prev => { + const next = new Map(prev); + [...new Set(groupIds)].forEach(id => next.set(id, true)); + return next; + }); + }, [virtualItems]); + + const onCollapseGroup = useCallback((groupId: string) => { + setExploreGroupStates(prev => { + const next = new Map(prev); + next.set(groupId, false); + return next; + }); + }, []); + + return { + exploreGroupStates, + onExploreGroupToggle, + onExpandAllInTurn, + onCollapseGroup, + }; +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts new file mode 100644 index 00000000..2264e190 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatCopyDialog.ts @@ -0,0 +1,38 @@ +/** + * Copy-dialog event handling for FlowChat. + */ + +import { useEffect } from 'react'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { notificationService } from '@/shared/notification-system'; +import { getElementText, copyTextToClipboard } from '@/shared/utils/textSelection'; +import { createLogger } from '@/shared/utils/logger'; + +const log = createLogger('useFlowChatCopyDialog'); + +export function useFlowChatCopyDialog(): void { + useEffect(() => { + const unsubscribe = globalEventBus.on('flowchat:copy-dialog', ({ dialogTurn }) => { + if (!dialogTurn) { + log.warn('Copy failed: dialog element not provided'); + return; + } + + const dialogElement = dialogTurn as HTMLElement; + const fullText = getElementText(dialogElement); + + if (!fullText || fullText.trim().length === 0) { + notificationService.warning('Dialog is empty, nothing to copy'); + return; + } + + copyTextToClipboard(fullText).then(success => { + if (!success) { + notificationService.error('Copy failed. Please try again.'); + } + }); + }); + + return unsubscribe; + }, []); +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFileActions.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFileActions.ts new file mode 100644 index 00000000..71e5c2aa --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFileActions.ts @@ -0,0 +1,68 @@ +/** + * File navigation actions for Modern FlowChat. + */ + +import { useCallback } from 'react'; +import path from 'path-browserify'; +import { createLogger } from '@/shared/utils/logger'; +import { notificationService } from '@/shared/notification-system'; +import { fileTabManager } from '@/shared/services/FileTabManager'; +import type { LineRange } from '@/component-library'; + +const log = createLogger('useFlowChatFileActions'); + +interface UseFlowChatFileActionsOptions { + workspacePath?: string; + onFileViewRequest?: (filePath: string, fileName: string, lineRange?: LineRange) => void; +} + +export function useFlowChatFileActions({ + workspacePath, + onFileViewRequest, +}: UseFlowChatFileActionsOptions) { + const handleFileViewRequest = useCallback(( + filePath: string, + fileName: string, + lineRange?: LineRange, + ) => { + log.debug('File view request', { + filePath, + fileName, + hasLineRange: !!lineRange, + hasExternalCallback: !!onFileViewRequest, + }); + + if (onFileViewRequest) { + onFileViewRequest(filePath, fileName, lineRange); + return; + } + + let absoluteFilePath = filePath; + const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(filePath); + + if (!isWindowsAbsolutePath && !path.isAbsolute(filePath) && workspacePath) { + absoluteFilePath = path.join(workspacePath, filePath); + log.debug('Converted relative path to absolute', { + relative: filePath, + absolute: absoluteFilePath, + }); + } + + try { + fileTabManager.openFile({ + filePath: absoluteFilePath, + fileName, + workspacePath, + jumpToRange: lineRange, + mode: 'agent', + }); + } catch (error) { + log.error('File navigation failed', error); + notificationService.error(`Unable to open file: ${absoluteFilePath}`); + } + }, [onFileViewRequest, workspacePath]); + + return { + handleFileViewRequest, + }; +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts new file mode 100644 index 00000000..6887d527 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatNavigation.ts @@ -0,0 +1,218 @@ +/** + * FlowChat navigation side effects. + * + * Handles cross-session focus requests and turn pinning events for the modern + * virtualized list. + */ + +import { useEffect, useState, type RefObject } from 'react'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { createLogger } from '@/shared/utils/logger'; +import { flowChatStore } from '../../store/FlowChatStore'; +import { useModernFlowChatStore, type VirtualItem } from '../../store/modernFlowChatStore'; +import { flowChatManager } from '../../services/FlowChatManager'; +import { + FLOWCHAT_FOCUS_ITEM_EVENT, + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatFocusItemRequest, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; +import type { VirtualMessageListRef } from './VirtualMessageList'; + +const log = createLogger('useFlowChatNavigation'); + +interface UseFlowChatNavigationOptions { + activeSessionId?: string; + virtualItems: VirtualItem[]; + virtualListRef: RefObject; +} + +interface ResolvedFocusTarget { + resolvedVirtualIndex?: number; + resolvedTurnId?: string; + resolvedTurnIndex?: number; + preferPinnedTurnNavigation: boolean; +} + +async function waitForCondition(predicate: () => boolean, timeoutMs: number): Promise { + const start = performance.now(); + while (performance.now() - start < timeoutMs) { + if (predicate()) return true; + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } + return predicate(); +} + +async function waitForAnimationFrames(frameCount: number): Promise { + let remaining = Math.max(0, frameCount); + while (remaining > 0) { + await new Promise(resolve => requestAnimationFrame(() => resolve())); + remaining -= 1; + } +} + +function resolveFocusTarget( + request: FlowChatFocusItemRequest, + currentVirtualItems: VirtualItem[], +): ResolvedFocusTarget { + const { sessionId, turnIndex, itemId, source } = request; + let resolvedVirtualIndex: number | undefined = undefined; + let resolvedTurnIndex = turnIndex; + let resolvedTurnId: string | undefined = undefined; + const targetSession = flowChatStore.getState().sessions.get(sessionId); + + if (targetSession && turnIndex && turnIndex >= 1 && turnIndex <= targetSession.dialogTurns.length) { + resolvedTurnId = targetSession.dialogTurns[turnIndex - 1]?.id; + } + + if (itemId) { + if (targetSession) { + for (let i = 0; i < targetSession.dialogTurns.length; i += 1) { + const turn = targetSession.dialogTurns[i]; + const found = turn.modelRounds?.some(round => round.items?.some(item => item.id === itemId)); + if (found) { + resolvedTurnIndex = i + 1; + resolvedTurnId = turn.id; + break; + } + } + } + + for (let i = 0; i < currentVirtualItems.length; i += 1) { + const item = currentVirtualItems[i]; + if (item.type === 'model-round') { + const hit = item.data?.items?.some(flowItem => flowItem?.id === itemId); + if (hit) { + resolvedVirtualIndex = i; + break; + } + } else if (item.type === 'explore-group') { + const hit = item.data?.allItems?.some(flowItem => flowItem?.id === itemId); + if (hit) { + resolvedVirtualIndex = i; + break; + } + } + } + } + + return { + resolvedVirtualIndex, + resolvedTurnId, + resolvedTurnIndex, + preferPinnedTurnNavigation: source === 'btw-back', + }; +} + +function navigateToResolvedTarget( + virtualListRef: RefObject, + target: ResolvedFocusTarget, +): void { + const list = virtualListRef.current; + if (!list) return; + + if (target.preferPinnedTurnNavigation && target.resolvedTurnId) { + list.pinTurnToTop(target.resolvedTurnId, { behavior: 'auto' }); + return; + } + + if (target.resolvedVirtualIndex != null) { + list.scrollToIndex(target.resolvedVirtualIndex); + return; + } + + if (target.resolvedTurnIndex) { + list.scrollToTurn(target.resolvedTurnIndex); + } +} + +export function useFlowChatNavigation({ + activeSessionId, + virtualItems, + virtualListRef, +}: UseFlowChatNavigationOptions): void { + const [pendingTurnPinRequest, setPendingTurnPinRequest] = useState(null); + + useEffect(() => { + const unsubscribe = globalEventBus.on(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, (request) => { + if (!request || request.sessionId !== activeSessionId) { + return; + } + + setPendingTurnPinRequest(request); + }); + + return unsubscribe; + }, [activeSessionId]); + + useEffect(() => { + if (!pendingTurnPinRequest) return; + if (pendingTurnPinRequest.sessionId !== activeSessionId) { + setPendingTurnPinRequest(null); + return; + } + + const accepted = virtualListRef.current?.pinTurnToTop(pendingTurnPinRequest.turnId, { + behavior: pendingTurnPinRequest.behavior ?? 'auto', + pinMode: pendingTurnPinRequest.pinMode, + }) ?? false; + if (accepted) { + setPendingTurnPinRequest(null); + } + }, [activeSessionId, pendingTurnPinRequest, virtualItems, virtualListRef]); + + useEffect(() => { + const unsubscribe = globalEventBus.on(FLOWCHAT_FOCUS_ITEM_EVENT, async (request) => { + const { sessionId, itemId } = request; + if (!sessionId) return; + + if (activeSessionId !== sessionId) { + try { + await flowChatManager.switchChatSession(sessionId); + } catch (error) { + log.warn('Failed to switch session for focus request', { sessionId, error }); + return; + } + } + + await waitForCondition(() => { + const modernActiveSessionId = useModernFlowChatStore.getState().activeSession?.sessionId; + return modernActiveSessionId === sessionId && !!virtualListRef.current; + }, 1500); + + const resolvedTarget = resolveFocusTarget( + request, + useModernFlowChatStore.getState().virtualItems, + ); + + navigateToResolvedTarget(virtualListRef, resolvedTarget); + + if (!itemId) return; + + await waitForAnimationFrames(2); + + const maxAttempts = 120; + let attempts = 0; + const tryFocus = () => { + attempts += 1; + const element = document.querySelector(`[data-flow-item-id="${CSS.escape(itemId)}"]`) as HTMLElement | null; + if (!element) { + if (attempts % 12 === 0 && !resolvedTarget.preferPinnedTurnNavigation) { + navigateToResolvedTarget(virtualListRef, resolvedTarget); + } + if (attempts < maxAttempts) { + requestAnimationFrame(tryFocus); + } + return; + } + + element.classList.add('flowchat-flow-item--focused'); + window.setTimeout(() => element.classList.remove('flowchat-flow-item--focused'), 1600); + }; + + requestAnimationFrame(tryFocus); + }); + + return unsubscribe; + }, [activeSessionId, virtualListRef]); +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatSessionRelationship.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatSessionRelationship.ts new file mode 100644 index 00000000..01b6f850 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatSessionRelationship.ts @@ -0,0 +1,61 @@ +/** + * Derived BTW/session relationship state for FlowChat header UI. + */ + +import { useEffect, useMemo, useState } from 'react'; +import { flowChatStore } from '../../store/FlowChatStore'; +import type { Session } from '../../types/flow-chat'; +import { resolveSessionRelationship } from '../../utils/sessionMetadata'; + +interface UseFlowChatSessionRelationshipResult { + isBtwSession: boolean; + btwOrigin: Session['btwOrigin'] | null; + btwParentTitle: string; +} + +export function useFlowChatSessionRelationship( + activeSession: Session | null, +): UseFlowChatSessionRelationshipResult { + const [btwOrigin, setBtwOrigin] = useState(null); + const [btwParentTitle, setBtwParentTitle] = useState(''); + + const isBtwSession = useMemo(() => { + return resolveSessionRelationship(activeSession).isBtw; + }, [activeSession]); + + useEffect(() => { + const syncRelationshipState = (state = flowChatStore.getState()) => { + const currentSessionId = activeSession?.sessionId; + if (!currentSessionId) { + setBtwOrigin(null); + setBtwParentTitle(''); + return; + } + + const session = state.sessions.get(currentSessionId); + if (!session) { + setBtwOrigin(null); + setBtwParentTitle(''); + return; + } + + const relationship = resolveSessionRelationship(session); + const nextOrigin = relationship.origin || null; + const parentId = relationship.parentSessionId; + const parent = parentId ? state.sessions.get(parentId) : undefined; + + setBtwOrigin(nextOrigin); + setBtwParentTitle(parent?.title || ''); + }; + + syncRelationshipState(); + const unsubscribe = flowChatStore.subscribe(syncRelationshipState); + return unsubscribe; + }, [activeSession?.sessionId]); + + return { + isBtwSession, + btwOrigin, + btwParentTitle, + }; +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatSync.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatSync.ts new file mode 100644 index 00000000..24f5665f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatSync.ts @@ -0,0 +1,31 @@ +/** + * FlowChat store synchronization effects. + */ + +import { useEffect } from 'react'; +import { agentAPI } from '@/infrastructure/api'; +import { flowChatStore } from '../../store/FlowChatStore'; +import { startAutoSync } from '../../services/storeSync'; + +export function useFlowChatSync(): void { + useEffect(() => { + const unsubscribe = startAutoSync(); + return () => { + unsubscribe(); + }; + }, []); + + useEffect(() => { + const unlisten = agentAPI.onSessionTitleGenerated((event) => { + flowChatStore.updateSessionTitle( + event.sessionId, + event.title, + 'generated', + ); + }); + + return () => { + unlisten(); + }; + }, []); +} diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts new file mode 100644 index 00000000..a5d38616 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatToolActions.ts @@ -0,0 +1,129 @@ +/** + * Tool confirmation/rejection actions for Modern FlowChat. + */ + +import { useCallback } from 'react'; +import { notificationService } from '@/shared/notification-system'; +import { createLogger } from '@/shared/utils/logger'; +import { flowChatStore } from '../../store/FlowChatStore'; +import type { DialogTurn, FlowItem, FlowToolItem, ModelRound } from '../../types/flow-chat'; + +const log = createLogger('useFlowChatToolActions'); + +interface ResolvedToolContext { + activeSessionId: string | null; + toolItem: FlowToolItem | null; + turnId: string | null; +} + +function resolveToolContext(toolId: string): ResolvedToolContext { + const latestState = flowChatStore.getState(); + const dialogTurns = Array.from(latestState.sessions.values()).flatMap(session => + session.dialogTurns as DialogTurn[], + ); + + let toolItem: FlowToolItem | null = null; + let turnId: string | null = null; + + for (const turn of dialogTurns) { + for (const modelRound of turn.modelRounds as ModelRound[]) { + const item = modelRound.items.find((candidate: FlowItem) => ( + candidate.type === 'tool' && candidate.id === toolId + )) as FlowToolItem | undefined; + + if (item) { + toolItem = item; + turnId = turn.id; + break; + } + } + + if (toolItem) { + break; + } + } + + return { + activeSessionId: latestState.activeSessionId, + toolItem, + turnId, + }; +} + +export function useFlowChatToolActions() { + const handleToolConfirm = useCallback(async (toolId: string, updatedInput?: any) => { + try { + const { activeSessionId, toolItem, turnId } = resolveToolContext(toolId); + + if (!toolItem || !turnId) { + notificationService.error(`Tool confirmation failed: tool item ${toolId} not found in current session`); + return; + } + + const finalInput = updatedInput || toolItem.toolCall?.input; + + if (activeSessionId) { + flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { + userConfirmed: true, + status: 'confirmed', + toolCall: { + ...toolItem.toolCall, + input: finalInput, + }, + } as any); + } + + if (!activeSessionId) { + throw new Error('No active session ID'); + } + + const { agentService } = await import('../../../shared/services/agent-service'); + await agentService.confirmToolExecution( + activeSessionId, + toolId, + 'confirm', + finalInput, + ); + } catch (error) { + log.error('Tool confirmation failed', error); + notificationService.error(`Tool confirmation failed: ${error}`); + } + }, []); + + const handleToolReject = useCallback(async (toolId: string) => { + try { + const { activeSessionId, toolItem, turnId } = resolveToolContext(toolId); + + if (!toolItem || !turnId) { + log.warn('Tool rejection failed: tool item not found', { toolId }); + return; + } + + if (activeSessionId) { + flowChatStore.updateModelRoundItem(activeSessionId, turnId, toolId, { + userConfirmed: false, + status: 'rejected', + } as any); + } + + if (!activeSessionId) { + throw new Error('No active session ID'); + } + + const { agentService } = await import('../../../shared/services/agent-service'); + await agentService.confirmToolExecution( + activeSessionId, + toolId, + 'reject', + ); + } catch (error) { + log.error('Tool rejection failed', error); + notificationService.error(`Tool rejection failed: ${error}`); + } + }, []); + + return { + handleToolConfirm, + handleToolReject, + }; +} diff --git a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts new file mode 100644 index 00000000..28051556 --- /dev/null +++ b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts @@ -0,0 +1,25 @@ +/** + * Shared navigation events for FlowChat viewport movement and focus. + */ + +export const FLOWCHAT_FOCUS_ITEM_EVENT = 'flowchat:focus-item'; +export const FLOWCHAT_PIN_TURN_TO_TOP_EVENT = 'flowchat:pin-turn-to-top'; + +export type FlowChatFocusItemSource = 'btw-back'; +export type FlowChatPinTurnToTopSource = 'send-message'; +export type FlowChatPinTurnToTopMode = 'transient' | 'sticky-latest'; + +export interface FlowChatFocusItemRequest { + sessionId: string; + turnIndex?: number; + itemId?: string; + source?: FlowChatFocusItemSource; +} + +export interface FlowChatPinTurnToTopRequest { + sessionId: string; + turnId: string; + behavior?: ScrollBehavior; + source?: FlowChatPinTurnToTopSource; + pinMode?: FlowChatPinTurnToTopMode; +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 57ea3ff1..0d260172 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -16,6 +16,11 @@ import type { FlowChatContext, DialogTurn } from './types'; import { ensureBackendSession, retryCreateBackendSession } from './SessionModule'; import { cleanupSessionBuffers } from './TextChunkModule'; import type { ImageContextData as ImageInputContextData } from '@/infrastructure/api/service-api/ImageContextTypes'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { + FLOWCHAT_PIN_TURN_TO_TOP_EVENT, + type FlowChatPinTurnToTopRequest, +} from '../../events/flowchatNavigation'; const log = createLogger('MessageModule'); @@ -137,6 +142,14 @@ export async function sendMessage( }; context.flowChatStore.addDialogTurn(sessionId, dialogTurn); + const pinRequest: FlowChatPinTurnToTopRequest = { + sessionId, + turnId: dialogTurnId, + behavior: 'auto', + source: 'send-message', + pinMode: 'sticky-latest', + }; + globalEventBus.emit(FLOWCHAT_PIN_TURN_TO_TOP_EVENT, pinRequest, 'MessageModule'); await stateMachineManager.transition(sessionId, SessionExecutionEvent.START, { taskId: sessionId, diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 7cd6727e..ba54a25e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -3,13 +3,14 @@ * Displays multiple questions, collects user answers and submits them */ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useLayoutEffect, useRef } from 'react'; import { Loader2, AlertCircle, Send, ChevronDown, ChevronRight } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import type { ToolCardProps } from '../types/flow-chat'; import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI'; import { createLogger } from '@/shared/utils/logger'; import { Button } from '@/component-library'; +import { useToolCardHeightContract } from './useToolCardHeightContract'; import './AskUserQuestionCard.scss'; const log = createLogger('AskUserQuestionCard'); @@ -55,6 +56,31 @@ export const AskUserQuestionCard: React.FC = ({ const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); const [isExpanded, setIsExpanded] = useState(false); + const [showCompletedSummary, setShowCompletedSummary] = useState(status === 'completed'); + const toolId = toolItem.id ?? toolCall?.id; + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ + toolId, + toolName: toolItem.toolName, + }); + const previousStatusRef = useRef(status); + + useLayoutEffect(() => { + const previousStatus = previousStatusRef.current; + previousStatusRef.current = status; + + if (previousStatus !== 'completed' && status === 'completed' && !showCompletedSummary) { + applyExpandedState(true, false, (nextExpanded) => { + setShowCompletedSummary(!nextExpanded); + }, { + reason: 'auto', + }); + return; + } + + if (status !== 'completed' && showCompletedSummary) { + setShowCompletedSummary(false); + } + }, [applyExpandedState, showCompletedSummary, status]); const isAllAnswered = useCallback(() => { if (questions.length === 0) return false; @@ -339,8 +365,12 @@ export const AskUserQuestionCard: React.FC = ({ } return ( -
- {status !== 'completed' ? ( +
+ {!showCompletedSummary ? ( <>
@@ -380,7 +410,7 @@ export const AskUserQuestionCard: React.FC = ({ <>
setIsExpanded(!isExpanded)} + onClick={() => applyExpandedState(isExpanded, !isExpanded, setIsExpanded)} >
{isExpanded ? : } diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx index 3844ec3d..c6c425b9 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx @@ -11,6 +11,7 @@ import { Tooltip } from '@/component-library'; import type { ToolCardProps } from '../types/flow-chat'; import { BaseToolCard, ToolCardHeader } from './BaseToolCard'; import { createLogger } from '@/shared/utils/logger'; +import { useToolCardHeightContract } from './useToolCardHeightContract'; import './CodeReviewToolCard.scss'; const log = createLogger('CodeReviewToolCard'); @@ -52,6 +53,11 @@ export const CodeReviewToolCard: React.FC = React.memo(({ const { t } = useTranslation('flow-chat'); const { toolResult, status } = toolItem; const [isExpanded, setIsExpanded] = useState(false); + const toolId = toolItem.id ?? toolItem.toolCall?.id; + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ + toolId, + toolName: toolItem.toolName, + }); const getStatusIcon = () => { switch (status) { @@ -139,6 +145,10 @@ export const CodeReviewToolCard: React.FC = React.memo(({ const hasIssues = issueStats && issueStats.total > 0; const hasData = reviewData !== null; + const toggleExpanded = useCallback(() => { + applyExpandedState(isExpanded, !isExpanded, setIsExpanded); + }, [applyExpandedState, isExpanded]); + const handleCardClick = useCallback((e: React.MouseEvent) => { const target = e.target as HTMLElement; if (target.closest('.preview-toggle-btn')) { @@ -146,16 +156,14 @@ export const CodeReviewToolCard: React.FC = React.memo(({ } if (hasData) { - window.dispatchEvent(new CustomEvent('tool-card-toggle')); - setIsExpanded(prev => !prev); + toggleExpanded(); } - }, [hasData]); + }, [hasData, toggleExpanded]); const handleToggleExpand = useCallback((e: React.MouseEvent) => { e.stopPropagation(); - window.dispatchEvent(new CustomEvent('tool-card-toggle')); - setIsExpanded(prev => !prev); - }, []); + toggleExpanded(); + }, [toggleExpanded]); const renderContent = () => { if (status === 'completed' && reviewData) { @@ -351,13 +359,15 @@ export const CodeReviewToolCard: React.FC = React.memo(({ const normalizedStatus = status === 'analyzing' ? 'running' : status; return ( - +
+ +
); }); diff --git a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx index 2852a604..d2116f4a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CreatePlanDisplay.tsx @@ -17,6 +17,7 @@ import { planBuildStateService } from '@/shared/services/PlanBuildStateService'; import yaml from 'yaml'; import { Tooltip, CubeLoading } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; +import { useToolCardHeightContract } from './useToolCardHeightContract'; import './CreatePlanDisplay.scss'; const log = createLogger('PlanDisplay'); @@ -78,6 +79,11 @@ export const PlanDisplay: React.FC = ({ }); const [isTodosExpanded, setIsTodosExpanded] = useState(false); + const toolCardId = cacheKey ?? planFilePath; + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ + toolId: toolCardId, + toolName: 'CreatePlan', + }); const hasAutoLoaded = useRef(false); @@ -295,6 +301,10 @@ ${JSON.stringify(simpleTodos, null, 2)} } }, [planFilePath, buildStatus, effectiveCacheKey, initialName, initialOverview, initialTodos]); + const handleToggleTodos = useCallback(() => { + applyExpandedState(isTodosExpanded, !isTodosExpanded, setIsTodosExpanded); + }, [applyExpandedState, isTodosExpanded]); + const isLoading = status === 'preparing' || status === 'streaming' || status === 'running'; if (!planData) { @@ -309,7 +319,11 @@ ${JSON.stringify(simpleTodos, null, 2)} } return ( -
+
setIsTodosExpanded(!isTodosExpanded)} + onClick={handleToggleTodos} > {t('toolCards.plan.remainingTodos', { count: remainingTodos })}