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();
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -25,11 +25,17 @@ type GlobalSessionsState = {
|
||||
upsertSession: (session: Session) => void;
|
||||
removeSessions: (ids: Iterable<string>) => 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;
|
||||
|
||||
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 => {
|
||||
if (typeof value !== 'string') {
|
||||
@@ -363,6 +369,19 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
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) => {
|
||||
if (inflightLoad) {
|
||||
return inflightLoad;
|
||||
@@ -370,6 +389,7 @@ export const useGlobalSessionsStore = create<GlobalSessionsState>((set, get) =>
|
||||
|
||||
set((state) => (state.status === 'loading' ? state : { status: 'loading' }));
|
||||
|
||||
const generation = loadGeneration;
|
||||
inflightLoad = (async () => {
|
||||
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);
|
||||
}
|
||||
|
||||
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'));
|
||||
return { activeSessions: nextActiveSessions, archivedSessions: nextArchivedSessions };
|
||||
} catch (error) {
|
||||
if (generation !== loadGeneration) {
|
||||
return { activeSessions: [], archivedSessions: [] };
|
||||
}
|
||||
const nextActiveSessions = mergeSessionLists(current.activeSessions, fallbackActive);
|
||||
const nextArchivedSessions = current.archivedSessions;
|
||||
console.warn('[GlobalSessions] Failed to load sessions, using fallback snapshot:', error);
|
||||
|
||||
@@ -616,6 +616,15 @@
|
||||
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
|
||||
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
|
||||
@@ -625,15 +634,37 @@
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Content cross-fade for the pill ↔ full composer morph. The wrapper animates
|
||||
its height (FLIP in ChatInput); the freshly mounted state fades in on top. */
|
||||
/* Draft starter chips leave the moment the keyboard starts rising
|
||||
(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 {
|
||||
from { opacity: 0; }
|
||||
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
|
||||
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>
|
||||
--mobile-mode <ios-sim-local|ios-sim-lan|android-local|android-lan>
|
||||
--mobile-task <task>
|
||||
--adb-address <host:port> Wireless ADB address for android-connect
|
||||
--vsix-cleanup <delete|keep>
|
||||
--version <semver>
|
||||
-h, --help
|
||||
|
||||
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
|
||||
`);
|
||||
}
|
||||
@@ -115,6 +116,9 @@ function parseArgs(argv) {
|
||||
case '--mobile-task':
|
||||
options.mobileTask = readValue();
|
||||
break;
|
||||
case '--adb-address':
|
||||
options.adbAddress = readValue();
|
||||
break;
|
||||
case '--vsix-cleanup':
|
||||
options.vsixCleanup = readValue();
|
||||
break;
|
||||
@@ -206,6 +210,29 @@ async function chooseValue(current, choices, message) {
|
||||
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() {
|
||||
for (const addresses of Object.values(os.networkInterfaces())) {
|
||||
for (const address of addresses || []) {
|
||||
@@ -432,6 +459,7 @@ async function mobileTools(options, config) {
|
||||
{ value: 'build', label: 'Build mobile web assets' },
|
||||
{ value: 'sync', label: 'Sync native projects' },
|
||||
{ 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-run', label: 'Android: install + launch existing APK' },
|
||||
{ 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 'sync': return mobileRun('Syncing native projects', 'sync');
|
||||
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':
|
||||
mobileRun('Building Android debug APK', 'build:android:debug');
|
||||
return mobileRun('Installing and launching Android app on USB device', 'android:run');
|
||||
|
||||
Reference in New Issue
Block a user