From df6561e2717ec7bf08cc0eca1ae6f21f4e5507ce Mon Sep 17 00:00:00 2001 From: GCWing Date: Tue, 29 Sep 2026 17:44:22 +0800 Subject: [PATCH] feat(flowchat): show foreground send state and stabilize scroll extent --- .../modern/ModernFlowChatContainer.tsx | 45 ++++-- .../components/modern/UserMessageItem.scss | 60 +++++++- .../components/modern/UserMessageItem.tsx | 40 ++++-- .../components/modern/VirtualItemRenderer.tsx | 2 + .../components/modern/VirtualMessageList.tsx | 12 +- ...owChatFollowOutput.leading-extent.test.tsx | 106 ++++++++++++++ .../modern/useFlowChatFollowOutput.test.tsx | 30 +++- .../modern/useFlowChatFollowOutput.ts | 16 +++ .../modern/useFlowChatLeadingExtent.test.tsx | 91 +++++++++++- .../modern/useFlowChatLeadingExtent.ts | 136 ++++++++++++------ .../modern/useSubmittedMessageMotion.test.tsx | 44 ++++-- .../modern/useSubmittedMessageMotion.ts | 61 ++++++-- .../src/flow_chat/hooks/useMessageSender.ts | 1 + .../src/flow_chat/services/FlowChatManager.ts | 1 + .../flow-chat-manager/EventHandlerModule.ts | 8 +- .../flow-chat-manager/MessageModule.ts | 41 ++++++ .../src/flow_chat/services/hostDialogQueue.ts | 9 +- .../submittedMessagePresentation.test.ts | 30 ++++ .../services/submittedMessagePresentation.ts | 115 +++++++++++++++ .../services/submittedMessageScrollIntent.ts | 15 +- .../local/LocalSessionDriver.ts | 9 +- .../src/flow_chat/session-drivers/shared.ts | 10 +- .../src/flow_chat/session-drivers/types.ts | 4 +- .../flow_chat/types/flow-chat-projection.ts | 3 + src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 27 files changed, 791 insertions(+), 101 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.leading-extent.test.tsx diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index ee036e09b0..a3f48dd63e 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -4,7 +4,7 @@ import { requireSessionOwningWorkspaceId } from '../../utils/sessionOrdering'; * Uses virtual scrolling with Zustand and syncs legacy store state. */ -import React, { useMemo, useCallback, useRef, useEffect, useLayoutEffect, useState } from 'react'; +import React, { useMemo, useCallback, useRef, useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { useShortcut } from '@/infrastructure/hooks/useShortcut'; import { FlowChatManager } from '@/flow_chat/services/FlowChatManager'; @@ -43,6 +43,7 @@ import { useVirtualItems, useActiveSession, useVisibleTurnInfo, + type VirtualItem, type VisibleTurnInfo, } from '../../store/modernFlowChatStore'; import type { Session, SessionHistoryPresentation } from '../../types/flow-chat'; @@ -124,6 +125,7 @@ import { } from '../../utils/flowChatTurnIdentity'; import type { FlowChatViewportSnapshot } from './flowChatViewportSnapshot'; import { peekConversationViewTransfer, registerConversationReader, takeConversationViewTransfer } from './flowChatViewHandoff'; +import { getSubmittedMessagePreviews, subscribeSubmittedMessagePreviews } from '../../services/submittedMessagePresentation'; const log = createLogger('ModernFlowChatContainer'); @@ -305,6 +307,12 @@ export const ModernFlowChatContainer: React.FC = ( const { t } = useTranslation('flow-chat'); const canonicalVirtualItems = useVirtualItems(); const activeSession = useActiveSession(); + const surfaceScope = getActiveSurfaceScope(); + const submittedPreviews = useSyncExternalStore( + subscribeSubmittedMessagePreviews, + () => getSubmittedMessagePreviews(surfaceScope, activeSession?.sessionId ?? ''), + () => getSubmittedMessagePreviews(surfaceScope, ''), + ); const [historyPresentation, setHistoryPresentation] = useState(null); const [viewportIntent, setViewportIntent] = useState(null); const [continuousProjectionSessionId, setContinuousProjectionSessionId] = useState(null); @@ -447,25 +455,43 @@ export const ModernFlowChatContainer: React.FC = ( isReadingTurnViewport && !renderedTranscriptReachesLatestTurn ); const virtualItems = useMemo(() => { + let items: VirtualItem[]; if (!activeSession || !renderedHistoryPresentation) { - return canonicalVirtualItems; - } - if ( + items = canonicalVirtualItems; + } else if ( isRenderingContinuousHistoryProjection && continuousHistoryVirtualItems ) { - return continuousHistoryVirtualItems; + items = continuousHistoryVirtualItems; + } else { + items = sessionToVirtualItems({ + ...activeSession, + dialogTurns: renderedHistoryPresentation.turns, + }); } - return sessionToVirtualItems({ - ...activeSession, - dialogTurns: renderedHistoryPresentation.turns, - }); + if (!activeSession || isViewportDetachedFromLiveTail || submittedPreviews.length === 0) return items; + const projectedTurnIds = new Set(activeSession.dialogTurns.map(turn => turn.id)); + const pendingItems = submittedPreviews + .filter(preview => !projectedTurnIds.has(preview.turnId)) + .map((preview, index) => ({ + type: 'user-message' as const, + data: preview.message, + turnId: preview.turnId, + absoluteTurnIndex: activeSessionKnownTurnCount + index + 1, + turnStatus: 'pending' as const, + submissionPhase: preview.phase, + submissionError: preview.error, + })); + return pendingItems.length ? [...items, ...pendingItems] : items; }, [ activeSession, + activeSessionKnownTurnCount, canonicalVirtualItems, continuousHistoryVirtualItems, isRenderingContinuousHistoryProjection, + isViewportDetachedFromLiveTail, renderedHistoryPresentation, + submittedPreviews, ]); // The transcript reads the pending list to mark the tool cards that are @@ -492,7 +518,6 @@ export const ModernFlowChatContainer: React.FC = ( const activeSessionIdRef = useRef(null); const sessionViewportStateRef = useRef>(new Map()); const viewScope = useConversationViewScope(); - const surfaceScope = getActiveSurfaceScope(); const transferredRevision = useRef(0); const incomingViewTransfer = activeSession?.sessionId && isViewportActive ? peekConversationViewTransfer({ surfaceId: surfaceScope.surfaceId, sessionId: activeSession.sessionId }, viewScope ? 'dock' : 'main') diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss index 95a666214c..994fb137eb 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss @@ -25,7 +25,7 @@ width: fit-content; max-width: var(--openbitfun-control-flow-chat-user-message-max-width); padding: var(--openbitfun-control-flow-chat-user-message-padding-block) var(--_user-message-padding-inline); - background: var(--openbitfun-color-action-quiet-hover); + background: transparent; border: none; border-radius: var(--_user-message-radius); margin-block: 0; @@ -40,6 +40,53 @@ position: relative; } +.user-message-item__surface { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--openbitfun-color-action-quiet-hover); + pointer-events: none; + transform-origin: right center; +} + +.user-message-item--editing .user-message-item__surface { + display: none; +} + +.user-message-item--forming .user-message-item__surface::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--openbitfun-color-action-quiet-hover); + pointer-events: none; + animation: user-message-forming-fill 3.4s ease-in-out 260ms infinite; +} + +@keyframes user-message-forming-fill { + 0%, 100% { opacity: 0.08; } + 50% { opacity: 0.16; } +} + +.user-message-item__submission-error { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--openbitfun-control-flow-chat-inline-gap); + width: fit-content; + max-width: var(--openbitfun-control-flow-chat-user-message-max-width); + margin-block-start: var(--openbitfun-space-1); + margin-inline: auto 0; + color: var(--openbitfun-color-status-danger-content); + font-size: var(--openbitfun-type-flow-meta-font-size); + line-height: var(--openbitfun-type-flow-meta-line-height); +} + +.user-message-item__submission-error-icon { + flex: none; +} + .user-message-item--editing { width: auto; max-width: none; @@ -82,6 +129,7 @@ } .user-message-item__main { + position: relative; display: flex; align-items: flex-start; justify-content: center; @@ -158,6 +206,12 @@ overflow-y: auto; } +.user-message-item--expanded:not(.user-message-item--editing) { + background: var(--openbitfun-color-action-quiet-hover); + + .user-message-item__surface { display: none; } +} + // Failed messages keep the message fill and mark the failed state through text styling. .user-message-item--failed { --_failed-font-size: var(--openbitfun-type-flow-control-font-size); @@ -395,6 +449,10 @@ } @media (prefers-reduced-motion: reduce) { + .user-message-item--forming .user-message-item__surface::after { + animation: none; + opacity: 0.08; + } .user-message-item, .user-message-item__timestamp, .user-message-item__actions, diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 8712a3eb71..0404320862 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -6,7 +6,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { withConversationExcerptFallback } from '@/shared/utils/conversationExcerpt'; import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost'; -import { RotateCcw, Loader2 } from 'lucide-react'; +import { CircleAlert, RotateCcw, Loader2 } from 'lucide-react'; import type { DialogTurn, FlowUserSteeringItem } from '../../types/flow-chat'; import type { ImageContext } from '@/shared/types/context'; import { flowChatManager } from '../../services/FlowChatManager'; @@ -27,7 +27,7 @@ import { globalEventBus } from '@/infrastructure/event-bus'; import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection'; import { observeElementResize } from '@/shared/utils/sharedResizeObserver'; import { formatContextForPrompt } from '@/shared/utils/contextPrompt'; -import { Dialog, DialogClose, Tooltip, Icon, IconButton } from '@openbitfun/ui'; +import { Button, Dialog, DialogClose, Tooltip, Icon, IconButton } from '@openbitfun/ui'; import { confirmDanger } from '@/infrastructure/confirm-dialog'; import { ToolProcessingDots } from '@openbitfun/ui/flow-chat'; import { UserMessageEditComposer } from './UserMessageEditComposer'; @@ -62,6 +62,8 @@ import { buildImagePayload } from '../../utils/imagePayload'; import { UserMessagePresentationContent, UserMessageTextContent } from './UserMessagePresentationContent'; import { UserMessageImage } from './UserMessageImage'; import { useSubmittedMessageMotion } from './useSubmittedMessageMotion'; +import { finishSubmittedMessagePreview } from '../../services/submittedMessagePresentation'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import './UserMessageItem.scss'; const log = createLogger('UserMessageItem'); @@ -72,6 +74,8 @@ interface UserMessageItemProps { absoluteTurnIndex?: number; turnStatus?: DialogTurn['status']; steeringStatus?: FlowUserSteeringItem['status']; + submissionPhase?: 'forming' | 'failed'; + submissionError?: string; } function buildPresentationRerunPayload(presentation: ComposerPresentation): { @@ -104,7 +108,7 @@ function buildPresentationRerunPayload(presentation: ComposerPresentation): { } export const UserMessageItem = React.memo( - ({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus }) => { + ({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError }) => { const { t, formatDate } = useI18n('flow-chat'); const { sessionId, @@ -198,7 +202,8 @@ export const UserMessageItem = React.memo( const resolvedTurnStatus = dialogTurn?.status ?? turnStatus; const isFailed = resolvedTurnStatus === 'error'; const resolvedSessionId = sessionId ?? currentSession?.sessionId; - useSubmittedMessageMotion(shellRef, resolvedSessionId, turnId, message?.id, isFailed || isEditing); + useSubmittedMessageMotion(shellRef, resolvedSessionId, turnId, message?.id, + isFailed || isEditing || submissionPhase === 'failed', submissionPhase); const sessionMachine = useSessionStateMachine(resolvedSessionId ?? null); const sessionExecutionState = sessionMachine && sessionMachine.sessionId === resolvedSessionId ? sessionMachine.currentState @@ -498,7 +503,10 @@ export const UserMessageItem = React.memo( contexts: restoredComposerContexts, ...(composerPresentation ? { composerPresentation } : {}), }); - }, [composerPresentation, messageContent, restoredComposerContexts]); + if (submissionPhase === 'failed' && resolvedSessionId) { + finishSubmittedMessagePreview(getActiveSurfaceScope(), resolvedSessionId, turnId); + } + }, [composerPresentation, messageContent, restoredComposerContexts, submissionPhase, resolvedSessionId, turnId]); const handleOpenUsageReport = useCallback((report: SessionUsageReport, initialTab?: SessionUsagePanelTab) => { void import('../../services/openSessionUsageReport').then(({ openSessionUsagePanel }) => { @@ -579,14 +587,15 @@ export const UserMessageItem = React.memo(
+
} ); diff --git a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx index f54c789d8e..acb157689e 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx @@ -62,6 +62,8 @@ export const VirtualItemRenderer = React.memo( turnId={item.turnId} absoluteTurnIndex={item.absoluteTurnIndex} turnStatus={item.turnStatus} + submissionPhase={item.submissionPhase} + submissionError={item.submissionError} /> ); 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 00537bc57b..9fd905a61a 100644 --- a/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx +++ b/src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx @@ -477,7 +477,14 @@ const VirtualMessageListSession = forwardRef ( + activeSession?.dialogTurns.length === 0 + && virtualItems.length === 1 + && virtualItems[0].type === 'user-message' + && virtualItems[0].submissionPhase !== undefined + )); useSessionReadOnOpen(activeSessionId, isViewportActive); const shouldRestoreInitialSnapshot = Boolean( initialViewportSnapshot @@ -679,7 +686,8 @@ const VirtualMessageListSession = forwardRef { const item = virtualItems.find(candidate => candidate.turnId === intent.turnId && candidate.type === 'user-message'); const turn = activeSessionRef.current?.dialogTurns.find(candidate => candidate.id === intent.turnId); - if (!item || turn?.userMessage.id !== intent.messageId) return null; + if (!item || item.type !== 'user-message' || item.data.id !== intent.messageId + || (turn && turn.userMessage.id !== intent.messageId)) return null; const scroller = scrollerElementRef.current; if (!scroller || !extentElementRef.current) return null; if (!getRenderedUserMessageElement(intent.turnId)) { diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.leading-extent.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.leading-extent.test.tsx new file mode 100644 index 0000000000..f7b7e7a1ac --- /dev/null +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.leading-extent.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom +import React, { act, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { VirtualItem } from '../../store/modernFlowChatStore'; +import { useFlowChatLeadingExtent } from './useFlowChatLeadingExtent'; +import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; +import { useFlowChatViewportOwner } from './useFlowChatViewportOwner'; +import { readingLinePxForViewport } from './flowChatTailFollow'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const cleanups: Array<() => void> = []; +afterEach(() => { + cleanups.splice(0).forEach(cleanup => cleanup()); + vi.restoreAllMocks(); vi.unstubAllGlobals(); +}); + +// Real extent, follow and ownership hooks with supplied browser geometry. +// This checks the feedback loop and idle behavior, not visual acceptance. +describe('follow and leading extent after completion', () => { + it.each([-2, -0.25, 0.25, 2])('settles with a %s px virtual/DOM difference and still follows late rendered output', difference => { + const host = document.createElement('div'); + const scroller = document.createElement('div'); + const extent = document.createElement('div'); + const row = document.createElement('div'); + row.className = 'virtual-item-wrapper'; row.dataset.virtualItemKey = 'block:answer'; + document.body.append(host, scroller); scroller.append(extent); extent.append(row); + const root = createRoot(host); + cleanups.push(() => { act(() => root.unmount()); host.remove(); scroller.remove(); }); + const frames = new Map(); + let now = 0, frameId = 0, actualOffset = 0, naturalTarget = 1800, streaming = true, writes = 0; + const line = readingLinePxForViewport(800, 160); + const maxOffset = () => Math.max(naturalTarget, Number.parseFloat(extent.style.minHeight || '800') - 800); + Object.defineProperties(scroller, { + clientHeight: { get: () => 800 }, + scrollHeight: { get: () => maxOffset() + 800 }, + scrollTop: { + get: () => Math.min(actualOffset, maxOffset()), + set: (value: number) => { actualOffset = Math.max(0, Math.min(value, maxOffset())); }, + }, + }); + const domRead = vi.fn(() => ({ top: 1700 + difference - scroller.scrollTop, + bottom: naturalTarget + line - scroller.scrollTop, + height: naturalTarget + line - 1700 - difference }) as DOMRect); + row.getBoundingClientRect = domRead; + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; }); + vi.stubGlobal('cancelAnimationFrame', (id: number) => frames.delete(id)); + vi.spyOn(performance, 'now').mockImplementation(() => now); + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {} }))); + const items = [{ type: 'model-round', turnId: 'turn', data: { id: 'answer' }, + timeline: { key: 'answer', kind: 'content' } }] as unknown as VirtualItem[]; + let follow: ReturnType; + function Harness() { + const ref = useRef(scroller); + const extentRef = useRef(extent); + const [, publishOffset] = useState(0); + const leading = useFlowChatLeadingExtent({ scope: 'completion', items, + virtualizer: { getItemBounds: () => ({ startPx: 1700, endPx: naturalTarget + line }) }, + scrollerRef: ref, extentRef, onAnchorRebased: () => {} }); + const owner = useFlowChatViewportOwner(ref); + follow = useFlowChatFollowOutput({ virtualItemCount: 1, isStreaming: streaming, isViewportActive: true, + scrollerRef: ref, viewportOwner: owner, isOpeningViewport: () => false, + readLayoutTarget: () => { leading.refresh(1000); return scroller.scrollHeight - scroller.clientHeight; }, + placeSubmittedMessage: () => null, hasRenderedOutput: () => true, + cancelPendingPlacement: () => {}, cancelNavigation: () => {}, + onViewportOffset: offset => { + writes++; + const before = domRead.mock.calls.length; + leading.capture(offset); + expect(domRead.mock.calls.length).toBe(before); + // The real list publishes follow offsets to the virtualizer, which + // may render again even after all content has stopped changing. + publishOffset(value => value + 1); + }, + }); + return null; + } + const render = () => act(() => root.render()); + const tick = () => { + now += 16; + const batch = [...frames.values()]; frames.clear(); + act(() => batch.forEach(callback => callback(now))); + }; + const settle = () => { for (let i = 0; i < 180 && frames.size; i++) tick(); }; + render(); settle(); + expect(scroller.scrollTop).toBe(1800); expect(frames.size).toBe(0); + naturalTarget = 2400; render(); tick(); + streaming = false; render(); settle(); + expect(scroller.scrollTop).toBe(2400); expect(frames.size).toBe(0); + // Completion compacts a card, while its leading content still exists. + naturalTarget = 2200; render(); settle(); + const completedWrites = writes; + for (let i = 0; i < 120; i++) { render(); tick(); } + expect(scroller.scrollTop).toBe(2400); + expect(writes).toBe(completedWrites); expect(frames.size).toBe(0); + expect(follow!.isFollowingOutputNow()).toBe(true); + // Buffered/typewriter output can finish after runtime completion. + naturalTarget = 2500; render(); settle(); + expect(scroller.scrollTop).toBe(2500); expect(frames.size).toBe(0); + act(() => follow!.handleUserScrollIntent('before')); + scroller.scrollTop = 2000; + naturalTarget = 2800; render(); settle(); + expect(scroller.scrollTop).toBe(2000); + expect(follow!.isFollowingOutputNow()).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx index 9882c36208..709271323f 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.test.tsx @@ -3,7 +3,11 @@ import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; -import { registerSubmittedMessageScrollIntent } from '../../services/submittedMessageScrollIntent'; +import { + claimSubmittedMessageScrollIntent, + finishSubmittedMessageScrollIntent, + registerSubmittedMessageScrollIntent, +} from '../../services/submittedMessageScrollIntent'; import { FlowChatReaderState } from '../../timeline/readerState'; import { useTimelineInteraction } from '../../timeline/useTimelineInteraction'; import { useFlowChatFollowOutput } from './useFlowChatFollowOutput'; @@ -16,6 +20,7 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual let root: Root, host: HTMLDivElement, scroller: HTMLDivElement, controller: Controller, owner: FlowChatViewportOwnerApi; let frames: Map, frameId: number, now: number; let normalTarget: number, floor: number | null, sentTop: number, output: boolean, renderable: boolean; + let cancelledPlacements: number; let props: { session: string; active: boolean; suspended: boolean; opening: boolean; count: number; startAtTail: boolean; streaming: boolean }; let reader: FlowChatReaderState; const placements: string[] = []; @@ -38,7 +43,7 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual return sentTop; }, hasRenderedOutput: () => output, - cancelPendingPlacement: () => {}, cancelNavigation: () => {}, + cancelPendingPlacement: () => { cancelledPlacements += 1; }, cancelNavigation: () => {}, }); useTimelineInteraction(ref, reader, controller.isFollowingOutputNow, undefined, () => controller.exitFollowOutput('reader-interaction')); @@ -61,6 +66,7 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual host = document.createElement('div'); scroller = document.createElement('div'); document.body.append(host, scroller); root = createRoot(host); normalTarget = 1000; floor = null; sentTop = 1400; output = false; renderable = true; + cancelledPlacements = 0; props = { session: 'session', active: true, suspended: false, opening: false, count: 2, startAtTail: true, streaming: true }; reader = new FlowChatReaderState(); frames = new Map(); frameId = 0; now = 0; placements.length = 0; readbacks.length = 0; @@ -92,6 +98,12 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual while (frames.size) { tick(); expect(scroller.scrollTop).toBeGreaterThanOrEqual(previous); previous = scroller.scrollTop; } expect(scroller.scrollTop).toBeCloseTo(1500, 0); expect(floor).toBe(1400); }); + it('does not place the same message again when completion notifies in a microtask', async () => { + render(); submit(); + await act(async () => { await Promise.resolve(); }); + expect(placements).toEqual(['turn-2']); + expect(controller.isFollowingOutputNow()).toBe(true); + }); it('does not let a tall user message start following before assistant output exists', () => { render(); submit(); normalTarget = 2100; signal(); settle(); expect(scroller.scrollTop).toBe(1400); output = true; signal(); settle(); expect(scroller.scrollTop).toBe(2100); @@ -134,6 +146,20 @@ describe('desktop follow ownership and lifecycle (supplied geometry, not visual render(); renderable = false; submit(); depart(); renderable = true; signal(); settle(); expect(placements).toEqual([]); expect(controller.isFollowingOutputNow()).toBe(false); }); + it('releases a cancelled submission before its row has rendered', async () => { + render(); renderable = false; submit(); + cancelledPlacements = 0; + const intent = claimSubmittedMessageScrollIntent(props.session, 1, true)!; + await act(async () => { + finishSubmittedMessageScrollIntent(intent); + await Promise.resolve(); + }); + renderable = true; normalTarget = 1800; signal(); settle(); + expect(cancelledPlacements).toBe(1); + expect(placements).toEqual([]); + expect(scroller.scrollTop).toBe(1000); + expect(controller.isFollowingOutputNow()).toBe(false); + }); it('a gesture interrupts animation immediately, and later output cannot regain control', () => { render(); normalTarget = 1400; signal(); tick(); depart(); scroller.scrollTop = 600; normalTarget = 2600; signal(); settle(); now += 10000; signal(); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts index 0084d92551..aba4f5b8e5 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatFollowOutput.ts @@ -3,6 +3,7 @@ import { traceViewportRepeating } from '@/infrastructure/diagnostics/flowChatVie import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { claimSubmittedMessageScrollIntent, finishSubmittedMessageScrollIntent, + isSubmittedMessageScrollIntentPending, subscribeSubmittedMessageScrollIntent, type SubmittedMessageScrollIntent, } from '../../services/submittedMessageScrollIntent'; import { getMotionAwareScrollBehavior } from '../../utils/motionPreference'; @@ -156,7 +157,22 @@ export function useFlowChatFollowOutput(options: Options) { const trySubmission = useCallback(() => { const o = current.current; if (!o.activeSessionId || !available()) return false; + const wasCancelled = Boolean(pending.current && !isSubmittedMessageScrollIntentPending(pending.current)); + if (wasCancelled) { + pending.current = null; + submittedTurn.current = null; + o.cancelPendingPlacement(); + } const intent = claimSubmittedMessageScrollIntent(o.activeSessionId, o.viewportId ?? 0); + if (wasCancelled && !intent) { + // A failed or queued send no longer owns a placement. Do not let the + // former pending-send phase resume tail following on its next signal. + following.current = false; + phase.current = 'reading'; + setIsFollowingOutput(false); + stop(); + o.viewportOwner.release('follow-output'); + } if (intent && intent !== pending.current) { if (pending.current) finishSubmittedMessageScrollIntent(pending.current); pending.current = intent; diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx index 9467cec78b..45ef874957 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { VirtualItem } from '../../store/modernFlowChatStore'; import { useFlowChatLeadingExtent } from './useFlowChatLeadingExtent'; import { getVirtualItemStableKey } from './virtualItemIdentity'; @@ -49,6 +49,76 @@ function setup() { } describe('desktop leading extent lifecycle', () => { + it.each([-2, -0.25, 0.25, 2])('does not turn a stable %s px cache/DOM difference into repeated travel', difference => { + const view = setup(); + view.layout([item('header', 'group-header'), item('after')], + [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: 2100 }], 2100); + const row = document.createElement('div'); + row.className = 'virtual-item-wrapper'; row.dataset.virtualItemKey = 'block:header'; + row.getBoundingClientRect = () => ({ + top: 2000 + difference - view.scroller.scrollTop, + bottom: 2040 + difference - view.scroller.scrollTop, + height: 40, + }) as DOMRect; + view.extent.append(row); + view.scroller.scrollTop = 1980; + view.api.refresh(1000); + view.api.capture(1980); + // No output, resize, input, or row movement: just accepted follow + // readbacks followed by layout notifications after runtime completion. + for (let frame = 0; frame < 80; frame++) { + view.api.refresh(1000); + view.scroller.scrollTop = view.scroller.scrollHeight - view.scroller.clientHeight; + view.api.capture(view.scroller.scrollTop); + } + expect(view.scroller.scrollTop).toBe(1980); + expect(view.extent.style.minHeight).toBe('2780px'); + }); + it('bridges mount, cache catch-up, and recycling without moving a stationary anchor', () => { + const view = setup(); + const entries = [item('header', 'group-header'), item('after')]; + const bounds = [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: 2100 }]; + view.layout(entries, bounds, 2100); + view.scroller.scrollTop = 1980; + view.api.refresh(1000); view.api.capture(1980); + const row = document.createElement('div'); + row.className = 'virtual-item-wrapper'; row.dataset.virtualItemKey = 'block:header'; + let start = 1998.75; + row.getBoundingClientRect = () => ({ top: start - view.scroller.scrollTop, + bottom: start + 40 - view.scroller.scrollTop, height: 40 }) as DOMRect; + view.extent.append(row); + view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2780px'); + expect(view.api.snapshot()).toEqual({ key: 'block:header', offset: 18.75 }); + // Delayed virtual measurements change; mounted content did not move. + view.layout(entries, bounds.map(b => ({ startPx: b.startPx + 3, endPx: b.endPx + 3 })), 2100); + view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2780px'); + row.remove(); view.api.refresh(1000); view.api.capture(1980); + expect(view.extent.style.minHeight).toBe('2780px'); + view.extent.append(row); view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2780px'); + // A genuine displacement above the leading row still moves its floor. + start += 120; + view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2900px'); + for (let i = 0; i < 20; i++) view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2900px'); + }); + it('reads DOM geometry only on layout or exact reader capture, never on follow frames', () => { + const view = setup(); + const row = document.createElement('div'); + row.className = 'virtual-item-wrapper'; row.dataset.virtualItemKey = 'block:member'; + const read = vi.fn(() => ({ top: 2039.75 - view.scroller.scrollTop, + bottom: 3239.75 - view.scroller.scrollTop, height: 1200 }) as DOMRect); + row.getBoundingClientRect = read; view.extent.append(row); + view.api.refresh(1000); read.mockClear(); + for (let offset = 2100; offset <= 2400; offset += 10) view.api.capture(offset); + expect(read).not.toHaveBeenCalled(); + expect(view.api.snapshot()).toEqual({ key: 'block:member', offset: -360.25 }); + view.api.capture(2500, true); expect(read).toHaveBeenCalledTimes(1); + expect(view.api.snapshot()).toEqual({ key: 'block:member', offset: -460.25 }); + }); it('reserves the reader position before shrink and lets regrowth consume it', () => { const view = setup(); view.api.refresh(1000); @@ -81,6 +151,25 @@ describe('desktop leading extent lifecycle', () => { view.api.refresh(1000); expect(view.rebases).toBe(1); }); + it('aligns a newly mounted fallback header to its actual top despite rounded virtual bounds', () => { + const view = setup(); + view.scroller.scrollTop = 2800; + view.api.refresh(1000); view.api.capture(2800); + const row = document.createElement('div'); + row.className = 'virtual-item-wrapper'; row.dataset.virtualItemKey = 'block:header'; + row.getBoundingClientRect = () => ({ top: 1998.75 - view.scroller.scrollTop, + bottom: 2038.75 - view.scroller.scrollTop, height: 40 }) as DOMRect; + view.extent.append(row); + view.layout([item('header', 'group-header'), item('after')], + [{ startPx: 2000, endPx: 2040 }, { startPx: 2040, endPx: 2100 }], 2100); + view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2790.75px'); + expect(view.api.snapshot()).toEqual({ key: 'block:header', offset: 8 }); + expect(view.rebases).toBe(1); + view.api.refresh(1000); + expect(view.extent.style.minHeight).toBe('2790.75px'); + expect(view.rebases).toBe(1); + }); it('releases the prior position on reader travel and on new turn/session/history scope', () => { const view = setup(); view.api.refresh(1000); view.api.capture(2800); view.api.capture(2200); diff --git a/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts index ed23c3af8a..5fa41c7144 100644 --- a/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts +++ b/src/web-ui/src/flow_chat/components/modern/useFlowChatLeadingExtent.ts @@ -2,10 +2,24 @@ import { useCallback, useMemo, useRef, type RefObject } from 'react'; import type { VirtualItem } from '../../store/modernFlowChatStore'; import type { FlowChatVirtualizer } from './useFlowChatVirtualizer'; import { getVirtualItemStableKey } from './virtualItemIdentity'; -import { leadingExtentFloor, type LeadingExtentAnchor } from './flowChatLeadingExtent'; +import { leadingExtentFloor } from './flowChatLeadingExtent'; import { FLOWCHAT_TURN_TOP_GAP_PX } from './flowChatTailFollow'; type Virtualizer = Pick; +type Bounds = { startPx: number; endPx: number }; +// Keep both coordinates from the same layout sample. Rounded virtual heights +// and normal-flow DOM positions need not agree, even after output has settled. +type Measurement = { cached: Bounds; mounted?: Bounds }; +type Anchor = { + key: string; + scrollTopPx: number; + heightPx: number; + measurement: Measurement; + group?: { key: string; measurement: Measurement }; +}; +type State = { scope: string; anchor: Anchor | null; turnFloor: number; measured: Map }; +const boundsOf = (measurement: Measurement) => measurement.mounted ?? measurement.cached; +const newState = (scope: string): State => ({ scope, anchor: null, turnFloor: 0, measured: new Map() }); /** Layout only. The follow/reader owners remain the only viewport writers. */ export function useFlowChatLeadingExtent(options: { @@ -17,7 +31,7 @@ export function useFlowChatLeadingExtent(options: { onAnchorRebased: () => void; }) { const { items, virtualizer, scrollerRef, extentRef, scope } = options; - const state = useRef<{ scope: string; anchor: LeadingExtentAnchor | null; turnFloor: number }>({ scope, anchor: null, turnFloor: 0 }); + const state = useRef(null); const onAnchorRebased = useRef(options.onAnchorRebased); onAnchorRebased.current = options.onAnchorRebased; const indexes = useMemo(() => new Map(items.map((item, index) => [getVirtualItemStableKey(item), index])), [items]); @@ -29,7 +43,7 @@ export function useFlowChatLeadingExtent(options: { return headers; }, [items]); const current = useCallback(() => { - if (state.current.scope !== scope) state.current = { scope, anchor: null, turnFloor: 0 }; + if (!state.current || state.current.scope !== scope) state.current = newState(scope); return state.current; }, [scope]); const install = useCallback((floor: number | null) => { @@ -39,22 +53,45 @@ export function useFlowChatLeadingExtent(options: { const value = floor === null ? '' : `${floor + scroller.clientHeight}px`; if (extent.style.minHeight !== value) extent.style.minHeight = value; }, [extentRef, scrollerRef]); - const mountedBounds = useCallback((key: string) => { + const measureLayout = useCallback(() => { const scroller = scrollerRef.current; - if (!scroller) return null; - const row = [...scroller.querySelectorAll('.virtual-item-wrapper[data-virtual-item-key]')] - .find(element => element.dataset.virtualItemKey === key); - if (!row) return null; - const rect = row.getBoundingClientRect(); - const startPx = scroller.scrollTop + rect.top - scroller.getBoundingClientRect().top; - return { startPx, endPx: startPx + rect.height }; - }, [scrollerRef]); + if (!scroller) return; + const measured = new Map(); + const origin = scroller.scrollTop - scroller.getBoundingClientRect().top; + for (const row of scroller.querySelectorAll('.virtual-item-wrapper[data-virtual-item-key]')) { + const viewport = row.closest('[data-flowchat-virtual-viewport], [data-flowchat-scroller]'); + if (viewport && viewport !== scroller) continue; + const key = row.dataset.virtualItemKey!; + const index = indexes.get(key); + const cached = index === undefined ? null : virtualizer.getItemBounds(index); + if (!cached) continue; + const rect = row.getBoundingClientRect(); + const startPx = origin + rect.top; + measured.set(key, { cached, mounted: { startPx, endPx: startPx + rect.height } }); + } + // Bounded by mounted rows; keep only scalars, never DOM/content instances. + current().measured = measured; + }, [current, indexes, scrollerRef, virtualizer]); + const measurementAt = useCallback((index: number): Measurement | null => { + const measured = current().measured.get(getVirtualItemStableKey(items[index])); + if (measured) return measured; + const cached = virtualizer.getItemBounds(index); + return cached ? { cached } : null; + }, [current, items, virtualizer]); + const groupAt = useCallback((index: number) => { + const groupId = items[index].timeline?.group?.groupId; + const groupIndex = groupId === undefined ? undefined : groupHeaders.get(groupId); + const measurement = groupIndex === undefined ? null : measurementAt(groupIndex); + return measurement && groupIndex !== undefined + ? { key: getVirtualItemStableKey(items[groupIndex]), measurement } : undefined; + }, [groupHeaders, items, measurementAt]); const capture = useCallback((offset: number, exact = false) => { const scroller = scrollerRef.current; if (!scroller?.clientHeight || !items.length) return; const s = current(); - // Frames use cached measurements only: no DOM traversal or row remeasure. + if (exact) measureLayout(); + // Frames reuse the layout snapshot: no DOM traversal or row remeasure. let low = 0, high = items.length; while (low < high) { const middle = (low + high) >>> 1; @@ -63,48 +100,63 @@ export function useFlowChatLeadingExtent(options: { else high = middle; } let index = low; - if (exact) { - const top = scroller.getBoundingClientRect().top; - const row = [...scroller.querySelectorAll('.virtual-item-wrapper[data-virtual-item-key]')] - .find(element => { - const rect = element.getBoundingClientRect(); - return rect.bottom > top && rect.top < top + scroller.clientHeight; - }); - if (row) index = indexes.get(row.dataset.virtualItemKey!) ?? index; + for (const [key, measurement] of s.measured) { + const bounds = boundsOf(measurement); + if (bounds.endPx > offset && bounds.startPx < offset + scroller.clientHeight) { + index = indexes.get(key) ?? index; + break; + } } const item = items[index]; if (!item) return; const key = getVirtualItemStableKey(item); - const bounds = (exact ? mountedBounds(key) : null) ?? virtualizer.getItemBounds(index); - if (!bounds || bounds.startPx >= offset + scroller.clientHeight) return; - const groupIndex = item.timeline?.group ? groupHeaders.get(item.timeline.group.groupId) : undefined; - const groupHeaderKey = groupIndex === undefined ? undefined : getVirtualItemStableKey(items[groupIndex]); - const groupBounds = groupIndex === undefined ? null - : (exact && groupHeaderKey ? mountedBounds(groupHeaderKey) : null) ?? virtualizer.getItemBounds(groupIndex); - s.anchor = { key, offsetPx: bounds.startPx - offset, heightPx: bounds.endPx - bounds.startPx, - groupHeaderKey, groupHeaderOffsetPx: groupBounds ? groupBounds.startPx - offset : undefined }; + const measurement = measurementAt(index); + if (!measurement) return; + const bounds = boundsOf(measurement); + if (bounds.startPx >= offset + scroller.clientHeight) return; + s.anchor = { key, scrollTopPx: offset, heightPx: bounds.endPx - bounds.startPx, measurement, group: groupAt(index) }; // Install before any later shrink, so native clamping cannot paint a frame // at the shortened tail before a ResizeObserver could repair it. install(Math.max(s.turnFloor, offset)); - }, [current, groupHeaders, indexes, install, items, mountedBounds, scrollerRef, virtualizer]); + }, [current, groupAt, indexes, install, items, measureLayout, measurementAt, scrollerRef, virtualizer]); const refresh = useCallback((turnFloor: number | null) => { const s = current(); + measureLayout(); s.turnFloor = turnFloor ?? 0; const anchor = s.anchor; let floor = turnFloor; if (anchor) { const primaryIndex = indexes.get(anchor.key); - const index = primaryIndex ?? (anchor.groupHeaderKey ? indexes.get(anchor.groupHeaderKey) : undefined); + const index = primaryIndex ?? (anchor.group ? indexes.get(anchor.group.key) : undefined); if (index === undefined) s.anchor = null; else { const key = getVirtualItemStableKey(items[index]); - const bounds = mountedBounds(key) ?? virtualizer.getItemBounds(index); - if (bounds) { - const resolved = leadingExtentFloor({ anchor, ...bounds, usesGroupHeader: primaryIndex === undefined }); - floor = Math.max(floor ?? 0, resolved.floorPx); - if (key !== anchor.key || resolved.offsetPx !== anchor.offsetPx) { - s.anchor = { key, offsetPx: resolved.offsetPx, heightPx: bounds.endPx - bounds.startPx }; + const measurement = measurementAt(index); + if (measurement) { + const usesGroupHeader = primaryIndex === undefined; + const previous = usesGroupHeader ? anchor.group!.measurement : anchor.measurement; + // Compare like with like. If a row mounted/unmounted, bridge through + // the pair of virtual samples rather than treating the DOM/cache + // discrepancy as travel. The next sample then has the new basis. + const useMounted = previous.mounted && measurement.mounted; + const before = useMounted ? previous.mounted! : previous.cached; + const bounds = useMounted ? measurement.mounted! : measurement.cached; + const offsetPx = before.startPx - anchor.scrollTopPx; + const resolved = leadingExtentFloor({ + anchor: { key, offsetPx, heightPx: anchor.heightPx, groupHeaderOffsetPx: offsetPx }, + ...bounds, usesGroupHeader, + }); + const rebased = usesGroupHeader || resolved.offsetPx !== offsetPx; + const actualBounds = boundsOf(measurement); + // A vanished reading position is an explicit semantic fallback, so + // align its surviving top against the best current measurement. + const anchorFloor = rebased ? Math.max(0, actualBounds.startPx - resolved.offsetPx) : resolved.floorPx; + floor = Math.max(floor ?? 0, anchorFloor); + s.anchor = { key, scrollTopPx: anchorFloor, measurement, + heightPx: rebased ? actualBounds.endPx - actualBounds.startPx : anchor.heightPx, + group: rebased ? undefined : groupAt(index) }; + if (rebased) { // A removed member/vanished card interior must not leave an older // reader anchor restoring the successor's former lower position. onAnchorRebased.current(); @@ -113,17 +165,17 @@ export function useFlowChatLeadingExtent(options: { } } install(floor); - }, [current, indexes, install, items, mountedBounds, virtualizer]); + }, [current, groupAt, indexes, install, items, measureLayout, measurementAt]); // Structural transactions use the same leading edge. Choosing a surviving // successor instead would cancel the upward movement of content after a fold. const snapshot = useCallback(() => { const anchor = current().anchor; if (!anchor) return null; - if (indexes.has(anchor.key)) return { key: anchor.key, offset: anchor.offsetPx }; - if (anchor.groupHeaderKey && indexes.has(anchor.groupHeaderKey)) return { - key: anchor.groupHeaderKey, - offset: Math.max(FLOWCHAT_TURN_TOP_GAP_PX, anchor.groupHeaderOffsetPx ?? FLOWCHAT_TURN_TOP_GAP_PX), + if (indexes.has(anchor.key)) return { key: anchor.key, offset: boundsOf(anchor.measurement).startPx - anchor.scrollTopPx }; + if (anchor.group && indexes.has(anchor.group.key)) return { + key: anchor.group.key, + offset: Math.max(FLOWCHAT_TURN_TOP_GAP_PX, boundsOf(anchor.group.measurement).startPx - anchor.scrollTopPx), }; return null; }, [current, indexes]); diff --git a/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.test.tsx b/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.test.tsx index 74bc140e63..2ef847feb3 100644 --- a/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.test.tsx @@ -4,15 +4,16 @@ import React, { act, StrictMode, useRef } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { activateSurface, getActiveSurfaceScope, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; -import { registerSubmittedMessage } from '../../services/submittedMessagePresentation'; +import { beginSubmittedMessagePreview, failSubmittedMessagePreview, registerSubmittedMessage } from '../../services/submittedMessagePresentation'; import { useSubmittedMessageMotion } from './useSubmittedMessageMotion'; // Lifecycle seam only; these tests do not simulate or assess visual playback. -function Message({ disabled = false }: { disabled?: boolean }) { +function Message({ disabled = false, phase }: { disabled?: boolean; phase?: 'forming' | 'failed' }) { const ref = useRef(null); - useSubmittedMessageMotion(ref, 'session', 'turn', 'message', disabled); + useSubmittedMessageMotion(ref, 'session', 'turn', 'message', disabled, phase); return
-
Message
+
Message
+ {phase === 'failed' &&
Failed
}
; @@ -72,10 +73,10 @@ describe('useSubmittedMessageMotion lifecycle', () => { render(null); submit(); render(); - expect(animations).toHaveLength(3); + expect(animations).toHaveLength(2); render(null); render(); - expect(animations).toHaveLength(3); + expect(animations).toHaveLength(2); expect(animations.every(animation => animation.cancel.mock.calls.length === 1)).toBe(true); }); @@ -83,19 +84,19 @@ describe('useSubmittedMessageMotion lifecycle', () => { submit(); vi.advanceTimersByTime(120); render(); - expect(animations).toHaveLength(3); + expect(animations).toHaveLength(2); expect(animations.every(animation => animation.currentTime === 120)).toBe(true); }); it('survives StrictMode effect rehearsal without permitting a real remount replay', () => { submit(); render(); - expect(animations).toHaveLength(6); - expect(animations.slice(0, 3).every(animation => animation.cancel.mock.calls.length === 1)).toBe(true); - expect(animations.slice(3).every(animation => animation.cancel.mock.calls.length === 0)).toBe(true); + expect(animations).toHaveLength(4); + expect(animations.slice(0, 2).every(animation => animation.cancel.mock.calls.length === 1)).toBe(true); + expect(animations.slice(2).every(animation => animation.cancel.mock.calls.length === 0)).toBe(true); render(null); render(); - expect(animations).toHaveLength(6); + expect(animations).toHaveLength(4); }); it('settles immediately when a user focuses an action', () => { @@ -116,7 +117,7 @@ describe('useSubmittedMessageMotion lifecycle', () => { Object.assign(media, { matches: false }); submit(); render(); - expect(animations).toHaveLength(3); + expect(animations).toHaveLength(2); Object.assign(media, { matches: true }); act(() => media.dispatchEvent(new Event('change'))); expect(animations.every(animation => animation.cancel.mock.calls.length === 1)).toBe(true); @@ -128,7 +129,7 @@ describe('useSubmittedMessageMotion lifecycle', () => { act(() => { activateSurface('peer-device'); }); expect(animations.every(animation => animation.cancel.mock.calls.length === 1)).toBe(true); render(); - expect(animations).toHaveLength(3); + expect(animations).toHaveLength(2); }); it('settles failures and does not restart when the message becomes editable again', () => { @@ -136,7 +137,22 @@ describe('useSubmittedMessageMotion lifecycle', () => { render(); render(); render(); - expect(animations).toHaveLength(3); + expect(animations).toHaveLength(2); expect(animations.every(animation => animation.cancel.mock.calls.length === 1)).toBe(true); }); + + it('plays the failure handoff once and leaves a remounted failed shell settled', () => { + const scope = getActiveSurfaceScope(); + beginSubmittedMessagePreview(scope, 'session', 'turn', { + id: 'message', content: 'Message', timestamp: Date.now(), + }); + render(); + expect(animations).toHaveLength(2); + failSubmittedMessagePreview(scope, 'session', 'turn', 'Send failed'); + render(); + expect(animations).toHaveLength(4); + render(null); + render(); + expect(animations).toHaveLength(4); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.ts b/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.ts index 73b633339d..a6629913e3 100644 --- a/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.ts +++ b/src/web-ui/src/flow_chat/components/modern/useSubmittedMessageMotion.ts @@ -2,13 +2,19 @@ import { useLayoutEffect, useRef, type RefObject } from 'react'; import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import { consumeSubmittedMessageArrival, + consumeSubmittedMessageFailure, type SubmittedMessageArrival, } from '../../services/submittedMessagePresentation'; const PARTS = [ - { selector: '.user-message-item', distance: 4, opacity: 0.4, delay: 0, duration: 220 }, - { selector: '.user-message-item__timestamp', distance: 2, opacity: 0, delay: 60, duration: 180 }, - { selector: '.user-message-item__actions', distance: 2, opacity: 0, delay: 100, duration: 180 }, + { selector: '.user-message-item__surface', fallbackSelector: '.user-message-item', + from: { opacity: 0.76, transform: 'translateY(1px) scale(0.985, 0.94)' }, + to: { opacity: 1, transform: 'translateY(0) scale(1)' }, delay: 0, duration: 220 }, + { selector: '.user-message-item__content', + from: { opacity: 0, transform: 'translateY(1px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, delay: 55, duration: 160 }, + { selector: '.user-message-item__images', + from: { opacity: 0 }, to: { opacity: 1 }, delay: 55, duration: 160 }, ] as const; /** Explicit send feedback, never a row-mount animation or a viewport writer. */ @@ -18,6 +24,7 @@ export function useSubmittedMessageMotion( turnId: string, messageId: string | undefined, disabled: boolean, + submissionPhase?: 'forming' | 'failed', ): void { const scope = getActiveSurfaceScope(); const key = scope.key('submitted-message-motion', scope.epoch, sessionId, turnId, messageId); @@ -46,12 +53,10 @@ export function useSubmittedMessageMotion( const elapsed = Math.max(0, performance.now() - arrival.startedAt); const animations: Animation[] = []; for (const part of PARTS) { - const element = shell.querySelector(part.selector); + const element = shell.querySelector(part.selector) + ?? ('fallbackSelector' in part ? shell.querySelector(part.fallbackSelector) : null); if (!element?.animate || elapsed >= part.delay + part.duration) continue; - const animation = element.animate([ - { opacity: part.opacity, transform: `translateY(${part.distance}px)` }, - { opacity: 1, transform: 'translateY(0)' }, - ], { + const animation = element.animate([part.from, part.to], { delay: part.delay, duration: part.duration, easing: 'cubic-bezier(0.23, 1, 0.32, 1)', @@ -86,4 +91,44 @@ export function useSubmittedMessageMotion( void Promise.all(animations.map(animation => animation.finished)).then(settle, () => {}); return cleanup; }, [shellRef, sessionId, turnId, messageId, key, disabled]); + + useLayoutEffect(() => { + if (submissionPhase !== 'failed' || !sessionId || !messageId) return; + const failure = consumeSubmittedMessageFailure(sessionId, turnId, messageId); + const shell = shellRef.current; + const bubble = shell?.querySelector('.user-message-item__surface'); + const view = shell?.ownerDocument.defaultView; + const media = view?.matchMedia?.('(prefers-reduced-motion: reduce)'); + if (!failure || !bubble?.animate || !view || media?.matches || shell?.ownerDocument.hidden + || !failure.scope.isCurrent() || shell.contains(shell.ownerDocument.activeElement)) return; + const elapsed = Math.max(0, performance.now() - failure.failedAt!); + if (elapsed >= 220) return; + const animations = [bubble.animate([ + { transform: 'translateX(0) scale(1)' }, + { transform: 'translateX(1px) scale(0.995, 0.97)', offset: 0.5 }, + { transform: 'translateX(0) scale(1)' }, + ], { duration: 190, easing: 'cubic-bezier(0.23, 1, 0.32, 1)' })]; + const feedback = shell.querySelector('.user-message-item__submission-error'); + if (feedback?.animate) animations.push(feedback.animate([ + { opacity: 0, transform: 'translateY(-2px)' }, + { opacity: 1, transform: 'translateY(0)' }, + ], { duration: 150, easing: 'ease-out', fill: 'backwards' })); + for (const animation of animations) { + animation.currentTime = elapsed; + } + const listeners = new AbortController(); + const settle = () => { + listeners.abort(); + animations.forEach(animation => animation.cancel()); + }; + const options = { signal: listeners.signal }; + shell.addEventListener('focusin', settle, options); + shell.addEventListener('pointerdown', settle, options); + failure.scope.signal.addEventListener('abort', settle, options); + media?.addEventListener('change', settle, options); + const onVisibility = () => { if (shell.ownerDocument.hidden) settle(); }; + shell.ownerDocument.addEventListener('visibilitychange', onVisibility, options); + void Promise.all(animations.map(animation => animation.finished)).then(() => listeners.abort(), () => {}); + return () => { listeners.abort(); settle(); }; + }, [shellRef, sessionId, turnId, messageId, submissionPhase]); } diff --git a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts index 7b178d2273..b616948588 100644 --- a/src/web-ui/src/flow_chat/hooks/useMessageSender.ts +++ b/src/web-ui/src/flow_chat/hooks/useMessageSender.ts @@ -267,6 +267,7 @@ export function useMessageSender(props: UseMessageSenderProps): UseMessageSender contexts: [...contexts], pendingLargePastes: { ...(options?.composerDraft?.pendingLargePastes ?? {}) }, }, + foregroundSubmission: true, ...(userMessageMetadata ? { userMessageMetadata } : {}), ...(options?.execution ? { execution: options.execution } : {}), onSessionConflictRetryStart: () => { diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 58c6cf8964..42965d16a3 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -83,6 +83,7 @@ type FlowChatSendMessageOptions = Pick< | 'imageContexts' | 'imageDisplayData' | 'pendingQueueDraft' + | 'foregroundSubmission' | 'sendImmediately' | 'userMessageMetadata' | 'execution' 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 c2de4f0e90..4353fd2486 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 @@ -5,6 +5,7 @@ import { projectUserQuestionTiming } from '../../utils/userQuestionTiming'; import { bindSubmittedMessageScrollIntent } from '../submittedMessageScrollIntent'; +import { finishSubmittedMessagePreview, getSubmittedMessagePreview } from '../submittedMessagePresentation'; import { FlowChatStore, mergeModelRoundAttemptDiagnostics } from '../../store/FlowChatStore'; import { initializeAcpPlanState } from '../acpPlanState'; import { isSessionTurnRetired } from '../../store/sessionMutationStore'; @@ -1871,6 +1872,7 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { userMessageMetadata?.kind === 'manual_compaction' ? 'manual_compaction' : 'user_dialog'; const freshSession = store.getState().sessions.get(sessionId); + const submittedPreview = getSubmittedMessagePreview(sessionId, turnId); let dialogTurn = freshSession?.dialogTurns.find((turn: DialogTurn) => turn.id === turnId); let projectedNewTurn = false; @@ -1928,7 +1930,7 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { sessionId, kind: turnKind, userMessage: { - id: `user_remote_${Date.now()}`, + id: submittedPreview?.message.id ?? `user_remote_${Date.now()}`, content: displayContent, timestamp: Date.now(), hasImages, @@ -1951,6 +1953,10 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { projectedNewTurn = true; } + if (submittedPreview && (projectedNewTurn || dialogTurn?.userMessage.id === submittedPreview.message.id)) { + finishSubmittedMessagePreview(submittedPreview.scope, sessionId, turnId); + } + if (projectedNewTurn) { reconcileBackgroundSubagentSession(sessionId); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index c6601f2735..8ce0c0abae 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -39,6 +39,13 @@ import { submitSteeringMessage } from '../steeringSubmission'; import { promoteAcceptedHostMessage } from '../hostQueueSubmission'; import { isAcpFlowSession } from '../../utils/acpSession'; import { updateSessionDraft } from '../sessionDraftService'; +import { + beginSubmittedMessagePreview, + failSubmittedMessagePreview, + finishSubmittedMessagePreview, +} from '../submittedMessagePresentation'; +import { finishSubmittedMessageScrollIntent, peekSubmittedMessageScrollIntent } from '../submittedMessageScrollIntent'; +import { FLOWCHAT_MESSAGE_SUBMITTED_EVENT } from '../../events/flowchatNavigation'; export { syncSessionModelSelection } from '../../utils/modelSync'; export { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; @@ -381,6 +388,7 @@ export async function sendMessage( const surfaceGenerationAtSend = context.flowChatStore.getSurfaceGeneration(); const surfaceIdAtSend = surfaceScopeAtSend.surfaceId; const draftSubmissionKey = session.draft ? surfaceScopeAtSend.key('draft-submit', sessionId) : undefined; + let previewTurnId: string | undefined; if (draftSubmissionKey) { if (draftSubmissions.has(draftSubmissionKey)) throw new Error('Draft submission is already in progress'); draftSubmissions.add(draftSubmissionKey); @@ -422,6 +430,27 @@ export async function sendMessage( throw new Error('Session history is still restoring, please retry once loading finishes'); } + if (options?.foregroundSubmission + && !options.bypassPendingQueue + && context.flowChatStore.getState().activeSessionId === sessionId) { + previewTurnId = refreshedSession.draft?.turnId || options.turnId?.trim() + || `dialog_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + const messageId = `user_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`; + options = { ...options, turnId: previewTurnId }; + // A locally submitted Turn gives up a detached history window at the send boundary. + window.dispatchEvent(new CustomEvent(FLOWCHAT_MESSAGE_SUBMITTED_EVENT, { + detail: { sessionId }, + })); + beginSubmittedMessagePreview(surfaceScopeAtSend, sessionId, previewTurnId, { + id: messageId, + content: displayMessage || message, + timestamp: Date.now(), + hasImages: (options.imageDisplayData?.length ?? 0) > 0, + images: options.imageDisplayData, + metadata: options.userMessageMetadata, + }); + } + if (!acpClientId) { // A driver with nothing to prepare returns void; awaiting only real // promises keeps the projection's optimistic turn synchronous with the @@ -463,10 +492,16 @@ export async function sendMessage( turnTracker, ); if (outcome === 'detached') { + if (previewTurnId) finishSubmittedMessagePreview(surfaceScopeAtSend, sessionId, previewTurnId); // The message steered or continued target-owned work; the shared // post-submission bookkeeping does not apply. return; } + if (outcome === 'queued' && previewTurnId) { + finishSubmittedMessagePreview(surfaceScopeAtSend, sessionId, previewTurnId); + const intent = peekSubmittedMessageScrollIntent(surfaceScopeAtSend, sessionId, previewTurnId); + if (intent) finishSubmittedMessageScrollIntent(intent); + } if (readySession.draft && turnTracker.hostAcceptedTurn) { context.flowChatStore.setState(state => { @@ -504,6 +539,7 @@ export async function sendMessage( isSurfaceChangedError(error) || context.flowChatStore.getSurfaceGeneration() !== surfaceGenerationAtSend ) { + if (previewTurnId) finishSubmittedMessagePreview(surfaceScopeAtSend, sessionId, previewTurnId); recoverSubmissionAfterSurfaceSwitch(context, surfaceIdAtSend, sessionId, turnTracker, { message, displayMessage, @@ -523,6 +559,11 @@ export async function sendMessage( if (draft?.phase === 'submitting') updateSessionDraft(context, sessionId, { phase: 'ready' }); } const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; + if (previewTurnId) { + failSubmittedMessagePreview(surfaceScopeAtSend, sessionId, previewTurnId, errorMessage); + const intent = peekSubmittedMessageScrollIntent(surfaceScopeAtSend, sessionId, previewTurnId); + if (intent) finishSubmittedMessageScrollIntent(intent); + } const currentState = stateMachineManager.getCurrentState(sessionId); const activeDialogTurnId = stateMachineManager diff --git a/src/web-ui/src/flow_chat/services/hostDialogQueue.ts b/src/web-ui/src/flow_chat/services/hostDialogQueue.ts index 5dd7960345..d29f2f4e24 100644 --- a/src/web-ui/src/flow_chat/services/hostDialogQueue.ts +++ b/src/web-ui/src/flow_chat/services/hostDialogQueue.ts @@ -7,7 +7,8 @@ import { FlowChatStore } from '../store/FlowChatStore'; import { isAcpFlowSession } from '../utils/acpSession'; import { resolveSessionDriverId } from '../session-drivers/resolve'; import { translateAgentIdentityFields } from '../../../../shared/agent-harness/wire'; -import { registerSubmittedMessageScrollIntent, finishSubmittedMessageScrollIntent } from './submittedMessageScrollIntent'; +import { registerSubmittedMessageScrollIntent, finishSubmittedMessageScrollIntent, peekSubmittedMessageScrollIntent } from './submittedMessageScrollIntent'; +import { getSubmittedMessagePreviews } from './submittedMessagePresentation'; const clients = new Map(); function currentAccount(): string { @@ -34,7 +35,11 @@ export function hostDialogQueue(sessionId: string): HostDialogQueue { // The queue has now selected the actual idempotent Turn id. Register // before invoking, since DialogTurnStarted can beat the RPC receipt. const intent = request.action === 'submit' - ? registerSubmittedMessageScrollIntent(scope, sessionId, request.message.turnId, null) : undefined; + ? peekSubmittedMessageScrollIntent(scope, sessionId, request.message.turnId) + ?? (getSubmittedMessagePreviews(scope, sessionId).length > 0 + ? undefined + : registerSubmittedMessageScrollIntent(scope, sessionId, request.message.turnId, null)) + : undefined; try { const result = await api.invoke( 'manage_dialog_queue', { request: translateAgentIdentityFields(request, 'legacy') }); diff --git a/src/web-ui/src/flow_chat/services/submittedMessagePresentation.test.ts b/src/web-ui/src/flow_chat/services/submittedMessagePresentation.test.ts index b23f8646c2..75a22f062c 100644 --- a/src/web-ui/src/flow_chat/services/submittedMessagePresentation.test.ts +++ b/src/web-ui/src/flow_chat/services/submittedMessagePresentation.test.ts @@ -1,7 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { activateSurface, getActiveSurfaceScope, LOCAL_SURFACE_ID } from '@/infrastructure/peer-device/deviceSurface'; import { + beginSubmittedMessagePreview, + consumeSubmittedMessageFailure, consumeSubmittedMessageArrival, + failSubmittedMessagePreview, + finishSubmittedMessagePreview, + getSubmittedMessagePreviews, registerSubmittedMessage, submittedMessageStatusDelay, } from './submittedMessagePresentation'; @@ -66,4 +71,29 @@ describe('submittedMessagePresentation', () => { vi.advanceTimersByTime(60); expect(submittedMessageStatusDelay('session', 'turn')).toBe(0); }); + + it('keeps a foreground preview scoped to its device and hands off a failed shell once', () => { + const scope = getActiveSurfaceScope(); + const preview = beginSubmittedMessagePreview(scope, 'session', 'turn', { + id: 'message', content: 'Hello', timestamp: Date.now(), + }); + expect(getSubmittedMessagePreviews(scope, 'session')).toEqual([preview]); + expect(getSubmittedMessagePreviews(scope, 'other-session')).toHaveLength(0); + failSubmittedMessagePreview(scope, 'session', 'turn', 'Unavailable'); + expect(getSubmittedMessagePreviews(scope, 'session')[0].phase).toBe('failed'); + expect(consumeSubmittedMessageFailure('session', 'turn', 'message')).toBeDefined(); + expect(consumeSubmittedMessageFailure('session', 'turn', 'message')).toBeUndefined(); + finishSubmittedMessagePreview(scope, 'session', 'turn'); + expect(getSubmittedMessagePreviews(scope, 'session')).toHaveLength(0); + }); + + it('drops a preview on device activation change', () => { + const scope = getActiveSurfaceScope(); + beginSubmittedMessagePreview(scope, 'session', 'turn', { + id: 'message', content: 'Hello', timestamp: Date.now(), + }); + activateSurface('peer-device'); + expect(getSubmittedMessagePreviews(scope, 'session')).toHaveLength(0); + expect(getSubmittedMessagePreviews(getActiveSurfaceScope(), 'session')).toHaveLength(0); + }); }); diff --git a/src/web-ui/src/flow_chat/services/submittedMessagePresentation.ts b/src/web-ui/src/flow_chat/services/submittedMessagePresentation.ts index 5281c5e605..7121de4efd 100644 --- a/src/web-ui/src/flow_chat/services/submittedMessagePresentation.ts +++ b/src/web-ui/src/flow_chat/services/submittedMessagePresentation.ts @@ -2,6 +2,8 @@ import { getActiveSurfaceScope, type SurfaceScope, } from '@/infrastructure/peer-device/deviceSurface'; +import type { DialogTurn } from '../types/flow-chat'; +import { registerSubmittedMessageScrollIntent } from './submittedMessageScrollIntent'; export const SUBMITTED_MESSAGE_PRESENTATION_MS = 300; const STATUS_REVEAL_AFTER_MS = 160; @@ -20,6 +22,119 @@ interface PendingArrival extends SubmittedMessageArrival { // The short lifetime also prevents an offscreen send from animating on a later visit. const arrivals = new Map(); +export interface SubmittedMessagePreview { + readonly scope: SurfaceScope; + readonly sessionId: string; + readonly turnId: string; + readonly message: DialogTurn['userMessage']; + readonly phase: 'forming' | 'failed'; + readonly error?: string; + readonly failedAt?: number; + readonly failureClaimed?: boolean; +} + +const EMPTY_PREVIEWS: readonly SubmittedMessagePreview[] = []; +const previews = new Map(); +const previewListeners = new Set<() => void>(); +const previewCleanups = new WeakMap void>(); + +function previewKey(scope: SurfaceScope, sessionId: string): string { + return scope.key('submitted-message-previews', scope.epoch, sessionId); +} + +export function subscribeSubmittedMessagePreviews(listener: () => void): () => void { + previewListeners.add(listener); + return () => { previewListeners.delete(listener); }; +} + +export function getSubmittedMessagePreviews( + scope: SurfaceScope, + sessionId: string, +): readonly SubmittedMessagePreview[] { + return scope.isCurrent() ? previews.get(previewKey(scope, sessionId)) ?? EMPTY_PREVIEWS : EMPTY_PREVIEWS; +} + +export function getSubmittedMessagePreview( + sessionId: string, + turnId: string, +): SubmittedMessagePreview | undefined { + return getSubmittedMessagePreviews(getActiveSurfaceScope(), sessionId) + .find(preview => preview.turnId === turnId); +} + +function publishPreviews(scope: SurfaceScope, sessionId: string, next: readonly SubmittedMessagePreview[]): void { + const key = previewKey(scope, sessionId); + if (next.length) previews.set(key, next); + else previews.delete(key); + previewListeners.forEach(listener => listener()); +} + +/** A foreground composer send owns this display-only row until a real Turn adopts it. */ +export function beginSubmittedMessagePreview( + scope: SurfaceScope, + sessionId: string, + turnId: string, + message: DialogTurn['userMessage'], +): SubmittedMessagePreview | undefined { + if (!scope.isCurrent()) return undefined; + const previous = getSubmittedMessagePreviews(scope, sessionId); + // A fresh attempt supersedes the failed shell, while other in-flight sends keep their identity. + for (const preview of previous) { + if (preview.phase === 'failed' || preview.turnId === turnId) { + previewCleanups.get(preview)?.(); + if (preview.phase === 'failed') arrivals.delete(arrivalKey(scope, sessionId, preview.turnId)); + } + } + const preview: SubmittedMessagePreview = { scope, sessionId, turnId, message, phase: 'forming' }; + registerSubmittedMessage(scope, sessionId, turnId, message.id); + registerSubmittedMessageScrollIntent(scope, sessionId, turnId, message.id); + const abort = () => finishSubmittedMessagePreview(scope, sessionId, turnId); + scope.signal.addEventListener('abort', abort, { once: true }); + previewCleanups.set(preview, () => scope.signal.removeEventListener('abort', abort)); + publishPreviews(scope, sessionId, [ + ...previous.filter(item => item.phase !== 'failed' && item.turnId !== turnId), preview, + ]); + return preview; +} + +export function finishSubmittedMessagePreview(scope: SurfaceScope, sessionId: string, turnId: string): void { + const current = previews.get(previewKey(scope, sessionId)); + const preview = current?.find(item => item.turnId === turnId); + if (!preview || !current) return; + previewCleanups.get(preview)?.(); + if (preview.phase === 'failed') arrivals.delete(arrivalKey(scope, sessionId, turnId)); + publishPreviews(scope, sessionId, current.filter(item => item !== preview)); +} + +export function failSubmittedMessagePreview( + scope: SurfaceScope, + sessionId: string, + turnId: string, + error: string, +): void { + const current = previews.get(previewKey(scope, sessionId)); + const preview = current?.find(item => item.turnId === turnId); + if (!preview || !current || !scope.isCurrent()) return; + const failed: SubmittedMessagePreview = { + ...preview, phase: 'failed', error, failedAt: performance.now(), failureClaimed: false, + }; + previewCleanups.set(failed, previewCleanups.get(preview)!); + previewCleanups.delete(preview); + publishPreviews(scope, sessionId, current.map(item => item === preview ? failed : item)); +} + +/** Only the first mounted instance may animate the failed handoff. */ +export function consumeSubmittedMessageFailure( + sessionId: string, turnId: string, messageId: string, +): SubmittedMessagePreview | undefined { + const preview = getSubmittedMessagePreview(sessionId, turnId); + if (preview?.phase !== 'failed' || preview.failureClaimed || preview.message.id !== messageId + || preview.failedAt === undefined || performance.now() - preview.failedAt > SUBMITTED_MESSAGE_PRESENTATION_MS) return undefined; + // The claim is deliberately mutable so a virtualized remount does not publish another row. + (preview as { failureClaimed: boolean }).failureClaimed = true; + return preview; +} + function arrivalKey(scope: SurfaceScope, sessionId: string, turnId: string): string { return scope.key('submitted-message', scope.epoch, sessionId, turnId); } diff --git a/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts b/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts index 2fa8de2dfc..f25b615b96 100644 --- a/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts +++ b/src/web-ui/src/flow_chat/services/submittedMessageScrollIntent.ts @@ -49,11 +49,24 @@ export function bindSubmittedMessageScrollIntent(sessionId: string, turnId: stri listeners.forEach(listener => listener()); } +export function peekSubmittedMessageScrollIntent(scope: SurfaceScope, sessionId: string, turnId: string): SubmittedMessageScrollIntent | undefined { + const intent = pending.get(keyFor(scope, sessionId)); + return intent?.turnId === turnId && intent.scope.isCurrent() ? intent : undefined; +} + export function finishSubmittedMessageScrollIntent(intent: SubmittedMessageScrollIntent): void { const key = keyFor(intent.scope, intent.sessionId); - if (pending.get(key) === intent) pending.delete(key); + const wasPending = pending.get(key) === intent; + if (wasPending) pending.delete(key); cleanups.get(intent)?.(); cleanups.delete(intent); + // Notify after the caller releases its own pending reference. The follow + // hook finishes an intent inside its placement transaction. + if (wasPending) queueMicrotask(() => listeners.forEach(listener => listener())); +} + +export function isSubmittedMessageScrollIntentPending(intent: SubmittedMessageScrollIntent): boolean { + return pending.get(keyFor(intent.scope, intent.sessionId)) === intent; } export function subscribeSubmittedMessageScrollIntent(listener: () => void): () => void { diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 9888a4de5b..910e339a79 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -45,6 +45,7 @@ import { sessionWorktreeMaterializationPlan } from '../../utils/sessionWorktree' import { cleanupSaveState, updateSessionMetadata } from '../../services/flow-chat-manager/PersistenceModule'; import { cleanupSessionBuffers } from '../../services/flow-chat-manager/TextChunkModule'; import { addSubmittedDialogTurn, applyGeneratingTitlePlaceholder } from '../shared'; +import { finishSubmittedMessagePreview, getSubmittedMessagePreview } from '../../services/submittedMessagePresentation'; import { initializeSessionTitleMetadata } from '../../services/sessionTitleMetadata'; import { inheritReviewPermissionMode } from '../../services/inheritReviewPermissionMode'; @@ -397,13 +398,17 @@ export const localSessionDriver: SessionDriver = { { composerDraft: options?.pendingQueueDraft, imageContexts: options?.imageContexts, imageDisplayData: options?.imageDisplayData }, options?.turnId); tracker.hostAcceptedTurn = true; surfaceScope.assertCurrent('accept host message'); + if (options?.turnId && accepted.receipt?.turnId && accepted.receipt.turnId !== options.turnId) { + // An existing outbox record can deduplicate the request under its older Turn id. + finishSubmittedMessagePreview(surfaceScope, sessionId, options.turnId); + } if (options?.sendImmediately) { await promoteAcceptedHostMessage(queue, accepted); surfaceScope.assertCurrent('accept immediate host message'); } context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); if (isFirstMessage) await updateSessionMetadata(context, sessionId, ['titleMetadata']); - return 'completed'; + return accepted.receipt?.status === 'started' ? 'completed' : 'queued'; } const dialogTurnId = options?.turnId?.trim() || @@ -429,7 +434,7 @@ export const localSessionDriver: SessionDriver = { sessionId: sessionId, agentType: currentAgentType, userMessage: { - id: `user_${Date.now()}`, + id: getSubmittedMessagePreview(sessionId, dialogTurnId)?.message.id ?? `user_${Date.now()}`, content: displayMessage || message, timestamp: Date.now(), hasImages, diff --git a/src/web-ui/src/flow_chat/session-drivers/shared.ts b/src/web-ui/src/flow_chat/session-drivers/shared.ts index 16ebf2f5d8..7af5317389 100644 --- a/src/web-ui/src/flow_chat/session-drivers/shared.ts +++ b/src/web-ui/src/flow_chat/session-drivers/shared.ts @@ -6,8 +6,8 @@ import { generateTempTitle } from '../utils/titleUtils'; import type { FlowChatContext } from '../services/flow-chat-manager/types'; import type { SurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; import type { DialogTurn } from '../types/flow-chat'; -import { registerSubmittedMessage } from '../services/submittedMessagePresentation'; -import { registerSubmittedMessageScrollIntent } from '../services/submittedMessageScrollIntent'; +import { finishSubmittedMessagePreview, getSubmittedMessagePreview, registerSubmittedMessage } from '../services/submittedMessagePresentation'; +import { registerSubmittedMessageScrollIntent, peekSubmittedMessageScrollIntent } from '../services/submittedMessageScrollIntent'; /** Register send feedback before the synchronous optimistic projection can render. */ export function addSubmittedDialogTurn( @@ -19,9 +19,13 @@ export function addSubmittedDialogTurn( const session = context.flowChatStore.getState().sessions.get(sessionId); if (session && !session.dialogTurns.some(existing => existing.id === turn.id)) { registerSubmittedMessage(scope, sessionId, turn.id, turn.userMessage.id); - registerSubmittedMessageScrollIntent(scope, sessionId, turn.id, turn.userMessage.id); + if (!getSubmittedMessagePreview(sessionId, turn.id) + && !peekSubmittedMessageScrollIntent(scope, sessionId, turn.id)) { + registerSubmittedMessageScrollIntent(scope, sessionId, turn.id, turn.userMessage.id); + } } context.flowChatStore.addDialogTurn(sessionId, turn); + finishSubmittedMessagePreview(scope, sessionId, turn.id); } /** diff --git a/src/web-ui/src/flow_chat/session-drivers/types.ts b/src/web-ui/src/flow_chat/session-drivers/types.ts index 8ef2009d72..4d1bae4d2a 100644 --- a/src/web-ui/src/flow_chat/session-drivers/types.ts +++ b/src/web-ui/src/flow_chat/session-drivers/types.ts @@ -38,6 +38,8 @@ export interface SendMessageOptions { * submission. Session drivers must not interpret it as transport input. */ pendingQueueDraft?: QueuedComposerDraft; + /** Only an active composer submission may show the temporary user-message shell. */ + foregroundSubmission?: boolean; /** * When true, bypass the pending-queue check. Used by the queue drain path * to actually start a new dialog turn after the previous one finished. @@ -121,7 +123,7 @@ export interface TurnTracker { * `detached` means the message was steered into (or continued) target-owned * work and the shared epilogue must be skipped. */ -export type StartTurnResult = 'completed' | 'detached'; +export type StartTurnResult = 'completed' | 'detached' | 'queued'; /** Localized strings the usage-report flow surfaces; supplied by the caller. */ export interface UsageReportUiParams { diff --git a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts index 863eccf3bc..67b8bd0254 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts @@ -27,6 +27,9 @@ type VirtualContent = turnId: string; absoluteTurnIndex?: number; turnStatus?: DialogTurn['status']; + /** Display-only state for a foreground send before its Turn is projected. */ + submissionPhase?: 'forming' | 'failed'; + submissionError?: string; } | { type: 'user-steering-message'; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 47376b0c3c..6d4fedff02 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -565,6 +565,7 @@ "clickToExpand": "Click to expand", "clickToCollapse": "Click to collapse", "fillToInput": "Fill to input", + "continueEditing": "Continue editing", "cannotRollback": "Cannot rollback", "rollbackDisabledBusy": "Wait until the session is idle before rolling back.", "rollbackDisabledRemote": "Remote workspaces do not support complete rollback because remote file snapshots are not recorded. No files or messages will be changed.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 49799f052c..deea0ccf2d 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -565,6 +565,7 @@ "clickToExpand": "点击展开", "clickToCollapse": "点击收起", "fillToInput": "填充到输入框", + "continueEditing": "继续编辑", "cannotRollback": "无法回滚", "rollbackDisabledBusy": "请等待会话空闲后再回滚。", "rollbackDisabledRemote": "远程工作区未记录文件快照,暂不支持文件与消息一致回滚;不会更改任何文件或消息。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 213a1e9a04..6142cecbe3 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -565,6 +565,7 @@ "clickToExpand": "點擊展開", "clickToCollapse": "點擊收起", "fillToInput": "填充到輸入框", + "continueEditing": "繼續編輯", "cannotRollback": "無法回滾", "rollbackDisabledBusy": "請等待會話閒置後再回滾。", "rollbackDisabledRemote": "遠端工作區未記錄檔案快照,暫不支援檔案與訊息一致回滾;不會變更任何檔案或訊息。",