Skip to content

Commit df6561e

Browse files
author
GCWing
committed
feat(flowchat): show foreground send state and stabilize scroll extent
1 parent 71bc498 commit df6561e

27 files changed

Lines changed: 791 additions & 101 deletions

‎src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx‎

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { requireSessionOwningWorkspaceId } from '../../utils/sessionOrdering';
44
* Uses virtual scrolling with Zustand and syncs legacy store state.
55
*/
66

7-
import React, { useMemo, useCallback, useRef, useEffect, useLayoutEffect, useState } from 'react';
7+
import React, { useMemo, useCallback, useRef, useEffect, useLayoutEffect, useState, useSyncExternalStore } from 'react';
88
import { useTranslation } from 'react-i18next';
99
import { useShortcut } from '@/infrastructure/hooks/useShortcut';
1010
import { FlowChatManager } from '@/flow_chat/services/FlowChatManager';
@@ -43,6 +43,7 @@ import {
4343
useVirtualItems,
4444
useActiveSession,
4545
useVisibleTurnInfo,
46+
type VirtualItem,
4647
type VisibleTurnInfo,
4748
} from '../../store/modernFlowChatStore';
4849
import type { Session, SessionHistoryPresentation } from '../../types/flow-chat';
@@ -124,6 +125,7 @@ import {
124125
} from '../../utils/flowChatTurnIdentity';
125126
import type { FlowChatViewportSnapshot } from './flowChatViewportSnapshot';
126127
import { peekConversationViewTransfer, registerConversationReader, takeConversationViewTransfer } from './flowChatViewHandoff';
128+
import { getSubmittedMessagePreviews, subscribeSubmittedMessagePreviews } from '../../services/submittedMessagePresentation';
127129

128130
const log = createLogger('ModernFlowChatContainer');
129131

@@ -305,6 +307,12 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
305307
const { t } = useTranslation('flow-chat');
306308
const canonicalVirtualItems = useVirtualItems();
307309
const activeSession = useActiveSession();
310+
const surfaceScope = getActiveSurfaceScope();
311+
const submittedPreviews = useSyncExternalStore(
312+
subscribeSubmittedMessagePreviews,
313+
() => getSubmittedMessagePreviews(surfaceScope, activeSession?.sessionId ?? ''),
314+
() => getSubmittedMessagePreviews(surfaceScope, ''),
315+
);
308316
const [historyPresentation, setHistoryPresentation] = useState<FlowChatHistoryPresentationState | null>(null);
309317
const [viewportIntent, setViewportIntent] = useState<FlowChatViewportIntent | null>(null);
310318
const [continuousProjectionSessionId, setContinuousProjectionSessionId] = useState<string | null>(null);
@@ -447,25 +455,43 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
447455
isReadingTurnViewport && !renderedTranscriptReachesLatestTurn
448456
);
449457
const virtualItems = useMemo(() => {
458+
let items: VirtualItem[];
450459
if (!activeSession || !renderedHistoryPresentation) {
451-
return canonicalVirtualItems;
452-
}
453-
if (
460+
items = canonicalVirtualItems;
461+
} else if (
454462
isRenderingContinuousHistoryProjection
455463
&& continuousHistoryVirtualItems
456464
) {
457-
return continuousHistoryVirtualItems;
465+
items = continuousHistoryVirtualItems;
466+
} else {
467+
items = sessionToVirtualItems({
468+
...activeSession,
469+
dialogTurns: renderedHistoryPresentation.turns,
470+
});
458471
}
459-
return sessionToVirtualItems({
460-
...activeSession,
461-
dialogTurns: renderedHistoryPresentation.turns,
462-
});
472+
if (!activeSession || isViewportDetachedFromLiveTail || submittedPreviews.length === 0) return items;
473+
const projectedTurnIds = new Set(activeSession.dialogTurns.map(turn => turn.id));
474+
const pendingItems = submittedPreviews
475+
.filter(preview => !projectedTurnIds.has(preview.turnId))
476+
.map((preview, index) => ({
477+
type: 'user-message' as const,
478+
data: preview.message,
479+
turnId: preview.turnId,
480+
absoluteTurnIndex: activeSessionKnownTurnCount + index + 1,
481+
turnStatus: 'pending' as const,
482+
submissionPhase: preview.phase,
483+
submissionError: preview.error,
484+
}));
485+
return pendingItems.length ? [...items, ...pendingItems] : items;
463486
}, [
464487
activeSession,
488+
activeSessionKnownTurnCount,
465489
canonicalVirtualItems,
466490
continuousHistoryVirtualItems,
467491
isRenderingContinuousHistoryProjection,
492+
isViewportDetachedFromLiveTail,
468493
renderedHistoryPresentation,
494+
submittedPreviews,
469495
]);
470496

