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')}
+
+ {t('chat.container.sessionLoadError.retry')}
+
+
+
+ );
+ }
- if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
- if (sessionMessageLoadState.status === 'error') {
- return (
-
- {returnToParentButton}
-
-
-
-
-
-
{t('chat.container.sessionLoadError.title')}
-
{t('chat.container.sessionLoadError.description')}
-
- {t('chat.container.sessionLoadError.retry')}
-
-
-
-
- {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 |