From 96c7be93427cdd1280a27b7fabf653dfc4666a11 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 17:27:07 +0300 Subject: [PATCH] feat(chat): animate draft session transition --- .../ui/src/components/chat/ChatContainer.tsx | 281 +++++++++--------- packages/ui/src/components/chat/ChatInput.tsx | 57 ++-- .../components/chat/composer/DOCUMENTATION.md | 8 + 3 files changed, 181 insertions(+), 165 deletions(-) diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 8b07222e..0d769c95 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -65,6 +65,8 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = []; const IDLE_SESSION_STATUS = { type: 'idle' as const }; const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom'; const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.'; +const DRAFT_EXIT_DURATION_MS = 100; +const COMPOSER_MOVE_DURATION_MS = 120; const CHAT_SCROLL_STYLE = { overflowAnchor: 'none', overscrollBehavior: 'contain', @@ -502,7 +504,7 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea ); }; -const DraftWelcome: React.FC = () => { +const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => { const { t } = useI18n(); const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null); @@ -516,7 +518,10 @@ const DraftWelcome: React.FC = () => { }, [draftTarget, selectedProjectId])); return ( -
+

{renderDraftTitle( projectLabel @@ -1093,11 +1098,62 @@ export const ChatContainer: React.FC = ({ void ensureSessionRenderable(currentSessionId); }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]); + const composerSlotRef = React.useRef(null); + const previousComposerRectRef = React.useRef(null); + const previousDraftOpenRef = React.useRef(draftOpen); + const previousDraftLayoutVisibleRef = React.useRef(draftOpen); + const [draftExitAnimating, setDraftExitAnimating] = React.useState(false); + const draftPresentationExiting = draftExitAnimating + || (previousDraftOpenRef.current && !draftOpen && Boolean(currentSessionId)); + const draftLayoutVisible = draftOpen || draftPresentationExiting; + + React.useLayoutEffect(() => { + if (draftOpen) { + setDraftExitAnimating(false); + return; + } + if (!previousDraftOpenRef.current || !currentSessionId) return; + + setDraftExitAnimating(true); + const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS); + return () => window.clearTimeout(timeoutId); + }, [currentSessionId, draftOpen]); + + React.useLayoutEffect(() => { + previousDraftOpenRef.current = draftOpen; + }, [draftOpen]); + + React.useLayoutEffect(() => { + const composerSlot = composerSlotRef.current; + if (!composerSlot) return; + + const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]'); + const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect(); + const previousRect = previousComposerRectRef.current; + const leftDraftLayout = previousDraftLayoutVisibleRef.current + && !draftLayoutVisible + && Boolean(currentSessionId); + const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false; + + if (leftDraftLayout && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) { + const deltaX = previousRect.left - currentRect.left; + const deltaY = previousRect.top - currentRect.top; + composerSlot.animate( + [ + { transform: `translate(${deltaX}px, ${deltaY}px)` }, + { transform: 'translate(0, 0)' }, + ], + { duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, + ); + } + + previousComposerRectRef.current = currentRect; + previousDraftLayoutVisibleRef.current = draftLayoutVisible; + }, [currentSessionId, draftLayoutVisible, isDesktopExpandedInput, useCompactDraftLayout]); + if (!currentSessionId && !draftOpen) { - // With auto-open, the draft welcome opens on the next tick (effect below), - // so the empty state is only ever transient here — render a neutral - // background instead of flashing the logo / "start a new chat" on refresh. - // Keep the empty state when there's nothing to auto-open or an init error to show. + // The auto-open effect runs on the next tick. Use a neutral background + // until then instead of flashing the standard empty state. if (autoOpenDraft && !initError) { return
; } @@ -1108,82 +1164,37 @@ export const ChatContainer: React.FC = ({ ); } - if (!currentSessionId && draftOpen) { - return ( - // No transform on this root: it would become the containing block for - // the fullscreen composer's position:fixed visual-viewport pinning in - // mobile browsers (see ChatInput's composerFormRef effect). -
-
- {useCompactDraftLayout && !isDesktopExpandedInput ? : null} -
- {promptReadOnly ? : } -
- {workStatusOverlayMountable ? ( - - ) : null} -
- {workStatusPanelMountable ? ( - - ) : null} -
- ); - } + const sessionSurface = (() => { + if (draftOpen || draftPresentationExiting) { + if (!useCompactDraftLayout || isDesktopExpandedInput) { + return null; + } + return ; + } - if (!currentSessionId) { - return null; - } + if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { + if (sessionMessageLoadState.status === 'error') { + return ( +
+
+
+ +
+

{t('chat.container.sessionLoadError.title')}

+

{t('chat.container.sessionLoadError.description')}

+ +
+
+ ); + } - if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { - if (sessionMessageLoadState.status === 'error') { - return ( -
- {returnToParentButton} -
-
-
- -
-

{t('chat.container.sessionLoadError.title')}

-

{t('chat.container.sessionLoadError.description')}

- -
-
-
- {promptReadOnly ? : } -
-
- ); - } - return ( -
- {returnToParentButton} -
@@ -1194,20 +1205,18 @@ export const ChatContainer: React.FC = ({
- {item.toolRows.map((row) => { - return ( -
- - - -
- ); - })} + {item.toolRows.map((row) => ( +
+ + + +
+ ))}
- - - + {item.textWidths.map((width, index) => ( + + ))}
@@ -1216,62 +1225,25 @@ export const ChatContainer: React.FC = ({
+ ); + } + + if (sessionMessages.length === 0 && !sessionIsWorking) { + return (
- {promptReadOnly ? : } -
-

- ); - } - - if (sessionMessages.length === 0 && !sessionIsWorking) { - return ( - // No transform here either — same fixed-positioning constraint as the - // draft branch above. -
- {returnToParentButton} -
- {!isDesktopExpandedInput ? ( -
- -
- ) : null} -
-
- {promptReadOnly ? : } -
-
- ); - } + /> + ); + } - return ( -
-
- {returnToParentButton} - = ({ isLoadingOlderPrompts={timelineController.isLoadingOlder} onLoadEarlierPrompts={handleLoadOlderClick} /> + ); + })(); + + return ( +
+
+ {returnToParentButton} + {sessionSurface}
- {!isDesktopExpandedInput && sessionMessages.length > 0 && ( + {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && ( )} - {promptReadOnly ? : } + {promptReadOnly ? ( + + ) : ( + + )}
{/* Inside the chat column, not beside it: as a row sibling it took diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index fa98b7d0..83a3c2e9 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -224,6 +224,7 @@ interface ChatInputProps { onOpenSettings?: () => void; scrollToBottom?: () => void; active?: boolean; + draftPresentationExiting?: boolean; } const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => { @@ -237,7 +238,12 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | return createChatDraftIdentity(getRuntimeKey(), directory, sessionId); }; -const ChatInputComponent: React.FC = ({ onOpenSettings, scrollToBottom, active = true }) => { +const ChatInputComponent: React.FC = ({ + onOpenSettings, + scrollToBottom, + active = true, + draftPresentationExiting = false, +}) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); @@ -2375,6 +2381,15 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const chatSurfaceMode = useChatSurfaceMode(); const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; + const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting) + && !isDesktopExpanded + && !isMobile + && !isVSCode + && !isMiniChatSurface; + const draftPresentationClassName = cn( + 'transition-opacity duration-100 ease-out motion-reduce:transition-none', + draftPresentationExiting && 'pointer-events-none opacity-0', + ); const hasPendingChanges = React.useMemo(() => { if (isMiniChatSurface) { @@ -2552,8 +2567,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo )} style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} > - {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( -
+ {showDesktopDraftPresentation ? ( +

{renderDraftTitle( draftProjectLabel @@ -2624,21 +2639,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? null : } /> - {!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( - + {!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? ( +
+ +
) : null} {isMobile && showDraftTargetSelectors && selectedDraftProject ? ( = ({ onOpenSettings, scrollTo /> ) : null}

- {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( + {showDesktopDraftPresentation ? ( submitPresetPrompt(starter.submitText, starter.ref.type)} - className="chat-input-column mt-4" + className={cn('chat-input-column mt-4', draftPresentationClassName)} /> ) : null} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 667c2db7..63136460 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -7,6 +7,14 @@ everything between typing and sending. own state and wires these modules together; it should not grow logic that belongs to one of them. +`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft +becomes its first session. Draft-only UI first fades for 100ms while the editor +stays in place. The parent then moves the editor to its final session position +with a 120ms transform-only FLIP animation. Reduced-motion mode skips these +transitions. Do not restore separate draft and session composer branches: +remounting the editor loses focus and interrupts the transition. Keep the +existing mobile fixed-position rules unchanged. + ## Layers | Directory | Owns |