feat(chat): animate draft session transition
This commit is contained in:
@@ -65,6 +65,8 @@ const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
|||||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||||
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
|
||||||
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
|
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 = {
|
const CHAT_SCROLL_STYLE = {
|
||||||
overflowAnchor: 'none',
|
overflowAnchor: 'none',
|
||||||
overscrollBehavior: 'contain',
|
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 { t } = useI18n();
|
||||||
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
|
||||||
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
||||||
@@ -516,7 +518,10 @@ const DraftWelcome: React.FC = () => {
|
|||||||
}, [draftTarget, selectedProjectId]));
|
}, [draftTarget, selectedProjectId]));
|
||||||
|
|
||||||
return (
|
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">
|
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||||
{renderDraftTitle(
|
{renderDraftTitle(
|
||||||
projectLabel
|
projectLabel
|
||||||
@@ -1093,11 +1098,62 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
void ensureSessionRenderable(currentSessionId);
|
void ensureSessionRenderable(currentSessionId);
|
||||||
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
|
}, [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) {
|
if (!currentSessionId && !draftOpen) {
|
||||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
// The auto-open effect runs on the next tick. Use a neutral background
|
||||||
// so the empty state is only ever transient here — render a neutral
|
// until then instead of flashing the standard empty state.
|
||||||
// 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.
|
|
||||||
if (autoOpenDraft && !initError) {
|
if (autoOpenDraft && !initError) {
|
||||||
return <div className="flex h-full flex-col bg-background" />;
|
return <div className="flex h-full flex-col bg-background" />;
|
||||||
}
|
}
|
||||||
@@ -1108,82 +1164,37 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentSessionId && draftOpen) {
|
const sessionSurface = (() => {
|
||||||
return (
|
if (draftOpen || draftPresentationExiting) {
|
||||||
// No transform on this root: it would become the containing block for
|
if (!useCompactDraftLayout || isDesktopExpandedInput) {
|
||||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
return null;
|
||||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
}
|
||||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
return <DraftWelcome exiting={draftPresentationExiting} />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentSessionId) {
|
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||||
return null;
|
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) {
|
return (
|
||||||
if (sessionMessageLoadState.status === 'error') {
|
<div
|
||||||
return (
|
className={cn(
|
||||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
'relative min-h-0',
|
||||||
{returnToParentButton}
|
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||||
<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'
|
|
||||||
)}
|
)}
|
||||||
aria-hidden={isDesktopExpandedInput}
|
aria-hidden={isDesktopExpandedInput}
|
||||||
>
|
>
|
||||||
@@ -1194,20 +1205,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
<div className="chat-message-column">
|
<div className="chat-message-column">
|
||||||
<div className="space-y-2.5 px-4 py-3">
|
<div className="space-y-2.5 px-4 py-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{item.toolRows.map((row) => {
|
{item.toolRows.map((row) => (
|
||||||
return (
|
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
|
||||||
<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="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.titleWidth)} />
|
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
||||||
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
|
</div>
|
||||||
</div>
|
))}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
|
{item.textWidths.map((width, index) => (
|
||||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
|
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
|
||||||
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1216,62 +1225,25 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionMessages.length === 0 && !sessionIsWorking) {
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
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',
|
'relative min-h-0',
|
||||||
isDesktopExpandedInput
|
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
|
||||||
? 'absolute inset-0 opacity-0 pointer-events-none'
|
|
||||||
: 'flex-1'
|
|
||||||
)}
|
)}
|
||||||
aria-hidden={isDesktopExpandedInput}
|
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 (
|
return (
|
||||||
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
|
<ChatViewport
|
||||||
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
|
currentSessionId={currentSessionId ?? ''}
|
||||||
{returnToParentButton}
|
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
|
||||||
<ChatViewport
|
|
||||||
currentSessionId={currentSessionId}
|
|
||||||
currentSessionKey={currentSessionKey ?? currentSessionId}
|
|
||||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
stickyUserHeader={stickyUserHeader}
|
stickyUserHeader={stickyUserHeader}
|
||||||
@@ -1302,22 +1274,41 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
|
|||||||
isLoadingOlderPrompts={timelineController.isLoadingOlder}
|
isLoadingOlderPrompts={timelineController.isLoadingOlder}
|
||||||
onLoadEarlierPrompts={handleLoadOlderClick}
|
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
|
<div
|
||||||
|
ref={composerSlotRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-10',
|
'relative z-10 flex min-h-0',
|
||||||
isDesktopExpandedInput
|
isDesktopExpandedInput
|
||||||
? 'flex-1 min-h-0 bg-background'
|
? 'flex-1 min-h-0 bg-background'
|
||||||
|
: draftLayoutVisible && !useCompactDraftLayout
|
||||||
|
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
|
||||||
: 'bg-background'
|
: 'bg-background'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
|
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
|
||||||
<ScrollToBottomButton
|
<ScrollToBottomButton
|
||||||
visible={timelineController.showScrollToBottom}
|
visible={timelineController.showScrollToBottom}
|
||||||
onClick={navigation.resumeToLatest}
|
onClick={navigation.resumeToLatest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
|
{promptReadOnly ? (
|
||||||
|
<ReadOnlyPromptBanner />
|
||||||
|
) : (
|
||||||
|
<ChatInput
|
||||||
|
active={active}
|
||||||
|
scrollToBottom={scrollToBottomOnSend}
|
||||||
|
draftPresentationExiting={draftPresentationExiting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inside the chat column, not beside it: as a row sibling it took
|
{/* Inside the chat column, not beside it: as a row sibling it took
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ interface ChatInputProps {
|
|||||||
onOpenSettings?: () => void;
|
onOpenSettings?: () => void;
|
||||||
scrollToBottom?: () => void;
|
scrollToBottom?: () => void;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
draftPresentationExiting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
|
||||||
@@ -237,7 +238,12 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
|
|||||||
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
|
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();
|
const { t } = useI18n();
|
||||||
// Track if we restored a draft on mount (for text selection)
|
// Track if we restored a draft on mount (for text selection)
|
||||||
const initialDraftRef = React.useRef<string | null>(null);
|
const initialDraftRef = React.useRef<string | null>(null);
|
||||||
@@ -2375,6 +2381,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
|
|
||||||
const chatSurfaceMode = useChatSurfaceMode();
|
const chatSurfaceMode = useChatSurfaceMode();
|
||||||
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
|
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(() => {
|
const hasPendingChanges = React.useMemo(() => {
|
||||||
if (isMiniChatSurface) {
|
if (isMiniChatSurface) {
|
||||||
@@ -2552,8 +2567,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
)}
|
)}
|
||||||
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
||||||
>
|
>
|
||||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
{showDesktopDraftPresentation ? (
|
||||||
<div className="chat-input-column mb-7 text-center">
|
<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">
|
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
|
||||||
{renderDraftTitle(
|
{renderDraftTitle(
|
||||||
draftProjectLabel
|
draftProjectLabel
|
||||||
@@ -2624,21 +2639,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
? null
|
? null
|
||||||
: <PendingChangesBar />}
|
: <PendingChangesBar />}
|
||||||
/>
|
/>
|
||||||
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
{!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
|
||||||
<DraftTargetSelectors
|
<div className={draftPresentationClassName}>
|
||||||
projects={draftProjects}
|
<DraftTargetSelectors
|
||||||
selectedProject={selectedDraftProject}
|
projects={draftProjects}
|
||||||
selectedDirectory={selectedDraftDirectory}
|
selectedProject={selectedDraftProject}
|
||||||
selectedBranchLabel={selectedDraftBranchLabel}
|
selectedDirectory={selectedDraftDirectory}
|
||||||
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
selectedBranchLabel={selectedDraftBranchLabel}
|
||||||
projectRootBranchOption={projectRootBranchOption}
|
selectedBranchIsKnown={selectedDraftBranchIsKnown}
|
||||||
worktreeBranchOptions={worktreeBranchOptions}
|
projectRootBranchOption={projectRootBranchOption}
|
||||||
branchItems={draftBranchItems}
|
worktreeBranchOptions={worktreeBranchOptions}
|
||||||
showBranchSelector={shouldShowDraftBranchSelector}
|
branchItems={draftBranchItems}
|
||||||
onProjectChange={handleDraftProjectChange}
|
showBranchSelector={shouldShowDraftBranchSelector}
|
||||||
onDirectoryChange={handleDraftDirectoryChange}
|
onProjectChange={handleDraftProjectChange}
|
||||||
theme={currentTheme}
|
onDirectoryChange={handleDraftDirectoryChange}
|
||||||
/>
|
theme={currentTheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||||
<MobileDraftTargetTriggers
|
<MobileDraftTargetTriggers
|
||||||
@@ -2902,10 +2919,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
{showDesktopDraftPresentation ? (
|
||||||
<DraftPresetChips
|
<DraftPresetChips
|
||||||
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
|
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
|
||||||
className="chat-input-column mt-4"
|
className={cn('chat-input-column mt-4', draftPresentationClassName)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ everything between typing and sending.
|
|||||||
own state and wires these modules together; it should not grow logic that
|
own state and wires these modules together; it should not grow logic that
|
||||||
belongs to one of them.
|
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
|
## Layers
|
||||||
|
|
||||||
| Directory | Owns |
|
| Directory | Owns |
|
||||||
|
|||||||
Reference in New Issue
Block a user