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:
Bohdan Triapitsyn
2026-07-05 00:05:38 +03:00
parent fb839b66a9
commit dbcb655e43
8 changed files with 239 additions and 27 deletions
@@ -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}
+35 -3
View File
@@ -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 ? (