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 { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||||
import type { QuotaProviderId, UsageWindow } from '@/types';
|
import type { QuotaProviderId, UsageWindow } from '@/types';
|
||||||
import type { WorktreeMetadata } from '@/types/worktree';
|
|
||||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||||
import { useUpdateStore } from '@/stores/useUpdateStore';
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
||||||
import { useSelectionStore } from '@/sync/selection-store';
|
import { useSelectionStore } from '@/sync/selection-store';
|
||||||
@@ -163,10 +162,47 @@ const useNativeMobileChrome = (): void => {
|
|||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
// iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard
|
// 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
|
// 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-
|
// window for the keyboard (dvh already shrinks), so applying the inset on top would
|
||||||
// counts and floats the composer a keyboard-height above the keyboard — skip it there.
|
// double-count — Android gets only the class/event signals below.
|
||||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
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 —
|
// No WebKit form accessory bar (prev/next arrows + Done) above the keyboard —
|
||||||
// there's a single input, so it only eats vertical space.
|
// there's a single input, so it only eats vertical space.
|
||||||
await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined);
|
await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined);
|
||||||
@@ -184,6 +220,7 @@ const useNativeMobileChrome = (): void => {
|
|||||||
const KB_HIDE_MS = 200;
|
const KB_HIDE_MS = 200;
|
||||||
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
||||||
let settleTimer: number | null = null;
|
let settleTimer: number | null = null;
|
||||||
|
let caretTimer: number | null = null;
|
||||||
let keyboardHeight = 0;
|
let keyboardHeight = 0;
|
||||||
let layoutApplied = false;
|
let layoutApplied = false;
|
||||||
let safeBottomPx = 0;
|
let safeBottomPx = 0;
|
||||||
@@ -198,7 +235,7 @@ const useNativeMobileChrome = (): void => {
|
|||||||
settleTimer = null;
|
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 }));
|
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,7 +252,15 @@ const useNativeMobileChrome = (): void => {
|
|||||||
}
|
}
|
||||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||||
root.classList.remove('oc-kb-hide');
|
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);
|
setInset(keyboardHeight);
|
||||||
setVar('--oc-kb-shift', slide);
|
setVar('--oc-kb-shift', slide);
|
||||||
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
||||||
@@ -228,6 +273,11 @@ const useNativeMobileChrome = (): void => {
|
|||||||
layoutApplied = true;
|
layoutApplied = true;
|
||||||
setVar('--oc-kb-shift', 0);
|
setVar('--oc-kb-shift', 0);
|
||||||
dispatchKb('oc:keyboard-settled', { open: true });
|
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);
|
}, KB_ANIM_MS + 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -241,6 +291,16 @@ const useNativeMobileChrome = (): void => {
|
|||||||
if (!keyboardOpen) return;
|
if (!keyboardOpen) return;
|
||||||
keyboardOpen = false;
|
keyboardOpen = false;
|
||||||
clearSettle();
|
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);
|
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||||
root.classList.remove('oc-keyboard-open');
|
root.classList.remove('oc-keyboard-open');
|
||||||
setInset(0);
|
setInset(0);
|
||||||
@@ -299,6 +359,12 @@ const useNativeMobileChrome = (): void => {
|
|||||||
}
|
}
|
||||||
cleanup.push(
|
cleanup.push(
|
||||||
clearSettle,
|
clearSettle,
|
||||||
|
() => {
|
||||||
|
if (caretTimer !== null) {
|
||||||
|
window.clearTimeout(caretTimer);
|
||||||
|
caretTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
() => document.removeEventListener('focusout', handleFocusOut, true),
|
() => document.removeEventListener('focusout', handleFocusOut, true),
|
||||||
() => void showHandle.remove(),
|
() => void showHandle.remove(),
|
||||||
() => void hideHandle.remove(),
|
() => void hideHandle.remove(),
|
||||||
@@ -308,7 +374,7 @@ const useNativeMobileChrome = (): void => {
|
|||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
cleanup.forEach((remove) => remove());
|
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-keyboard-inset');
|
||||||
root.style.removeProperty('--oc-kb-shift');
|
root.style.removeProperty('--oc-kb-shift');
|
||||||
root.style.removeProperty('--oc-kb-layout');
|
root.style.removeProperty('--oc-kb-layout');
|
||||||
@@ -2156,20 +2222,27 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
opencodeClient.setDirectory(currentDirectory);
|
opencodeClient.setDirectory(currentDirectory);
|
||||||
}, [currentDirectory, isConnected]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
|
if (!isConnected) return;
|
||||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
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
|
// Discover all worktrees for every known project so the draft session's
|
||||||
// worktree/branch dropdown can list every available branch — not only the
|
// worktree/branch dropdown can list every available branch — not only the
|
||||||
// current one. Mirrors ElectronMiniChatApp + desktop SessionSidebar.
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
if (projects.length === 0) return;
|
if (!isConnected || projects.length === 0) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
const worktreesByProject = new Map(useSessionUIStore.getState().availableWorktreesByProject);
|
||||||
const allWorktrees: WorktreeMetadata[] = [];
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
projects.map(async (project) => {
|
projects.map(async (project) => {
|
||||||
@@ -2181,16 +2254,18 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
|
cachedIsGitRepo ?? (await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath)));
|
||||||
if (!isGitRepo) return;
|
if (!isGitRepo) return;
|
||||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||||
if (cancelled || worktrees.length === 0) return;
|
if (cancelled) return;
|
||||||
worktreesByProject.set(projectPath, worktrees);
|
worktreesByProject.set(projectPath, worktrees);
|
||||||
allWorktrees.push(...worktrees);
|
|
||||||
} catch {
|
} 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;
|
if (cancelled) return;
|
||||||
|
const allWorktrees = Array.from(worktreesByProject.values()).flat();
|
||||||
useSessionUIStore.setState({
|
useSessionUIStore.setState({
|
||||||
availableWorktrees: allWorktrees,
|
availableWorktrees: allWorktrees,
|
||||||
availableWorktreesByProject: worktreesByProject,
|
availableWorktreesByProject: worktreesByProject,
|
||||||
@@ -2202,7 +2277,7 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [projects]);
|
}, [isConnected, projects]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RuntimeEndpointChangedDetail } from '@/lib/runtime-switch';
|
|||||||
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
import { disposeTerminalInputTransport } from '@/lib/terminalApi';
|
||||||
import { useConfigStore } from '@/stores/useConfigStore';
|
import { useConfigStore } from '@/stores/useConfigStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
@@ -25,6 +26,9 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
|||||||
lastDisconnectReason: null,
|
lastDisconnectReason: null,
|
||||||
});
|
});
|
||||||
useProjectsStore.getState().resetForRuntimeSwitch();
|
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);
|
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||||
resetStreamingState();
|
resetStreamingState();
|
||||||
|
|||||||
@@ -801,7 +801,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-full flex-col bg-background transform-gpu">
|
<div className="relative flex h-full flex-col bg-background transform-gpu">
|
||||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
{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">
|
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||||
{renderDraftTitle(
|
{renderDraftTitle(
|
||||||
draftProjectLabel
|
draftProjectLabel
|
||||||
@@ -812,7 +812,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
|||||||
</h1>
|
</h1>
|
||||||
<DraftPresetChips
|
<DraftPresetChips
|
||||||
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
||||||
className="mt-8 max-w-md"
|
className="oc-draft-starters mt-8 max-w-md"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
import { ComposerDictation } from '@/components/dictation/ComposerDictation';
|
||||||
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
// sessionStore removed — currentSessionId comes from useSessionUIStore
|
||||||
@@ -1008,7 +1009,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
||||||
// Mobile pill composer: when the keyboard is closed the composer collapses
|
// Mobile pill composer: when the keyboard is closed the composer collapses
|
||||||
// into a narrow pill (+ / placeholder / mic) with a round new-session button
|
// 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 [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false);
|
||||||
const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
|
const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
|
||||||
const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
|
const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
|
||||||
@@ -4152,11 +4155,41 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [isMobile, mobileComposerExpanded, mobileComposerBusy, setExpandedInput]);
|
}, [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.
|
// Reset the picker search whenever a draft picker sheet opens/closes.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setMobileDraftPickerQuery('');
|
setMobileDraftPickerQuery('');
|
||||||
}, [mobileDraftPicker]);
|
}, [mobileDraftPicker]);
|
||||||
|
|
||||||
|
|
||||||
// ── Composer drag handle (mobile): swipe up = fullscreen, swipe down =
|
// ── Composer drag handle (mobile): swipe up = fullscreen, swipe down =
|
||||||
// leave fullscreen or dismiss the keyboard. ────────────────────────────
|
// leave fullscreen or dismiss the keyboard. ────────────────────────────
|
||||||
const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => {
|
const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => {
|
||||||
@@ -4588,7 +4621,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isMobile && !mobileComposerExpanded ? (
|
{isMobile && !mobileComposerExpanded ? (
|
||||||
<div className="oc-composer-morph-fade flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<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"
|
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 }}
|
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||||
@@ -4666,7 +4699,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col relative overflow-visible",
|
"flex flex-col relative overflow-visible",
|
||||||
isMobile && 'oc-composer-morph-fade',
|
|
||||||
isComposerExpanded && 'flex-1 min-h-0',
|
isComposerExpanded && 'flex-1 min-h-0',
|
||||||
"border border-border/80",
|
"border border-border/80",
|
||||||
"focus-within:ring-1",
|
"focus-within:ring-1",
|
||||||
|
|||||||
@@ -126,7 +126,16 @@ export const MobileOverlayPanel: React.FC<MobileOverlayPanelProps> = ({
|
|||||||
</div>
|
</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}
|
{children}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
{footer ? (
|
{footer ? (
|
||||||
|
|||||||
@@ -25,11 +25,17 @@ type GlobalSessionsState = {
|
|||||||
upsertSession: (session: Session) => void;
|
upsertSession: (session: Session) => void;
|
||||||
removeSessions: (ids: Iterable<string>) => void;
|
removeSessions: (ids: Iterable<string>) => void;
|
||||||
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
|
archiveSessions: (ids: Iterable<string>, archivedAt?: number) => void;
|
||||||
|
/** Drop every session from the previous runtime instance and go back to the
|
||||||
|
unloaded state, so a fresh load runs against the new endpoint. */
|
||||||
|
resetForRuntimeSwitch: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 500;
|
const PAGE_SIZE = 500;
|
||||||
|
|
||||||
let inflightLoad: Promise<LoadResult> | null = null;
|
let inflightLoad: Promise<LoadResult> | null = null;
|
||||||
|
// Bumped on runtime switch: an in-flight load from the previous instance must
|
||||||
|
// not apply its (stale) snapshot after the reset.
|
||||||
|
let loadGeneration = 0;
|
||||||
|
|
||||||
const normalizePath = (value?: string | null): string | null => {
|
const normalizePath = (value?: string | null): string | null => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
@@ -363,6 +369,19 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
|||||||
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
|
set((state) => applySnapshot(state, activeSessions, archivedSessions, status));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetForRuntimeSwitch: () => {
|
||||||
|
loadGeneration += 1;
|
||||||
|
inflightLoad = null;
|
||||||
|
set({
|
||||||
|
activeSessions: [],
|
||||||
|
archivedSessions: [],
|
||||||
|
sessionsByDirectory: new Map(),
|
||||||
|
reviewTransferBySessionId: new Map(),
|
||||||
|
hasLoaded: false,
|
||||||
|
status: 'idle',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
loadSessions: async (fallbackActive) => {
|
loadSessions: async (fallbackActive) => {
|
||||||
if (inflightLoad) {
|
if (inflightLoad) {
|
||||||
return inflightLoad;
|
return inflightLoad;
|
||||||
@@ -370,6 +389,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
|||||||
|
|
||||||
set((state) => (state.status === 'loading' ? state : { status: 'loading' }));
|
set((state) => (state.status === 'loading' ? state : { status: 'loading' }));
|
||||||
|
|
||||||
|
const generation = loadGeneration;
|
||||||
inflightLoad = (async () => {
|
inflightLoad = (async () => {
|
||||||
const current = get();
|
const current = get();
|
||||||
|
|
||||||
@@ -395,9 +415,17 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
|||||||
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
|
console.warn('[GlobalSessions] Failed to load archived sessions, preserving current snapshot:', archivedResult.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (generation !== loadGeneration) {
|
||||||
|
// Runtime switched mid-load: this snapshot belongs to the previous
|
||||||
|
// instance — drop it.
|
||||||
|
return { activeSessions: [], archivedSessions: [] };
|
||||||
|
}
|
||||||
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready'));
|
set((state) => applySnapshot(state, nextActiveSessions, nextArchivedSessions, 'ready'));
|
||||||
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (generation !== loadGeneration) {
|
||||||
|
return { activeSessions: [], archivedSessions: [] };
|
||||||
|
}
|
||||||
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
|
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
|
||||||
const nextArchivedSessions = current.archivedSessions;
|
const nextArchivedSessions = current.archivedSessions;
|
||||||
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
|
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
|
||||||
|
|||||||
@@ -616,6 +616,15 @@
|
|||||||
transition-duration: 0.2s;
|
transition-duration: 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* WKWebView draws the text caret as a native layer that ignores CSS transforms:
|
||||||
|
after the keyboard rise it visibly flies from the pre-keyboard position to
|
||||||
|
the final one. Hide it during the transition (+ UIKit's reposition lag,
|
||||||
|
see oc-kb-caret-hold timing in useNativeMobileChrome) and pop it back in. */
|
||||||
|
:root.oc-capacitor-app.oc-kb-caret-hold textarea,
|
||||||
|
:root.oc-capacitor-app.oc-kb-caret-hold input {
|
||||||
|
caret-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
|
/* The composer keeps its 1rem bottom padding while the keyboard is down (breathing
|
||||||
room above the home indicator), but that gap looks artificial sitting right above
|
room above the home indicator), but that gap looks artificial sitting right above
|
||||||
the keyboard — so tighten it while the keyboard is open. Snaps at the start of the
|
the keyboard — so tighten it while the keyboard is open. Snaps at the start of the
|
||||||
@@ -625,15 +634,37 @@
|
|||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content cross-fade for the pill ↔ full composer morph. The wrapper animates
|
/* Draft starter chips leave the moment the keyboard starts rising
|
||||||
its height (FLIP in ChatInput); the freshly mounted state fades in on top. */
|
(oc-keyboard-open lands at keyboardWillShow) and return when it's gone —
|
||||||
|
with the keyboard up there is only room for the draft title. Instant
|
||||||
|
show/hide (no squish animation); the title's own keyboard compensation
|
||||||
|
(.oc-draft-center below) carries the smooth motion. */
|
||||||
|
:root.oc-capacitor-app.oc-keyboard-open .oc-draft-starters {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The draft title is vertically centered, so it has no scroll-pinning to
|
||||||
|
compensate the keyboard like the chat does: during the slide the shell keeps
|
||||||
|
its full height and only snaps shorter at settle, which made the centered
|
||||||
|
title glide down (starters collapsing) and then JUMP up (shell snap). Ride
|
||||||
|
the same choreography as the composer: translate the centered block up by
|
||||||
|
half the keyboard shift (exactly how far the center moves after the snap),
|
||||||
|
with the transition gated on .oc-kb-animating so the settle swap — shift
|
||||||
|
back to 0 in the same frame the layout shrinks — is invisible. */
|
||||||
|
:root.oc-capacitor-app .oc-draft-center {
|
||||||
|
transform: translateY(calc(-0.5 * var(--oc-kb-shift, 0px)));
|
||||||
|
}
|
||||||
|
:root.oc-capacitor-app.oc-kb-animating .oc-draft-center {
|
||||||
|
transition: transform 0.25s cubic-bezier(0.38, 0.7, 0.125, 1);
|
||||||
|
}
|
||||||
|
:root.oc-capacitor-app.oc-kb-animating.oc-kb-hide .oc-draft-center {
|
||||||
|
transition-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes oc-composer-morph-fade {
|
@keyframes oc-composer-morph-fade {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
}
|
}
|
||||||
.oc-composer-morph-fade {
|
|
||||||
animation: oc-composer-morph-fade 0.24s ease-out both;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Voice overlay variant: the overlay BACKGROUND appears immediately (it rides
|
/* Voice overlay variant: the overlay BACKGROUND appears immediately (it rides
|
||||||
the morphing shape), but its content stays hidden until the shape has mostly
|
the morphing shape), but its content stays hidden until the shape has mostly
|
||||||
|
|||||||
+34
-1
@@ -67,12 +67,13 @@ Options:
|
|||||||
--web-mode <hmr|hmr-lan|full>
|
--web-mode <hmr|hmr-lan|full>
|
||||||
--mobile-mode <ios-sim-local|ios-sim-lan|android-local|android-lan>
|
--mobile-mode <ios-sim-local|ios-sim-lan|android-local|android-lan>
|
||||||
--mobile-task <task>
|
--mobile-task <task>
|
||||||
|
--adb-address <host:port> Wireless ADB address for android-connect
|
||||||
--vsix-cleanup <delete|keep>
|
--vsix-cleanup <delete|keep>
|
||||||
--version <semver>
|
--version <semver>
|
||||||
-h, --help
|
-h, --help
|
||||||
|
|
||||||
Mobile tasks:
|
Mobile tasks:
|
||||||
build, sync, android-devices, android-deploy-usb, android-run, android-logcat,
|
build, sync, android-devices, android-connect, android-deploy-usb, android-run, android-logcat,
|
||||||
ios-sim-build, ios-sim-run, ios-sim-serve, ios-sim-kill, ios-device-sync-debug
|
ios-sim-build, ios-sim-run, ios-sim-serve, ios-sim-kill, ios-device-sync-debug
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -115,6 +116,9 @@ function parseArgs(argv) {
|
|||||||
case '--mobile-task':
|
case '--mobile-task':
|
||||||
options.mobileTask = readValue();
|
options.mobileTask = readValue();
|
||||||
break;
|
break;
|
||||||
|
case '--adb-address':
|
||||||
|
options.adbAddress = readValue();
|
||||||
|
break;
|
||||||
case '--vsix-cleanup':
|
case '--vsix-cleanup':
|
||||||
options.vsixCleanup = readValue();
|
options.vsixCleanup = readValue();
|
||||||
break;
|
break;
|
||||||
@@ -206,6 +210,29 @@ async function chooseValue(current, choices, message) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function chooseText(current, message, placeholder) {
|
||||||
|
if (current) return current;
|
||||||
|
ensurePromptable();
|
||||||
|
const value = await text({ message, placeholder });
|
||||||
|
if (isCancel(value)) {
|
||||||
|
cancel('Operation cancelled.');
|
||||||
|
process.exit(130);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAdbAddress(address) {
|
||||||
|
const normalized = String(address || '').trim();
|
||||||
|
if (!/^[^\s:]+:\d{1,5}$/.test(normalized)) {
|
||||||
|
throw new Error('Invalid wireless ADB address. Use host:port, e.g. 192.168.1.139:38181');
|
||||||
|
}
|
||||||
|
const port = Number(normalized.split(':').at(-1));
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error('Invalid wireless ADB port. Use a port between 1 and 65535.');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
function detectLanIp() {
|
function detectLanIp() {
|
||||||
for (const addresses of Object.values(os.networkInterfaces())) {
|
for (const addresses of Object.values(os.networkInterfaces())) {
|
||||||
for (const address of addresses || []) {
|
for (const address of addresses || []) {
|
||||||
@@ -432,6 +459,7 @@ async function mobileTools(options, config) {
|
|||||||
{ value: 'build', label: 'Build mobile web assets' },
|
{ value: 'build', label: 'Build mobile web assets' },
|
||||||
{ value: 'sync', label: 'Sync native projects' },
|
{ value: 'sync', label: 'Sync native projects' },
|
||||||
{ value: 'android-devices', label: 'Android: list USB devices' },
|
{ value: 'android-devices', label: 'Android: list USB devices' },
|
||||||
|
{ value: 'android-connect', label: 'Android: connect wireless ADB device' },
|
||||||
{ value: 'android-deploy-usb', label: 'Android: rebuild + deploy to USB device' },
|
{ value: 'android-deploy-usb', label: 'Android: rebuild + deploy to USB device' },
|
||||||
{ value: 'android-run', label: 'Android: install + launch existing APK' },
|
{ value: 'android-run', label: 'Android: install + launch existing APK' },
|
||||||
{ value: 'android-logcat', label: 'Android: logcat' },
|
{ value: 'android-logcat', label: 'Android: logcat' },
|
||||||
@@ -453,6 +481,11 @@ async function mobileTools(options, config) {
|
|||||||
case 'build': return mobileRun('Building mobile web assets', 'build');
|
case 'build': return mobileRun('Building mobile web assets', 'build');
|
||||||
case 'sync': return mobileRun('Syncing native projects', 'sync');
|
case 'sync': return mobileRun('Syncing native projects', 'sync');
|
||||||
case 'android-devices': return mobileRun('Listing Android USB devices', 'android:devices');
|
case 'android-devices': return mobileRun('Listing Android USB devices', 'android:devices');
|
||||||
|
case 'android-connect': {
|
||||||
|
const address = validateAdbAddress(await chooseText(options.adbAddress, 'Enter wireless ADB address', '192.168.1.139:38181'));
|
||||||
|
step(`Connecting wireless ADB device at ${address}`, () => run('node', ['scripts/with-mobile-env.mjs', `adb connect ${quote(address)}`], { cwd: mobileCwd }));
|
||||||
|
return mobileRun('Listing Android devices', 'android:devices');
|
||||||
|
}
|
||||||
case 'android-deploy-usb':
|
case 'android-deploy-usb':
|
||||||
mobileRun('Building Android debug APK', 'build:android:debug');
|
mobileRun('Building Android debug APK', 'build:android:debug');
|
||||||
return mobileRun('Installing and launching Android app on USB device', 'android:run');
|
return mobileRun('Installing and launching Android app on USB device', 'android:run');
|
||||||
|
|||||||
Reference in New Issue
Block a user