471497
// The transcript reads the pending list to mark the tool cards that are
@@ -492,7 +518,6 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
492518
const activeSessionIdRef = useRef<string | null>(null);
493519
const sessionViewportStateRef = useRef<Map<string, SessionViewportState>>(new Map());
494520
const viewScope = useConversationViewScope();
495-
const surfaceScope = getActiveSurfaceScope();
496521
const transferredRevision = useRef(0);
497522
const incomingViewTransfer = activeSession?.sessionId && isViewportActive
498523
? peekConversationViewTransfer({ surfaceId: surfaceScope.surfaceId, sessionId: activeSession.sessionId }, viewScope ? 'dock' : 'main')

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
width: fit-content;
2626
max-width: var(--openbitfun-control-flow-chat-user-message-max-width);
2727
padding: var(--openbitfun-control-flow-chat-user-message-padding-block) var(--_user-message-padding-inline);
28-
background: var(--openbitfun-color-action-quiet-hover);
28+
background: transparent;
2929
border: none;
3030
border-radius: var(--_user-message-radius);
3131
margin-block: 0;
@@ -40,6 +40,53 @@
4040
position: relative;
4141
}
4242

43+
.user-message-item__surface {
44+
position: absolute;
45+
inset: 0;
46+
border-radius: inherit;
47+
background: var(--openbitfun-color-action-quiet-hover);
48+
pointer-events: none;
49+
transform-origin: right center;
50+
}
51+
52+
.user-message-item--editing .user-message-item__surface {
53+
display: none;
54+
}
55+
56+
.user-message-item--forming .user-message-item__surface::after {
57+
content: '';
58+
position: absolute;
59+
inset: 0;
60+
border-radius: inherit;
61+
background: var(--openbitfun-color-action-quiet-hover);
62+
pointer-events: none;
63+
animation: user-message-forming-fill 3.4s ease-in-out 260ms infinite;
64+
}
65+
66+
@keyframes user-message-forming-fill {
67+
0%, 100% { opacity: 0.08; }
68+
50% { opacity: 0.16; }
69+
}
70+
71+
.user-message-item__submission-error {
72+
display: flex;
73+
align-items: center;
74+
justify-content: flex-end;
75+
flex-wrap: wrap;
76+
gap: var(--openbitfun-control-flow-chat-inline-gap);
77+
width: fit-content;
78+
max-width: var(--openbitfun-control-flow-chat-user-message-max-width);
79+
margin-block-start: var(--openbitfun-space-1);
80+
margin-inline: auto 0;
81+
color: var(--openbitfun-color-status-danger-content);
82+
font-size: var(--openbitfun-type-flow-meta-font-size);
83+
line-height: var(--openbitfun-type-flow-meta-line-height);
84+
}
85+
86+
.user-message-item__submission-error-icon {
87+
flex: none;
88+
}
89+
4390
.user-message-item--editing {
4491
width: auto;
4592
max-width: none;
@@ -82,6 +129,7 @@
82129
}
83130

84131
.user-message-item__main {
132+
position: relative;
85133
display: flex;
86134
align-items: flex-start;
87135
justify-content: center;
@@ -158,6 +206,12 @@
158206
overflow-y: auto;
159207
}
160208

209+
.user-message-item--expanded:not(.user-message-item--editing) {
210+
background: var(--openbitfun-color-action-quiet-hover);
211+
212+
.user-message-item__surface { display: none; }
213+
}
214+
161215
// Failed messages keep the message fill and mark the failed state through text styling.
162216
.user-message-item--failed {
163217
--_failed-font-size: var(--openbitfun-type-flow-control-font-size);
@@ -395,6 +449,10 @@
395449
}
396450

