Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -43,6 +43,7 @@ import {
useVirtualItems,
useActiveSession,
useVisibleTurnInfo,
type VirtualItem,
type VisibleTurnInfo,
} from '../../store/modernFlowChatStore';
import type { Session, SessionHistoryPresentation } from '../../types/flow-chat';
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -305,6 +307,12 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
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<FlowChatHistoryPresentationState | null>(null);
const [viewportIntent, setViewportIntent] = useState<FlowChatViewportIntent | null>(null);
const [continuousProjectionSessionId, setContinuousProjectionSessionId] = useState<string | null>(null);
Expand Down Expand Up @@ -447,25 +455,43 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
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
Expand All @@ -492,7 +518,6 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
const activeSessionIdRef = useRef<string | null>(null);
const sessionViewportStateRef = useRef<Map<string, SessionViewportState>>(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')
Expand Down
60 changes: 59 additions & 1 deletion src/web-ui/src/flow_chat/components/modern/UserMessageItem.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -82,6 +129,7 @@
}

.user-message-item__main {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 29 additions & 11 deletions src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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');
Expand All @@ -72,6 +74,8 @@ interface UserMessageItemProps {
absoluteTurnIndex?: number;
turnStatus?: DialogTurn['status'];
steeringStatus?: FlowUserSteeringItem['status'];
submissionPhase?: 'forming' | 'failed';
submissionError?: string;
}

function buildPresentationRerunPayload(presentation: ComposerPresentation): {
Expand Down Expand Up @@ -104,7 +108,7 @@ function buildPresentationRerunPayload(presentation: ComposerPresentation): {
}

export const UserMessageItem = React.memo<UserMessageItemProps>(
({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus }) => {
({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError }) => {
const { t, formatDate } = useI18n('flow-chat');
const {
sessionId,
Expand Down Expand Up @@ -198,7 +202,8 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
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
Expand Down Expand Up @@ -498,7 +503,10 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
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 }) => {
Expand Down Expand Up @@ -579,14 +587,15 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
<div
data-openbitfun-product-component="user-message-item"
data-openbitfun-product-part="root"
data-openbitfun-state={[expanded && 'expanded', isFailed && 'failed'].filter(Boolean).join(' ') || undefined}
data-openbitfun-state={[expanded && 'expanded', isFailed && 'failed', submissionPhase].filter(Boolean).join(' ') || undefined}
ref={containerRef}
className={`user-message-item ${expanded ? 'user-message-item--expanded' : ''}${isFailed ? ' user-message-item--failed' : ''}${isEditing ? ' user-message-item--editing' : ''}`}
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' : ''}`}
data-testid="chat-user-message"
data-turn-id={turnId}
data-status={resolvedTurnStatus || ''}
data-failed={isFailed ? 'true' : 'false'}
data-status={submissionPhase === 'failed' ? 'error' : resolvedTurnStatus || ''}
data-failed={isFailed || submissionPhase === 'failed' ? 'true' : 'false'}
>
<span className="user-message-item__surface" aria-hidden="true" />
{isEditing ? (
<div className="user-message-item__edit-layout">
<UserMessageEditComposer
Expand Down Expand Up @@ -699,7 +708,16 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
)}
</div>

<div className="user-message-item__meta" data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="meta">
{submissionPhase === 'failed' && (
<div className="user-message-item__submission-error" role="alert">
<Icon glyph={CircleAlert} size="sm" className="user-message-item__submission-error-icon" />
<span title={submissionError}>{t('error.sendFailed')}</span>
<Button type="button" variant="outline" size="sm" onClick={handleFillToInput}>
{t('message.continueEditing')}
</Button>
</div>
)}
{!submissionPhase && <div className="user-message-item__meta" data-openbitfun-product-component="user-message-item" data-openbitfun-product-part="meta">
{!isEditing && sentTime && sentAtLabel && sentTimestamp !== null && (
<time
className="user-message-item__timestamp"
Expand Down Expand Up @@ -762,7 +780,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
) : null}
</div>
)}
</div>
</div>}

</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
turnId={item.turnId}
absoluteTurnIndex={item.absoluteTurnIndex}
turnStatus={item.turnStatus}
submissionPhase={item.submissionPhase}
submissionError={item.submissionError}
/>
);

Expand Down
12 changes: 10 additions & 2 deletions src/web-ui/src/flow_chat/components/modern/VirtualMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,14 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
const onViewportSnapshotRef = useRef(onViewportSnapshot);
onViewportSnapshotRef.current = onViewportSnapshot;
const [isAtBottom, setIsAtBottom] = useState(true);
const [isOpenViewportSettled, setIsOpenViewportSettled] = useState(false);
// A brand-new transcript with only the foreground send shell has no history
// geometry to settle. Reveal that first bubble in the submission frame.
const [isOpenViewportSettled, setIsOpenViewportSettled] = useState(() => (
activeSession?.dialogTurns.length === 0
&& virtualItems.length === 1
&& virtualItems[0].type === 'user-message'
&& virtualItems[0].submissionPhase !== undefined
));
useSessionReadOnOpen(activeSessionId, isViewportActive);
const shouldRestoreInitialSnapshot = Boolean(
initialViewportSnapshot
Expand Down Expand Up @@ -679,7 +686,8 @@ const VirtualMessageListSession = forwardRef<VirtualMessageListRef, VirtualMessa
const placeSubmittedMessage = useCallback((intent: SubmittedMessageScrollIntent): number | null => {
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)) {
Expand Down
Loading
Loading