From dbcb655e43c74868033f2760c75b5099c1d34e34 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 00:05:38 +0300 Subject: [PATCH] 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. --- packages/ui/src/apps/MobileApp.tsx | 105 +++++++++++++++--- packages/ui/src/apps/runtimeEndpointReset.ts | 4 + .../ui/src/components/chat/ChatContainer.tsx | 4 +- packages/ui/src/components/chat/ChatInput.tsx | 38 ++++++- .../src/components/ui/MobileOverlayPanel.tsx | 11 +- .../ui/src/stores/useGlobalSessionsStore.ts | 28 +++++ packages/ui/src/styles/mobile.css | 41 ++++++- scripts/oc-dev.mjs | 35 +++++- 8 files changed, 239 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 93a48f08..bce07ebe 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -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) => { + const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record) => { 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(); - 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; diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index 8a61c818..22caf529 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -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(); diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a50ad22c..6fc96c59 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -801,7 +801,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr return (
{useCompactDraftLayout && !isDesktopExpandedInput ? ( -
+

{renderDraftTitle( draftProjectLabel @@ -812,7 +812,7 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr

useInputStore.getState().requestPresetSubmit(text)} - className="mt-8 max-w-md" + className="oc-draft-starters mt-8 max-w-md" />
) : null} diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 688976b4..9dd05cf7 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollTo const [mobileControlsPanel, setMobileControlsPanel] = React.useState(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 = ({ 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 = ({ onOpenSettings, scrollTo )} > {isMobile && !mobileComposerExpanded ? ( -
+
= ({ onOpenSettings, scrollTo
= ({
); })()} - + {children} {footer ? ( diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 354c76c0..2599e608 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -25,11 +25,17 @@ type GlobalSessionsState = { upsertSession: (session: Session) => void; removeSessions: (ids: Iterable) => void; archiveSessions: (ids: Iterable, 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 | 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((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((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((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); diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index de223475..fb393ce8 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -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 diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index ade8433e..c530d099 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -67,12 +67,13 @@ Options: --web-mode --mobile-mode --mobile-task + --adb-address Wireless ADB address for android-connect --vsix-cleanup --version -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');