397451
@media (prefers-reduced-motion: reduce) {
452+
.user-message-item--forming .user-message-item__surface::after {
453+
animation: none;
454+
opacity: 0.08;
455+
}
398456
.user-message-item,
399457
.user-message-item__timestamp,
400458
.user-message-item__actions,

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx‎

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
77
import { withConversationExcerptFallback } from '@/shared/utils/conversationExcerpt';
88
import { getAppearanceOverlayHost } from '@/infrastructure/appearance/runtime/AppearanceOverlayHost';
9-
import { RotateCcw, Loader2 } from 'lucide-react';
9+
import { CircleAlert, RotateCcw, Loader2 } from 'lucide-react';
1010
import type { DialogTurn, FlowUserSteeringItem } from '../../types/flow-chat';
1111
import type { ImageContext } from '@/shared/types/context';
1212
import { flowChatManager } from '../../services/FlowChatManager';
@@ -27,7 +27,7 @@ import { globalEventBus } from '@/infrastructure/event-bus';
2727
import { shouldIgnoreCardToggleClick } from '@/shared/utils/textSelection';
2828
import { observeElementResize } from '@/shared/utils/sharedResizeObserver';
2929
import { formatContextForPrompt } from '@/shared/utils/contextPrompt';
30-
import { Dialog, DialogClose, Tooltip, Icon, IconButton } from '@openbitfun/ui';
30+
import { Button, Dialog, DialogClose, Tooltip, Icon, IconButton } from '@openbitfun/ui';
3131
import { confirmDanger } from '@/infrastructure/confirm-dialog';
3232
import { ToolProcessingDots } from '@openbitfun/ui/flow-chat';
3333
import { UserMessageEditComposer } from './UserMessageEditComposer';
@@ -62,6 +62,8 @@ import { buildImagePayload } from '../../utils/imagePayload';
6262
import { UserMessagePresentationContent, UserMessageTextContent } from './UserMessagePresentationContent';
6363
import { UserMessageImage } from './UserMessageImage';
6464
import { useSubmittedMessageMotion } from './useSubmittedMessageMotion';
65+
import { finishSubmittedMessagePreview } from '../../services/submittedMessagePresentation';
66+
import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface';
6567
import './UserMessageItem.scss';
6668

6769
const log = createLogger('UserMessageItem');
@@ -72,6 +74,8 @@ interface UserMessageItemProps {
7274
absoluteTurnIndex?: number;
7375
turnStatus?: DialogTurn['status'];
7476
steeringStatus?: FlowUserSteeringItem['status'];
77+
submissionPhase?: 'forming' | 'failed';
78+
submissionError?: string;
7579
}
7680

7781
function buildPresentationRerunPayload(presentation: ComposerPresentation): {
@@ -104,7 +108,7 @@ function buildPresentationRerunPayload(presentation: ComposerPresentation): {
104108
}
105109

106110
export const UserMessageItem = React.memo<UserMessageItemProps>(
107-
({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus }) => {
111+
({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError }) => {
108112
const { t, formatDate } = useI18n('flow-chat');
109113
const {
110114
sessionId,
@@ -198,7 +202,8 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
198202
const resolvedTurnStatus = dialogTurn?.status ?? turnStatus;
199203
const isFailed = resolvedTurnStatus === 'error';
200204
const resolvedSessionId = sessionId ?? currentSession?.sessionId;
201-
useSubmittedMessageMotion(shellRef, resolvedSessionId, turnId, message?.id, isFailed || isEditing);
205+
useSubmittedMessageMotion(shellRef, resolvedSessionId, turnId, message?.id,
206+
isFailed || isEditing || submissionPhase === 'failed', submissionPhase);
202207
const sessionMachine = useSessionStateMachine(resolvedSessionId ?? null);
203208
const sessionExecutionState = sessionMachine && sessionMachine.sessionId === resolvedSessionId
204209
? sessionMachine.currentState
@@ -498,7 +503,10 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
498503
contexts: restoredComposerContexts,
499504
...(composerPresentation ? { composerPresentation } : {}),
500505
});
501-
}, [composerPresentation, messageContent, restoredComposerContexts]);
506+
if (submissionPhase === 'failed' && resolvedSessionId) {
507+
finishSubmittedMessagePreview(getActiveSurfaceScope(), resolvedSessionId, turnId);
508+
}
509+
}, [composerPresentation, messageContent, restoredComposerContexts, submissionPhase, resolvedSessionId, turnId]);
502510

