feat(mobile): synchronized pill composer transitions and draft screen fixes
Drop the animated pill/composer morph in favor of instant swaps that are synchronized with the keyboard choreography: a new oc:keyboard-intent event collapses the composer (flushSync) before the hide compensation is measured, so keyboard travel and composer height change land as a single chat motion on both iOS and Android (Android also gains keyboard signals and deterministic re-pins around its native resize). The WKWebView caret is hidden during the transition so it no longer flies to its new position. Draft screen: starter chips hide instantly while the keyboard is up and the centered title rides the keyboard shift compensation instead of double-jumping; the composer drag handle also works in dictation mode; the highlight mirror is disabled on mobile so the caret matches the text. Fixes: worktree discovery and the GitHub auth probe now wait for the runtime connection (no more empty branch pickers / stale auth on cold start), worktree discovery merges per project instead of clobbering the persisted map, the cross-project session list resets on instance switch (with an in-flight load guard) so no stale sessions linger, and mobile overlay content contains its overscroll instead of bouncing the page.
This commit is contained in:
@@ -801,7 +801,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
return (
|
||||
<div className="relative flex h-full flex-col bg-background transform-gpu">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
@@ -812,7 +812,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
</h1>
|
||||
<DraftPresetChips
|
||||
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
||||
className="mt-8 max-w-md"
|
||||
className="oc-draft-starters mt-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||
@@ -1008,7 +1009,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
||||
// Mobile pill composer: when the keyboard is closed the composer collapses
|
||||
// into a narrow pill (+ / placeholder / mic) with a round new-session button
|
||||
// beside it. Any interaction expands back into the full composer.
|
||||
// beside it. Any interaction expands back into the full composer. The swap
|
||||
// is deliberately INSTANT and synchronized with the keyboard choreography,
|
||||
// so the chat compensates keyboard + composer height in a single motion.
|
||||
const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false);
|
||||
const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
|
||||
const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
|
||||
@@ -4152,11 +4155,41 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isMobile, mobileComposerExpanded, mobileComposerBusy, setExpandedInput]);
|
||||
|
||||
const mobileComposerBusyRef = React.useRef(false);
|
||||
mobileComposerBusyRef.current = mobileComposerBusy;
|
||||
|
||||
// Capacitor: collapse in the SAME frame the keyboard starts hiding. The
|
||||
// hide choreography dispatches oc:keyboard-intent BEFORE restoring the
|
||||
// shell layout and measuring the chat compensation; flushSync commits the
|
||||
// pill swap first, so keyboard land + composer shrink are measured (and
|
||||
// compensated) as ONE motion instead of a two-step staircase. The delayed
|
||||
// effect above stays as the fallback for non-Capacitor and for overlays
|
||||
// closing without a keyboard transition.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || typeof window === 'undefined') return;
|
||||
const handleIntent = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ open?: boolean }>).detail;
|
||||
if (!detail || detail.open !== false) return;
|
||||
if (!mobileComposerExpandedRef.current) return;
|
||||
// Something still holds the composer open (dictation, an overlay
|
||||
// that closed the keyboard, drag) — the fallback path handles it.
|
||||
if (mobileComposerBusyRef.current) return;
|
||||
mobileExpandIntentRef.current = null;
|
||||
flushSync(() => {
|
||||
setMobileComposerExpanded(false);
|
||||
setExpandedInput(false);
|
||||
});
|
||||
};
|
||||
window.addEventListener('oc:keyboard-intent', handleIntent);
|
||||
return () => window.removeEventListener('oc:keyboard-intent', handleIntent);
|
||||
}, [isMobile, setExpandedInput]);
|
||||
|
||||
// Reset the picker search whenever a draft picker sheet opens/closes.
|
||||
React.useEffect(() => {
|
||||
setMobileDraftPickerQuery('');
|
||||
}, [mobileDraftPicker]);
|
||||
|
||||
|
||||
// ── Composer drag handle (mobile): swipe up = fullscreen, swipe down =
|
||||
// leave fullscreen or dismiss the keyboard. ────────────────────────────
|
||||
const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => {
|
||||
@@ -4588,7 +4621,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
)}
|
||||
>
|
||||
{isMobile && !mobileComposerExpanded ? (
|
||||
<div className="oc-composer-morph-fade flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
@@ -4666,7 +4699,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
isMobile && 'oc-composer-morph-fade',
|
||||
isComposerExpanded && 'flex-1 min-h-0',
|
||||
"border border-border/80",
|
||||
"focus-within:ring-1",
|
||||
|
||||
@@ -126,7 +126,16 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<ScrollableOverlay useScrollShadow disableHorizontal outerClassName={cn('min-h-0 flex-1', contentMaxHeight)} className="px-2 py-2 pwa-overlay-scroll">
|
||||
<ScrollableOverlay
|
||||
useScrollShadow
|
||||
disableHorizontal
|
||||
// Contain the scroll inside the panel: without this, iOS chains the
|
||||
// rubber-band overscroll to the page behind the sheet, which reads
|
||||
// as a weird content bounce while scrolling the overlay.
|
||||
preventOverscroll
|
||||
outerClassName={cn('min-h-0 flex-1', contentMaxHeight)}
|
||||
className="px-2 py-2 pwa-overlay-scroll"
|
||||
>
|
||||
{children}
|
||||
</ScrollableOverlay>
|
||||
{footer ? (
|
||||
|
||||
Reference in New Issue
Block a user