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 da3838c2..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} @@ -212,11 +252,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/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 && ( { + 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(); @@ -395,7 +412,7 @@ const SubagentItemsContainer = React.memo(({ return unsubscribe; }, [parentTaskToolId]); - + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -464,15 +481,11 @@ const SubagentItemsContainer = React.memo(({ }; }, [isCollapsed]); - if (isCollapsed) { - return null; - } - return ( -
+
{items.map((item, idx) => ( diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index 5a5725ad..6d506a77 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -3,34 +3,24 @@ * Uses virtual scrolling with Zustand and syncs legacy store state. */ -import React, { useEffect, useMemo, useCallback, useRef, useState } from 'react'; +import React, { useMemo, useCallback, useRef, useEffect, useState } from 'react'; import { VirtualMessageList, VirtualMessageListRef } from './VirtualMessageList'; -import { FlowChatHeader } from './FlowChatHeader'; +import { FlowChatHeader, type FlowChatHeaderTurnSummary } from './FlowChatHeader'; import { WelcomePanel } from '../WelcomePanel'; import { FlowChatContext, FlowChatContextValue } from './FlowChatContext'; -import { useVirtualItems, useActiveSession, useVisibleTurnInfo } from '../../store/modernFlowChatStore'; -import type { VirtualItem } from '../../store/modernFlowChatStore'; -import { flowChatStore } from '../../store/FlowChatStore'; -import { startAutoSync } from '../../services/storeSync'; -import { useModernFlowChatStore } from '../../store/modernFlowChatStore'; -import { globalEventBus } from '../../../infrastructure/event-bus'; -import { getElementText, copyTextToClipboard } from '../../../shared/utils/textSelection'; -import type { FlowChatConfig, FlowToolItem, DialogTurn, ModelRound, FlowItem, Session } from '../../types/flow-chat'; -import { notificationService } from '../../../shared/notification-system'; -import { agentAPI } from '@/infrastructure/api'; -import { fileTabManager } from '@/shared/services/FileTabManager'; +import { useExploreGroupState } from './useExploreGroupState'; +import { useFlowChatFileActions } from './useFlowChatFileActions'; +import { useFlowChatNavigation } from './useFlowChatNavigation'; +import { useFlowChatCopyDialog } from './useFlowChatCopyDialog'; +import { useFlowChatSessionRelationship } from './useFlowChatSessionRelationship'; +import { useFlowChatSync } from './useFlowChatSync'; +import { useFlowChatToolActions } from './useFlowChatToolActions'; +import { useVirtualItems, useActiveSession, useVisibleTurnInfo, type VisibleTurnInfo } from '../../store/modernFlowChatStore'; +import type { FlowChatConfig } from '../../types/flow-chat'; import type { LineRange } from '@/component-library'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; -import path from 'path-browserify'; -import { createLogger } from '@/shared/utils/logger'; -import { flowChatManager } from '../../services/FlowChatManager'; -import { resolveSessionRelationship } from '../../utils/sessionMetadata'; import './ModernFlowChatContainer.scss'; -const log = createLogger('ModernFlowChatContainer'); - -type ExploreGroupVirtualItem = Extract; - 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,127 @@ 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; + } + + const resolvedLatestTurnId = latestTurnId; + const resolvedSessionId = sessionId; + + autoPinnedSessionIdRef.current = resolvedSessionId; + setPendingHeaderTurnId(resolvedLatestTurnId); + + const frameId = requestAnimationFrame(() => { + const accepted = virtualListRef.current?.pinTurnToTop(resolvedLatestTurnId, { + 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} />
@@ -517,7 +248,12 @@ export const ModernFlowChatContainer: React.FC = ( }} /> ) : ( - + )}
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/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index a9536aef..89af287d 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -49,6 +49,14 @@ } } +.subagent-items-wrapper--collapsed { + display: none; +} + +.subagent-items-wrapper--expanded { + display: block; +} + // Subagent container for items under the same parent task. .subagent-items-container { // Blend with the TaskTool card. @@ -72,6 +80,10 @@ overflow-y: auto; } +.subagent-items-container--collapsed { + overflow: hidden; +} + // TaskTool card bottom radius is controlled by TaskToolDisplay.scss. // Keep the legacy subagent-item class for compatibility. 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..b488fa08 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,211 @@ * 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 preserves the current viewport by default. + * - A new turn first pins the latest user message near the top for reading. + * - Follow mode starts explicitly via "jump to latest", or automatically once + * the latest turn's streaming output grows enough to consume the sticky tail space. + * - User upward scroll intent exits follow and cancels any pending auto-follow arm. + * - "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 { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; +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`. +const TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX = 6; + +// 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 isEditableElement(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) { + return false; + } + + return ( + target.isContentEditable || + target.closest('input, textarea, select, [contenteditable="true"]') !== null + ); +} + +function isUpwardScrollIntentKey(event: KeyboardEvent): boolean { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) { + return false; + } + + return ( + event.key === 'ArrowUp' || + event.key === 'PageUp' || + event.key === 'Home' || + (event.key === ' ' && event.shiftKey) + ); +} + +function isPointerOnScrollbarGutter( + scroller: HTMLElement, + clientX: number, + clientY: number, +): boolean { + const rect = scroller.getBoundingClientRect(); + const verticalScrollbarWidth = Math.max(0, scroller.offsetWidth - scroller.clientWidth); + const horizontalScrollbarHeight = Math.max(0, scroller.offsetHeight - scroller.clientHeight); + + const isWithinVerticalScrollbar = ( + verticalScrollbarWidth > 0 && + clientX >= rect.right - verticalScrollbarWidth && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ); + + const isWithinHorizontalScrollbar = ( + horizontalScrollbarHeight > 0 && + clientY >= rect.bottom - horizontalScrollbarHeight && + clientY <= rect.bottom && + clientX >= rect.left && + clientX <= rect.right + ); + + return isWithinVerticalScrollbar || isWithinHorizontalScrollbar; +} + +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 +216,51 @@ 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 touchScrollIntentStartYRef = useRef(null); + const scrollbarPointerInteractionActiveRef = useRef(false); + 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 followOutputControllerRef = useRef<{ + handleUserScrollIntent: () => void; + handleScroll: () => void; + scheduleFollowToLatest: (reason: string) => void; + }>({ + handleUserScrollIntent: () => {}, + handleScroll: () => {}, + scheduleFollowToLatest: () => {}, + }); + const deferredFollowReasonRef = useRef(null); const isInputActive = useChatInputState(state => state.isActive); const isInputExpanded = useChatInputState(state => state.isExpanded); @@ -44,70 +269,1422 @@ 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 latestTurnId = userMessageItems[userMessageItems.length - 1]?.item.turnId ?? null; + const latestUserMessageIndex = userMessageItems[userMessageItems.length - 1]?.index ?? 0; + const latestTurnAutoFollowStateRef = useRef<{ + turnId: string | null; + sawPositiveFloor: boolean; + }>({ + turnId: latestTurnId, + sawPositiveFloor: false, + }); + const hasPrimedMountedStreamingTurnFollowRef = useRef(false); + const previousLatestTurnIdForFollowRef = useRef(latestTurnId); + const previousSessionIdForFollowRef = useRef(activeSession?.sessionId); + + 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; + // Only preserve a sticky pin after a real floor has been measured. + // Provisional fallback reservations should shrink on the next resolve. + const shouldPreserveCurrentPx = ( + currentPinReservation.mode === pinMode && + currentPinReservation.targetTurnId === turnId && + ( + pinMode === 'transient' || + currentPinReservation.floorPx > COMPENSATION_EPSILON_PX + ) + ); + 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 rawMaxScrollTop = effectiveScrollHeight - scroller.clientHeight; + const maxScrollTop = Math.max(0, rawMaxScrollTop); + // When content is shorter than the viewport, the clamped max scroll range is 0 + // even though we still need to reserve the underflow gap before the target can pin. + const missingTailSpace = Math.max(0, desiredScrollTop - rawMaxScrollTop); + + 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) { + const fallbackBehavior: ScrollBehavior = request.pinMode === 'sticky-latest' + ? 'auto' + : targetItem.index === 0 + ? 'auto' + : request.attempts === 0 && request.behavior === 'smooth' + ? 'smooth' + : 'auto'; + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const provisionalPinPx = request.pinMode === 'sticky-latest' + ? Math.max(maxScrollTop, currentPinReservation.px) + : 0; + + if (request.pinMode === 'sticky-latest' && provisionalPinPx > COMPENSATION_EPSILON_PX) { + // Reserve enough tail space before the target is rendered so the first + // fallback scroll does not briefly land on the physical bottom. + const nextReservationState: BottomReservationState = { + ...bottomReservationStateRef.current, + pin: { + kind: 'pin', + px: provisionalPinPx, + floorPx: 0, + mode: request.pinMode, + targetTurnId: request.turnId, + }, + }; + updateBottomReservationState(nextReservationState); + applyFooterCompensationNow(nextReservationState); + previousMeasuredHeightRef.current = Math.max( + 0, + scroller.scrollHeight - getTotalBottomCompensationPx(nextReservationState), + ); + } + + virtuoso.scrollToIndex({ + index: targetItem.index, + align: 'start', + behavior: fallbackBehavior, + }); + return false; + } + + 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; + } - if (userMessageItems.length > 0) { - const firstMessage = userMessageItems[0]; - const userMessage = firstMessage.item.type === 'user-message' - ? firstMessage.item.data + // 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 + ); + // Sticky latest pins should keep correcting post-layout drift until the + // target stabilizes, while transient jumps still back off if the user + // has already moved away from the requested position. + const shouldRealign = ( + frameLabel !== 'immediate' && + deltaToViewportTop != null && + Math.abs(deltaToViewportTop) > 1.5 && + ( + request.pinMode === 'transient' + ? Math.abs(scroller.scrollTop - targetScrollTop) <= 2 + : 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(); + const alignedWithinTolerance = Math.abs(alignedRect.top - resolvedMetrics.viewportTop) <= 1.5; + + return alignedWithinTolerance; + }, [ + buildPinReservation, + applyFooterCompensationNow, + getTotalBottomCompensationPx, + latestTurnId, + 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); + }, []); + + const shouldSuspendAutoFollow = useCallback(() => { + const collapseIntent = pendingCollapseIntentRef.current; + return ( + layoutTransitionCountRef.current > 0 || + (collapseIntent.active && collapseIntent.expiresAtMs >= performance.now()) + ); + }, []); + + const scheduleFollowToLatestWithViewportState = useCallback((reason: string) => { + const collapseIntentActive = shouldSuspendAutoFollow(); + if (collapseIntentActive) { + deferredFollowReasonRef.current = reason; + return; + } + deferredFollowReasonRef.current = null; + followOutputControllerRef.current.scheduleFollowToLatest(reason); + }, [shouldSuspendAutoFollow]); + + 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); + scheduleFollowToLatestWithViewportState('resize-observer'); + }); + resizeObserverRef.current.observe(resizeTarget); + + mutationObserverRef.current?.disconnect(); + mutationObserverRef.current = new MutationObserver(() => { + scheduleHeightMeasure(2); + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + scheduleFollowToLatestWithViewportState('mutation-observer'); + }); + 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); + if (layoutTransitionCountRef.current === 0 && pendingCollapseIntentRef.current.active) { + pendingCollapseIntentRef.current = { + active: false, + anchorScrollTop: 0, + toolId: null, + toolName: null, + expiresAtMs: 0, + distanceFromBottomBeforeCollapse: 0, + baseTotalCompensationPx: 0, + cumulativeShrinkPx: 0, + }; + } + if (layoutTransitionCountRef.current === 0 && deferredFollowReasonRef.current && !shouldSuspendAutoFollow()) { + const deferredReason = deferredFollowReasonRef.current; + deferredFollowReasonRef.current = null; + followOutputControllerRef.current.scheduleFollowToLatest(`${deferredReason}-after-transition`); + } + }; + 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(); + followOutputControllerRef.current.handleScroll(); + + if (anchorLockRef.current.active && performance.now() > anchorLockRef.current.lockUntilMs && layoutTransitionCountRef.current === 0) { + releaseAnchorLock('expired-after-scroll'); + } + }; + scrollerElement.addEventListener('scroll', handleScroll, { passive: true }); + + const handleWheel = (event: WheelEvent) => { + if (event.deltaY < 0) { + followOutputControllerRef.current.handleUserScrollIntent(); + } + }; + + const handleTouchStart = (event: TouchEvent) => { + touchScrollIntentStartYRef.current = event.touches[0]?.clientY ?? null; + }; + + const handleTouchMove = (event: TouchEvent) => { + const startY = touchScrollIntentStartYRef.current; + const currentY = event.touches[0]?.clientY; + if (startY === null || currentY === undefined) { + return; + } + + if (currentY - startY > TOUCH_SCROLL_INTENT_EXIT_THRESHOLD_PX) { + touchScrollIntentStartYRef.current = currentY; + followOutputControllerRef.current.handleUserScrollIntent(); + } + }; + + const resetTouchScrollIntent = () => { + touchScrollIntentStartYRef.current = null; + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!isUpwardScrollIntentKey(event) || isEditableElement(event.target)) { + return; + } + + followOutputControllerRef.current.handleUserScrollIntent(); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (event.pointerType === 'touch' || event.button !== 0) { + return; + } + + if (!isPointerOnScrollbarGutter(scrollerElement, event.clientX, event.clientY)) { + return; + } + + scrollbarPointerInteractionActiveRef.current = true; + followOutputControllerRef.current.handleUserScrollIntent(); + }; + + const handlePointerMove = (event: PointerEvent) => { + if (!scrollbarPointerInteractionActiveRef.current || event.pointerType === 'touch') { + return; + } + + if ((event.buttons & 1) !== 1) { + scrollbarPointerInteractionActiveRef.current = false; + return; + } + + followOutputControllerRef.current.handleUserScrollIntent(); + }; + + const endScrollbarPointerInteraction = () => { + scrollbarPointerInteractionActiveRef.current = false; + }; + + scrollerElement.addEventListener('wheel', handleWheel, { passive: true }); + scrollerElement.addEventListener('touchstart', handleTouchStart, { passive: true }); + scrollerElement.addEventListener('touchmove', handleTouchMove, { passive: true }); + scrollerElement.addEventListener('touchend', resetTouchScrollIntent, { passive: true }); + scrollerElement.addEventListener('touchcancel', resetTouchScrollIntent, { passive: true }); + scrollerElement.addEventListener('keydown', handleKeyDown, true); + scrollerElement.addEventListener('pointerdown', handlePointerDown, true); + window.addEventListener('pointermove', handlePointerMove, true); + window.addEventListener('pointerup', endScrollbarPointerInteraction, true); + window.addEventListener('pointercancel', endScrollbarPointerInteraction, 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); + scrollerElement.removeEventListener('wheel', handleWheel); + scrollerElement.removeEventListener('touchstart', handleTouchStart); + scrollerElement.removeEventListener('touchmove', handleTouchMove); + scrollerElement.removeEventListener('touchend', resetTouchScrollIntent); + scrollerElement.removeEventListener('touchcancel', resetTouchScrollIntent); + scrollerElement.removeEventListener('keydown', handleKeyDown, true); + scrollerElement.removeEventListener('pointerdown', handlePointerDown, true); + window.removeEventListener('pointermove', handlePointerMove, true); + window.removeEventListener('pointerup', endScrollbarPointerInteraction, true); + window.removeEventListener('pointercancel', endScrollbarPointerInteraction, true); + 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; + touchScrollIntentStartYRef.current = null; + scrollbarPointerInteractionActiveRef.current = false; + + 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, + latestTurnId, + pendingTurnPin?.pinMode, + pendingTurnPin?.turnId, + 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); + scheduleFollowToLatestWithViewportState('range-changed'); + }, [scheduleFollowToLatestWithViewportState, schedulePinReservationReconcile, scheduleVisibleTurnMeasure]); + + useEffect(() => { + if (userMessageItems.length === 0) { + const setVisibleTurnInfo = useModernFlowChatStore.getState().setVisibleTurnInfo; setVisibleTurnInfo(null); + return; + } + + scheduleVisibleTurnMeasure(2); + schedulePinReservationReconcile(2); + }, [activeSession?.sessionId, schedulePinReservationReconcile, scheduleVisibleTurnMeasure, scrollerElement, userMessageItems, virtualItems.length]); + + useEffect(() => { + if (!pendingTurnPin) return; + + if (performance.now() > pendingTurnPin.expiresAtMs) { + setPendingTurnPin(null); + return; } - }, [userMessageItems.length]); + + 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 isStreamingOutput = React.useMemo(() => { + if (isProcessing) { + return true; + } + + const dialogTurns = activeSession?.dialogTurns; + const lastDialogTurn = dialogTurns && dialogTurns.length > 0 + ? dialogTurns[dialogTurns.length - 1] + : undefined; + + if (!lastDialogTurn) { + return false; + } + + if ( + lastDialogTurn.status === 'processing' || + lastDialogTurn.status === 'image_analyzing' + ) { + return true; + } + + return lastDialogTurn.modelRounds.some(round => round.isStreaming); + }, [activeSession, isProcessing]); + + const scrollToLatestEndPositionInternal = useCallback((behavior: ScrollBehavior) => { + if (virtuosoRef.current && virtualItems.length > 0) { + releaseAnchorLock('scroll-to-latest'); + setPendingTurnPin(null); + virtuosoRef.current.scrollTo({ top: 999999999, behavior }); + } + }, [getTotalBottomCompensationPx, releaseAnchorLock, virtualItems.length]); + + const requestTurnPinToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { + const requestedPinMode = options?.pinMode ?? 'transient'; + const requestedBehavior = options?.behavior ?? 'auto'; + const targetItem = userMessageItems.find(({ item }) => item.turnId === turnId); + if (!targetItem || !virtuosoRef.current) { + return false; + } + + if (targetItem.index === 0 && requestedPinMode === 'transient') { + // The first turn has a deterministic destination, so bypass the deferred + // pin pipeline and snap to the true top immediately. + setPendingTurnPin(null); + virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); + + return true; + } + + setPendingTurnPin({ + turnId, + behavior: requestedBehavior, + pinMode: requestedPinMode, + expiresAtMs: performance.now() + 1500, + attempts: 0, + }); + return true; + }, [userMessageItems]); + + const performAutoFollowSync = useCallback(() => { + if (!latestTurnId) { + return; + } + + const currentPinReservation = bottomReservationStateRef.current.pin; + const totalBottomCompensationPx = getTotalBottomCompensationPx(); + const hasPendingLatestStickyPin = ( + pendingTurnPin?.turnId === latestTurnId && + pendingTurnPin.pinMode === 'sticky-latest' + ); + const hasAppliedLatestStickyPin = ( + currentPinReservation.mode === 'sticky-latest' && + currentPinReservation.targetTurnId === latestTurnId + ); + const shouldKeepStickyLatest = ( + hasAppliedLatestStickyPin && + currentPinReservation.floorPx > COMPENSATION_EPSILON_PX + ); + const shouldPreserveSyntheticTail = ( + hasAppliedLatestStickyPin && + totalBottomCompensationPx > COMPENSATION_EPSILON_PX + ); + + if (hasPendingLatestStickyPin) { + return; + } + + if (!hasAppliedLatestStickyPin) { + requestTurnPinToTop(latestTurnId, { + behavior: 'auto', + pinMode: 'sticky-latest', + }); + return; + } + + if (shouldKeepStickyLatest) { + return; + } + + if (shouldPreserveSyntheticTail) { + return; + } + + scrollToLatestEndPositionInternal('auto'); + }, [ + getTotalBottomCompensationPx, + latestTurnId, + pendingTurnPin?.pinMode, + pendingTurnPin?.turnId, + requestTurnPinToTop, + scrollToLatestEndPositionInternal, + ]); + + const { + isFollowingOutput, + enterFollowOutput, + exitFollowOutput, + armFollowOutputForNewTurn, + activateArmedFollowOutput, + cancelPendingAutoFollowArm, + scheduleFollowToLatest, + handleUserScrollIntent, + handleScroll: handleFollowOutputScroll, + } = useFlowChatFollowOutput({ + activeSessionId: activeSession?.sessionId, + latestTurnId, + virtualItemCount: virtualItems.length, + isStreaming: isStreamingOutput, + scrollerRef: scrollerElementRef, + performUserFollowScroll: () => { + scrollToLatestEndPositionInternal('smooth'); + }, + performAutoFollowScroll: performAutoFollowSync, + performLatestTurnStickyPin: () => { + if (latestTurnId) { + requestTurnPinToTop(latestTurnId, { + behavior: 'auto', + pinMode: 'sticky-latest', + }); + } + }, + shouldSuspendAutoFollow, + getAutoFollowDistanceFromBottom: (scroller) => ( + Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop - getTotalBottomCompensationPx()) + ), + }); + + useEffect(() => { + if (hasPrimedMountedStreamingTurnFollowRef.current) { + return; + } + + hasPrimedMountedStreamingTurnFollowRef.current = true; + if (!latestTurnId || !isStreamingOutput) { + return; + } + + latestTurnAutoFollowStateRef.current = { + turnId: latestTurnId, + sawPositiveFloor: false, + }; + armFollowOutputForNewTurn(); + }, [ + activeSession?.sessionId, + armFollowOutputForNewTurn, + isStreamingOutput, + latestTurnId, + virtualItems.length, + ]); + + useEffect(() => { + const previousSessionId = previousSessionIdForFollowRef.current; + if (previousSessionId !== activeSession?.sessionId) { + previousSessionIdForFollowRef.current = activeSession?.sessionId; + previousLatestTurnIdForFollowRef.current = latestTurnId; + latestTurnAutoFollowStateRef.current = { + turnId: latestTurnId, + sawPositiveFloor: false, + }; + return; + } + + const previousLatestTurnId = previousLatestTurnIdForFollowRef.current; + if (previousLatestTurnId === latestTurnId) { + return; + } + + previousLatestTurnIdForFollowRef.current = latestTurnId; + latestTurnAutoFollowStateRef.current = { + turnId: latestTurnId, + sawPositiveFloor: false, + }; + + if (!latestTurnId) { + cancelPendingAutoFollowArm(); + return; + } + + armFollowOutputForNewTurn(); + }, [ + activeSession?.sessionId, + armFollowOutputForNewTurn, + cancelPendingAutoFollowArm, + latestTurnId, + ]); + + useEffect(() => { + const trackingState = latestTurnAutoFollowStateRef.current; + if ( + !latestTurnId || + trackingState.turnId !== latestTurnId || + isFollowingOutput || + !isStreamingOutput + ) { + return; + } + + const hasPendingLatestStickyPin = ( + pendingTurnPin?.turnId === latestTurnId && + pendingTurnPin.pinMode === 'sticky-latest' + ); + if (hasPendingLatestStickyPin) { + return; + } + + if ( + bottomReservationState.pin.mode !== 'sticky-latest' || + bottomReservationState.pin.targetTurnId !== latestTurnId + ) { + return; + } + + if (bottomReservationState.pin.floorPx > COMPENSATION_EPSILON_PX) { + trackingState.sawPositiveFloor = true; + return; + } + + if (activateArmedFollowOutput()) { + latestTurnAutoFollowStateRef.current = { + turnId: null, + sawPositiveFloor: false, + }; + } + }, [ + activateArmedFollowOutput, + bottomReservationState.pin.floorPx, + bottomReservationState.pin.mode, + bottomReservationState.pin.targetTurnId, + isFollowingOutput, + isStreamingOutput, + latestTurnId, + pendingTurnPin?.pinMode, + pendingTurnPin?.turnId, + ]); + + followOutputControllerRef.current = { + handleUserScrollIntent, + handleScroll: handleFollowOutputScroll, + scheduleFollowToLatest, + }; + const scrollToTurn = useCallback((turnIndex: number) => { if (!virtuosoRef.current) return; if (turnIndex < 1 || turnIndex > userMessageItems.length) return; @@ -115,6 +1692,9 @@ export const VirtualMessageList = forwardRef((_, ref) => const targetItem = userMessageItems[turnIndex - 1]; if (!targetItem) return; + exitFollowOutput('scroll-to-turn'); + clearPinReservationForUserNavigation(); + if (targetItem.index === 0) { virtuosoRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } else { @@ -124,46 +1704,55 @@ export const VirtualMessageList = forwardRef((_, ref) => align: 'center', }); } - }, [userMessageItems]); + }, [clearPinReservationForUserNavigation, exitFollowOutput, userMessageItems]); const scrollToIndex = useCallback((index: number) => { if (!virtuosoRef.current) return; if (index < 0 || index >= virtualItems.length) return; + exitFollowOutput('scroll-to-index'); + clearPinReservationForUserNavigation(); + if (index === 0) { virtuosoRef.current.scrollTo({ top: 0, behavior: 'auto' }); } else { virtuosoRef.current.scrollToIndex({ index, align: 'center', behavior: 'auto' }); } - }, [virtualItems.length]); + }, [clearPinReservationForUserNavigation, exitFollowOutput, virtualItems.length]); + + const pinTurnToTop = useCallback((turnId: string, options?: { behavior?: ScrollBehavior; pinMode?: FlowChatPinTurnToTopMode }) => { + const shouldExitFollowOutput = !( + options?.pinMode === 'sticky-latest' && + turnId === latestTurnId + ); + if (shouldExitFollowOutput) { + exitFollowOutput('pin-turn-to-top'); + // Drop stale sticky tail padding before transient jumps so the previous + // latest-turn reservation cannot leak into the new viewport. + clearPinReservationForUserNavigation(); + } - const scrollToBottom = useCallback(() => { + return requestTurnPinToTop(turnId, options); + }, [clearPinReservationForUserNavigation, exitFollowOutput, latestTurnId, requestTurnPinToTop]); + + 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(() => { + enterFollowOutput('jump-to-latest'); + }, [enterFollowOutput]); 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 +1841,8 @@ export const VirtualMessageList = forwardRef((_, ref) => return true; }, [lastItemInfo.isTurnProcessing, isProcessing, processingPhase]); + const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx(bottomReservationState)); + // ── Render ──────────────────────────────────────────────────────────── if (virtualItems.length === 0) { return ( @@ -277,10 +1868,12 @@ export const VirtualMessageList = forwardRef((_, ref) => index={index} /> )} - followOutput={handleFollowOutput} + followOutput={false} alignToBottom={false} - initialTopMostItemIndex={0} + // New mounts start near the latest user turn to avoid flashing older + // content before sticky pin logic can finish. + initialTopMostItemIndex={latestUserMessageIndex} overscan={{ main: 1200, reverse: 1200 }} @@ -300,20 +1893,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/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts new file mode 100644 index 00000000..5c28d0b9 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -0,0 +1,336 @@ +/** + * Follow-output controller for the modern virtualized FlowChat list. + * + * Keeps follow state local to the viewport layer while separating the + * "when should we follow" policy from the low-level list scroll mechanics. + */ + +import { useCallback, useEffect, useRef, useState, type RefObject } from 'react'; + +const PROGRAMMATIC_SCROLL_GUARD_MS = 160; +const AUTO_FOLLOW_BOTTOM_THRESHOLD_PX = 24; +const USER_SCROLL_DIRECTION_EPSILON_PX = 0.5; +const USER_SCROLL_INTENT_WINDOW_MS = 450; + +export type FollowOutputEnterReason = 'jump-to-latest' | 'auto-follow'; +export type FollowOutputExitReason = + | 'session-changed' + | 'user-scroll-up' + | 'scroll-to-turn' + | 'scroll-to-index' + | 'pin-turn-to-top'; + +interface UseFlowChatFollowOutputOptions { + activeSessionId?: string; + latestTurnId: string | null; + virtualItemCount: number; + isStreaming: boolean; + scrollerRef: RefObject; + performUserFollowScroll: () => void; + performAutoFollowScroll: () => void; + performLatestTurnStickyPin: () => void; + shouldSuspendAutoFollow?: () => boolean; + getAutoFollowDistanceFromBottom?: (scroller: HTMLElement) => number; +} + +interface UseFlowChatFollowOutputResult { + isFollowingOutput: boolean; + enterFollowOutput: (reason: FollowOutputEnterReason) => void; + exitFollowOutput: (reason: FollowOutputExitReason) => void; + armFollowOutputForNewTurn: () => void; + activateArmedFollowOutput: () => boolean; + cancelPendingAutoFollowArm: () => void; + scheduleFollowToLatest: (reason: string) => void; + handleUserScrollIntent: () => void; + handleScroll: () => void; +} + +function getDistanceFromBottom(scroller: HTMLElement): number { + return Math.max(0, scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop); +} + +export function useFlowChatFollowOutput({ + activeSessionId, + latestTurnId, + virtualItemCount, + isStreaming, + scrollerRef, + performUserFollowScroll, + performAutoFollowScroll, + performLatestTurnStickyPin, + shouldSuspendAutoFollow, + getAutoFollowDistanceFromBottom, +}: UseFlowChatFollowOutputOptions): UseFlowChatFollowOutputResult { + const [isFollowingOutput, setIsFollowingOutput] = useState(false); + + const isFollowingOutputRef = useRef(isFollowingOutput); + const followFrameRef = useRef(null); + const programmaticScrollUntilMsRef = useRef(0); + const explicitUserScrollIntentUntilMsRef = useRef(0); + const lastObservedScrollTopRef = useRef(0); + const previousSessionIdRef = useRef(activeSessionId); + const armedAutoFollowTurnIdRef = useRef(null); + + const setFollowingOutput = useCallback((nextValue: boolean) => { + isFollowingOutputRef.current = nextValue; + setIsFollowingOutput(prev => (prev === nextValue ? prev : nextValue)); + }, []); + + const cancelScheduledFollow = useCallback(() => { + if (followFrameRef.current !== null) { + cancelAnimationFrame(followFrameRef.current); + followFrameRef.current = null; + } + }, []); + + const cancelPendingAutoFollowArm = useCallback(() => { + armedAutoFollowTurnIdRef.current = null; + }, []); + + const runProgrammaticScroll = useCallback((scrollAction: () => void) => { + programmaticScrollUntilMsRef.current = performance.now() + PROGRAMMATIC_SCROLL_GUARD_MS; + explicitUserScrollIntentUntilMsRef.current = 0; + scrollAction(); + const scroller = scrollerRef.current; + if (scroller) { + lastObservedScrollTopRef.current = scroller.scrollTop; + } + }, [scrollerRef]); + + const enterFollowOutput = useCallback((reason: FollowOutputEnterReason) => { + cancelPendingAutoFollowArm(); + cancelScheduledFollow(); + explicitUserScrollIntentUntilMsRef.current = 0; + setFollowingOutput(true); + const followAction = reason === 'jump-to-latest' + ? performUserFollowScroll + : performAutoFollowScroll; + runProgrammaticScroll(followAction); + }, [ + cancelPendingAutoFollowArm, + cancelScheduledFollow, + performAutoFollowScroll, + performUserFollowScroll, + runProgrammaticScroll, + setFollowingOutput, + ]); + + const exitFollowOutput = useCallback((_reason: FollowOutputExitReason) => { + cancelPendingAutoFollowArm(); + cancelScheduledFollow(); + explicitUserScrollIntentUntilMsRef.current = 0; + setFollowingOutput(false); + const scroller = scrollerRef.current; + if (scroller) { + lastObservedScrollTopRef.current = scroller.scrollTop; + } + }, [cancelPendingAutoFollowArm, cancelScheduledFollow, scrollerRef, setFollowingOutput]); + + const armFollowOutputForNewTurn = useCallback(() => { + if (!latestTurnId) { + cancelPendingAutoFollowArm(); + return; + } + + armedAutoFollowTurnIdRef.current = latestTurnId; + cancelScheduledFollow(); + setFollowingOutput(false); + runProgrammaticScroll(performLatestTurnStickyPin); + }, [ + cancelPendingAutoFollowArm, + cancelScheduledFollow, + latestTurnId, + performLatestTurnStickyPin, + runProgrammaticScroll, + setFollowingOutput, + ]); + + const activateArmedFollowOutput = useCallback(() => { + const armedTurnId = armedAutoFollowTurnIdRef.current; + const isAlreadyFollowing = isFollowingOutputRef.current; + const isArmedForLatestTurn = Boolean(latestTurnId && armedTurnId === latestTurnId); + const isAutoFollowSuspended = shouldSuspendAutoFollow?.() === true; + + if (!latestTurnId || !isArmedForLatestTurn || isAlreadyFollowing) { + return false; + } + + if (isAutoFollowSuspended) { + return false; + } + + cancelPendingAutoFollowArm(); + cancelScheduledFollow(); + setFollowingOutput(true); + runProgrammaticScroll(performAutoFollowScroll); + return true; + }, [ + cancelPendingAutoFollowArm, + cancelScheduledFollow, + latestTurnId, + performAutoFollowScroll, + runProgrammaticScroll, + setFollowingOutput, + shouldSuspendAutoFollow, + ]); + + const handleUserScrollIntent = useCallback(() => { + if (!isFollowingOutputRef.current && armedAutoFollowTurnIdRef.current === null) { + return; + } + + const now = performance.now(); + if (now <= programmaticScrollUntilMsRef.current) { + return; + } + explicitUserScrollIntentUntilMsRef.current = now + USER_SCROLL_INTENT_WINDOW_MS; + }, []); + + const scheduleFollowToLatest = useCallback((_reason: string) => { + if ( + !isFollowingOutputRef.current || + !isStreaming || + virtualItemCount === 0 || + shouldSuspendAutoFollow?.() === true + ) { + return; + } + + if (followFrameRef.current !== null) { + return; + } + + followFrameRef.current = requestAnimationFrame(() => { + followFrameRef.current = null; + + if (!isFollowingOutputRef.current || !isStreaming || virtualItemCount === 0) { + return; + } + + if (shouldSuspendAutoFollow?.() === true) { + return; + } + + const scroller = scrollerRef.current; + if (!scroller) { + return; + } + + const rawDistanceFromBottom = getDistanceFromBottom(scroller); + const distanceFromBottom = getAutoFollowDistanceFromBottom?.(scroller) ?? rawDistanceFromBottom; + if (distanceFromBottom <= AUTO_FOLLOW_BOTTOM_THRESHOLD_PX) { + return; + } + + runProgrammaticScroll(performAutoFollowScroll); + }); + }, [getAutoFollowDistanceFromBottom, isStreaming, performAutoFollowScroll, runProgrammaticScroll, scrollerRef, shouldSuspendAutoFollow, virtualItemCount]); + + const handleScroll = useCallback(() => { + const scroller = scrollerRef.current; + if (!scroller) { + return; + } + + const currentScrollTop = scroller.scrollTop; + const previousScrollTop = lastObservedScrollTopRef.current; + lastObservedScrollTopRef.current = currentScrollTop; + + if (!isFollowingOutputRef.current && armedAutoFollowTurnIdRef.current === null) { + return; + } + + if (performance.now() <= programmaticScrollUntilMsRef.current) { + return; + } + + if (shouldSuspendAutoFollow?.() === true) { + return; + } + + const upwardDelta = previousScrollTop - currentScrollTop; + if (upwardDelta > USER_SCROLL_DIRECTION_EPSILON_PX) { + const now = performance.now(); + const hasRecentExplicitUserIntent = now <= explicitUserScrollIntentUntilMsRef.current; + const distanceFromBottom = getDistanceFromBottom(scroller); + if (!hasRecentExplicitUserIntent) { + if ( + isFollowingOutputRef.current && + distanceFromBottom <= AUTO_FOLLOW_BOTTOM_THRESHOLD_PX + ) { + return; + } + return; + } + + explicitUserScrollIntentUntilMsRef.current = 0; + + if (!isFollowingOutputRef.current) { + cancelPendingAutoFollowArm(); + return; + } + + exitFollowOutput('user-scroll-up'); + } + }, [cancelPendingAutoFollowArm, exitFollowOutput, scrollerRef, shouldSuspendAutoFollow]); + + useEffect(() => { + const scroller = scrollerRef.current; + if (scroller) { + lastObservedScrollTopRef.current = scroller.scrollTop; + } + }, [scrollerRef]); + + useEffect(() => { + const previousSessionId = previousSessionIdRef.current; + if (previousSessionId === activeSessionId) { + return; + } + + previousSessionIdRef.current = activeSessionId; + cancelPendingAutoFollowArm(); + cancelScheduledFollow(); + explicitUserScrollIntentUntilMsRef.current = 0; + const nextFollowState = Boolean(activeSessionId && virtualItemCount === 0); + + if (nextFollowState) { + setFollowingOutput(true); + return; + } + + setFollowingOutput(false); + }, [ + activeSessionId, + cancelPendingAutoFollowArm, + cancelScheduledFollow, + latestTurnId, + setFollowingOutput, + virtualItemCount, + ]); + + useEffect(() => { + if (!isFollowingOutput || !isStreaming) { + return; + } + + scheduleFollowToLatest('streaming-started'); + }, [isFollowingOutput, isStreaming, scheduleFollowToLatest]); + + useEffect(() => { + return () => { + cancelScheduledFollow(); + }; + }, [cancelScheduledFollow]); + + return { + isFollowingOutput, + enterFollowOutput, + exitFollowOutput, + armFollowOutputForNewTurn, + activateArmedFollowOutput, + cancelPendingAutoFollowArm, + scheduleFollowToLatest, + handleUserScrollIntent, + handleScroll, + }; +} 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/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); 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 })}