503511
const handleOpenUsageReport = useCallback((report: SessionUsageReport, initialTab?: SessionUsagePanelTab) => {
504512
void import('../../services/openSessionUsageReport').then(({ openSessionUsagePanel }) => {
@@ -579,14 +587,15 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
579587
<div
580588
data-openbitfun-product-component="user-message-item"
581589
data-openbitfun-product-part="root"
582-
data-openbitfun-state={[expanded && 'expanded', isFailed && 'failed'].filter(Boolean).join(' ') || undefined}
590+
data-openbitfun-state={[expanded && 'expanded', isFailed && 'failed', submissionPhase].filter(Boolean).join(' ') || undefined}
583591
ref={containerRef}
584-
className={`user-message-item ${expanded ? 'user-message-item--expanded' : ''}${isFailed ? ' user-message-item--failed' : ''}${isEditing ? ' user-message-item--editing' : ''}`}
592+
className={`user-message-item ${expanded ? 'user-message-item--expanded' : ''}${isFailed ? ' user-message-item--failed' : ''}${isEditing ? ' user-message-item--editing' : ''}${submissionPhase === 'forming' ? ' user-message-item--forming' : ''}${submissionPhase === 'failed' ? ' user-message-item--submission-failed' : ''}`}
585593
data-testid="chat-user-message"
586594
data-turn-id={turnId}
587-
data-status={resolvedTurnStatus || ''}
588-
data-failed={isFailed ? 'true' : 'false'}
595+
data-status={submissionPhase === 'failed' ? 'error' : resolvedTurnStatus || ''}
596+
data-failed={isFailed || submissionPhase === 'failed' ? 'true' : 'false'}
589597
>
598+
<span className="user-message-item__surface" aria-hidden="true" />
590599
{isEditing ? (
591600
<div className="user-message-item__edit-layout">
592601
<UserMessageEditComposer
@@ -699,7 +708,16 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
699708
)}
700709
</div>
701710

702-
<div className="user-message-item__meta" data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="meta">
711+
{submissionPhase === 'failed' && (
712+
<div className="user-message-item__submission-error" role="alert">
713+
<Icon glyph={CircleAlert} size="sm" className="user-message-item__submission-error-icon" />
714+
<span title={submissionError}>{t('error.sendFailed')}</span>
715+
<Button type="button" variant="outline" size="sm" onClick={handleFillToInput}>
716+
{t('message.continueEditing')}
717+
</Button>
718+
</div>
719+
)}
720+
{!submissionPhase && <div className="user-message-item__meta" data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="meta">
703721
{!isEditing && sentTime && sentAtLabel && sentTimestamp !== null && (
704722
<time
705723
className="user-message-item__timestamp"
@@ -762,7 +780,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
762780
) : null}
763781
</div>
764782
)}
765-
</div>
783+
</div>}
766784

767785
</div>
768786
);

‎src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
6262
turnId={item.turnId}
6363
absoluteTurnIndex={item.absoluteTurnIndex}
6464
turnStatus={item.turnStatus}
65+
submissionPhase={item.submissionPhase}
66+
submissionError={item.submissionError}
6567
/>
6668
);
6769

‎src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,14 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
477477
const onViewportSnapshotRef = useRef(onViewportSnapshot);
478478
onViewportSnapshotRef.current = onViewportSnapshot;
479479
const [isAtBottom, setIsAtBottom] = useState(true);
480-
const [isOpenViewportSettled, setIsOpenViewportSettled] = useState(false);
480+
// A brand-new transcript with only the foreground send shell has no history
481+
// geometry to settle. Reveal that first bubble in the submission frame.
482+
const [isOpenViewportSettled, setIsOpenViewportSettled] = useState(() => (
483+
activeSession?.dialogTurns.length === 0
484+
&& virtualItems.length === 1
485+
&& virtualItems[0].type === 'user-message'
486+
&& virtualItems[0].submissionPhase !== undefined
487+
));
481488
useSessionReadOnOpen(activeSessionId, isViewportActive);
482489
const shouldRestoreInitialSnapshot = Boolean(
483490
initialViewportSnapshot
@@ -679,7 +686,8 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
679686
const placeSubmittedMessage = useCallback((intent: SubmittedMessageScrollIntent): number | null => {
680687
const item = virtualItems.find(candidate => candidate.turnId === intent.turnId && candidate.type === 'user-message');
681688
const turn = activeSessionRef.current?.dialogTurns.find(candidate => candidate.id === intent.turnId);
682-
if (!item || turn?.userMessage.id !== intent.messageId) return null;
689+
if (!item || item.type !== 'user-message' || item.data.id !== intent.messageId
690+
|| (turn && turn.userMessage.id !== intent.messageId)) return null;
683691
const scroller = scrollerElementRef.current;
684692
if (!scroller || !extentElementRef.current) return null;
685693
if (!getRenderedUserMessageElement(intent.turnId)) {

0 commit comments

Comments
 (0)