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
+90 -15
View File
@@ -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}
+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 ? (
@@ -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);
+36 -5
View File
@@ -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
View File
@@ -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');