feat(chat): animate draft session transition

This commit is contained in:
Bohdan Triapitsyn
2026-08-22 01:10:19 +03:00
parent 70a7a77ded
commit 96c7be9342
3 changed files with 181 additions and 165 deletions
+136 -145
View File
@@ -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 (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<div className={cn(
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-100 ease-out motion-reduce:transition-none',
exiting && 'pointer-events-none opacity-0',
)}>
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
projectLabel
@@ -1093,11 +1098,62 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
const previousComposerRectRef = React.useRef<DOMRect | null>(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 <div className="flex h-full flex-col bg-background" />;
}
@@ -1108,82 +1164,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
);
}
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).
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 bg-background'
: useCompactDraftLayout
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
);
}
const sessionSurface = (() => {
if (draftOpen || draftPresentationExiting) {
if (!useCompactDraftLayout || isDesktopExpandedInput) {
return null;
}
return <DraftWelcome exiting={draftPresentationExiting} />;
}
if (!currentSessionId) {
return null;
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
);
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div data-composer-bound className="relative flex h-full flex-col bg-background">
{returnToParentButton}
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
<Icon name="error-warning" className="size-4" />
</div>
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
{t('chat.container.sessionLoadError.retry')}
</Button>
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return (
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
return (
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
)}
aria-hidden={isDesktopExpandedInput}
>
@@ -1194,20 +1205,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
{item.toolRows.map((row) => (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
))}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
{item.textWidths.map((width, index) => (
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
))}
</div>
</div>
</div>
@@ -1216,62 +1225,25 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
</div>
</div>
</div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
// No transform here either — same fixed-positioning constraint as the
// draft branch above.
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
)}
aria-hidden={isDesktopExpandedInput}
>
{!isDesktopExpandedInput ? (
<div className="absolute inset-0 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
/>
);
}
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
currentSessionKey={currentSessionKey ?? currentSessionId}
return (
<ChatViewport
currentSessionId={currentSessionId ?? ''}
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
@@ -1302,22 +1274,41 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
isLoadingOlderPrompts={timelineController.isLoadingOlder}
onLoadEarlierPrompts={handleLoadOlderClick}
/>
);
})();
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
{sessionSurface}
<div
ref={composerSlotRef}
className={cn(
'relative z-10',
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: draftLayoutVisible && !useCompactDraftLayout
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
: 'bg-background'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
)}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? (
<ReadOnlyPromptBanner />
) : (
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
draftPresentationExiting={draftPresentationExiting}
/>
)}
</div>
{/* Inside the chat column, not beside it: as a row sibling it took
+37 -20
View File
@@ -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<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
active = true,
draftPresentationExiting = false,
}) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
@@ -2375,6 +2381,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ 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<ChatInputProps> = ({ onOpenSettings, scrollTo
)}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
<div className="chat-input-column mb-7 text-center">
{showDesktopDraftPresentation ? (
<div className={cn('chat-input-column mb-7 text-center', draftPresentationClassName)}>
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
{renderDraftTitle(
draftProjectLabel
@@ -2624,21 +2639,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
? null
: <PendingChangesBar />}
/>
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<DraftTargetSelectors
projects={draftProjects}
selectedProject={selectedDraftProject}
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
showBranchSelector={shouldShowDraftBranchSelector}
onProjectChange={handleDraftProjectChange}
onDirectoryChange={handleDraftDirectoryChange}
theme={currentTheme}
/>
{!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
<div className={draftPresentationClassName}>
<DraftTargetSelectors
projects={draftProjects}
selectedProject={selectedDraftProject}
selectedDirectory={selectedDraftDirectory}
selectedBranchLabel={selectedDraftBranchLabel}
selectedBranchIsKnown={selectedDraftBranchIsKnown}
projectRootBranchOption={projectRootBranchOption}
worktreeBranchOptions={worktreeBranchOptions}
branchItems={draftBranchItems}
showBranchSelector={shouldShowDraftBranchSelector}
onProjectChange={handleDraftProjectChange}
onDirectoryChange={handleDraftDirectoryChange}
theme={currentTheme}
/>
</div>
) : null}
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<MobileDraftTargetTriggers
@@ -2902,10 +2919,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
) : null}
</div>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
{showDesktopDraftPresentation ? (
<DraftPresetChips
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
className="chat-input-column mt-4"
className={cn('chat-input-column mt-4', draftPresentationClassName)}
/>
) : null}
</form>
@@ -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 |