diff --git a/scripts/theme-color-governance-baseline.json b/scripts/theme-color-governance-baseline.json index 3fe6a07925..74de88feef 100644 --- a/scripts/theme-color-governance-baseline.json +++ b/scripts/theme-color-governance-baseline.json @@ -42,7 +42,7 @@ "max": 0 }, "surfaceTokenRenames.registeredUnique": { - "max": 8 + "max": 7 }, "surfaceTokenRenames.activeUnique": { "max": 0 diff --git a/scripts/theme-css-var-contract.mjs b/scripts/theme-css-var-contract.mjs index 23cbdf8f08..1c12fd1406 100644 --- a/scripts/theme-css-var-contract.mjs +++ b/scripts/theme-css-var-contract.mjs @@ -259,12 +259,6 @@ export const SURFACE_TOKEN_RENAME_CONTRACTS = [ owner: 'src/web-ui/src/component-library/components/FlowChatCards/SnapshotCard', reason: 'Snapshot operation color is a card-local role and should not look like a reusable operation namespace for other surfaces.', }, - { - key: '--delay', - canonical: '--flowchat-scroll-anchor-delay', - owner: 'src/web-ui/src/flow_chat/components/modern/ScrollAnchor', - reason: 'ScrollAnchor animation delay is a Flow Chat runtime input; the generic key is too easy to collide with unrelated animation code.', - }, { key: '--um-failed-fs', canonical: '--user-message-failed-font-size', diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md index 412e675f0f..e34a8e9001 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_SCROLL_STABILITY.md @@ -275,9 +275,10 @@ identity. It retries the active generation against real DOM geometry. RAF retries remain as a bounded fallback for browsers that coalesce range updates. When the static initial-history renderer hands off to Virtuoso with a pending target, that target becomes Virtuoso's `initialTopMostItemIndex`; the normal pin -resolver then performs exact alignment after mount. The right-side -`ScrollAnchor` must bind to the actual scroller element, because the stable ref -object does not change identity when its `.current` node is replaced. +resolver then performs exact alignment after mount. The left-side +`FlowChatTurnRail` is mounted outside the scroller and delegates navigation to +the same container-owned turn-pin request, so it does not need to rebind across +renderer handoffs or write the FlowChat viewport directly. Mounting an already-streaming session is not a new-turn event. Session entry resumes tail follow directly, while sticky pinning remains reserved for a new @@ -366,10 +367,10 @@ This path is safer than doing nothing, but it is more likely to show visible mov ## C. Static Initial-History Turn Navigation The initial-history path renders a bounded static window plus estimated leading -and trailing spacers before handing the complete projection to Virtuoso. Header -turn selection and the right-side scroll anchor may target a turn outside that -window. The list first materializes a new window around the target and then -issues the requested scroll. +and trailing spacers before handing the complete projection to Virtuoso. The +left-side turn rail may target a turn outside that window. The list first +materializes a new window around the target and then issues the requested +scroll. A smooth scroll does not update `scrollTop` synchronously. The window swap can therefore emit a scroll event at the old, browser-clamped physical bottom before @@ -391,8 +392,8 @@ second viewport writer. ## D. Arbitrary Turn Navigation Through Virtuoso -Header turn selection and the right-side scroll anchor use the same top-aligned -pin transaction: +The left-side turn rail delegates to the container-owned top-aligned pin +transaction: 1. record generation, session, target turn, behavior, and pin mode 2. exit tail follow without removing established physical range @@ -410,6 +411,19 @@ Do not clear the previous pin/footer range in step 2. The target may be outside the current Virtuoso range, and removing the footer first lets the browser clamp the old position to the physical bottom before materialization succeeds. +The turn rail is an independent overlay surface. Its height is bounded to 60% +of the FlowChat content area; overflow scrolls only the rail, and keeping the +current marker visible may update only the rail list's `scrollTop`. Rail wheel, +keyboard, hover, and tooltip behavior must never become another writer for the +outer FlowChat viewport. + +The existing visible-turn DOM measurement also collects every distinct turn +whose rendered items intersect the readable viewport. The first intersecting +turn remains the semantic current turn, while every intersecting turn marker +uses the same rail emphasis. Publish a new ordered `visibleTurnIds` snapshot +only when membership or order changes so ordinary scroll frames do not cause +redundant rail renders. + ## Why Transition Tracking Exists User-initiated expand/collapse still uses animated layout properties such as: 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 4ae9b151df..991198219a 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.scss @@ -55,28 +55,12 @@ } } - &__turn-nav { - position: relative; - display: flex; - align-items: center; - gap: 0; - } - &__background-activity-nav { position: relative; display: flex; align-items: center; } - &__turn-nav-button { - flex: 0 0 auto; - - &:not(:disabled):hover, - &--active { - background: color-mix(in srgb, var(--element-bg-soft) 82%, transparent); - } - } - &__background-activity-nav-button { flex: 0 0 auto; @@ -240,24 +224,6 @@ } } - &__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; - } - &__background-activity-panel { position: absolute; top: calc(100% + 8px); @@ -276,17 +242,6 @@ 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: var(--flowchat-font-size-xs); - color: var(--color-text-secondary); - } - &__background-activity-panel-header { display: flex; align-items: center; @@ -298,15 +253,6 @@ 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; - } - &__background-activity-list { display: flex; flex-direction: column; @@ -369,51 +315,6 @@ } } - &__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(--color-accent-500) 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: var(--flowchat-font-size-xs); - line-height: 1; - white-space: nowrap; - } - - &__turn-list-title { - min-width: 0; - flex: 1; - font-size: var(--flowchat-font-size-sm); - color: inherit; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - &__background-activity-list-item { display: flex; flex-direction: column; diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx index 43baa02f74..1afd65f524 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.test.tsx @@ -66,11 +66,6 @@ function createProps(overrides: Partial = {}): FlowChatHead totalTurns: 2, currentUserMessage: 'First prompt', visible: true, - turns: [ - { turnId: 'turn-1', turnIndex: 1, title: 'First prompt' }, - { turnId: 'turn-2', turnIndex: 2, title: 'Second prompt' }, - ], - onJumpToTurn: vi.fn(), ...overrides, }; } @@ -130,80 +125,14 @@ describe('FlowChatHeader', () => { expect(header?.style.getPropertyValue('--flowchat-header-side-width')).toBe('196px'); }); - it('closes the turn list as soon as a different turn selection is accepted', () => { - const onJumpToTurn = vi.fn(() => true); - const initialProps = createProps({ onJumpToTurn }); - - act(() => { - root.render(); - }); - - const turnListButton = container.querySelector('[data-testid="flowchat-header-turn-list"]'); - expect(turnListButton).not.toBeNull(); - - act(() => { - turnListButton?.click(); - }); - - expect(container.querySelector('[role="dialog"]')).not.toBeNull(); - - const turnItems = Array.from(container.querySelectorAll('.flowchat-header__turn-list-item')); - expect(turnItems).toHaveLength(2); - - act(() => { - turnItems[1]?.click(); - }); - - expect(onJumpToTurn).toHaveBeenCalledWith('turn-2'); - expect(container.querySelector('[role="dialog"]')).toBeNull(); - - act(() => { - root.render(); - }); - - expect(container.querySelector('[role="dialog"]')).toBeNull(); - }); - - it('closes the turn list and notifies the container when selecting the current turn', () => { - const onJumpToTurn = vi.fn(() => true); - + it('omits the list, previous-turn, and next-turn navigation controls', () => { act(() => { - root.render(); - }); - - const turnListButton = container.querySelector('[data-testid="flowchat-header-turn-list"]'); - act(() => { - turnListButton?.click(); - }); - - const currentTurnItem = container.querySelector('.flowchat-header__turn-list-item'); - act(() => { - currentTurnItem?.click(); - }); - - expect(onJumpToTurn).toHaveBeenCalledWith('turn-1'); - expect(container.querySelector('[role="dialog"]')).toBeNull(); - }); - - it('keeps the turn list open when the container rejects the selection', () => { - const onJumpToTurn = vi.fn(() => false); - - act(() => { - root.render(); - }); - - const turnListButton = container.querySelector('[data-testid="flowchat-header-turn-list"]'); - act(() => { - turnListButton?.click(); - }); - - const turnItems = Array.from(container.querySelectorAll('.flowchat-header__turn-list-item')); - act(() => { - turnItems[1]?.click(); + root.render(); }); - expect(onJumpToTurn).toHaveBeenCalledWith('turn-2'); - expect(container.querySelector('[role="dialog"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="flowchat-header-turn-list"]')).toBeNull(); + expect(container.querySelector('[data-testid="flowchat-header-turn-prev"]')).toBeNull(); + expect(container.querySelector('[data-testid="flowchat-header-turn-next"]')).toBeNull(); }); it('renders background activity menus in a portal outside the scrollable panel', () => { 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 60ed8489bf..00c76b6acd 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx @@ -6,7 +6,7 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, useCallback } from 'react'; import { createPortal } from 'react-dom'; -import { Activity, Bot, ChevronDown, ChevronUp, GitPullRequest, Keyboard, List, MoreHorizontal, Search, Square, Terminal, X } from 'lucide-react'; +import { Activity, Bot, ChevronDown, ChevronUp, GitPullRequest, Keyboard, MoreHorizontal, Search, Square, Terminal, X } from 'lucide-react'; import { Tooltip, IconButton, Input } from '@/component-library'; import { useTranslation } from 'react-i18next'; import { SessionFilesBadge } from './SessionFilesBadge'; @@ -15,13 +15,6 @@ import { computeFixedPopoverPosition } from '@/shared/utils/fixedPopoverViewport import { createReviewPlatformTab } from '@/shared/utils/tabUtils'; import './FlowChatHeader.scss'; -export interface FlowChatHeaderTurnSummary { - turnId: string; - turnIndex: number; - backendTurnIndex?: number; - title: string; -} - export interface FlowChatHeaderSubagentSummary { sessionId: string; title: string; @@ -54,20 +47,8 @@ export interface FlowChatHeaderProps { visible: boolean; /** Session ID. */ sessionId?: string; - /** Ordered turn summaries used by header navigation. */ - turns?: FlowChatHeaderTurnSummary[]; - /** Jump to a specific turn. Return false only when the selection is rejected. */ - onJumpToTurn?: (turnId: string) => boolean | void; /** Jump to the currently displayed turn. */ onJumpToCurrentTurn?: () => void; - /** Jump to the previous turn. */ - onJumpToPreviousTurn?: () => void; - /** Jump to the next turn. */ - onJumpToNextTurn?: () => void; - /** Whether the previous-turn action can navigate within the loaded turn range. */ - canJumpToPreviousTurn?: boolean; - /** Whether the next-turn action can navigate within the loaded turn range. */ - canJumpToNextTurn?: boolean; /** Current search query string. */ searchQuery?: string; /** Called when the user types in the search box. */ @@ -109,13 +90,7 @@ export const FlowChatHeader: React.FC = ({ currentUserMessage, visible, sessionId, - turns = [], - onJumpToTurn, onJumpToCurrentTurn, - onJumpToPreviousTurn, - onJumpToNextTurn, - canJumpToPreviousTurn, - canJumpToNextTurn, searchQuery = '', onSearchChange, searchMatchCount = 0, @@ -136,7 +111,6 @@ export const FlowChatHeader: React.FC = ({ }) => { const { t } = useTranslation('flow-chat'); const { currentWorkspace } = useWorkspaceContext(); - const [isTurnListOpen, setIsTurnListOpen] = useState(false); const [isBackgroundActivityPanelOpen, setIsBackgroundActivityPanelOpen] = useState(false); const [openBackgroundSectionMenuId, setOpenBackgroundSectionMenuId] = useState<'subagents' | 'commands' | null>(null); const [openBackgroundSubagentMenuId, setOpenBackgroundSubagentMenuId] = useState(null); @@ -145,11 +119,9 @@ export const FlowChatHeader: React.FC = ({ const headerRef = useRef(null); const leftActionsRef = useRef(null); const rightActionsRef = useRef(null); - const turnListRef = useRef(null); const backgroundActivityPanelRef = useRef(null); const backgroundActivityMenuAnchorRef = useRef(null); const backgroundActivityMenuRef = useRef(null); - const activeTurnItemRef = useRef(null); const searchInputRef = useRef(null); const [backgroundActivityMenuPosition, setBackgroundActivityMenuPosition] = useState<{ top: number; @@ -160,24 +132,13 @@ export const FlowChatHeader: React.FC = ({ const truncatedMessage = currentUserMessage.length > 50 ? currentUserMessage.slice(0, 50) + '...' : currentUserMessage; - const turnListTooltip = t('flowChatHeader.turnList'); - const untitledTurnLabel = t('flowChatHeader.untitledTurn'); const turnBadgeLabel = t('flowChatHeader.turnBadge', { current: currentTurn }); - const previousTurnDisabled = !(canJumpToPreviousTurn ?? currentTurn > 1); - const nextTurnDisabled = !(canJumpToNextTurn ?? (currentTurn > 0 && currentTurn < totalTurns)); - const hasTurnNavigation = turns.length > 0 && !!onJumpToTurn; const hasBackgroundSubagents = backgroundSubagents.length > 0; const hasBackgroundCommands = backgroundCommands.length > 0; const hasBackgroundActivities = hasBackgroundSubagents || hasBackgroundCommands; const backgroundActivityCount = backgroundSubagents.length + backgroundCommands.length; - const displayTurns = useMemo(() => ( - turns.map(turn => ({ - ...turn, - title: turn.title.trim() || untitledTurnLabel, - })) - ), [turns, untitledTurnLabel]); const displayBackgroundSubagents = useMemo(() => ( backgroundSubagents.map((subagent) => ({ ...subagent, @@ -217,16 +178,14 @@ export const FlowChatHeader: React.FC = ({ }, [updateBackgroundActivityMenuPosition]); useEffect(() => { - if (!isTurnListOpen && !isBackgroundActivityPanelOpen) return; + if (!isBackgroundActivityPanelOpen) return; const handlePointerDown = (event: MouseEvent) => { const target = event.target as Node; if ( - !turnListRef.current?.contains(target) && !backgroundActivityPanelRef.current?.contains(target) && !backgroundActivityMenuRef.current?.contains(target) ) { - setIsTurnListOpen(false); setIsBackgroundActivityPanelOpen(false); setOpenBackgroundSectionMenuId(null); setOpenBackgroundSubagentMenuId(null); @@ -236,7 +195,6 @@ export const FlowChatHeader: React.FC = ({ const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { - setIsTurnListOpen(false); setIsBackgroundActivityPanelOpen(false); setOpenBackgroundSectionMenuId(null); setOpenBackgroundSubagentMenuId(null); @@ -251,7 +209,7 @@ export const FlowChatHeader: React.FC = ({ document.removeEventListener('mousedown', handlePointerDown); document.removeEventListener('keydown', handleKeyDown); }; - }, [isBackgroundActivityPanelOpen, isTurnListOpen]); + }, [isBackgroundActivityPanelOpen]); useLayoutEffect(() => { if (!hasOpenBackgroundActivityMenu) { @@ -283,10 +241,6 @@ export const FlowChatHeader: React.FC = ({ } }, [searchOpenRequest]); - useEffect(() => { - setIsTurnListOpen(false); - }, [currentTurn]); - useEffect(() => { if (!hasBackgroundActivities) { setIsBackgroundActivityPanelOpen(false); @@ -306,21 +260,6 @@ export const FlowChatHeader: React.FC = ({ }; }, [isSearchOpen]); - useEffect(() => { - if (!isTurnListOpen) return; - - const frameId = requestAnimationFrame(() => { - activeTurnItemRef.current?.scrollIntoView({ - block: 'center', - inline: 'nearest', - }); - }); - - return () => { - cancelAnimationFrame(frameId); - }; - }, [currentTurn, displayTurns.length, isTurnListOpen]); - useLayoutEffect(() => { const header = headerRef.current; const leftActions = leftActionsRef.current; @@ -378,15 +317,8 @@ export const FlowChatHeader: React.FC = ({ [handleCloseSearch, onSearchNext, onSearchPrev], ); - const handleToggleTurnList = () => { - if (!hasTurnNavigation) return; - setIsBackgroundActivityPanelOpen(false); - setIsTurnListOpen(prev => !prev); - }; - const handleToggleBackgroundActivityPanel = () => { if (!hasBackgroundActivities) return; - setIsTurnListOpen(false); setOpenBackgroundSectionMenuId(null); setOpenBackgroundSubagentMenuId(null); setOpenBackgroundCommandMenuId(null); @@ -397,14 +329,6 @@ export const FlowChatHeader: React.FC = ({ createReviewPlatformTab(currentWorkspace?.rootPath); }, [currentWorkspace?.rootPath]); - const handleTurnSelect = (turnId: string) => { - if (!onJumpToTurn) return; - const accepted = onJumpToTurn(turnId); - if (accepted !== false) { - setIsTurnListOpen(false); - } - }; - const handleSubagentSelect = (sessionId: string) => { onOpenBackgroundSubagent?.(sessionId); setIsBackgroundActivityPanelOpen(false); @@ -962,73 +886,6 @@ export const FlowChatHeader: React.FC = ({ )} -
- - - - - - - - - - - {isTurnListOpen && hasTurnNavigation && ( -
-
- {turnListTooltip} - {currentTurn}/{totalTurns} -
-
- {displayTurns.map(turn => ( - - ))} -
-
- )} -
); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss new file mode 100644 index 0000000000..c78513b8af --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.scss @@ -0,0 +1,118 @@ +.flowchat-turn-rail { + position: absolute; + left: 4px; + top: 20%; + bottom: 20%; + z-index: 8; + width: 30px; + display: flex; + align-items: center; + pointer-events: none; + + &__list { + width: 100%; + max-height: 100%; + display: grid; + grid-auto-rows: 14px; + align-content: start; + padding: 3px 1px; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + pointer-events: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + } + + &__item { + position: relative; + width: 26px; + height: 14px; + margin: 0; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + cursor: pointer; + outline: none; + } + + &__bar { + position: absolute; + left: 2px; + top: 50%; + width: 10px; + height: 2px; + border-radius: 1px; + background: var(--color-text-muted); + opacity: 0.7; + transform: translateY(-50%); + transform-origin: left center; + transition: + width 140ms ease, + height 140ms ease, + background-color 140ms ease, + opacity 140ms ease, + transform 100ms ease; + } + + &__item--visible &__bar { + width: 16px; + height: 2px; + background: var(--color-text-primary); + opacity: 1; + } + + &__item:hover &__bar, + &__item:focus-visible &__bar { + width: 22px; + height: 3px; + background: var(--color-text-primary); + opacity: 1; + } + + &__item:focus-visible &__bar { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-500) 35%, transparent); + } + + &__item:active &__bar { + transform: translateY(-50%) scaleX(0.92); + } + + &__tooltip-content { + min-width: 160px; + max-width: 260px; + display: grid; + gap: 4px; + } + + &__tooltip-turn { + color: var(--color-text-secondary); + font-size: var(--flowchat-font-size-xxs); + font-weight: 600; + line-height: var(--flowchat-compact-line-height); + letter-spacing: 0; + } + + &__tooltip-message { + display: -webkit-box; + overflow: hidden; + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-sm); + line-height: var(--flowchat-support-line-height); + letter-spacing: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; + -webkit-box-orient: vertical; + -webkit-line-clamp: 5; + } +} + +@media (prefers-reduced-motion: reduce) { + .flowchat-turn-rail__bar { + transition: none; + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx new file mode 100644 index 0000000000..44266f89de --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.test.tsx @@ -0,0 +1,187 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { FlowChatTurnRail, type FlowChatTurnRailItem } from './FlowChatTurnRail'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, options?: { current?: number }) => { + if (key === 'flowChatTurnRail.label') return 'Turns'; + if (key === 'flowChatTurnRail.untitledTurn') return 'Untitled turn'; + if (key === 'flowChatHeader.turnBadge') return `Turn ${options?.current ?? 0}`; + return key; + }, + }), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ + children, + content, + }: { + children: React.ReactElement; + content: React.ReactNode; + }) => ( + + {children} + {content} + + ), +})); + +const turns: FlowChatTurnRailItem[] = [ + { turnId: 'turn-1', turnIndex: 1, content: 'First user message' }, + { turnId: 'turn-2', turnIndex: 2, content: 'Second user message' }, + { turnId: 'turn-3', turnIndex: 3, content: 'Third user message' }, + { turnId: 'turn-4', turnIndex: 4, content: 'Fourth user message' }, +]; + +describe('FlowChatTurnRail', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('renders every turn and highlights all viewport turns consistently', () => { + act(() => { + root.render( + , + ); + }); + + const items = container.querySelectorAll('.flowchat-turn-rail__item'); + expect(items).toHaveLength(4); + expect(items[1].getAttribute('aria-current')).toBe('step'); + expect(items[1].className).toContain('flowchat-turn-rail__item--visible'); + expect(items[2].className).toContain('flowchat-turn-rail__item--visible'); + expect(items[1].className).toBe(items[2].className); + expect(items[0].getAttribute('aria-current')).toBeNull(); + expect(items[0].className).not.toContain('flowchat-turn-rail__item--visible'); + }); + + it('shows the turn number and user message in the tooltip without a timestamp', () => { + act(() => { + root.render( + , + ); + }); + + const tooltip = container.querySelector('[data-testid="tooltip-content"]'); + expect(tooltip?.textContent).toContain('Turn 1'); + expect(tooltip?.textContent).toContain('First user message'); + expect(tooltip?.querySelector('.flowchat-turn-rail__tooltip-time')).toBeNull(); + }); + + it('delegates clicks to the shared turn navigation callback', () => { + const onNavigate = vi.fn(); + act(() => { + root.render( + , + ); + }); + + act(() => { + container.querySelector('[data-turn-id="turn-3"]')?.click(); + }); + + expect(onNavigate).toHaveBeenCalledOnce(); + expect(onNavigate).toHaveBeenCalledWith('turn-3'); + }); + + it('keeps the active turn visible by scrolling only the rail list', () => { + act(() => { + root.render( + , + ); + }); + + const list = container.querySelector('.flowchat-turn-rail__list'); + const target = container.querySelector('[data-turn-id="turn-4"]'); + expect(list).not.toBeNull(); + expect(target).not.toBeNull(); + if (!list || !target) return; + + Object.defineProperty(list, 'clientHeight', { configurable: true, value: 40 }); + Object.defineProperty(target, 'offsetTop', { configurable: true, value: 60 }); + Object.defineProperty(target, 'offsetHeight', { configurable: true, value: 20 }); + list.scrollTop = 0; + + act(() => { + root.render( + , + ); + }); + + expect(list.scrollTop).toBe(40); + }); + + it('moves keyboard focus through the vertical turn list', () => { + act(() => { + root.render( + , + ); + }); + + const current = container.querySelector('[data-turn-id="turn-2"]'); + const next = container.querySelector('[data-turn-id="turn-3"]'); + expect(current).not.toBeNull(); + expect(next).not.toBeNull(); + if (!current || !next) return; + + act(() => { + current.focus(); + current.dispatchEvent(new KeyboardEvent('keydown', { + key: 'ArrowDown', + bubbles: true, + })); + }); + + expect(document.activeElement).toBe(next); + expect(next.tabIndex).toBe(0); + expect(current.tabIndex).toBe(-1); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx new file mode 100644 index 0000000000..03803ddca3 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx @@ -0,0 +1,175 @@ +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Tooltip } from '@/component-library'; +import { observeElementResize } from '@/shared/utils/sharedResizeObserver'; +import './FlowChatTurnRail.scss'; + +export interface FlowChatTurnRailItem { + turnId: string; + turnIndex: number; + content: string; +} + +interface FlowChatTurnRailProps { + turns: readonly FlowChatTurnRailItem[]; + currentTurnId: string | null; + visibleTurnIds: readonly string[]; + onNavigate: (turnId: string) => void; +} + +export const FlowChatTurnRail: React.FC = ({ + turns, + currentTurnId, + visibleTurnIds, + onNavigate, +}) => { + const { t } = useTranslation('flow-chat'); + const railRef = useRef(null); + const listRef = useRef(null); + const itemRefs = useRef(new Map()); + const [focusTurnId, setFocusTurnId] = useState( + currentTurnId ?? turns[0]?.turnId ?? null, + ); + const visibleTurnIdSet = useMemo(() => new Set(visibleTurnIds), [visibleTurnIds]); + + useEffect(() => { + const focusTurnStillExists = focusTurnId !== null && turns.some(turn => turn.turnId === focusTurnId); + if (!focusTurnStillExists) { + setFocusTurnId(currentTurnId ?? turns[0]?.turnId ?? null); + return; + } + + if ( + currentTurnId && + railRef.current && + !railRef.current.contains(document.activeElement) + ) { + setFocusTurnId(currentTurnId); + } + }, [currentTurnId, focusTurnId, turns]); + + const keepCurrentTurnVisible = useCallback(() => { + if (!currentTurnId) return; + + const list = listRef.current; + const activeItem = itemRefs.current.get(currentTurnId); + if (!list || !activeItem || list.clientHeight <= 0) return; + + const itemTop = activeItem.offsetTop; + const itemBottom = itemTop + activeItem.offsetHeight; + const visibleTop = list.scrollTop; + const visibleBottom = visibleTop + list.clientHeight; + + if (itemTop < visibleTop) { + list.scrollTop = itemTop; + } else if (itemBottom > visibleBottom) { + list.scrollTop = itemBottom - list.clientHeight; + } + }, [currentTurnId]); + + useLayoutEffect(() => { + keepCurrentTurnVisible(); + }, [keepCurrentTurnVisible, turns.length]); + + useEffect(() => { + const list = listRef.current; + if (!list) return; + + return observeElementResize(list, keepCurrentTurnVisible); + }, [keepCurrentTurnVisible]); + + const focusTurnAt = useCallback((index: number) => { + const turn = turns[index]; + if (!turn) return; + + setFocusTurnId(turn.turnId); + itemRefs.current.get(turn.turnId)?.focus(); + }, [turns]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent, index: number) => { + let nextIndex: number | null = null; + + switch (event.key) { + case 'ArrowUp': + nextIndex = Math.max(0, index - 1); + break; + case 'ArrowDown': + nextIndex = Math.min(turns.length - 1, index + 1); + break; + case 'Home': + nextIndex = 0; + break; + case 'End': + nextIndex = turns.length - 1; + break; + default: + return; + } + + event.preventDefault(); + event.stopPropagation(); + focusTurnAt(nextIndex); + }, [focusTurnAt, turns.length]); + + if (turns.length === 0) return null; + + const navigationLabel = t('flowChatTurnRail.label'); + const untitledTurnLabel = t('flowChatTurnRail.untitledTurn'); + + return ( + + ); +}; + +FlowChatTurnRail.displayName = 'FlowChatTurnRail'; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index e4beb1f9e1..9c3b1f0305 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -241,6 +241,14 @@ function flushAnimationFrame() { }); } +function clickTurnRailItem(container: HTMLElement, turnId: string) { + const item = container.querySelector(`[data-turn-id="${turnId}"]`); + expect(item).not.toBeNull(); + act(() => { + item?.click(); + }); +} + describe('ModernFlowChatContainer historical empty state', () => { let container: HTMLDivElement; let root: Root; @@ -957,6 +965,7 @@ describe('ModernFlowChatContainer historical empty state', () => { turnIndex: 2, totalTurns: 2, userMessage: 'Latest restored prompt', + visibleTurnIds: ['turn-99', 'turn-100'], }; await act(async () => { @@ -966,25 +975,25 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(headerPropsMock.latest).toMatchObject({ currentTurn: 100, totalTurns: 100, - canJumpToPreviousTurn: true, - canJumpToNextTurn: false, }); - expect(headerPropsMock.latest?.turns).toMatchObject([ - { turnId: 'turn-99', turnIndex: 99 }, - { turnId: 'turn-100', turnIndex: 100 }, - ]); + const previousTurnRailItem = container.querySelector('[data-turn-id="turn-99"]'); + const currentTurnRailItem = container.querySelector('[data-turn-id="turn-100"]'); + expect(previousTurnRailItem?.dataset.turnIndex).toBe('99'); + expect(currentTurnRailItem?.dataset.turnIndex).toBe('100'); + expect(currentTurnRailItem?.getAttribute('aria-current')).toBe('step'); + expect(previousTurnRailItem?.className).toContain('flowchat-turn-rail__item--visible'); + expect(currentTurnRailItem?.className).toContain('flowchat-turn-rail__item--visible'); - act(() => { - (headerPropsMock.latest?.onJumpToPreviousTurn as (() => void) | undefined)?.(); - }); + clickTurnRailItem(container, 'turn-99'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-99', { behavior: 'smooth', pinMode: 'transient', }); + }); - it('retries header turn selection without advancing header state until the virtual list accepts it', async () => { + it('retries turn-rail selection without advancing visible-turn state until the virtual list accepts it', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1014,12 +1023,7 @@ describe('ModernFlowChatContainer historical empty state', () => { totalTurns: 2, }); - let initialAccepted = true; - await act(async () => { - initialAccepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? true; - }); - - expect(initialAccepted).toBe(false); + clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'smooth', pinMode: 'transient', @@ -1065,7 +1069,7 @@ describe('ModernFlowChatContainer historical empty state', () => { }); }); - it('delegates accepted header turn selections to the list without container-level retry', async () => { + it('delegates accepted turn-rail selections to the list without container-level retry', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1090,12 +1094,7 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - let accepted = false; - await act(async () => { - accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? false; - }); - - expect(accepted).toBe(true); + clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'smooth', pinMode: 'transient', @@ -1154,12 +1153,7 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - let accepted = false; - await act(async () => { - accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? false; - }); - - expect(accepted).toBe(true); + clickTurnRailItem(container, 'turn-1'); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-1', { behavior: 'smooth', pinMode: 'transient', @@ -1170,7 +1164,7 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(pendingCallCount); }); - it('rejects stale header turn selections without issuing a pin request', async () => { + it('does not render stale turn-rail targets', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1195,16 +1189,11 @@ describe('ModernFlowChatContainer historical empty state', () => { }); const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - let accepted = true; - await act(async () => { - accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-missing') ?? true; - }); - - expect(accepted).toBe(false); + expect(container.querySelector('[data-turn-id="turn-missing"]')).toBeNull(); expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount); }); - it('keeps long-session header turn selections single-shot after the list accepts the pin', async () => { + it('keeps long-session turn-rail selections single-shot after the list accepts the pin', async () => { const turns = Array.from({ length: 25 }, (_, index) => { const turnNumber = index + 1; return createTurn(`turn-${turnNumber}`, `Prompt ${turnNumber}`); @@ -1235,15 +1224,10 @@ describe('ModernFlowChatContainer historical empty state', () => { currentTurn: 25, totalTurns: 25, }); - expect(headerPropsMock.latest?.turns).toHaveLength(25); + expect(container.querySelectorAll('.flowchat-turn-rail__item')).toHaveLength(25); const beforeSelectionCallCount = virtualListMock.pinTurnToTopWithStatus.mock.calls.length; - let accepted = false; - await act(async () => { - accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-7') ?? false; - }); - - expect(accepted).toBe(true); + clickTurnRailItem(container, 'turn-7'); expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); expect(virtualListMock.pinTurnToTopWithStatus).toHaveBeenLastCalledWith('turn-7', { behavior: 'smooth', @@ -1256,7 +1240,7 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(beforeSelectionCallCount + 1); }); - it('cancels not-yet-accepted header turn retry when the user scrolls manually', async () => { + it('cancels a not-yet-accepted turn-navigation retry when the user scrolls manually', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1281,12 +1265,7 @@ describe('ModernFlowChatContainer historical empty state', () => { root.render(); }); - let accepted = true; - await act(async () => { - accepted = (headerPropsMock.latest?.onJumpToTurn as ((turnId: string) => boolean) | undefined)?.('turn-1') ?? true; - }); - - expect(accepted).toBe(false); + clickTurnRailItem(container, 'turn-1'); expect(headerPropsMock.latest).toMatchObject({ currentTurn: 2, totalTurns: 2, @@ -1308,7 +1287,7 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(virtualListMock.pinTurnToTopWithStatus.mock.calls.length).toBe(retryCallCount); }); - it('does not expose previous navigation before the loaded tail range in partial history', async () => { + it('does not synthesize unloaded turn-rail targets in partial history', async () => { stateMocks.activeSession = createSession({ isHistorical: false, historyState: 'ready', @@ -1338,15 +1317,10 @@ describe('ModernFlowChatContainer historical empty state', () => { expect(headerPropsMock.latest).toMatchObject({ currentTurn: 99, totalTurns: 100, - canJumpToPreviousTurn: false, - canJumpToNextTurn: true, }); - - act(() => { - (headerPropsMock.latest?.onJumpToPreviousTurn as (() => void) | undefined)?.(); - }); - - expect(virtualListMock.pinTurnToTop).not.toHaveBeenCalled(); + expect(container.querySelectorAll('.flowchat-turn-rail__item')).toHaveLength(2); + expect(container.querySelector('[data-turn-id="turn-98"]')).toBeNull(); + expect(virtualListMock.pinTurnToTopWithStatus).not.toHaveBeenCalled(); }); it('lets streaming restored sessions use follow-output instead of container sticky anchoring', async () => { 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 b80408a4c8..79e48f0180 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -17,8 +17,8 @@ import { FlowChatHeader, type FlowChatHeaderCommandSummary, type FlowChatHeaderSubagentSummary, - type FlowChatHeaderTurnSummary, } from './FlowChatHeader'; +import { FlowChatTurnRail, type FlowChatTurnRailItem } from './FlowChatTurnRail'; import { BackgroundCommandInputDialog } from '../background-command/BackgroundCommandInputDialog'; import { WelcomePanel } from '../WelcomePanel'; import { HistorySessionPlaceholder } from './HistorySessionPlaceholder'; @@ -103,6 +103,12 @@ interface ModernFlowChatContainerProps { type BackgroundSubagentSummary = BackgroundSubagentActivityItem; +interface FlowChatTurnSummary { + turnId: string; + turnIndex: number; + backendTurnIndex?: number; +} + type BackgroundCommandSummary = { execSessionKey: string; execSessionId: number; @@ -120,7 +126,7 @@ type BackgroundCommandSummary = { const LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS = 8; const HISTORY_INITIAL_CONTENT_PAINT_MAX_ATTEMPTS = 30; const HISTORY_LOADING_LAYER_STALL_WARN_MS = 800; -const HEADER_TURN_PIN_RETRY_MAX_ATTEMPTS = 120; +const TURN_PIN_RETRY_MAX_ATTEMPTS = 120; const MOCK_BACKGROUND_ACTIVITIES_STORAGE_KEY = 'bitfun.flowChat.mockBackgroundActivities'; const MOCK_BACKGROUND_SUBAGENTS: BackgroundSubagentSummary[] = [ @@ -251,8 +257,7 @@ export const ModernFlowChatContainer: React.FC = ( activeSession?.config.dispatchJobId, ); const visibleTurnInfo = useVisibleTurnInfo(); - const [pendingHeaderTurnId, setPendingHeaderTurnId] = useState(null); - const [queuedHeaderTurnPinId, setQueuedHeaderTurnPinId] = useState(null); + const [queuedTurnPinId, setQueuedTurnPinId] = useState(null); const [pendingHistoryOpenSession, setPendingHistoryOpenSession] = useState(null); const [searchOpenRequest, setSearchOpenRequest] = useState(0); // Track whether a slash-command or @-mention popup is open in ChatInput. @@ -274,8 +279,8 @@ export const ModernFlowChatContainer: React.FC = ( const autoPinnedTurnKeyRef = useRef(null); const releasedHistoryCompletionKeyRef = useRef(null); const visibleTurnInfoRef = useRef(visibleTurnInfo); - const turnSummariesRef = useRef([]); - const requestHeaderTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => FlowChatTurnPinRequestStatus) | null>(null); + const turnSummariesRef = useRef([]); + const requestTurnPinRef = useRef<((turnId: string, behavior?: ScrollBehavior) => FlowChatTurnPinRequestStatus) | null>(null); const virtualListRef = useRef(null); const chatScopeRef = useRef(null); const [historyInitialContentReadyKey, setHistoryInitialContentReadyKey] = useState(null); @@ -524,57 +529,53 @@ export const ModernFlowChatContainer: React.FC = ( return null; }, [t]); - const turnSummaryCacheRef = useRef>(new Map()); - - // Clear cache on session change - useEffect(() => { - turnSummaryCacheRef.current.clear(); - }, [activeSession?.sessionId]); - - const turnSummaries = useMemo(() => { - const cache = turnSummaryCacheRef.current; + const turnSummaries = useMemo(() => { const turns = activeSession?.dialogTurns ?? []; - const result: FlowChatHeaderTurnSummary[] = []; - for (let i = 0; i < turns.length; i++) { - const turn = turns[i]; + const result: FlowChatTurnSummary[] = []; + for (const turn of turns) { if (!turn.userMessage) continue; - const cached = cache.get(turn.id); - if (cached) { - result.push({ ...cached, turnIndex: result.length + 1 }); - continue; - } - const summary: FlowChatHeaderTurnSummary = { + result.push({ turnId: turn.id, turnIndex: result.length + 1, backendTurnIndex: turn.backendTurnIndex, - title: resolveLocalCommandHeaderTitle(turn.userMessage?.metadata) - ?? turn.userMessage?.content ?? '', - }; - cache.set(turn.id, summary); - result.push(summary); + }); } return result; - }, [activeSession?.dialogTurns, resolveLocalCommandHeaderTitle]); - const headerTotalTurns = activeSession?.isPartial === true + }, [activeSession?.dialogTurns]); + const sessionTotalTurnCount = activeSession?.isPartial === true ? Math.max(activeSession.totalTurnCount ?? turnSummaries.length, turnSummaries.length) : turnSummaries.length; - const headerTurnIndexOffset = activeSession?.isPartial === true - ? Math.max(0, headerTotalTurns - turnSummaries.length) + const absoluteTurnIndexOffset = activeSession?.isPartial === true + ? Math.max(0, sessionTotalTurnCount - turnSummaries.length) : 0; - const headerTurnSummaries = useMemo(() => { - if (headerTurnIndexOffset === 0 && activeSession?.isPartial !== true) { + const absoluteTurnSummaries = useMemo(() => { + if (absoluteTurnIndexOffset === 0 && activeSession?.isPartial !== true) { return turnSummaries; } return turnSummaries.map(turn => ({ ...turn, turnIndex: typeof turn.backendTurnIndex === 'number' ? turn.backendTurnIndex + 1 - : turn.turnIndex + headerTurnIndexOffset, + : turn.turnIndex + absoluteTurnIndexOffset, + })); + }, [absoluteTurnIndexOffset, activeSession?.isPartial, turnSummaries]); + const absoluteTurnSummaryById = useMemo(() => { + return new Map(absoluteTurnSummaries.map(turn => [turn.turnId, turn])); + }, [absoluteTurnSummaries]); + const turnRailItems = useMemo(() => { + const userMessageByTurnId = new Map( + (activeSession?.dialogTurns ?? []).map(turn => [ + turn.id, + turn.userMessage?.content ?? '', + ]), + ); + + return absoluteTurnSummaries.map(turn => ({ + turnId: turn.turnId, + turnIndex: turn.turnIndex, + content: userMessageByTurnId.get(turn.turnId) ?? '', })); - }, [activeSession?.isPartial, headerTurnIndexOffset, turnSummaries]); - const headerTurnSummaryById = useMemo(() => { - return new Map(headerTurnSummaries.map(turn => [turn.turnId, turn])); - }, [headerTurnSummaries]); + }, [absoluteTurnSummaries, activeSession?.dialogTurns]); const latestTurnId = turnSummaries[turnSummaries.length - 1]?.turnId; const hasPendingHistoryCompletion = activeSession?.sessionId ? flowChatStore.hasPendingSessionHistoryCompletion(activeSession.sessionId) @@ -704,16 +705,11 @@ export const ModernFlowChatContainer: React.FC = ( return { ...navigationVisibleTurnInfo, - turnIndex: headerTurnSummaryById.get(navigationVisibleTurnInfo.turnId)?.turnIndex - ?? navigationVisibleTurnInfo.turnIndex + headerTurnIndexOffset, - totalTurns: headerTotalTurns, + turnIndex: absoluteTurnSummaryById.get(navigationVisibleTurnInfo.turnId)?.turnIndex + ?? navigationVisibleTurnInfo.turnIndex + absoluteTurnIndexOffset, + totalTurns: sessionTotalTurnCount, }; - }, [headerTotalTurns, headerTurnIndexOffset, headerTurnSummaryById, navigationVisibleTurnInfo]); - const canJumpToPreviousTurn = (navigationVisibleTurnInfo?.turnIndex ?? 0) > 1; - const canJumpToNextTurn = !!navigationVisibleTurnInfo && - navigationVisibleTurnInfo.turnIndex > 0 && - navigationVisibleTurnInfo.turnIndex < turnSummaries.length; - + }, [absoluteTurnIndexOffset, absoluteTurnSummaryById, navigationVisibleTurnInfo, sessionTotalTurnCount]); useEffect(() => { visibleTurnInfoRef.current = visibleTurnInfo; }, [visibleTurnInfo]); @@ -735,21 +731,7 @@ export const ModernFlowChatContainer: React.FC = ( return effectiveVisibleTurnInfo?.userMessage ?? ''; }, [activeSession?.dialogTurns, effectiveVisibleTurnInfo?.turnId, effectiveVisibleTurnInfo?.userMessage, resolveLocalCommandHeaderTitle]); - 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]); - - const requestHeaderTurnPin = useCallback((turnId: string, behavior: ScrollBehavior = 'smooth'): FlowChatTurnPinRequestStatus => { + const requestTurnPin = useCallback((turnId: string, behavior: ScrollBehavior = 'smooth'): FlowChatTurnPinRequestStatus => { const isLatestTurn = turnSummaries[turnSummaries.length - 1]?.turnId === turnId; const targetTurn = findDialogTurn(activeSession?.dialogTurns, turnId); const pinMode = isLatestTurn && shouldUseStickyLatestPin(targetTurn) @@ -762,15 +744,14 @@ export const ModernFlowChatContainer: React.FC = ( }) ?? 'rejected'; }, [activeSession?.dialogTurns, turnSummaries]); useEffect(() => { - requestHeaderTurnPinRef.current = requestHeaderTurnPin; - }, [requestHeaderTurnPin]); + requestTurnPinRef.current = requestTurnPin; + }, [requestTurnPin]); const handleVirtualListUserScrollIntent = useCallback(() => { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(null); }, []); useEffect(() => { - if (!queuedHeaderTurnPinId) return; + if (!queuedTurnPinId) return; let cancelled = false; let frameId: number | null = null; @@ -779,30 +760,26 @@ export const ModernFlowChatContainer: React.FC = ( const retry = () => { if (cancelled) return; - if (visibleTurnInfoRef.current?.turnId === queuedHeaderTurnPinId) { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + if (visibleTurnInfoRef.current?.turnId === queuedTurnPinId) { + setQueuedTurnPinId(null); return; } - const targetStillExists = turnSummariesRef.current.some(turn => turn.turnId === queuedHeaderTurnPinId); + const targetStillExists = turnSummariesRef.current.some(turn => turn.turnId === queuedTurnPinId); if (!targetStillExists) { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(null); return; } - const pinStatus = requestHeaderTurnPinRef.current?.(queuedHeaderTurnPinId, 'auto') ?? 'rejected'; + const pinStatus = requestTurnPinRef.current?.(queuedTurnPinId, 'auto') ?? 'rejected'; if (pinStatus === 'settled' || pinStatus === 'pending') { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(null); return; } attempts += 1; - if (attempts >= HEADER_TURN_PIN_RETRY_MAX_ATTEMPTS) { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + if (attempts >= TURN_PIN_RETRY_MAX_ATTEMPTS) { + setQueuedTurnPinId(null); return; } @@ -818,7 +795,7 @@ export const ModernFlowChatContainer: React.FC = ( } }; }, [ - queuedHeaderTurnPinId, + queuedTurnPinId, ]); useLayoutEffect(() => { @@ -829,8 +806,7 @@ export const ModernFlowChatContainer: React.FC = ( useEffect(() => { setHistoryInitialContentReadyKey(null); setHistoryInitialContentPostPaintKey(null); - setPendingHeaderTurnId(null); - setQueuedHeaderTurnPinId(null); + setQueuedTurnPinId(null); }, [activeSession?.sessionId]); useLayoutEffect(() => { @@ -849,7 +825,6 @@ export const ModernFlowChatContainer: React.FC = ( : null; if (latestTurnUsesFollowOutput) { autoPinnedTurnKeyRef.current = resolvedLatestTurnKey; - setPendingHeaderTurnId(null); startupTrace.markPhase('historical_session_latest_anchor_skipped', { sessionId, latestTurnId, @@ -910,8 +885,6 @@ export const ModernFlowChatContainer: React.FC = ( }); } - setPendingHeaderTurnId(resolvedLatestTurnId); - let frameId: number | null = null; let cancelled = false; let attempts = 0; @@ -948,7 +921,6 @@ export const ModernFlowChatContainer: React.FC = ( } if (attempts >= LATEST_TURN_AUTO_PIN_MAX_ATTEMPTS) { - setPendingHeaderTurnId(null); startupTrace.markPhase('historical_session_latest_anchor_failed', { sessionId, latestTurnId: resolvedLatestTurnId, @@ -1113,42 +1085,24 @@ export const ModernFlowChatContainer: React.FC = ( const targetStillExists = turnSummaries.some(turn => turn.turnId === turnId); if (!targetStillExists) { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(null); return false; } - const pinStatus = requestHeaderTurnPin(turnId); + const pinStatus = requestTurnPin(turnId); if (pinStatus === 'settled') { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(null); return true; } if (pinStatus === 'pending') { - setQueuedHeaderTurnPinId(null); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(null); return true; } - setQueuedHeaderTurnPinId(turnId); - setPendingHeaderTurnId(null); + setQueuedTurnPinId(turnId); return false; - }, [requestHeaderTurnPin, turnSummaries]); - - const handleJumpToPreviousTurn = useCallback(() => { - if (!navigationVisibleTurnInfo || navigationVisibleTurnInfo.turnIndex <= 1) return; - const previousTurn = turnSummaries[navigationVisibleTurnInfo.turnIndex - 2]; - if (!previousTurn) return; - handleJumpToTurn(previousTurn.turnId); - }, [handleJumpToTurn, navigationVisibleTurnInfo, turnSummaries]); - - const handleJumpToNextTurn = useCallback(() => { - if (!navigationVisibleTurnInfo || navigationVisibleTurnInfo.turnIndex >= turnSummaries.length) return; - const nextTurn = turnSummaries[navigationVisibleTurnInfo.turnIndex]; - if (!nextTurn) return; - handleJumpToTurn(nextTurn.turnId); - }, [handleJumpToTurn, navigationVisibleTurnInfo, turnSummaries]); + }, [requestTurnPin, turnSummaries]); const handleRetryHistoryLoad = useCallback(() => { const sessionId = activeSession?.sessionId; @@ -1555,16 +1509,10 @@ export const ModernFlowChatContainer: React.FC = ( currentUserMessage={currentHeaderMessage} visible={virtualItems.length > 0} sessionId={activeSession?.sessionId} - turns={headerTurnSummaries} - onJumpToTurn={handleJumpToTurn} onJumpToCurrentTurn={() => { const turnId = effectiveVisibleTurnInfo?.turnId; if (turnId) handleJumpToTurn(turnId); }} - onJumpToPreviousTurn={handleJumpToPreviousTurn} - onJumpToNextTurn={handleJumpToNextTurn} - canJumpToPreviousTurn={canJumpToPreviousTurn} - canJumpToNextTurn={canJumpToNextTurn} searchQuery={searchQuery} onSearchChange={onSearchChange} searchMatchCount={searchMatches.length} @@ -1658,6 +1606,14 @@ export const ModernFlowChatContainer: React.FC = ( /> )} + {virtualItems.length > 0 ? ( + + ) : null} {showHistoryLoadingLayer && (
({ - useVirtualItems: () => [{ - type: 'user-message', - turnId: 'turn-a', - data: { - id: 'user-turn-a', - content: 'turn-a', - timestamp: 1, - }, - }], -})); - -vi.mock('@/infrastructure/i18n', () => ({ - i18nService: { - formatDate: () => 'now', - }, -})); - -describe('ScrollAnchor', () => { - let container: HTMLDivElement; - let root: Root; - - beforeEach(() => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - }); - - afterEach(() => { - act(() => root.unmount()); - container.remove(); - vi.useRealTimers(); - }); - - it('rebinds scroll listeners when the concrete scroller element changes', () => { - vi.useFakeTimers(); - const firstScroller = document.createElement('div'); - const secondScroller = document.createElement('div'); - const onAnchorNavigate = vi.fn(); - - act(() => { - root.render( - , - ); - }); - - act(() => { - firstScroller.dispatchEvent(new Event('scroll')); - }); - expect(container.querySelector('.scroll-anchor')?.className).toContain('scrolling'); - - act(() => { - vi.advanceTimersByTime(801); - }); - expect(container.querySelector('.scroll-anchor')?.className).not.toContain('scrolling'); - - act(() => { - root.render( - , - ); - }); - - act(() => { - firstScroller.dispatchEvent(new Event('scroll')); - }); - expect(container.querySelector('.scroll-anchor')?.className).not.toContain('scrolling'); - - act(() => { - secondScroller.dispatchEvent(new Event('scroll')); - }); - expect(container.querySelector('.scroll-anchor')?.className).toContain('scrolling'); - - }); -}); diff --git a/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx b/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx deleted file mode 100644 index f377fe1042..0000000000 --- a/src/web-ui/src/flow_chat/components/modern/ScrollAnchor.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Scroll anchor component. - * Shows user message markers with hover preview and jump navigation. - */ - -import React, { useState, useMemo, useCallback, useRef, useEffect } from 'react'; -import { useVirtualItems } from '../../store/modernFlowChatStore'; -import { i18nService } from '@/infrastructure/i18n'; -import './ScrollAnchor.scss'; - -interface ScrollAnchorProps { - onAnchorNavigate: (turnId: string) => void; - scrollerRef?: React.RefObject; - scrollerElement?: HTMLElement | null; -} - -interface AnchorPoint { - id: string; - turnId: string; - index: number; - position: number; - content: string; - timestamp: number; - turnNumber: number; -} - - -export const ScrollAnchor: React.FC = ({ - onAnchorNavigate, - scrollerRef, - scrollerElement, -}) => { - const virtualItems = useVirtualItems(); - const [hoveredAnchor, setHoveredAnchor] = useState(null); - const [previewPosition, setPreviewPosition] = useState({ x: 0, y: 0 }); - const [isScrolling, setIsScrolling] = useState(false); - - const scrollTimeoutRef = useRef(null); - const [isHovering, setIsHovering] = useState(false); - - useEffect(() => { - // The ref object is stable while the static-history renderer hands off to - // Virtuoso. Depend on the actual element as well so the listener follows - // that DOM replacement instead of staying attached to a disconnected node. - const scroller = scrollerElement ?? scrollerRef?.current; - if (!scroller) return; - - const handleScroll = () => { - setIsScrolling(true); - - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - - scrollTimeoutRef.current = setTimeout(() => { - setIsScrolling(false); - }, 800); - }; - - scroller.addEventListener('scroll', handleScroll, { passive: true }); - - return () => { - scroller.removeEventListener('scroll', handleScroll); - if (scrollTimeoutRef.current) { - clearTimeout(scrollTimeoutRef.current); - } - }; - }, [scrollerElement, scrollerRef]); - - useEffect(() => { - const scroller = scrollerElement ?? scrollerRef?.current; - if (!scroller) return; - - if (isHovering) { - scroller.classList.add('anchor-hovering'); - } else { - scroller.classList.remove('anchor-hovering'); - } - - return () => { - scroller.classList.remove('anchor-hovering'); - }; - }, [isHovering, scrollerElement, scrollerRef]); - - const anchorPoints = useMemo(() => { - if (virtualItems.length === 0) return []; - - const userMessageItems = virtualItems - .map((item, index) => ({ item, index })) - .filter(({ item }) => item.type === 'user-message'); - - if (userMessageItems.length === 0) return []; - - return userMessageItems.map(({ item, index }, turnIndex) => { - const userMessage = (item as any).data; - const position = 2 + (index / virtualItems.length) * 96; - - return { - id: userMessage.id, - turnId: item.turnId, - index, - position, - content: userMessage.content || '', - timestamp: userMessage.timestamp || Date.now(), - turnNumber: turnIndex + 1, - }; - }); - }, [virtualItems]); - - const handleAnchorClick = useCallback((anchor: AnchorPoint) => { - onAnchorNavigate(anchor.turnId); - - setHoveredAnchor(null); - }, [onAnchorNavigate]); - - const handleAnchorMouseEnter = useCallback((anchor: AnchorPoint, event: React.MouseEvent) => { - setHoveredAnchor(anchor); - setPreviewPosition({ x: event.clientX, y: event.clientY }); - }, []); - - const handleAnchorMouseLeave = useCallback(() => { - setHoveredAnchor(null); - }, []); - - const formatTimestamp = (timestamp: number) => { - const date = new Date(timestamp); - const now = new Date(); - const diff = now.getTime() - date.getTime(); - const minutes = Math.floor(diff / 60000); - const hours = Math.floor(diff / 3600000); - const days = Math.floor(diff / 86400000); - - if (minutes < 1) return 'Just now'; - if (minutes < 60) return `${minutes} min ago`; - if (hours < 24) return `${hours} hr ago`; - if (days < 7) return `${days} days ago`; - - return i18nService.formatDate(date, { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - const truncateContent = (content: string, maxLength: number = 100) => { - if (content.length <= maxLength) return content; - return content.substring(0, maxLength) + '...'; - }; - - const handleContainerMouseEnter = useCallback(() => { - setIsHovering(true); - }, []); - - const handleContainerMouseLeave = useCallback(() => { - setIsHovering(false); - }, []); - - if (anchorPoints.length === 0) return null; - - return ( - <> -
-
- {anchorPoints.map((anchor, idx) => ( -
{ - e.stopPropagation(); - handleAnchorClick(anchor); - }} - onMouseEnter={(e) => handleAnchorMouseEnter(anchor, e)} - onMouseLeave={handleAnchorMouseLeave} - /> - ))} -
-
- - {hoveredAnchor && ( -
-
- - {hoveredAnchor.turnNumber}/{anchorPoints.length} - -
-
- User message - - {formatTimestamp(hoveredAnchor.timestamp)} - -
-
- {truncateContent(hoveredAnchor.content, 150)} -
-
- )} - - ); -}; - -ScrollAnchor.displayName = 'ScrollAnchor'; 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 8d32af4452..186595abbc 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.scss @@ -36,13 +36,6 @@ position: relative; z-index: 1; // Keep scroll content below overlays. overflow-x: hidden; // Disable horizontal scrolling. - - // Show scrollbar thumb when the anchor track is hovered. - &.anchor-hovering { - &::-webkit-scrollbar-thumb { - background: var(--scrollbar-thumb) !important; - } - } } // Ensure the real scroller always spans the full panel width, diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx index 860bf99ae3..fa24d5867a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.session-boundary.test.tsx @@ -67,9 +67,6 @@ const flowDiagnosticsMocks = vi.hoisted(() => ({ enabled: false, trace: vi.fn(), })); -const scrollAnchorMocks = vi.hoisted(() => ({ - targetTurnId: null as string | null, -})); vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ flowChatDiagnostics: { @@ -255,20 +252,6 @@ vi.mock('../StickyTaskIndicator', () => ({ StickyTaskIndicator: () => null, })); -vi.mock('./ScrollAnchor', () => ({ - ScrollAnchor: ({ onAnchorNavigate }: { onAnchorNavigate: (turnId: string) => void }) => ( -