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:
@@ -45,7 +45,6 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import type { QuotaProviderId, UsageWindow } from '@/types';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
@@ -163,10 +162,47 @@ const useNativeMobileChrome = (): void => {
|
||||
if (disposed) return;
|
||||
// iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard
|
||||
// overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the
|
||||
// window for the keyboard (dvh already shrinks), so applying the inset on top double-
|
||||
// counts and floats the composer a keyboard-height above the keyboard — skip it there.
|
||||
// window for the keyboard (dvh already shrinks), so applying the inset on top would
|
||||
// double-count — Android gets only the class/event signals below.
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (platform === 'android') return;
|
||||
if (platform === 'android') {
|
||||
// Android resizes the WebView natively, so no inset/transform
|
||||
// choreography — but the UI still needs the open/closed signal:
|
||||
// oc-keyboard-open drives CSS (draft starters, composer padding), and
|
||||
// the settled event gives the chat its one deterministic re-pin after
|
||||
// the native resize (the auto-follow idle gate ignores it otherwise).
|
||||
const willShowHandle = await Keyboard.addListener('keyboardWillShow', () => {
|
||||
root.classList.add('oc-keyboard-open');
|
||||
// The composer already expanded on tap — re-pin the chat to it now,
|
||||
// so the native resize that follows is the only remaining movement.
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } }));
|
||||
});
|
||||
const didShowHandle = await Keyboard.addListener('keyboardDidShow', () => {
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } }));
|
||||
});
|
||||
const willHideHandle = await Keyboard.addListener('keyboardWillHide', () => {
|
||||
// Same single-motion trick as iOS: collapse the composer into the
|
||||
// pill synchronously (flushSync in ChatInput) so the native window
|
||||
// growth and the composer shrink land together, not as two steps.
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-intent', { detail: { open: false } }));
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
});
|
||||
const didHideHandle = await Keyboard.addListener('keyboardDidHide', () => {
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: false } }));
|
||||
});
|
||||
const removeAll = () => {
|
||||
void willShowHandle.remove();
|
||||
void didShowHandle.remove();
|
||||
void willHideHandle.remove();
|
||||
void didHideHandle.remove();
|
||||
};
|
||||
if (disposed) {
|
||||
removeAll();
|
||||
return;
|
||||
}
|
||||
cleanup.push(removeAll);
|
||||
return;
|
||||
}
|
||||
// No WebKit form accessory bar (prev/next arrows + Done) above the keyboard —
|
||||
// there's a single input, so it only eats vertical space.
|
||||
await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined);
|
||||
@@ -184,6 +220,7 @@ const useNativeMobileChrome = (): void => {
|
||||
const KB_HIDE_MS = 200;
|
||||
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
||||
let settleTimer: number | null = null;
|
||||
let caretTimer: number | null = null;
|
||||
let keyboardHeight = 0;
|
||||
let layoutApplied = false;
|
||||
let safeBottomPx = 0;
|
||||
@@ -198,7 +235,7 @@ const useNativeMobileChrome = (): void => {
|
||||
settleTimer = null;
|
||||
}
|
||||
};
|
||||
const dispatchKb = (type: 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
||||
const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
};
|
||||
|
||||
@@ -215,7 +252,15 @@ const useNativeMobileChrome = (): void => {
|
||||
}
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-kb-hide');
|
||||
root.classList.add('oc-keyboard-open', 'oc-kb-animating');
|
||||
// WKWebView renders the caret as a native layer that doesn't ride CSS
|
||||
// transforms — after the rise it visibly "flies" from the pre-keyboard
|
||||
// position to the final one. Hide it for the transition (plus the lag
|
||||
// window where UIKit animates it into place) and pop it back in.
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold');
|
||||
setInset(keyboardHeight);
|
||||
setVar('--oc-kb-shift', slide);
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
||||
@@ -228,6 +273,11 @@ const useNativeMobileChrome = (): void => {
|
||||
layoutApplied = true;
|
||||
setVar('--oc-kb-shift', 0);
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
// Reveal the caret only after UIKit's own caret reposition window.
|
||||
caretTimer = window.setTimeout(() => {
|
||||
caretTimer = null;
|
||||
root.classList.remove('oc-kb-caret-hold');
|
||||
}, 250);
|
||||
}, KB_ANIM_MS + 20);
|
||||
});
|
||||
|
||||
@@ -241,6 +291,16 @@ const useNativeMobileChrome = (): void => {
|
||||
if (!keyboardOpen) return;
|
||||
keyboardOpen = false;
|
||||
clearSettle();
|
||||
// Fired BEFORE any layout change: lets the composer collapse into its
|
||||
// pill synchronously (flushSync in ChatInput), so the keyboard hide
|
||||
// compensation below measures keyboard + composer shrink as ONE delta
|
||||
// instead of two staggered steps.
|
||||
dispatchKb('oc:keyboard-intent', { open: false });
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
root.classList.remove('oc-kb-caret-hold');
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
setInset(0);
|
||||
@@ -299,6 +359,12 @@ const useNativeMobileChrome = (): void => {
|
||||
}
|
||||
cleanup.push(
|
||||
clearSettle,
|
||||
() => {
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
},
|
||||
() => document.removeEventListener('focusout', handleFocusOut, true),
|
||||
() => void showHandle.remove(),
|
||||
() => void hideHandle.remove(),
|
||||
@@ -308,7 +374,7 @@ const useNativeMobileChrome = (): void => {
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-platform-android');
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android');
|
||||
root.style.removeProperty('--oc-keyboard-inset');
|
||||
root.style.removeProperty('--oc-kb-shift');
|
||||
root.style.removeProperty('--oc-kb-layout');
|
||||
@@ -2156,20 +2222,27 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
opencodeClient.setDirectory(currentDirectory);
|
||||
}, [currentDirectory, isConnected]);
|
||||
|
||||
// Gated on isConnected (and re-run on reconnect/instance switch): probing the
|
||||
// GitHub auth status before the runtime is reachable cached a "not connected"
|
||||
// answer that stuck until something else forced a re-check.
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, refreshGitHubAuthStatus]);
|
||||
}, [apis.github, isConnected, refreshGitHubAuthStatus]);
|
||||
|
||||
// Discover all worktrees for every known project so the draft session's
|
||||
// worktree/branch dropdown can list every available branch — not only the
|
||||
// current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar.
|
||||
// Gated on isConnected: running before the runtime is reachable made every
|
||||
// per-project probe fail silently, leaving the map empty until some later
|
||||
// projects-store update happened to re-run this effect (the "switch projects
|
||||
// back and forth to see worktrees" bug).
|
||||
React.useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
if (!isConnected || projects.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const run = async () => {
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
const worktreesByProject = new Map(useSessionUIStore.getState().availableWorktreesByProject);
|
||||
|
||||
await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
@@ -2181,16 +2254,18 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
|
||||
if (!isGitRepo) return;
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
if (cancelled) return;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
||||
// Worktree discovery is best-effort per project: a failed probe keeps
|
||||
// that project's previously known (persisted) worktrees instead of
|
||||
// wiping the whole map.
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) return;
|
||||
const allWorktrees = Array.from(worktreesByProject.values()).flat();
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
@@ -2202,7 +2277,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projects]);
|
||||
}, [isConnected, projects]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
|
||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
@@ -25,6 +26,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
lastDisconnectReason: null,
|
||||
});
|
||||
useProjectsStore.getState().resetForRuntimeSwitch();
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
|
||||
Reference in New Issue
Block a user