diff --git a/src/web-ui/src/app/scenes/SceneViewport.scss b/src/web-ui/src/app/scenes/SceneViewport.scss index cdaac68cdc..4f8f869194 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.scss +++ b/src/web-ui/src/app/scenes/SceneViewport.scss @@ -57,7 +57,7 @@ margin: 0; } - // ── Lazy chunk loading (matches flow_chat ProcessingIndicator dots) ── + // ── Lazy chunk loading ──────────────────────────────── &__lazy-fallback { position: absolute; @@ -67,13 +67,6 @@ justify-content: center; pointer-events: none; z-index: 1; - - .processing-indicator { - max-width: none; - width: auto; - margin: 0; - padding: 0; - } } // ── Scene slot ──────────────────────────────────────── diff --git a/src/web-ui/src/app/scenes/SceneViewport.tsx b/src/web-ui/src/app/scenes/SceneViewport.tsx index 34bc98a2a0..30e051f6c6 100644 --- a/src/web-ui/src/app/scenes/SceneViewport.tsx +++ b/src/web-ui/src/app/scenes/SceneViewport.tsx @@ -21,7 +21,7 @@ import type { SceneTabId } from '../components/SceneBar/types'; import { useSceneManager } from '../hooks/useSceneManager'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useDialogCompletionNotify } from '../hooks/useDialogCompletionNotify'; -import { ProcessingIndicator } from '@/flow_chat/components/modern/ProcessingIndicator'; +import { DotMatrixLoader } from '@/component-library'; import SettingsScene from './settings/SettingsScene'; import AssistantScene from './assistant/AssistantScene'; import SessionScene from './session/SessionScene'; @@ -195,7 +195,7 @@ const SceneViewport: React.FC = ({ workspacePath, isEntering aria-busy="true" aria-label={t('loading.scenes')} > - + ) : null } diff --git a/src/web-ui/src/app/scenes/assistant/AssistantScene.scss b/src/web-ui/src/app/scenes/assistant/AssistantScene.scss index 83feaeb343..be7b6ffd70 100644 --- a/src/web-ui/src/app/scenes/assistant/AssistantScene.scss +++ b/src/web-ui/src/app/scenes/assistant/AssistantScene.scss @@ -11,12 +11,5 @@ align-items: center; justify-content: center; pointer-events: none; - - .processing-indicator { - max-width: none; - width: auto; - margin: 0; - padding: 0; - } } } diff --git a/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx b/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx index bb01db57c0..2e0bfe9a11 100644 --- a/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx +++ b/src/web-ui/src/app/scenes/assistant/AssistantScene.tsx @@ -2,7 +2,7 @@ import React, { Suspense, lazy, useMemo, useEffect } from 'react'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { WorkspaceKind } from '@/shared/types'; -import { ProcessingIndicator } from '@/flow_chat/components/modern/ProcessingIndicator'; +import { DotMatrixLoader } from '@/component-library'; import { useMyAgentStore } from '../my-agent/myAgentStore'; import './AssistantScene.scss'; @@ -78,7 +78,7 @@ const AssistantScene: React.FC = ({ workspacePath }) => { aria-busy="true" aria-label={t('loading.scenes')} > - + )} > diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index b30f25bf65..ffb86a6a9e 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -18,6 +18,11 @@ import { SessionExecutionState, } from '@/flow_chat/state-machine/types'; import { scheduleModelResponseStatus } from '@/flow_chat/services/flow-chat-manager/RuntimeStatusModule'; +import { + getRuntimeStatus, + showRuntimeStatus, + useRuntimeStatusStore, +} from '@/flow_chat/store/runtimeStatusStore'; const mocks = vi.hoisted(() => ({ listJobs: vi.fn(), @@ -245,10 +250,12 @@ describe('DispatchJobObserver', () => { beforeEach(() => { vi.useFakeTimers(); dispatchJobStore.getState().clear(); + useRuntimeStatusStore.getState().reset(); flowChatStore.setState(() => ({ sessions: new Map(), activeSessionId: null, })); + useRuntimeStatusStore.getState().reset(); stateMachineManager.clear(); mocks.listJobs.mockReset().mockResolvedValue([]); mocks.status.mockReset(); @@ -526,25 +533,6 @@ describe('DispatchJobObserver', () => { it('cancels delayed runtime status rendering when a terminal snapshot drains', async () => { registerRunningJob(); installProcessingProjection(); - flowChatStore.updateDialogTurn('session-1', 'turn-1', turn => ({ - ...turn, - modelRounds: turn.modelRounds.map(round => ({ - ...round, - items: [{ - id: 'runtime-status-main-round-1', - type: 'text', - content: '\u200B', - timestamp: 1, - status: 'streaming', - isStreaming: true, - isMarkdown: false, - runtimeStatus: { - phase: 'waiting_model', - scope: 'main', - }, - }], - })), - })); const context = createTerminalContext(); scheduleModelResponseStatus( context, @@ -553,7 +541,13 @@ describe('DispatchJobObserver', () => { 'round-1', { delayMs: 1000 }, ); + showRuntimeStatus({ + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + }); expect(context.runtimeStatusTimers.size).toBe(1); + expect(getRuntimeStatus('session-1')).toBeDefined(); mocks.status.mockResolvedValue(status({ state: 'succeeded', cursor: 0, @@ -562,28 +556,11 @@ describe('DispatchJobObserver', () => { const cleanup = installDispatchJobObserver(context); await vi.advanceTimersByTimeAsync(0); - const settledItems = flowChatStore - .getState() - .sessions - .get('session-1')! - .dialogTurns[0] - .modelRounds[0] - .items; expect(context.runtimeStatusTimers.size).toBe(0); - expect(settledItems).toHaveLength(0); + expect(getRuntimeStatus('session-1')).toBeUndefined(); await vi.advanceTimersByTimeAsync(1000); - const itemsAfterTimerDeadline = flowChatStore - .getState() - .sessions - .get('session-1')! - .dialogTurns[0] - .modelRounds[0] - .items; - expect(itemsAfterTimerDeadline).toHaveLength(0); - expect(itemsAfterTimerDeadline.some(item => ( - item.type === 'text' && item.runtimeStatus - ))).toBe(false); + expect(getRuntimeStatus('session-1')).toBeUndefined(); cleanup(); }); diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index 5c708cacec..76d7e7285e 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -6,6 +6,7 @@ import { notificationService } from '@/shared/notification-system'; import { agenticEventListener } from '@/flow_chat/services/AgenticEventListener'; import type { FlowChatContext } from '@/flow_chat/services/flow-chat-manager/types'; import { clearRuntimeStatus } from '@/flow_chat/services/flow-chat-manager/RuntimeStatusModule'; +import { clearRuntimeStatusState } from '@/flow_chat/store/runtimeStatusStore'; import { stateMachineManager } from '@/flow_chat/state-machine'; import { SessionExecutionEvent, @@ -258,6 +259,8 @@ function reconcileDispatchTerminalRuntime( const lastTurn = dialogTurns[dialogTurns.length - 1]; if (lastTurn) { clearRuntimeStatus(context, sessionId, lastTurn.id); + } else { + clearRuntimeStatusState({ sessionId }); } context.activeTextItems?.get(sessionId)?.clear(); context.contentBuffers?.get(sessionId)?.clear(); diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss index 4270d0dc45..1ee511670d 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss @@ -136,24 +136,6 @@ } } - &--runtime-status { - display: flex; - align-items: center; - gap: var(--flowchat-control-gap); - min-height: 1.5rem; - color: var(--color-text-muted); - font-size: var(--flowchat-font-size-base); - line-height: var(--flowchat-support-line-height); - opacity: 0.86; - } - - &__runtime-status-icon { - flex: 0 0 auto; - } - - &__runtime-status-text { - overflow-wrap: anywhere; - } } @media (prefers-reduced-motion: reduce) { diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.test.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.test.tsx index 3d749d3619..2a2333e9f5 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.test.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.test.tsx @@ -16,13 +16,6 @@ vi.mock('@/component-library', () => ({ mocks.markdownRenderer(props); return
{props.content}
; }, - DotMatrixLoader: () =>
, -})); - -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: () => [], - }), })); vi.mock('./modern/FlowChatContext', () => ({ @@ -70,4 +63,5 @@ describe('FlowTextBlock', () => { expect(mocks.markdownRenderer).toHaveBeenCalledTimes(1); }); + }); diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx index 0634b88096..ca7ecb9e20 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.tsx @@ -5,9 +5,7 @@ */ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; import { MarkdownRenderer } from '@/component-library'; -import { DotMatrixLoader } from '@/component-library'; import type { MarkdownTraceContext } from '@/component-library'; import type { FlowTextItem } from '../types/flow-chat'; import { useFlowChatContext } from './modern/FlowChatContext'; @@ -34,34 +32,6 @@ interface FlowTextBlockProps { testAttributes?: Record<`data-${string}`, string | number | boolean | undefined>; } -const RuntimeStatusBlock: React.FC> = ({ - textItem, - className = '', - testId, - testAttributes, -}) => { - const { t } = useTranslation('flow-chat/processing-hints'); - const rawHints = t('items', { returnObjects: true }); - const hints = Array.isArray(rawHints) - ? rawHints.filter((item): item is string => typeof item === 'string') - : []; - const hintIndex = hints.length > 0 - ? Math.abs(textItem.id.split('').reduce((acc, ch) => acc + ch.charCodeAt(0), 0)) % hints.length - : 0; - const hint = hints[hintIndex] ?? ''; - - return ( -
- - {hint && {hint}} -
- ); -}; - /** * Use React.memo to avoid unnecessary re-renders. * Re-render only when key textItem fields change. @@ -169,17 +139,6 @@ export const FlowTextBlock = React.memo(({ const isActivelyStreaming = (isStreaming && isContentGrowing) || isRevealing; const markdownTraceContext = isStartupRenderTraceEnabled() ? traceContext : undefined; - if (textItem.runtimeStatus) { - return ( - - ); - } - return (
{ }; }); -vi.mock('../modern/ProcessingIndicator', () => ({ - ProcessingIndicator: () =>
, -})); - -vi.mock('../modern/processingIndicatorVisibility', () => ({ - shouldReserveProcessingIndicatorSpace: () => false, - shouldShowProcessingIndicator: () => false, -})); - vi.mock('../modern/useExploreGroupState', () => ({ useExploreGroupState: () => ({ exploreGroupStates: {}, @@ -72,6 +63,7 @@ vi.mock('./DeepReviewActionBar', () => ({ })); vi.mock('@/component-library', () => ({ + DotMatrixLoader: () => , IconButton: ({ children, onClick, @@ -163,6 +155,7 @@ vi.mock('../../store/modernFlowChatStore', () => ({ vi.mock('../../services/ReviewActionBarPersistenceService', () => ({ loadPersistedReviewState: vi.fn(() => Promise.resolve(null)), + persistReviewActionState: vi.fn(() => Promise.resolve()), })); function createReviewSession(): Session { diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss index d5a67621c0..c20b16c972 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss @@ -142,6 +142,14 @@ } } + &__runtime-status .runtime-status-slot__content { + width: 100%; + max-width: 900px; + margin: 0 auto; + padding: 0 var(--flowchat-content-inline-pad); + box-sizing: border-box; + } + &__action-bar-wrapper { position: absolute; inset: 0; @@ -265,6 +273,12 @@ } } +@media (max-width: 768px) { + .btw-session-panel__runtime-status .runtime-status-slot__content { + padding: 0 var(--flowchat-content-inline-pad-mobile); + } +} + @keyframes btw-session-panel-stop-spin { to { transform: rotate(360deg); 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 5745d2f9c8..57c188e8ed 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -4,11 +4,7 @@ import path from 'path-browserify'; import {CornerUpLeft, Link2, Loader2, Square, Sparkles} from 'lucide-react'; import {FlowChatContext, FlowChatVolatileContext} from '../modern/FlowChatContext'; import {VirtualItemRenderer} from '../modern/VirtualItemRenderer'; -import {ProcessingIndicator} from '../modern/ProcessingIndicator'; -import { - shouldReserveProcessingIndicatorSpace, - shouldShowProcessingIndicator, -} from '../modern/processingIndicatorVisibility'; +import {RuntimeStatusSlot} from '../modern/RuntimeStatusSlot'; import {useExploreGroupState} from '../modern/useExploreGroupState'; import {ScrollToBottomButton} from '@/flow_chat'; import {flowChatStore} from '../../store/FlowChatStore'; @@ -372,52 +368,7 @@ export const BtwSessionPanel: React.FC = ({ }), [exploreGroupStates]); const lastDialogTurn = childSession?.dialogTurns[childSession.dialogTurns.length - 1]; - const lastModelRound = lastDialogTurn?.modelRounds[lastDialogTurn.modelRounds.length - 1]; - const lastItem = lastModelRound?.items[lastModelRound.items.length - 1]; - const lastItemContent = lastItem && 'content' in lastItem ? String((lastItem as any).content || '') : ''; const isTurnProcessing = isActiveReviewTurnStatus(lastDialogTurn?.status); - const [isContentGrowing, setIsContentGrowing] = useState(true); - const lastContentRef = useRef(lastItemContent); - const contentTimeoutRef = useRef | null>(null); - - useEffect(() => { - if (lastItemContent !== lastContentRef.current) { - lastContentRef.current = lastItemContent; - setIsContentGrowing(true); - if (contentTimeoutRef.current) clearTimeout(contentTimeoutRef.current); - contentTimeoutRef.current = setTimeout(() => { - setIsContentGrowing(false); - }, 500); - } - - return () => { - if (contentTimeoutRef.current) { - clearTimeout(contentTimeoutRef.current); - } - }; - }, [lastItemContent]); - - useEffect(() => { - if (!isTurnProcessing) { - setIsContentGrowing(false); - } - }, [isTurnProcessing]); - - const showProcessingIndicator = useMemo(() => { - return shouldShowProcessingIndicator({ - isTurnProcessing, - lastItem, - isContentGrowing, - }); - }, [isTurnProcessing, lastItem, isContentGrowing]); - - const reserveProcessingIndicatorSpace = useMemo(() => { - return shouldReserveProcessingIndicatorSpace({ - isTurnProcessing, - lastItem, - isContentGrowing, - }); - }, [isTurnProcessing, lastItem, isContentGrowing]); const canStopReviewSession = (viewKind === 'review-check' || childKind === 'review' || childKind === 'deep_review') && @@ -1100,20 +1051,18 @@ export const BtwSessionPanel: React.FC = ({
{t('session.empty')}
) : null ) : ( - <> - {virtualItems.map((item, index) => ( - - ))} - ( + - + )) )} +
{ coordinator.release('test-cleanup'); }); + it('coalesces scheduled anchor restores into the existing frame using the latest request', () => { + let scheduledFrame: FrameRequestCallback | null = null; + const requestFrame = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + scheduledFrame = callback; + return 1; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + scroller.append(pinnedItem); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(pinnedItem, 57); + + const coordinator = new FlowChatViewportCoordinator(); + coordinator.pinElement(pinnedItem); + const restoreAnchor = vi.spyOn(coordinator, 'restoreElementAnchor'); + setRect(pinnedItem, 87); + + expect(coordinator.scheduleElementAnchorRestore(scroller, 'scroll-handler:first')).toBe(true); + expect(coordinator.scheduleElementAnchorRestore(scroller, 'scroll-handler:latest')).toBe(true); + expect(requestFrame).toHaveBeenCalledTimes(1); + + expect(scheduledFrame).not.toBeNull(); + (scheduledFrame as FrameRequestCallback)(0); + expect(restoreAnchor).toHaveBeenCalledTimes(1); + expect(restoreAnchor).toHaveBeenCalledWith(scroller, 'scroll-handler:latest'); + expect(scroller.scrollTop).toBe(730); + coordinator.release('test-cleanup'); + }); + + it('cancels a scheduled anchor restore when semantic ownership is released', () => { + let scheduledFrame: FrameRequestCallback | null = null; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + scheduledFrame = callback; + return 17; + }); + const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + + const scroller = document.createElement('div'); + scroller.dataset.virtuosoScroller = 'true'; + const pinnedItem = document.createElement('div'); + scroller.append(pinnedItem); + document.body.append(scroller); + setScrollerGeometry(scroller, 700); + setRect(scroller, 0); + setRect(pinnedItem, 57); + + const coordinator = new FlowChatViewportCoordinator(); + coordinator.pinElement(pinnedItem); + const restoreAnchor = vi.spyOn(coordinator, 'restoreElementAnchor'); + coordinator.scheduleElementAnchorRestore(scroller, 'scroll-handler'); + coordinator.release('stream-end-pinned-item'); + + expect(cancelFrame).toHaveBeenCalledWith(17); + expect(scheduledFrame).not.toBeNull(); + (scheduledFrame as FrameRequestCallback)(0); + expect(restoreAnchor).not.toHaveBeenCalled(); + expect(coordinator.getMode()).toBe('idle'); + }); + it('stops the animation-frame guard after element preservation settles', () => { vi.spyOn(window, 'requestAnimationFrame').mockImplementation(() => 17); const cancelFrame = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); diff --git a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts index d19b153fc0..224b2f9a30 100644 --- a/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts +++ b/src/web-ui/src/flow_chat/components/modern/FlowChatViewportCoordinator.ts @@ -21,6 +21,11 @@ type ElementAnchor = { preservationPhase: 'active' | 'retained' | null; }; +type PendingElementAnchorRestore = { + scroller: HTMLElement; + source: string; +}; + const ELEMENT_ANCHOR_EPSILON_PX = 0.5; const ELEMENT_ANCHOR_RANGE_GUARD_PX = 1; @@ -41,6 +46,7 @@ export class FlowChatViewportCoordinator { private mode: FlowChatViewportAnchorMode = 'idle'; private elementAnchor: ElementAnchor | null = null; private anchorGuardFrame: number | null = null; + private pendingElementAnchorRestore: PendingElementAnchorRestore | null = null; private rangeHost: FlowChatViewportRangeHost | null = null; setRangeHost(host: FlowChatViewportRangeHost | null): void { @@ -62,7 +68,7 @@ export class FlowChatViewportCoordinator { pinItem(reason = 'unspecified'): void { const previousMode = this.mode; - this.stopAnchorGuard(); + this.cancelElementAnchorRestoreWork(); this.elementAnchor = null; this.mode = 'pinned-item'; if (flowChatDiagnostics.isEnabled()) { @@ -102,7 +108,7 @@ export class FlowChatViewportCoordinator { } const previousMode = this.mode; - this.stopAnchorGuard(); + this.cancelElementAnchorRestoreWork(); this.elementAnchor = null; this.mode = 'following-tail'; if (flowChatDiagnostics.isEnabled()) { @@ -146,6 +152,7 @@ export class FlowChatViewportCoordinator { const previousPhase = anchor.preservationPhase; anchor.preservationPhase = 'retained'; this.stopAnchorGuard(); + this.startAnchorGuard(); if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ hypothesis: 'E', @@ -180,6 +187,7 @@ export class FlowChatViewportCoordinator { const elementRect = element.getBoundingClientRect(); const scrollerRect = scroller.getBoundingClientRect(); + this.cancelElementAnchorRestoreWork(); this.elementAnchor = { element, scroller, @@ -289,6 +297,25 @@ export class FlowChatViewportCoordinator { return true; } + scheduleElementAnchorRestore(scroller: HTMLElement, source = 'external'): boolean { + this.validateElementAnchor(`schedule-restore:${source}`); + if ( + !this.elementAnchor || + (this.mode !== 'preserving-element' && this.mode !== 'pinned-item') + ) { + return false; + } + + this.pendingElementAnchorRestore = { scroller, source }; + if (typeof requestAnimationFrame === 'undefined') { + this.pendingElementAnchorRestore = null; + return this.restoreElementAnchor(scroller, source); + } + + this.startAnchorGuard(); + return true; + } + restoreScrollPositionOnce( scroller: HTMLElement, targetScrollTop: number, @@ -314,7 +341,7 @@ export class FlowChatViewportCoordinator { const previousMode = this.mode; const hadElementAnchor = Boolean(this.elementAnchor); const previousPreservationPhase = this.elementAnchor?.preservationPhase ?? null; - this.stopAnchorGuard(); + this.cancelElementAnchorRestoreWork(); this.elementAnchor = null; this.mode = 'idle'; if (flowChatDiagnostics.isEnabled()) { @@ -335,13 +362,17 @@ export class FlowChatViewportCoordinator { } private startAnchorGuard(): void { - if ( - this.anchorGuardFrame !== null || - typeof requestAnimationFrame === 'undefined' || + const shouldContinuouslyGuard = ( + this.mode === 'pinned-item' || ( this.mode === 'preserving-element' && - this.elementAnchor?.preservationPhase === 'retained' + this.elementAnchor?.preservationPhase === 'active' ) + ); + if ( + this.anchorGuardFrame !== null || + typeof requestAnimationFrame === 'undefined' || + (!shouldContinuouslyGuard && !this.pendingElementAnchorRestore) ) { return; } @@ -357,6 +388,11 @@ export class FlowChatViewportCoordinator { this.anchorGuardFrame = null; } + private cancelElementAnchorRestoreWork(): void { + this.pendingElementAnchorRestore = null; + this.stopAnchorGuard(); + } + private runAnchorGuardFrame = (): void => { this.anchorGuardFrame = null; this.validateElementAnchor('anchor-guard'); @@ -366,13 +402,19 @@ export class FlowChatViewportCoordinator { (this.mode !== 'pinned-item' && this.mode !== 'preserving-element') || ( this.mode === 'preserving-element' && - anchor.preservationPhase === 'retained' + anchor.preservationPhase === 'retained' && + !this.pendingElementAnchorRestore ) ) { return; } - this.restoreElementAnchor(anchor.scroller, 'anchor-guard'); + const pendingRestore = this.pendingElementAnchorRestore; + this.pendingElementAnchorRestore = null; + this.restoreElementAnchor( + pendingRestore?.scroller ?? anchor.scroller, + pendingRestore?.source ?? 'anchor-guard', + ); this.startAnchorGuard(); }; } diff --git a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss b/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss deleted file mode 100644 index 0f6a610b5f..0000000000 --- a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.scss +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Processing indicator layout + fun hint text (dot matrix lives in DotMatrixLoader). - */ - -.processing-indicator { - width: 100%; - max-width: 900px; - /* Keep below the Virtuoso footer spacer; tight margin avoids a tall “dead” band above the input. */ - margin: 0 auto 0.35rem auto; - padding: 0 var(--flowchat-content-inline-pad); - box-sizing: border-box; - - // Responsive: small screens - @media (max-width: 768px) { - max-width: 100%; - padding: 0 var(--flowchat-content-inline-pad-mobile); - } -} - -.processing-indicator__content { - display: flex; - align-items: center; - justify-content: flex-start; - gap: var(--flowchat-control-gap); - min-height: 28px; - padding: 0.3rem 0; -} - -// Hint text — use the same low-contrast breathe rhythm as active tool cards. -.processing-indicator__hint { - font-size: 0.8125rem; - color: var(--color-text-secondary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 320px; - // Keep send feedback immediate; only the low-contrast opacity breath remains. - animation: hint-breathe 2.2s ease-in-out infinite; - - @media (max-width: 768px) { - max-width: 200px; - } -} - -@keyframes hint-fade-in { - from { - opacity: 0; - transform: translateX(-4px); - } - to { - opacity: 1; - transform: translateX(0); - } -} - -// Mirrors BaseToolCard's tool-card-text-fade. -@keyframes hint-breathe { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.68; - } -} - -@media (prefers-reduced-motion: reduce) { - .processing-indicator__hint { - animation: none; - } -} diff --git a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx b/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx deleted file mode 100644 index 904591ac47..0000000000 --- a/src/web-ui/src/flow_chat/components/modern/ProcessingIndicator.tsx +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Processing indicator. - * After 1s of continuous processing, shows a compact 3×3 Rubik-style dot matrix and - * rotating fun hint text together (matrix on the left). - * reserveSpace keeps layout height even when hidden. - */ - -import React, { useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { DotMatrixLoader } from '@/component-library'; -import './ProcessingIndicator.scss'; - -interface ProcessingIndicatorProps { - visible: boolean; - /** When true, preserve height to avoid layout jumps. */ - reserveSpace?: boolean; -} - -export const ProcessingIndicator: React.FC = ({ visible, reserveSpace = false }) => { - const { t } = useTranslation('flow-chat/processing-hints'); - const rawHints = t('items', { returnObjects: true }); - const hints = Array.isArray(rawHints) - ? rawHints.filter((item): item is string => typeof item === 'string') - : []; - - const [showHint, setShowHint] = useState(false); - const [hintIndex, setHintIndex] = useState(0); - - const delayTimerRef = useRef | null>(null); - const rotateTimerRef = useRef | null>(null); - - useEffect(() => { - if (visible && hints.length > 0) { - const initialIndex = Math.floor(Math.random() * hints.length); - setHintIndex(initialIndex); - - delayTimerRef.current = setTimeout(() => { - setShowHint(true); - rotateTimerRef.current = setInterval(() => { - setHintIndex(prev => (prev + 1) % hints.length); - }, 5000); - }, 1000); - } else { - if (delayTimerRef.current) { - clearTimeout(delayTimerRef.current); - delayTimerRef.current = null; - } - if (rotateTimerRef.current) { - clearInterval(rotateTimerRef.current); - rotateTimerRef.current = null; - } - setShowHint(false); - } - - return () => { - if (delayTimerRef.current) clearTimeout(delayTimerRef.current); - if (rotateTimerRef.current) clearInterval(rotateTimerRef.current); - }; - }, [visible, hints.length]); - - const shouldRender = visible || reserveSpace; - if (!shouldRender) return null; - - return ( -
-
- {showHint && hints.length > 0 && ( - <> - - - {hints[hintIndex]} - - - )} -
-
- ); -}; diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss new file mode 100644 index 0000000000..56f2087ac0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.scss @@ -0,0 +1,48 @@ +.runtime-status-slot { + width: 100%; + height: 24px; + min-height: 24px; + flex: 0 0 24px; + box-sizing: border-box; + overflow: hidden; + visibility: hidden; + pointer-events: none; + + &--visible { + visibility: visible; + } + + &__content { + display: flex; + align-items: center; + gap: var(--flowchat-control-gap); + height: 24px; + min-width: 0; + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-base); + line-height: var(--flowchat-support-line-height); + opacity: 0.86; + } + + &__hint { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &--footer &__content { + width: 100%; + max-width: 900px; + margin: 0 auto; + padding: 0 var(--flowchat-content-inline-pad); + box-sizing: border-box; + } + + @media (max-width: 768px) { + &--footer &__content { + max-width: 100%; + padding: 0 var(--flowchat-content-inline-pad-mobile); + } + } +} diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx new file mode 100644 index 0000000000..5166911ca0 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.test.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom + +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { RuntimeStatusSlot } from './RuntimeStatusSlot'; +import { useRuntimeStatusStore } from '../../store/runtimeStatusStore'; + +vi.mock('@/component-library', () => ({ + DotMatrixLoader: () => , +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: () => ['Working on it'], + }), +})); + +describe('RuntimeStatusSlot', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + useRuntimeStatusStore.getState().reset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('keeps the same fixed slot mounted while visibility changes', () => { + act(() => { + root.render(); + }); + const slot = container.querySelector('.runtime-status-slot'); + expect(slot).not.toBeNull(); + expect(slot?.dataset.runtimeStatusVisible).toBe('false'); + + act(() => { + useRuntimeStatusStore.getState().show({ + sessionId: 'session-1', + turnId: 'turn-1', + roundId: 'round-1', + }); + }); + expect(container.querySelector('.runtime-status-slot')).toBe(slot); + expect(slot?.dataset.runtimeStatusVisible).toBe('true'); + expect(slot?.textContent).toContain('Working on it'); + + act(() => { + useRuntimeStatusStore.getState().clear({ sessionId: 'session-1' }); + }); + expect(container.querySelector('.runtime-status-slot')).toBe(slot); + expect(slot?.dataset.runtimeStatusVisible).toBe('false'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx new file mode 100644 index 0000000000..32bd7e7772 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/RuntimeStatusSlot.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { DotMatrixLoader } from '@/component-library'; +import { useRuntimeStatusStore } from '../../store/runtimeStatusStore'; +import './RuntimeStatusSlot.scss'; + +interface RuntimeStatusSlotProps { + sessionId?: string | null; + placement?: 'footer' | 'inline'; + className?: string; +} + +function stableHintIndex(seed: string, hintCount: number): number { + if (hintCount === 0) return 0; + const hash = seed.split('').reduce((value, character) => value + character.charCodeAt(0), 0); + return Math.abs(hash) % hintCount; +} + +export const RuntimeStatusSlot: React.FC = ({ + sessionId, + placement = 'inline', + className = '', +}) => { + const status = useRuntimeStatusStore(state => ( + sessionId ? state.bySessionId.get(sessionId) : undefined + )); + const { t } = useTranslation('flow-chat/processing-hints'); + const rawHints = t('items', { returnObjects: true }); + const hints = Array.isArray(rawHints) + ? rawHints.filter((item): item is string => typeof item === 'string') + : []; + const hint = status + ? hints[stableHintIndex(`${status.turnId}:${status.roundId}`, hints.length)] ?? '' + : ''; + const visible = Boolean(status && hint); + + return ( +
+
+ + {hint} +
+
+ ); +}; 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 95abc83b8b..70c5ae2830 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 @@ -8,9 +8,11 @@ import { VirtualMessageList, type VirtualMessageListRef, } from './VirtualMessageList'; +import { FlowChatViewportCoordinator } from './FlowChatViewportCoordinator'; import { clampPinReservationPxToViewport, consumeBottomReservationForContentGrowth, + ensureCollapseReservationForScrollTop, getCanceledUnsettledStickyPinGrowthPx, isTurnPinRequestIdentityCurrent, protectCurrentCollapseReservation, @@ -18,6 +20,7 @@ import { releasePinReservationForUserNavigation, resolveAutoCollapseAnchorScrollTop, resolveProvisionalStickyPinReservationPx, + settleRetainedCollapseReservationForAnchor, settleCollapseReservationForPreservedViewport, shouldBypassShrinkCompensationInTailFollow, shouldPreserveCollapseReservationAfterIntent, @@ -56,8 +59,23 @@ const inputStateMocks = vi.hoisted(() => ({ isExpanded: false, inputHeight: 0, })); +const flowDiagnosticsMocks = vi.hoisted(() => ({ + enabled: false, + trace: vi.fn(), +})); + +vi.mock('@/infrastructure/diagnostics/flowChatDiagnostics', () => ({ + flowChatDiagnostics: { + isEnabled: () => flowDiagnosticsMocks.enabled, + trace: flowDiagnosticsMocks.trace, + }, +})); vi.mock('react-i18next', () => ({ + initReactI18next: { + type: '3rdParty', + init: vi.fn(), + }, useTranslation: () => ({ t: (key: string) => { const translations: Record = { @@ -225,15 +243,6 @@ vi.mock('../StickyTaskIndicator', () => ({ StickyTaskIndicator: () => null, })); -vi.mock('./ProcessingIndicator', () => ({ - ProcessingIndicator: () => null, -})); - -vi.mock('./processingIndicatorVisibility', () => ({ - shouldReserveProcessingIndicatorSpace: () => false, - shouldShowProcessingIndicator: () => false, -})); - vi.mock('./ScrollAnchor', () => ({ ScrollAnchor: () => null, })); @@ -394,6 +403,8 @@ describe('VirtualMessageList session boundary', () => { inputStateMocks.isActive = false; inputStateMocks.isExpanded = false; inputStateMocks.inputHeight = 0; + flowDiagnosticsMocks.enabled = false; + flowDiagnosticsMocks.trace.mockReset(); }); afterEach(() => { @@ -643,6 +654,108 @@ describe('VirtualMessageList session boundary', () => { }); }); + it('extends and protects collapse range needed to restore a captured scroll position', () => { + const currentState = { + collapse: { kind: 'collapse' as const, px: 111.5, floorPx: 0 }, + pin: { + kind: 'pin' as const, + px: 0, + floorPx: 0, + mode: 'transient' as const, + targetTurnId: null, + }, + }; + + const ensured = ensureCollapseReservationForScrollTop(currentState, { + targetScrollTop: 933.33, + scrollHeight: 1_897, + clientHeight: 1_023, + }); + + expect(ensured.collapse.px).toBeCloseTo(171.83, 2); + expect(ensured.collapse.floorPx).toBeCloseTo(171.83, 2); + }); + + it('does not synchronously shrink surplus collapse range while retaining an anchor', () => { + const retained = ensureCollapseReservationForScrollTop({ + collapse: { kind: 'collapse', px: 205.85, floorPx: 0 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }, { + targetScrollTop: 1_003.33, + scrollHeight: 2_242, + clientHeight: 1_023, + }); + + expect(retained).toEqual({ + collapse: { kind: 'collapse', px: 205.85, floorPx: 0 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }); + }); + + it('does not reserve a viewport merely to keep scrollTop zero reachable', () => { + const retained = ensureCollapseReservationForScrollTop({ + collapse: { kind: 'collapse', px: 559.49, floorPx: 559.49 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }, { + targetScrollTop: 0, + scrollHeight: 1_023, + clientHeight: 1_023, + }); + + expect(retained.collapse).toEqual({ + kind: 'collapse', + px: 559.49, + floorPx: 559.49, + }); + expect(settleRetainedCollapseReservationForAnchor(retained, { + targetScrollTop: 0, + scrollHeight: 1_023, + clientHeight: 1_023, + }).collapse).toEqual({ + kind: 'collapse', + px: 0, + floorPx: 0, + }); + }); + + it('settles retained collapse estimates to the physical range required by the anchor', () => { + const settled = settleRetainedCollapseReservationForAnchor({ + collapse: { kind: 'collapse', px: 1_669.04813, floorPx: 559.48962 }, + pin: { + kind: 'pin', + px: 0, + floorPx: 0, + mode: 'transient', + targetTurnId: null, + }, + }, { + targetScrollTop: 662, + scrollHeight: 3_354, + clientHeight: 1_023, + }); + + expect(settled.collapse.px).toBeCloseTo(1.04813, 4); + expect(settled.collapse.floorPx).toBeCloseTo(1.04813, 4); + }); + it('drains a sticky pin floor only from measured content growth', () => { const currentState = { collapse: { kind: 'collapse' as const, px: 20, floorPx: 0 }, @@ -854,6 +967,221 @@ describe('VirtualMessageList session boundary', () => { })); }); + it('restores a following-tail collapse anchor when delayed measurement clamps scrollTop', () => { + const session = createSession('session-a', 'turn-a'); + session.dialogTurns[0].status = 'processing'; + session.dialogTurns[0].modelRounds = [{ + id: 'round-turn-a', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof session.dialogTurns[number]['modelRounds'][number]]; + stateMocks.activeSession = session; + stateMocks.virtualItems = [createItem('turn-a'), createModelItem('turn-a')]; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const anchor = container.querySelector('[data-turn-id="turn-a"]'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(anchor).not.toBeNull(); + if (!scroller || !footer || !anchor) { + return; + } + + let contentHeight = 2_076; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => contentHeight + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 1_100 }, + }); + const footerHeightBeforeCollapse = Number.parseFloat(footer.style.height); + act(() => { + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'tool-a', + toolName: 'Write', + cardHeight: 200, + anchorElement: anchor, + reason: 'auto', + }, + })); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo( + footerHeightBeforeCollapse + 100, + 2, + ); + + contentHeight -= 250; + scroller.scrollTop = 1_050; + act(() => { + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + expect(scroller.scrollTop).toBe(1_100); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo( + footerHeightBeforeCollapse + 151, + 2, + ); + }); + + it('retains following-tail collapse protection after the animation finalizer', () => { + flowDiagnosticsMocks.enabled = true; + const session = createSession('session-a', 'turn-a'); + session.dialogTurns[0].status = 'processing'; + session.dialogTurns[0].modelRounds = [{ + id: 'round-turn-a', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof session.dialogTurns[number]['modelRounds'][number]]; + stateMocks.activeSession = session; + stateMocks.virtualItems = [createItem('turn-a'), createModelItem('turn-a')]; + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const anchor = container.querySelector('[data-turn-id="turn-a"]'); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(anchor).not.toBeNull(); + if (!scroller || !footer || !anchor) { + return; + } + + let contentHeight = 2_076; + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => contentHeight + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 1_100 }, + }); + const footerHeightBeforeCollapse = Number.parseFloat(footer.style.height); + act(() => { + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + vi.useFakeTimers(); + try { + act(() => { + window.dispatchEvent(new CustomEvent('flowchat:tool-card-collapse-intent', { + detail: { + toolId: 'tool-a', + toolName: 'Write', + cardHeight: 200, + anchorElement: anchor, + reason: 'auto', + }, + })); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo( + footerHeightBeforeCollapse + 100, + 2, + ); + + rafCallbacks = []; + act(() => { + vi.advanceTimersByTime(300); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + const provisionalFooterHeight = Number.parseFloat(footer.style.height); + expect(provisionalFooterHeight).toBeCloseTo( + footerHeightBeforeCollapse + 100, + 2, + ); + const findRetainTrace = () => flowDiagnosticsMocks.trace.mock.calls.find(([event]) => ( + event.location === 'VirtualMessageList.retainCollapseRangeForQuietSettlement' + )); + if (!findRetainTrace()) { + act(() => { + vi.advanceTimersByTime(1_000); + }); + } + expect(findRetainTrace()).toBeDefined(); + + act(() => { + vi.advanceTimersByTime(120); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + const settledFooterHeight = Number.parseFloat(footer.style.height); + const settleTrace = flowDiagnosticsMocks.trace.mock.calls.find(([event]) => ( + event.location === 'VirtualMessageList.settleRetainedCollapseRange' + )); + expect(settleTrace).toBeDefined(); + expect(settledFooterHeight).toBeLessThan(provisionalFooterHeight); + expect(scroller.scrollTop).toBe(1_100); + + stateMocks.activeSession = { + ...session, + dialogTurns: session.dialogTurns.map(turn => ({ + ...turn, + status: 'completed', + modelRounds: turn.modelRounds.map(round => ({ + ...round, + status: 'completed', + isStreaming: false, + })), + })), + }; + act(() => { + root.render(); + }); + expect(Number.parseFloat(footer.style.height)).toBeCloseTo(settledFooterHeight, 2); + + contentHeight -= 250; + scroller.scrollTop = 1_050; + act(() => { + scroller.dispatchEvent(new Event('scroll', { bubbles: true })); + }); + + expect(scroller.scrollTop).toBe(1_100); + expect(Number.parseFloat(footer.style.height)).toBeGreaterThan(settledFooterHeight); + + act(() => { + vi.advanceTimersByTime(120); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + const wasSettledAnchorReleased = () => flowDiagnosticsMocks.trace.mock.calls.some(([event]) => ( + event.location === 'VirtualMessageList.releaseSettledCollapseAnchor' + )); + for (let attempt = 0; attempt < 3 && !wasSettledAnchorReleased(); attempt += 1) { + act(() => { + vi.runOnlyPendingTimers(); + }); + for (let frame = 0; frame < 4; frame += 1) { + flushAnimationFrame(); + } + } + expect(wasSettledAnchorReleased()).toBe(true); + } finally { + vi.useRealTimers(); + } + }); + it('keeps static initial history position when background updates arrive after an upward scroll', () => { let nowMs = 1_000; const nowSpy = vi.spyOn(performance, 'now').mockImplementation(() => nowMs); @@ -1080,6 +1408,98 @@ describe('VirtualMessageList session boundary', () => { expect(Number.parseFloat(footer.style.height)).toBe(baselineFooterHeight); }); + it('releases a settled sticky pin at stream end even when its target is no longer rendered', () => { + const listRef = React.createRef(); + const session = createSessionWithTurns('session-a', ['turn-a', 'turn-b']); + const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + latestTurn.status = 'processing'; + latestTurn.modelRounds = [{ + id: 'round-turn-b', + status: 'streaming', + isStreaming: true, + items: [], + startTime: 1, + } as typeof latestTurn.modelRounds[number]]; + stateMocks.activeSession = session; + stateMocks.virtualItems = ['turn-a', 'turn-b'].flatMap(turnId => [ + createItem(turnId), + createModelItem(turnId), + ]); + + act(() => { + root.render(); + }); + + const scroller = container.querySelector('[data-virtuoso-scroller="true"]'); + const footer = container.querySelector('.message-list-footer'); + const target = container.querySelector( + '[data-turn-id="turn-b"][data-item-type="user-message"]', + ); + expect(scroller).not.toBeNull(); + expect(footer).not.toBeNull(); + expect(target).not.toBeNull(); + if (!scroller || !footer || !target) { + return; + } + + Object.defineProperties(scroller, { + clientHeight: { configurable: true, value: 1_000 }, + scrollHeight: { + configurable: true, + get: () => 1_200 + (Number.parseFloat(footer.style.height) || 0), + }, + scrollTop: { configurable: true, writable: true, value: 200 }, + }); + vi.spyOn(scroller, 'getBoundingClientRect').mockReturnValue(createRect({ + top: 0, + bottom: 1_000, + height: 1_000, + })); + const targetDocumentTop = 700; + vi.spyOn(target, 'getBoundingClientRect').mockImplementation(() => { + const top = targetDocumentTop - scroller.scrollTop; + return createRect({ top, bottom: top + 40, height: 40 }); + }); + + let status: ReturnType = 'rejected'; + act(() => { + status = listRef.current?.pinTurnToTopWithStatus('turn-b', { + behavior: 'auto', + pinMode: 'sticky-latest', + }) ?? 'rejected'; + }); + expect(status).toBe('settled'); + expect(scroller.scrollTop).toBe(643); + const footerHeightBeforeStreamEnd = Number.parseFloat(footer.style.height); + expect(footerHeightBeforeStreamEnd).toBe(443); + + const release = vi.spyOn(FlowChatViewportCoordinator.prototype, 'release'); + virtuosoMocks.renderedRange = { start: 0, end: 2 }; + stateMocks.activeSession = { + ...session, + dialogTurns: session.dialogTurns.map(turn => turn.id === latestTurn.id + ? { + ...turn, + status: 'completed', + modelRounds: turn.modelRounds.map(round => ({ + ...round, + status: 'completed', + isStreaming: false, + })), + } + : turn), + }; + act(() => { + root.render(); + }); + + expect(container.querySelector( + '[data-turn-id="turn-b"][data-item-type="user-message"]', + )).toBeNull(); + expect(release).toHaveBeenCalledWith('stream-end-pinned-item'); + expect(Number.parseFloat(footer.style.height)).toBe(footerHeightBeforeStreamEnd); + }); + it('centers the exact text range when navigating to a search match', () => { const listRef = React.createRef(); stateMocks.activeSession = createSessionWithTurns('session-a', ['turn-a', 'turn-b']); 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 a6caec5dec..9248143bcd 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -26,11 +26,7 @@ import { ScrollToTurnHeaderButton } from '../ScrollToTurnHeaderButton'; import { useScrollToTurnHeader } from '../../hooks/useScrollToTurnHeader'; import { useVisibleTaskInfo } from '../../hooks/useVisibleTaskInfo'; import { StickyTaskIndicator } from '../StickyTaskIndicator'; -import { ProcessingIndicator } from './ProcessingIndicator'; -import { - shouldReserveProcessingIndicatorSpace, - shouldShowProcessingIndicator, -} from './processingIndicatorVisibility'; +import { RuntimeStatusSlot } from './RuntimeStatusSlot'; import { ScrollAnchor } from './ScrollAnchor'; import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; import type { FlowChatPinTurnToTopMode } from '../../events/flowchatNavigation'; @@ -71,6 +67,7 @@ import { COMPENSATION_EPSILON_PX, consumeBottomReservationForContentGrowth, createInitialBottomReservationState, + ensureCollapseReservationForScrollTop, getCanceledUnsettledStickyPinGrowthPx, getReservationTotalPx, isTurnPinRequestIdentityCurrent, @@ -81,6 +78,7 @@ import { resolveProvisionalStickyPinReservationPx, sanitizeBottomReservationState, settleCollapseReservationForPreservedViewport, + settleRetainedCollapseReservationForAnchor, shouldBypassShrinkCompensationInTailFollow, shouldPreserveCollapseReservationAfterIntent, shouldClearExpiredProvisionalStickyPin, @@ -121,12 +119,14 @@ const SEARCH_NAVIGATION_MAX_ATTEMPTS = 24; const COLLAPSE_INTENT_TTL_MS = FLOWCHAT_COLLAPSE_INTENT_TTL_MS; const AUTO_COLLAPSE_SETTLE_FRAMES = FLOWCHAT_AUTO_COLLAPSE_SETTLE_FRAMES; const STICKY_PIN_GROWTH_SETTLE_MS = 300; +const RETAINED_COLLAPSE_QUIET_SETTLE_MS = 120; +const RETAINED_COLLAPSE_QUIET_SETTLE_FRAMES = 2; +const RETAINED_COLLAPSE_RELEASE_QUIET_MS = COLLAPSE_INTENT_TTL_MS; type FlowChatVirtuosoContext = { footerRef: React.RefCallback; previousHistoryBoundaryStatusNode: React.ReactNode; - reserveSpaceForIndicator: boolean; - showBreathingIndicator: boolean; + runtimeStatusSessionId: string | null; }; const FlowChatVirtuosoHeader = ({ context }: ContextProp) => ( @@ -137,16 +137,12 @@ const FlowChatVirtuosoHeader = ({ context }: ContextProp) => ( - <> - -
- +
+ +
); const FLOW_CHAT_VIRTUOSO_COMPONENTS: Components = { @@ -220,6 +216,12 @@ interface PendingCollapseIntentState { cumulativeShrinkPx: number; } +interface RetainedCollapseAnchorState { + anchorScrollTop: number; + toolId: string | null; + toolName: string | null; +} + function createInactiveCollapseIntentState(): PendingCollapseIntentState { return { active: false, @@ -474,9 +476,14 @@ const VirtualMessageListSession = forwardRef( createInactiveCollapseIntentState(), ); + const retainedCollapseAnchorRef = useRef(null); const collapseIntentFinalizeTimerRef = useRef(null); const collapseIntentAnimationTimerRef = useRef(null); const collapseIntentSettleFrameRef = useRef(null); + const retainedCollapseSettleTimerRef = useRef(null); + const retainedCollapseSettleFrameRef = useRef(null); + const retainedCollapseReleaseTimerRef = useRef(null); + const retainedCollapseSettleGenerationRef = useRef(0); const pendingStickyPinGrowthRef = useRef<{ targetTurnId: string | null; amountPx: number; @@ -536,7 +543,6 @@ const VirtualMessageListSession = forwardRef { return inputStackFooterPxRef.current + compensationPx; @@ -546,6 +552,22 @@ const VirtualMessageListSession = forwardRef { + retainedCollapseSettleGenerationRef.current += 1; + if (retainedCollapseSettleTimerRef.current !== null) { + window.clearTimeout(retainedCollapseSettleTimerRef.current); + retainedCollapseSettleTimerRef.current = null; + } + if (retainedCollapseSettleFrameRef.current !== null) { + cancelAnimationFrame(retainedCollapseSettleFrameRef.current); + retainedCollapseSettleFrameRef.current = null; + } + if (retainedCollapseReleaseTimerRef.current !== null) { + window.clearTimeout(retainedCollapseReleaseTimerRef.current); + retainedCollapseReleaseTimerRef.current = null; + } + }, []); + const applyFooterHeightToElement = useCallback((footer: HTMLDivElement, compensationPx: number) => { const heightPx = getFooterHeightPx(compensationPx); footer.style.height = `${heightPx}px`; @@ -651,10 +673,12 @@ const VirtualMessageListSession = forwardRef { const previousGeometry = previousScrollerGeometryRef.current; @@ -665,7 +689,11 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX || Math.abs(scroller.clientHeight - previousGeometry.clientHeight) > COMPENSATION_EPSILON_PX; - if (!viewportGeometryChanged || pendingCollapseIntentRef.current.active) { + if ( + !viewportGeometryChanged || + pendingCollapseIntentRef.current.active || + retainedCollapseAnchorRef.current !== null + ) { return false; } @@ -902,6 +930,240 @@ const VirtualMessageListSession = forwardRef { + const currentState = bottomReservationStateRef.current; + const nextState = ensureCollapseReservationForScrollTop(currentState, { + targetScrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }); + const reservationChanged = !areBottomReservationStatesEqual(currentState, nextState); + if (reservationChanged) { + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + } + + const scrollTopBefore = scroller.scrollTop; + const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight); + const restoredScrollTop = Math.min(maxScrollTop, Math.max(scrollTopBefore, targetScrollTop)); + const scrollTopChanged = restoredScrollTop - scrollTopBefore > COMPENSATION_EPSILON_PX; + if (scrollTopChanged) { + scroller.scrollTop = restoredScrollTop; + } + + if (flowChatDiagnostics.isEnabled() && (reservationChanged || scrollTopChanged)) { + flowChatDiagnostics.trace({ + hypothesis: 'E', + location: 'VirtualMessageList.preserveCollapseAnchorScrollTop', + message: 'Collapse anchor range restored synchronously', + data: () => ({ + source, + targetScrollTop, + scrollTopBefore, + scrollTopAfter: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + reservationBefore: currentState, + reservationAfter: bottomReservationStateRef.current, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } + + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( + scroller, + bottomReservationStateRef.current, + ); + recordScrollerGeometry(scroller); + return reservationChanged || scrollTopChanged; + }, [ + applyFooterCompensationNow, + recordScrollerGeometry, + snapshotMeasuredContentHeight, + updateBottomReservationState, + ]); + + const settleRetainedCollapseRange = useCallback((reason: string, generation: number) => { + if (generation !== retainedCollapseSettleGenerationRef.current) { + return; + } + const retainedAnchor = retainedCollapseAnchorRef.current; + const scroller = scrollerElementRef.current; + if (!retainedAnchor || !scroller || pendingCollapseIntentRef.current.active) { + return; + } + + const targetScrollTop = Math.max(retainedAnchor.anchorScrollTop, scroller.scrollTop); + retainedAnchor.anchorScrollTop = targetScrollTop; + const currentState = bottomReservationStateRef.current; + const nextState = settleRetainedCollapseReservationForAnchor(currentState, { + targetScrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }); + const reservationChanged = !areBottomReservationStatesEqual(currentState, nextState); + if (reservationChanged) { + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + } + preserveCollapseAnchorScrollTop(scroller, targetScrollTop, `quiet-settle:${reason}`); + + const releaseGeneration = retainedCollapseSettleGenerationRef.current; + retainedCollapseReleaseTimerRef.current = window.setTimeout(() => { + retainedCollapseReleaseTimerRef.current = null; + if (releaseGeneration !== retainedCollapseSettleGenerationRef.current) { + return; + } + retainedCollapseAnchorRef.current = null; + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'F', + location: 'VirtualMessageList.releaseSettledCollapseAnchor', + message: 'Settled collapse anchor released after layout remained quiet', + data: () => ({ + reason, + retainedAnchor, + reservation: bottomReservationStateRef.current, + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } + }, RETAINED_COLLAPSE_RELEASE_QUIET_MS); + + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'F', + location: 'VirtualMessageList.settleRetainedCollapseRange', + message: 'Retained collapse range settled to current anchor geometry', + data: () => ({ + reason, + retainedAnchor, + targetScrollTop, + reservationBefore: currentState, + reservationAfter: bottomReservationStateRef.current, + releasedPx: Math.max( + 0, + getReservationTotalPx(currentState.collapse) - + getReservationTotalPx(bottomReservationStateRef.current.collapse), + ), + scrollTop: scroller.scrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + coordinatorMode: viewportCoordinatorRef.current.getMode(), + }), + }); + } + }, [ + applyFooterCompensationNow, + preserveCollapseAnchorScrollTop, + updateBottomReservationState, + ]); + + const scheduleRetainedCollapseSettlement = useCallback((reason: string) => { + if ( + retainedCollapseAnchorRef.current === null || + pendingCollapseIntentRef.current.active + ) { + return; + } + + clearRetainedCollapseSettlement(); + const generation = retainedCollapseSettleGenerationRef.current; + retainedCollapseSettleTimerRef.current = window.setTimeout(() => { + retainedCollapseSettleTimerRef.current = null; + const settleAfterFrames = (remainingFrames: number) => { + retainedCollapseSettleFrameRef.current = requestAnimationFrame(() => { + if (generation !== retainedCollapseSettleGenerationRef.current) { + retainedCollapseSettleFrameRef.current = null; + return; + } + if (remainingFrames > 1) { + settleAfterFrames(remainingFrames - 1); + return; + } + retainedCollapseSettleFrameRef.current = null; + settleRetainedCollapseRange(reason, generation); + }); + }; + settleAfterFrames(RETAINED_COLLAPSE_QUIET_SETTLE_FRAMES); + }, RETAINED_COLLAPSE_QUIET_SETTLE_MS); + }, [clearRetainedCollapseSettlement, settleRetainedCollapseRange]); + + const retainCollapseRangeForQuietSettlement = useCallback(( + anchor: RetainedCollapseAnchorState, + reason: string, + ) => { + const scroller = scrollerElementRef.current; + const currentState = bottomReservationStateRef.current; + if (currentState.collapse.px <= COMPENSATION_EPSILON_PX) { + clearRetainedCollapseSettlement(); + retainedCollapseAnchorRef.current = null; + return; + } + const previousRetainedAnchor = retainedCollapseAnchorRef.current; + const nextRetainedAnchor: RetainedCollapseAnchorState = { + anchorScrollTop: Math.max( + anchor.anchorScrollTop, + previousRetainedAnchor?.anchorScrollTop ?? 0, + scroller?.scrollTop ?? 0, + ), + toolId: anchor.toolId ?? previousRetainedAnchor?.toolId ?? null, + toolName: anchor.toolName ?? previousRetainedAnchor?.toolName ?? null, + }; + retainedCollapseAnchorRef.current = nextRetainedAnchor; + + const nextState = scroller + ? ensureCollapseReservationForScrollTop(currentState, { + targetScrollTop: nextRetainedAnchor.anchorScrollTop, + scrollHeight: scroller.scrollHeight, + clientHeight: scroller.clientHeight, + }) + : protectCurrentCollapseReservation(currentState); + if (!areBottomReservationStatesEqual(currentState, nextState)) { + updateBottomReservationState(nextState); + applyFooterCompensationNow(nextState); + } + if (scroller) { + preserveCollapseAnchorScrollTop( + scroller, + nextRetainedAnchor.anchorScrollTop, + `retain:${reason}`, + ); + } + scheduleRetainedCollapseSettlement(reason); + + if (flowChatDiagnostics.isEnabled()) { + flowChatDiagnostics.trace({ + hypothesis: 'E', + location: 'VirtualMessageList.retainCollapseRangeForQuietSettlement', + message: 'Collapse range retained until delayed measurements settle', + data: () => ({ + reason, + retainedAnchor: nextRetainedAnchor, + reservationBefore: currentState, + reservationAfter: bottomReservationStateRef.current, + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + }), + }); + } + }, [ + applyFooterCompensationNow, + clearRetainedCollapseSettlement, + preserveCollapseAnchorScrollTop, + scheduleRetainedCollapseSettlement, + updateBottomReservationState, + ]); + const clearPendingStickyPinGrowth = useCallback((_reason: string) => { if (stickyPinGrowthSettleTimerRef.current !== null) { window.clearTimeout(stickyPinGrowthSettleTimerRef.current); @@ -1117,9 +1379,9 @@ const VirtualMessageListSession = forwardRef { return () => { + clearRetainedCollapseSettlement(); cancelLatestEndAnchorStabilization(); cancelStaticInitialHistoryBottomGuard(); if (historyProjectionHandoffReleaseFrameRef.current !== null) { @@ -2875,7 +3190,11 @@ const VirtualMessageListSession = forwardRef { if (!scrollerElement) { @@ -2972,7 +3291,8 @@ const VirtualMessageListSession = forwardRef { const now = performance.now(); const intent = pendingCollapseIntentRef.current; - const collapseProtectionActive = intent.active; + const retainedCollapseAnchor = retainedCollapseAnchorRef.current; + const collapseProtectionActive = intent.active || retainedCollapseAnchor !== null; // Reactive shrink-clamp restore: in follow + streaming mode, an upward // jump in scrollTop that we did NOT request from JS and that is NOT @@ -2982,12 +3302,9 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX + ) { + preserveCollapseAnchorScrollTop( + scrollerElement, + collapseAnchorScrollTop, + intent.active ? 'scroll-active-collapse' : 'scroll-retained-collapse', + ); + if (!intent.active) { + scheduleRetainedCollapseSettlement('delayed-scroll-clamp'); + } + return; + } if ( intentCheckScrollDelta < -COMPENSATION_EPSILON_PX && isFollowingOutputRef.current && @@ -3049,13 +3385,19 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX && - !collapseProtectionActive + !intent.active ) { const nextScrollTop = scrollerElement.scrollTop; const scrollDelta = nextScrollTop - previousScrollTopRef.current; if (scrollDelta > COMPENSATION_EPSILON_PX) { const nextCompensationState = consumeBottomCompensation(scrollDelta); applyFooterCompensationNow(nextCompensationState); + if (retainedCollapseAnchorRef.current) { + retainedCollapseAnchorRef.current.anchorScrollTop = Math.max( + retainedCollapseAnchorRef.current.anchorScrollTop, + scrollerElement.scrollTop, + ); + } previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( scrollerElement, nextCompensationState, @@ -3063,7 +3405,7 @@ const VirtualMessageListSession = forwardRef).detail; const previousIntent = pendingCollapseIntentRef.current; + clearRetainedCollapseSettlement(); if (flowChatDiagnostics.isEnabled()) { flowChatDiagnostics.trace({ hypothesis: 'E', @@ -3465,6 +3808,7 @@ const VirtualMessageListSession = forwardRef COMPENSATION_EPSILON_PX && + collapseAnchorToRetain !== null + ); + if (shouldRetainFollowingTailCollapse && collapseAnchorToRetain) { + retainCollapseRangeForQuietSettlement( + collapseAnchorToRetain, + 'stream-end', + ); + } const pendingStickyPinGrowthPx = pendingStickyPinGrowthRef.current.amountPx; + const stickyPinTarget = ( + currentReservationState.pin.mode === 'sticky-latest' && + currentReservationState.pin.targetTurnId + ) + ? currentReservationState.pin.targetTurnId + : null; if ( - collapsePx > COMPENSATION_EPSILON_PX || - pendingStickyPinGrowthPx > COMPENSATION_EPSILON_PX - ) { - const stickyPinTarget = ( - currentReservationState.pin.mode === 'sticky-latest' && - currentReservationState.pin.targetTurnId + !shouldRetainFollowingTailCollapse && + ( + collapsePx > COMPENSATION_EPSILON_PX || + pendingStickyPinGrowthPx > COMPENSATION_EPSILON_PX || + stickyPinTarget ) - ? currentReservationState.pin.targetTurnId - : null; + ) { const resolvedPinMetrics = scroller && stickyPinTarget ? resolveTurnPinMetrics( stickyPinTarget, getTotalBottomCompensationPx(currentReservationState), ) : null; - if (stickyPinTarget && !resolvedPinMetrics) { - // Keep the existing reservation until the virtualized target is - // measurable. Clearing it here would necessarily move the viewport. - return; - } - const requiredPinPx = resolvedPinMetrics ? sanitizeReservationPx(resolvedPinMetrics.missingTailSpace) : 0; @@ -4053,38 +4416,98 @@ const VirtualMessageListSession = forwardRef ({ + coordinatorModeBefore, + coordinatorModeAfter: viewportCoordinatorRef.current.getMode(), + ownsElementAnchorBefore, + reservationBefore: currentReservationState, + reservationAfter: bottomReservationStateRef.current, + scrollTop: scroller?.scrollTop ?? null, + scrollHeight: scroller?.scrollHeight ?? null, + clientHeight: scroller?.clientHeight ?? null, + }), + }); + } + + if (scroller) { + previousScrollTopRef.current = scroller.scrollTop; + previousMeasuredHeightRef.current = snapshotMeasuredContentHeight( + scroller, + bottomReservationStateRef.current, + ); + recordScrollerGeometry(scroller); + } }, [ applyFooterCompensationNow, clearCollapseIntentScheduling, clearPendingStickyPinGrowth, + clearTurnPinRequest, getTotalBottomCompensationPx, isStreamingOutput, + recordScrollerGeometry, + retainCollapseRangeForQuietSettlement, resolveTurnPinMetrics, + snapshotMeasuredContentHeight, updateBottomReservationState, ]); @@ -4180,6 +4603,7 @@ const VirtualMessageListSession = forwardRef= virtualItems.length) return; + retainedCollapseAnchorRef.current = null; exitFollowOutput('scroll-to-index'); clearPinReservationForUserNavigation(); @@ -4631,6 +5059,7 @@ const VirtualMessageListSession = forwardRef { + retainedCollapseAnchorRef.current = null; onUserScrollIntent?.(); enterFollowOutput('jump-to-latest'); }, [enterFollowOutput, onUserScrollIntent]); @@ -5089,83 +5519,6 @@ const VirtualMessageListSession = forwardRef { - const dialogTurns = activeSession?.dialogTurns; - const lastDialogTurn = dialogTurns && dialogTurns.length > 0 - ? dialogTurns[dialogTurns.length - 1] - : undefined; - const modelRounds = lastDialogTurn?.modelRounds; - const lastModelRound = modelRounds && modelRounds.length > 0 - ? modelRounds[modelRounds.length - 1] - : undefined; - const items = lastModelRound?.items; - const lastItem = items && items.length > 0 - ? items[items.length - 1] - : undefined; - - const content = lastItem && 'content' in lastItem ? (lastItem as any).content : ''; - const isTurnProcessing = - lastDialogTurn?.status === 'processing' || - lastDialogTurn?.status === 'finishing' || - lastDialogTurn?.status === 'image_analyzing'; - - return { lastItem, lastDialogTurn, content, isTurnProcessing }; - }, [activeSession]); - - const [isContentGrowing, setIsContentGrowing] = useState(true); - const lastContentRef = useRef(lastItemInfo.content); - const contentTimeoutRef = useRef(null); - - useEffect(() => { - const currentContent = lastItemInfo.content; - - if (currentContent !== lastContentRef.current) { - lastContentRef.current = currentContent; - setIsContentGrowing(true); - - if (contentTimeoutRef.current) { - clearTimeout(contentTimeoutRef.current); - } - - contentTimeoutRef.current = setTimeout(() => { - setIsContentGrowing(false); - }, 500); - } - - return () => { - if (contentTimeoutRef.current) { - clearTimeout(contentTimeoutRef.current); - } - }; - }, [lastItemInfo.content]); - - useEffect(() => { - if (!lastItemInfo.isTurnProcessing && !isProcessing) { - setIsContentGrowing(false); - } - }, [lastItemInfo.isTurnProcessing, isProcessing]); - - const showBreathingIndicator = React.useMemo(() => { - return shouldShowProcessingIndicator({ - isTurnProcessing: lastItemInfo.isTurnProcessing, - isSessionProcessing: isProcessing, - processingPhase, - lastItem: lastItemInfo.lastItem, - isContentGrowing, - }); - }, [isProcessing, processingPhase, lastItemInfo, isContentGrowing]); - - const reserveSpaceForIndicator = React.useMemo(() => { - return shouldReserveProcessingIndicatorSpace({ - isTurnProcessing: lastItemInfo.isTurnProcessing, - isSessionProcessing: isProcessing, - processingPhase, - lastItem: lastItemInfo.lastItem, - isContentGrowing, - }); - }, [lastItemInfo.isTurnProcessing, lastItemInfo.lastItem, isProcessing, processingPhase, isContentGrowing]); - const footerHeightPx = getFooterHeightPx(getTotalBottomCompensationPx()); const initialHistoryTransitionState = React.useMemo(() => { if (!activeSessionId || !latestTurnId) { @@ -5848,13 +6201,11 @@ const VirtualMessageListSession = forwardRef ( `${activeSessionId ?? 'no-active-session'}:${getVirtualItemStableKey(item)}` @@ -5925,7 +6276,6 @@ const VirtualMessageListSession = forwardRef ) : null} -
+ > + +
) : ( item.turnId === turnId); if (!targetItem) return; + retainedCollapseAnchorRef.current = null; exitFollowOutput('scroll-to-turn'); clearPinReservationForUserNavigation(); diff --git a/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts b/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts index ff36b07173..4c63da3d40 100644 --- a/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts +++ b/src/web-ui/src/flow_chat/components/modern/flowChatScrollStability.ts @@ -134,9 +134,12 @@ export function settleCollapseReservationForPreservedViewport( geometry.scrollHeight - currentTotalPx, ); const requiredTotalPx = Math.max( - 0, - geometry.scrollTop + geometry.clientHeight - contentHeightWithoutReservation + - sanitizeReservationPx(geometry.rangeGuardPx ?? 1), + getRequiredTotalPxForScrollTop({ + targetScrollTop: geometry.scrollTop, + contentHeightWithoutReservation, + clientHeight: geometry.clientHeight, + rangeGuardPx: geometry.rangeGuardPx, + }), ); const protectedTotalPx = Math.max( getReservationTotalPx(currentState.pin) + currentState.collapse.floorPx, @@ -158,6 +161,80 @@ export function settleCollapseReservationForPreservedViewport( }); } +export function ensureCollapseReservationForScrollTop( + currentState: BottomReservationState, + geometry: { + targetScrollTop: number; + scrollHeight: number; + clientHeight: number; + rangeGuardPx?: number; + }, +): BottomReservationState { + const currentPinPx = getReservationTotalPx(currentState.pin); + const currentCollapsePx = getReservationTotalPx(currentState.collapse); + const currentTotalPx = currentCollapsePx + currentPinPx; + const contentHeightWithoutReservation = Math.max( + 0, + sanitizeReservationPx(geometry.scrollHeight) - currentTotalPx, + ); + const requiredTotalPx = Math.max( + currentPinPx, + getRequiredTotalPxForScrollTop({ + targetScrollTop: geometry.targetScrollTop, + contentHeightWithoutReservation, + clientHeight: geometry.clientHeight, + rangeGuardPx: geometry.rangeGuardPx, + }), + ); + const requiredCollapsePx = Math.max(0, requiredTotalPx - currentPinPx); + const nextCollapsePx = Math.max(currentCollapsePx, requiredCollapsePx); + + return sanitizeBottomReservationState({ + ...currentState, + collapse: { + ...currentState.collapse, + px: nextCollapsePx, + floorPx: Math.max(currentState.collapse.floorPx, requiredCollapsePx), + }, + }); +} + +export function settleRetainedCollapseReservationForAnchor( + currentState: BottomReservationState, + geometry: { + targetScrollTop: number; + scrollHeight: number; + clientHeight: number; + rangeGuardPx?: number; + }, +): BottomReservationState { + const currentPinPx = getReservationTotalPx(currentState.pin); + const currentCollapsePx = getReservationTotalPx(currentState.collapse); + const contentHeightWithoutReservation = Math.max( + 0, + sanitizeReservationPx(geometry.scrollHeight) - currentPinPx - currentCollapsePx, + ); + const requiredTotalPx = Math.max( + currentPinPx, + getRequiredTotalPxForScrollTop({ + targetScrollTop: geometry.targetScrollTop, + contentHeightWithoutReservation, + clientHeight: geometry.clientHeight, + rangeGuardPx: geometry.rangeGuardPx, + }), + ); + const requiredCollapsePx = Math.max(0, requiredTotalPx - currentPinPx); + + return sanitizeBottomReservationState({ + ...currentState, + collapse: { + ...currentState.collapse, + px: requiredCollapsePx, + floorPx: requiredCollapsePx, + }, + }); +} + export function reconcileUnsignaledShrinkReservation( currentState: BottomReservationState, fallbackRequiredCollapsePx: number, @@ -240,6 +317,25 @@ function sanitizeReservationPx(value: number): number { return Number.isFinite(value) ? Math.max(0, value) : 0; } +function getRequiredTotalPxForScrollTop(geometry: { + targetScrollTop: number; + contentHeightWithoutReservation: number; + clientHeight: number; + rangeGuardPx?: number; +}): number { + const targetScrollTop = sanitizeReservationPx(geometry.targetScrollTop); + if (targetScrollTop <= COMPENSATION_EPSILON_PX) { + return 0; + } + return Math.max( + 0, + targetScrollTop + + sanitizeReservationPx(geometry.clientHeight) - + sanitizeReservationPx(geometry.contentHeightWithoutReservation) + + sanitizeReservationPx(geometry.rangeGuardPx ?? 1), + ); +} + export function sanitizeBottomReservationState(state: BottomReservationState): BottomReservationState { const collapsePx = sanitizeReservationPx(state.collapse.px); const collapseFloorPx = Math.min(collapsePx, sanitizeReservationPx(state.collapse.floorPx)); diff --git a/src/web-ui/src/flow_chat/components/modern/processingIndicatorVisibility.test.ts b/src/web-ui/src/flow_chat/components/modern/processingIndicatorVisibility.test.ts deleted file mode 100644 index 200d7f8559..0000000000 --- a/src/web-ui/src/flow_chat/components/modern/processingIndicatorVisibility.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { FlowTextItem, FlowToolItem } from '../../types/flow-chat'; -import { - shouldReserveProcessingIndicatorSpace, - shouldShowProcessingIndicator, -} from './processingIndicatorVisibility'; - -function runtimeStatusItem(): FlowTextItem { - return { - id: 'runtime-status-main-round-1', - type: 'text', - content: '\u200B', - timestamp: 1000, - status: 'streaming', - isStreaming: true, - isMarkdown: false, - runtimeStatus: { - phase: 'waiting_model', - scope: 'main', - }, - }; -} - -describe('processingIndicatorVisibility', () => { - it('hides and does not reserve the footer processing indicator when inline runtime status is visible', () => { - const input = { - isTurnProcessing: true, - isSessionProcessing: true, - processingPhase: 'thinking', - lastItem: runtimeStatusItem(), - isContentGrowing: false, - }; - - expect(shouldShowProcessingIndicator(input)).toBe(false); - expect(shouldReserveProcessingIndicatorSpace(input)).toBe(false); - }); - - it('keeps existing behavior for idle text waits without inline runtime status', () => { - const input = { - isTurnProcessing: true, - isSessionProcessing: false, - processingPhase: 'thinking', - lastItem: { - id: 'answer-1', - type: 'text', - content: 'Partial answer', - timestamp: 1000, - status: 'streaming', - isStreaming: true, - } satisfies FlowTextItem, - isContentGrowing: false, - }; - - expect(shouldShowProcessingIndicator(input)).toBe(true); - expect(shouldReserveProcessingIndicatorSpace(input)).toBe(true); - }); - - it('keeps hiding the footer indicator while a tool card is already running', () => { - const input = { - isTurnProcessing: true, - isSessionProcessing: true, - lastItem: { - id: 'tool-1', - type: 'tool', - toolName: 'Shell', - toolCall: { input: {}, id: 'tool-1' }, - timestamp: 1000, - status: 'running', - } satisfies FlowToolItem, - isContentGrowing: false, - }; - - expect(shouldShowProcessingIndicator(input)).toBe(false); - expect(shouldReserveProcessingIndicatorSpace(input)).toBe(true); - }); -}); diff --git a/src/web-ui/src/flow_chat/components/modern/processingIndicatorVisibility.ts b/src/web-ui/src/flow_chat/components/modern/processingIndicatorVisibility.ts deleted file mode 100644 index 367fb35537..0000000000 --- a/src/web-ui/src/flow_chat/components/modern/processingIndicatorVisibility.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { FlowItem } from '../../types/flow-chat'; - -type ProcessingPhaseLike = string | null | undefined; -type LastFlowItem = FlowItem & { - content?: string; - runtimeStatus?: unknown; -}; - -export interface ProcessingIndicatorVisibilityInput { - isTurnProcessing: boolean; - isSessionProcessing?: boolean; - processingPhase?: ProcessingPhaseLike; - lastItem?: LastFlowItem; - isContentGrowing: boolean; -} - -function hasProcessing(input: ProcessingIndicatorVisibilityInput): boolean { - return input.isTurnProcessing || input.isSessionProcessing === true; -} - -function isInlineRuntimeStatus(item: LastFlowItem | undefined): boolean { - return item?.type === 'text' && Boolean(item.runtimeStatus); -} - -export function shouldShowProcessingIndicator(input: ProcessingIndicatorVisibilityInput): boolean { - if (!hasProcessing(input)) return false; - if (input.processingPhase === 'tool_confirming') return false; - if (isInlineRuntimeStatus(input.lastItem)) return false; - if (!input.lastItem) return true; - - if (input.lastItem.type === 'text' || input.lastItem.type === 'thinking') { - const hasContent = Boolean(input.lastItem.content); - if (hasContent && input.isContentGrowing) return false; - } - - if (input.lastItem.type === 'tool') { - const toolStatus = input.lastItem.status; - if (toolStatus === 'running' || toolStatus === 'streaming' || toolStatus === 'preparing') { - return false; - } - } - - return hasProcessing(input); -} - -export function shouldReserveProcessingIndicatorSpace(input: ProcessingIndicatorVisibilityInput): boolean { - if (!hasProcessing(input)) return false; - if (input.processingPhase === 'tool_confirming') return false; - if (isInlineRuntimeStatus(input.lastItem)) return false; - return true; -} diff --git a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx index f659428612..342307976c 100644 --- a/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx +++ b/src/web-ui/src/flow_chat/components/subagent/SubagentProjectionView.tsx @@ -10,6 +10,8 @@ import { FLOWCHAT_COLLAPSE_DURATION_MS } from '../modern/flowChatCollapseMotion' import { FlowChatStore } from '../../store/FlowChatStore'; import { getSubagentProjectionState } from '../../utils/subagentProjection'; import { ensureBtwSessionAvailable } from '../../services/btwSessionPane'; +import { RuntimeStatusSlot } from '../modern/RuntimeStatusSlot'; +import { useRuntimeStatusStore } from '../../store/runtimeStatusStore'; import './SubagentProjectionView.scss'; interface SubagentProjectionViewProps { @@ -223,6 +225,11 @@ export const SubagentProjectionView: React.FC = ({ ?? projectionState?.session?.sessionId ?? directSubagentSessionId; const items = liveItems; + const runtimeStatus = useRuntimeStatusStore(state => ( + resolvedSubagentSessionId + ? state.bySessionId.get(resolvedSubagentSessionId) + : undefined + )); useEffect(() => { if (!resolvedSubagentSessionId || itemsProp !== undefined) { @@ -318,7 +325,7 @@ export const SubagentProjectionView: React.FC = ({ const shouldRenderProjection = Boolean(resolvedSubagentSessionId) && - items.length > 0; + (items.length > 0 || projectionState?.isRunning === true || Boolean(runtimeStatus)); if (!shouldRenderProjection) { return null; @@ -347,6 +354,7 @@ export const SubagentProjectionView: React.FC = ({ compactText, item.id === lastVisibleItemId, ))} +
diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 6b9bffeb33..5cdbf0a4a8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -1774,7 +1774,7 @@ function handleToolEvent( return; } - clearRuntimeStatus(context, sessionId, turnId); + clearRuntimeStatus(context, sessionId, turnId, { roundId }); touchPendingTurnCompletion(context, sessionId, turnId); const eventData: ToolEventData = { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index 159829a059..1d8d481649 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DialogTurn, FlowTextItem, ModelRound } from '../../types/flow-chat'; +import type { DialogTurn, ModelRound } from '../../types/flow-chat'; import { convertDialogTurnToBackendFormat, debouncedSaveDialogTurn, @@ -94,37 +94,6 @@ describe('PersistenceModule', () => { vi.clearAllMocks(); }); - it('filters transient runtime status items from persisted text items', () => { - const runtimeItem: FlowTextItem = { - id: 'runtime-status', - type: 'text', - content: '\u200B', - timestamp: 1001, - status: 'streaming', - isStreaming: true, - isMarkdown: false, - runtimeStatus: { - phase: 'waiting_model', - scope: 'main', - }, - }; - const realItem: FlowTextItem = { - id: 'real-text', - type: 'text', - content: 'Visible answer', - timestamp: 1002, - status: 'completed', - isStreaming: false, - isMarkdown: true, - }; - const turn = createDialogTurn('processing'); - turn.modelRounds[0].items = [runtimeItem, realItem]; - - const persisted = convertDialogTurnToBackendFormat(turn, 0); - - expect(persisted.modelRounds[0].textItems.map((item: any) => item.id)).toEqual(['real-text']); - }); - it('persists dialog turn token usage metadata when available', () => { const turn = createDialogTurn('completed'); turn.tokenUsage = { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index eb88a53319..8375793507 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -7,7 +7,6 @@ import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext, DialogTurn } from './types'; import { buildSessionMetadata } from '../../utils/sessionMetadata'; import { settleInterruptedDialogTurn } from '../../utils/dialogTurnStability'; -import { isRuntimeStatusItem } from './RuntimeStatusModule'; import { DEFERRED_TOOL_GATEWAY_NAME, effectiveToolInvocation, @@ -94,7 +93,7 @@ export function calculateTurnHash(dialogTurn: DialogTurn): string { lastRoundData: dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1] ? { ...dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1], - items: dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1].items.filter(item => !isRuntimeStatusItem(item)), + items: dialogTurn.modelRounds[dialogTurn.modelRounds.length - 1].items, } : null, error: dialogTurn.error, @@ -416,7 +415,7 @@ export function convertDialogTurnToBackendFormat(dialogTurn: DialogTurn, turnInd renderHints: round.renderHints, textItems: round.items .map((item, index) => ({ item, index })) - .filter(({ item }) => item.type === 'text' && !isRuntimeStatusItem(item)) + .filter(({ item }) => item.type === 'text') .map(({ item, index }) => { return { id: item.id, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.test.ts index 349b755267..09eee54240 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.test.ts @@ -1,12 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { DialogTurn, FlowTextItem, ModelRound } from '../../types/flow-chat'; +import type { DialogTurn, ModelRound } from '../../types/flow-chat'; import { clearRuntimeStatus, scheduleModelResponseStatus, } from './RuntimeStatusModule'; -import enFlowChat from '@/locales/en-US/flow-chat.json'; -import zhCnFlowChat from '@/locales/zh-CN/flow-chat.json'; -import zhTwFlowChat from '@/locales/zh-TW/flow-chat.json'; +import { + getRuntimeStatus, + showRuntimeStatus, + useRuntimeStatusStore, +} from '../../store/runtimeStatusStore'; const SESSION_ID = 'session-1'; const TURN_ID = 'turn-1'; @@ -51,109 +53,95 @@ function createContext(turn = createTurn()): any { sessions: new Map([[SESSION_ID, session]]), activeSessionId: SESSION_ID, }), - addModelRoundItem: (_sessionId: string, _turnId: string, item: any, roundId?: string) => { - const targetRound = turn.modelRounds.find(round => round.id === roundId); - targetRound?.items.push(item); - }, - updateDialogTurn: (_sessionId: string, _turnId: string, updater: (next: DialogTurn) => DialogTurn) => { - const next = updater(turn); - Object.assign(turn, next); - }, }, }; } -function resolveLocalePath(resource: unknown, key: string): unknown { - return key.split('.').reduce((value, segment) => { - if (!value || typeof value !== 'object') { - return undefined; - } - return (value as Record)[segment]; - }, resource); -} - describe('RuntimeStatusModule', () => { beforeEach(() => { vi.useFakeTimers(); + useRuntimeStatusStore.getState().reset(); }); afterEach(() => { vi.useRealTimers(); }); - it('delays model response status so fast responses do not create UI noise', () => { + it('delays model response status without adding a model round item', () => { const turn = createTurn(); const context = createContext(turn); scheduleModelResponseStatus(context, SESSION_ID, TURN_ID, ROUND_ID, { delayMs: 1000 }); vi.advanceTimersByTime(999); - expect(turn.modelRounds[0].items).toHaveLength(0); + expect(getRuntimeStatus(SESSION_ID)).toBeUndefined(); vi.advanceTimersByTime(1); - const [statusItem] = turn.modelRounds[0].items as FlowTextItem[]; - expect(statusItem.runtimeStatus?.phase).toBe('waiting_model'); - expect(statusItem.runtimeStatus?.scope).toBe('main'); - expect(statusItem.runtimeStatus?.messageKey).toBe('runtimeStatus.waitingForModelResponse'); - expect(statusItem.content).toBe('\u200B'); - expect(context.activeTextItems.get(SESSION_ID)?.get(ROUND_ID)).toBe(statusItem.id); + expect(getRuntimeStatus(SESSION_ID)).toEqual({ + sessionId: SESSION_ID, + turnId: TURN_ID, + roundId: ROUND_ID, + }); + expect(turn.modelRounds[0].items).toHaveLength(0); + expect(context.activeTextItems.size).toBe(0); }); - it('uses a runtime status i18n key that exists in every flow-chat locale', () => { - const turn = createTurn(); + it('does not show a delayed status after visible round output arrives', () => { + const turn = createTurn([{ id: 'text-1', type: 'text' }]); const context = createContext(turn); scheduleModelResponseStatus(context, SESSION_ID, TURN_ID, ROUND_ID, { delayMs: 1000 }); vi.advanceTimersByTime(1000); - const [statusItem] = turn.modelRounds[0].items as FlowTextItem[]; - const messageKey = statusItem.runtimeStatus?.messageKey; + expect(getRuntimeStatus(SESSION_ID)).toBeUndefined(); + }); + + it('clears a pending timer and an already-visible session status', () => { + const context = createContext(); + scheduleModelResponseStatus(context, SESSION_ID, TURN_ID, ROUND_ID, { delayMs: 1000 }); + clearRuntimeStatus(context, SESSION_ID, TURN_ID, { roundId: ROUND_ID }); + vi.advanceTimersByTime(1000); + expect(getRuntimeStatus(SESSION_ID)).toBeUndefined(); - expect(messageKey).toBe('runtimeStatus.waitingForModelResponse'); - expect(resolveLocalePath(enFlowChat, messageKey!)).toBe('Waiting for model response...'); - expect(resolveLocalePath(zhCnFlowChat, messageKey!)).toBe('等待模型响应...'); - expect(resolveLocalePath(zhTwFlowChat, messageKey!)).toBe('等待模型回應...'); + showRuntimeStatus({ + sessionId: SESSION_ID, + turnId: TURN_ID, + roundId: ROUND_ID, + }); + clearRuntimeStatus(context, SESSION_ID, TURN_ID, { roundId: ROUND_ID }); + expect(getRuntimeStatus(SESSION_ID)).toBeUndefined(); }); - it('clears a pending status timer before it can render', () => { - const turn = createTurn(); - const context = createContext(turn); + it('does not let an older round clear the status owned by a newer round', () => { + const context = createContext(); + showRuntimeStatus({ + sessionId: SESSION_ID, + turnId: TURN_ID, + roundId: 'round-2', + }); - scheduleModelResponseStatus(context, SESSION_ID, TURN_ID, ROUND_ID, { delayMs: 1000 }); clearRuntimeStatus(context, SESSION_ID, TURN_ID, { roundId: ROUND_ID }); - vi.advanceTimersByTime(1000); - expect(turn.modelRounds[0].items).toHaveLength(0); + expect(getRuntimeStatus(SESSION_ID)?.roundId).toBe('round-2'); }); - it('removes an already-rendered runtime status item without touching real output', () => { - const runtimeItem: FlowTextItem = { - id: 'runtime-status', - type: 'text', - content: '\u200B', - timestamp: 1001, - status: 'streaming', - isStreaming: true, - isMarkdown: false, - runtimeStatus: { - phase: 'waiting_model', - scope: 'main', - }, - }; - const realItem: FlowTextItem = { - id: 'real-text', - type: 'text', - content: 'Hello', - timestamp: 1002, - status: 'streaming', - isStreaming: true, - isMarkdown: true, - }; - const turn = createTurn([runtimeItem, realItem]); + it('cancels an older pending round when a newer round owns the session slot', () => { + const turn = createTurn(); + turn.modelRounds.push({ + ...turn.modelRounds[0], + id: 'round-2', + index: 1, + items: [], + }); const context = createContext(turn); - clearRuntimeStatus(context, SESSION_ID, TURN_ID, { roundId: ROUND_ID }); + scheduleModelResponseStatus(context, SESSION_ID, TURN_ID, ROUND_ID, { delayMs: 500 }); + scheduleModelResponseStatus(context, SESSION_ID, TURN_ID, 'round-2', { delayMs: 1000 }); + + vi.advanceTimersByTime(500); + expect(getRuntimeStatus(SESSION_ID)).toBeUndefined(); - expect(turn.modelRounds[0].items.map(item => item.id)).toEqual(['real-text']); + vi.advanceTimersByTime(500); + expect(getRuntimeStatus(SESSION_ID)?.roundId).toBe('round-2'); }); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.ts index a3ab490545..8054445c33 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/RuntimeStatusModule.ts @@ -1,48 +1,28 @@ /** - * Transient runtime status helpers. + * Session-scoped runtime wait status. * - * These items explain long-running work in the current UI only. They must not - * be treated as assistant output or persisted into session history. + * Runtime status is transient UI state. It never enters model round items, so + * showing or hiding it cannot change the virtualized conversation structure. */ -import type { FlowTextItem } from '../../types/flow-chat'; +import { + clearRuntimeStatusState, + showRuntimeStatus, +} from '../../store/runtimeStatusStore'; import type { DialogTurn, FlowChatContext } from './types'; export const MODEL_RESPONSE_STATUS_DELAY_MS = 1000; -export const DEFAULT_MODEL_RESPONSE_STATUS_MESSAGE_KEY = 'runtimeStatus.waitingForModelResponse'; -const RUNTIME_STATUS_CONTENT = '\u200B'; -type RuntimeStatus = NonNullable; -type RuntimeStatusScope = RuntimeStatus['scope']; interface RuntimeStatusOptions { delayMs?: number; - scope?: RuntimeStatusScope; - messageKey?: string; - subagentSessionId?: string; } interface ClearRuntimeStatusOptions { roundId?: string; - scope?: RuntimeStatusScope; - subagentSessionId?: string; } -export function isRuntimeStatusItem(item: unknown): item is FlowTextItem { - return Boolean( - item && - typeof item === 'object' && - (item as { type?: unknown }).type === 'text' && - (item as { runtimeStatus?: unknown }).runtimeStatus, - ); -} - -function runtimeStatusKey( - sessionId: string, - turnId: string, - roundId: string, - scope: RuntimeStatusScope, -): string { - return `${sessionId}:${turnId}:${roundId}:${scope}`; +function runtimeStatusKey(sessionId: string, turnId: string, roundId: string): string { + return `${sessionId}:${turnId}:${roundId}`; } function getDialogTurn(context: FlowChatContext, sessionId: string, turnId: string): DialogTurn | undefined { @@ -55,52 +35,7 @@ function getDialogTurn(context: FlowChatContext, sessionId: string, turnId: stri } function hasVisibleOutputForRound(turn: DialogTurn, roundId: string): boolean { - const round = turn.modelRounds.find(candidate => candidate.id === roundId); - if (!round) { - return true; - } - - return round.items.some(item => !isRuntimeStatusItem(item)); -} - -function hasVisibleSubagentOutput(turn: DialogTurn, subagentSessionId: string): boolean { - return turn.modelRounds.some(round => - round.items.some(item => { - const maybeSubagentItem = item as { subagentSessionId?: string }; - return maybeSubagentItem.subagentSessionId === subagentSessionId && !isRuntimeStatusItem(item); - }), - ); -} - -function createRuntimeStatusItem( - roundId: string, - options: Required> & RuntimeStatusOptions, -): FlowTextItem { - const now = Date.now(); - return { - id: `runtime-status-${options.scope}-${options.subagentSessionId || roundId}`, - type: 'text', - content: RUNTIME_STATUS_CONTENT, - timestamp: now, - status: 'streaming', - isStreaming: true, - isMarkdown: false, - runtimeStatus: { - phase: 'waiting_model', - scope: options.scope, - messageKey: options.messageKey || DEFAULT_MODEL_RESPONSE_STATUS_MESSAGE_KEY, - }, - ...(options.subagentSessionId && { - subagentSessionId: options.subagentSessionId, - }), - }; -} - -function ensureActiveTextItems(context: FlowChatContext, sessionId: string): Map { - if (!context.activeTextItems.has(sessionId)) { - context.activeTextItems.set(sessionId, new Map()); - } - return context.activeTextItems.get(sessionId)!; + return turn.modelRounds.find(candidate => candidate.id === roundId)?.items.length !== 0; } export function scheduleModelResponseStatus( @@ -110,34 +45,34 @@ export function scheduleModelResponseStatus( roundId: string, options: RuntimeStatusOptions = {}, ): void { - const scope = options.scope || 'main'; - const statusTargetId = options.subagentSessionId || roundId; - const key = runtimeStatusKey(sessionId, turnId, statusTargetId, scope); - const existingTimer = context.runtimeStatusTimers.get(key); - if (existingTimer) { - clearTimeout(existingTimer); + const key = runtimeStatusKey(sessionId, turnId, roundId); + const sessionTimerPrefix = `${sessionId}:`; + for (const [pendingKey, pendingTimer] of context.runtimeStatusTimers.entries()) { + if (!pendingKey.startsWith(sessionTimerPrefix)) { + continue; + } + clearTimeout(pendingTimer); + context.runtimeStatusTimers.delete(pendingKey); } + // A new round owns the session slot immediately, but remains visually hidden + // until the delay elapses. + clearRuntimeStatusState({ sessionId }); + const delayMs = options.delayMs ?? MODEL_RESPONSE_STATUS_DELAY_MS; const timer = setTimeout(() => { context.runtimeStatusTimers.delete(key); const turn = getDialogTurn(context, sessionId, turnId); - if (!turn) { - return; - } - - if (options.subagentSessionId) { - if (hasVisibleSubagentOutput(turn, options.subagentSessionId)) { - return; - } - } else if (hasVisibleOutputForRound(turn, roundId)) { + if (!turn || hasVisibleOutputForRound(turn, roundId)) { return; } - const statusItem = createRuntimeStatusItem(roundId, { ...options, scope }); - context.flowChatStore.addModelRoundItem(sessionId, turnId, statusItem, roundId); - ensureActiveTextItems(context, sessionId).set(roundId, statusItem.id); + showRuntimeStatus({ + sessionId, + turnId, + roundId, + }); }, delayMs); context.runtimeStatusTimers.set(key, timer); @@ -149,86 +84,21 @@ export function clearRuntimeStatus( turnId: string, options: ClearRuntimeStatusOptions = {}, ): void { - let cancelledTimer = false; + const timerPrefix = `${sessionId}:${turnId}:`; + const targetTimerKey = options.roundId + ? runtimeStatusKey(sessionId, turnId, options.roundId) + : null; for (const [key, timer] of context.runtimeStatusTimers.entries()) { - const [timerSessionId, timerTurnId, timerTargetId, timerScope] = key.split(':'); - if (timerSessionId !== sessionId || timerTurnId !== turnId) { - continue; - } - if (options.roundId && timerTargetId !== options.roundId) { - continue; - } - if (options.subagentSessionId && timerTargetId !== options.subagentSessionId) { - continue; - } - if (options.scope && timerScope !== options.scope) { + if (targetTimerKey ? key !== targetTimerKey : !key.startsWith(timerPrefix)) { continue; } clearTimeout(timer); context.runtimeStatusTimers.delete(key); - cancelledTimer = true; - } - - const activeItems = context.activeTextItems.get(sessionId); - const turn = getDialogTurn(context, sessionId, turnId); - const hasMatchingRuntimeStatus = turn?.modelRounds.some(round => { - if (options.roundId && round.id !== options.roundId) { - return false; - } - - return round.items.some(item => { - if (!isRuntimeStatusItem(item)) { - return false; - } - if (options.scope && item.runtimeStatus?.scope !== options.scope) { - return false; - } - if (options.subagentSessionId && item.subagentSessionId !== options.subagentSessionId) { - return false; - } - return true; - }); - }) ?? false; - - if (!hasMatchingRuntimeStatus) { - if (cancelledTimer && options.roundId) { - activeItems?.delete(options.roundId); - } - return; } - context.flowChatStore.updateDialogTurn(sessionId, turnId, turn => { - const removedIds: string[] = []; - const modelRounds = turn.modelRounds.map(round => { - if (options.roundId && round.id !== options.roundId) { - return round; - } - - const items = round.items.filter(item => { - if (!isRuntimeStatusItem(item)) { - return true; - } - if (options.scope && item.runtimeStatus?.scope !== options.scope) { - return true; - } - if (options.subagentSessionId && item.subagentSessionId !== options.subagentSessionId) { - return true; - } - removedIds.push(item.id); - return false; - }); - - return items.length === round.items.length ? round : { ...round, items }; - }); - - if (activeItems && removedIds.length > 0) { - for (const [roundId, itemId] of activeItems.entries()) { - if (removedIds.includes(itemId)) { - activeItems.delete(roundId); - } - } - } - - return { ...turn, modelRounds }; + clearRuntimeStatusState({ + sessionId, + turnId, + roundId: options.roundId, }); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts index b5d8bc0d43..af751f2e65 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.ts @@ -5,6 +5,10 @@ import type { FlowChatContext, FlowTextItem } from './types'; import { clearRuntimeStatus } from './RuntimeStatusModule'; import { isAcpFlowSession } from '../../utils/acpSession'; +import { + clearRuntimeStatusState, + resetRuntimeStatuses, +} from '../../store/runtimeStatusStore'; function resolveAttemptStreamKey(roundId: string, attemptId?: string, attemptIndex?: number): string { if (typeof attemptId === 'string' && attemptId.length > 0) { @@ -154,7 +158,6 @@ export function processNormalTextChunkInternal( } else { context.flowChatStore.updateModelRoundItemSilent(sessionId, turnId, textItemId, { content: cleanedContent, - runtimeStatus: undefined, isStreaming: true, isMarkdown: true, timestamp: Date.now(), @@ -331,6 +334,7 @@ export function cleanupSessionBuffers(context: FlowChatContext, sessionId: strin context.runtimeStatusTimers.delete(key); } } + clearRuntimeStatusState({ sessionId }); // P1-11: Drop terminal-event dedup keys belonging to this session so the // set does not grow unbounded across the lifetime of the app. @@ -360,6 +364,7 @@ export function clearAllBuffers(context: FlowChatContext): void { clearTimeout(timer); } context.runtimeStatusTimers.clear(); + resetRuntimeStatuses(); for (const timer of context.saveDebouncers.values()) { clearTimeout(timer); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index f5558dd68f..87022c75b8 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -268,10 +268,7 @@ function runningStatusRank(status: string | undefined): number { } function substantiveRoundItems(round: ModelRound): AnyFlowItem[] { - return round.items.filter(item => ( - item.type !== 'text' || - !(item as AnyFlowItem & { runtimeStatus?: unknown }).runtimeStatus - )); + return round.items; } type RunningStreamItem = AnyFlowItem & { @@ -435,7 +432,6 @@ function normalizeSupersededItem(item: AnyFlowItem, endedAt: number): AnyFlowIte ...item, isStreaming: false, status: 'completed', - runtimeStatus: undefined, }; } @@ -3778,7 +3774,7 @@ export class FlowChatStore { }, modelRounds: dialogTurn.modelRounds.map((round, roundIndex) => { const textItems = round.items - .filter(item => item.type === 'text' && !(item as any).runtimeStatus) + .filter(item => item.type === 'text') .map(item => ({ id: item.id, content: (item as any).content || '', diff --git a/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts b/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts new file mode 100644 index 0000000000..6f51cb5e24 --- /dev/null +++ b/src/web-ui/src/flow_chat/store/runtimeStatusStore.ts @@ -0,0 +1,67 @@ +import { create } from 'zustand'; + +export interface RuntimeStatusEntry { + sessionId: string; + turnId: string; + roundId: string; +} + +export interface RuntimeStatusFilter { + sessionId: string; + turnId?: string; + roundId?: string; +} + +interface RuntimeStatusStoreState { + bySessionId: Map; + show: (entry: RuntimeStatusEntry) => void; + clear: (filter: RuntimeStatusFilter) => void; + reset: () => void; +} + +function matchesFilter(entry: RuntimeStatusEntry, filter: RuntimeStatusFilter): boolean { + return entry.sessionId === filter.sessionId + && (!filter.turnId || entry.turnId === filter.turnId) + && (!filter.roundId || entry.roundId === filter.roundId); +} + +export const useRuntimeStatusStore = create((set) => ({ + bySessionId: new Map(), + + show: (entry) => set((state) => { + if (state.bySessionId.get(entry.sessionId) === entry) { + return state; + } + const next = new Map(state.bySessionId); + next.set(entry.sessionId, entry); + return { bySessionId: next }; + }), + + clear: (filter) => set((state) => { + const current = state.bySessionId.get(filter.sessionId); + if (!current || !matchesFilter(current, filter)) { + return state; + } + const next = new Map(state.bySessionId); + next.delete(filter.sessionId); + return { bySessionId: next }; + }), + + reset: () => set({ bySessionId: new Map() }), +})); + +export function getRuntimeStatus(sessionId: string): RuntimeStatusEntry | undefined { + return useRuntimeStatusStore.getState().bySessionId.get(sessionId); +} + +export function showRuntimeStatus(entry: RuntimeStatusEntry): void { + useRuntimeStatusStore.getState().show(entry); +} + +export function clearRuntimeStatusState(filter: RuntimeStatusFilter): void { + useRuntimeStatusStore.getState().clear(filter); +} + +export function resetRuntimeStatuses(): void { + useRuntimeStatusStore.getState().reset(); +} diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 5c4adf187f..4bdc321d0d 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -24,7 +24,7 @@ export interface FlowItem { /** * Session-scoped subagent linkage. - * Used by parent Task tools and subagent-targeted runtime status markers. + * Used by parent Task tools and projected subagent output. */ subagentSessionId?: string; } @@ -34,15 +34,6 @@ export interface FlowTextItem extends FlowItem { content: string; isStreaming: boolean; isMarkdown?: boolean; - /** - * Transient runtime status rendered in the current conversation only. - * It is not persisted as assistant content. - */ - runtimeStatus?: { - phase: 'waiting_model' | 'streaming' | 'waiting_tool' | 'running_tool' | 'waiting_permission' | 'saving' | 'recovering'; - scope: 'main' | 'subagent' | 'tool'; - messageKey?: string; - }; } export interface FlowThinkingItem extends FlowItem { diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index dec2d40420..b5dd592c45 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -112,10 +112,6 @@ function latestAssistantSnippet(turn: DialogTurn | undefined): string | undefine const round = turn.modelRounds[roundIndex]; for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) { const item = round.items[itemIndex]; - if (item.type === 'text' && item.runtimeStatus) { - continue; - } - const plainText = item.type === 'thinking' ? markdownToPlainText((item as FlowThinkingItem).content) : item.type === 'text' diff --git a/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts index 39daa6e269..fe1ba2d940 100644 --- a/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts +++ b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.test.ts @@ -134,21 +134,6 @@ describe('formatTranscriptTurnText', () => { expect(text).toContain('````\nbefore\n```\nafter\n````'); }); - it('drops transient runtime status text items', () => { - const turn = runtimeTurn(); - (turn.modelRounds[0].items as any[]).push({ - id: 'x2', - type: 'text', - content: 'Waiting for model…', - isStreaming: true, - timestamp: 5, - status: 'streaming', - runtimeStatus: { phase: 'waiting_model', scope: 'main' }, - }); - - const text = formatTranscriptTurnText(collectRuntimeTurn(turn), 'result', labels); - expect(text).not.toContain('Waiting for model'); - }); }); describe('collectPersistedTurn', () => { diff --git a/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts index d3097b86f0..c495f0ea17 100644 --- a/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts +++ b/src/web-ui/src/flow_chat/utils/dialogTranscriptExport.ts @@ -140,8 +140,7 @@ function collectRuntimeRound(round: ModelRound): TranscriptExportRound { if (item.type === 'text') { const textItem = item as FlowTextItem; const content = normalizeText(textItem.content); - // Transient runtime status lines are presentation-only. - if (content && !textItem.runtimeStatus) { + if (content) { items.push({ kind: 'text', content }); } return;