import React from 'react'; import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; import { McpIcon } from '@/components/icons/McpIcon'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { Button } from '@/components/ui/button'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; import { preloadProviderLogos } from '@/hooks/useProviderLogo'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRouter } from '@/hooks/useRouter'; import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution'; import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { sessionEvents } from '@/lib/sessionEvents'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; import { useMcpStore } from '@/stores/useMcpStore'; 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'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-context'; import { SyncAppEffects } from './AppEffects'; import { MobileChangesSurface } from './MobileChangesSurface'; import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; import { useFontsReady } from './useFontsReady'; import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation'; import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; import { useNativePushRegistration } from './useNativePushRegistration'; const MOBILE_SETTINGS_PAGES = [ 'appearance', 'chat', 'notifications', 'sessions', 'git', 'magic-prompts', 'behavior', 'mcp', 'providers', 'usage', 'voice', 'about', ] as const; type MobileAppProps = { apis: RuntimeAPIs; }; const isCapacitorMobileApp = (): boolean => { if (typeof window === 'undefined') return false; const maybeCapacitor = (window as typeof window & { Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; }).Capacitor; if (maybeCapacitor?.isNativePlatform?.() === true) return true; return window.location.protocol === 'capacitor:'; }; const useNativeMobileChrome = (): void => { React.useEffect(() => { if (!isCapacitorMobileApp()) return; let disposed = false; const cleanup: Array<() => void> = []; const root = document.documentElement; // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). root.classList.add('oc-capacitor-app'); // Platform marker: Android resizes the window for the keyboard (no manual inset), so the // shell's height transition (meant for iOS's animated --oc-keyboard-inset) must be off there // — otherwise the height animates against the instant native resize and the header bounces. const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); if (capacitorPlatform === 'android') { root.classList.add('oc-platform-android'); } const setInset = (px: number) => { root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); }; void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => { if (disposed) return; // Keep the status bar transparent over the WebView. A custom UIScene lifecycle // (iOS 26) plus returning from background can silently drop the overlay state, // letting an opaque status-bar background flash in at the top — so re-assert it // on mount, once shortly after (startup race), and whenever the app re-activates. const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); const applyStatusBar = async () => { if (platform === 'android') { // Android doesn't feed env(safe-area-inset-top) to CSS, so overlaying the status bar // makes content render under it. Inset the WebView below the bar instead and paint the // bar with the resolved theme background (the splash colours the theme system persists). const isDark = document.documentElement.classList.contains('dark'); const themeBg = (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || (isDark ? '#171515' : '#fffdf4'); await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); await StatusBar.show().catch(() => undefined); return; } await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); await StatusBar.show().catch(() => undefined); }; await applyStatusBar(); const retry = window.setTimeout(() => void applyStatusBar(), 400); cleanup.push(() => window.clearTimeout(retry)); const { App } = await import('@capacitor/app'); const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { if (isActive) void applyStatusBar(); }); if (disposed) { void stateHandle.remove(); return; } cleanup.push(() => void stateHandle.remove()); }).catch(() => undefined); void import('@capacitor/keyboard').then(async ({ Keyboard }) => { 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. const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); if (platform === 'android') return; await Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => undefined); // `keyboardWillShow` fires at the START of the iOS keyboard animation and // carries the final height, so we set the inset once here and let the CSS // transition (tuned to mimic the iOS keyboard curve/duration) carry the rise. // visualViewport tracking was tried but doesn't shrink under WKWebView's // `resize: 'none'`, so it never reported the keyboard — this event is the // reliable signal. const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { root.classList.add('oc-keyboard-open'); setInset(info.keyboardHeight); }); const hideHandle = await Keyboard.addListener('keyboardWillHide', () => { root.classList.remove('oc-keyboard-open'); setInset(0); }); if (disposed) { void showHandle.remove(); void hideHandle.remove(); return; } cleanup.push(() => void showHandle.remove(), () => void hideHandle.remove()); }).catch(() => undefined); return () => { disposed = true; cleanup.forEach((remove) => remove()); root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-platform-android'); root.style.removeProperty('--oc-keyboard-inset'); }; }, []); }; const useNativeMobileLifecycle = (onResume: () => void): void => { const wasInactiveRef = React.useRef(false); React.useEffect(() => { if (!isCapacitorMobileApp()) return; let disposed = false; const cleanup: Array<() => void> = []; const resumeAfterInactive = () => { if (!wasInactiveRef.current) return; wasInactiveRef.current = false; onResume(); }; void import('@capacitor/app').then(async ({ App }) => { if (disposed) return; const state = await App.addListener('appStateChange', ({ isActive }) => { document.documentElement.classList.toggle('oc-native-app-active', isActive); if (!isActive) { wasInactiveRef.current = true; return; } resumeAfterInactive(); }); const resume = await App.addListener('resume', resumeAfterInactive); if (disposed) { void state.remove(); void resume.remove(); return; } cleanup.push(() => void state.remove(), () => void resume.remove()); }).catch(() => undefined); return () => { disposed = true; cleanup.forEach((remove) => remove()); }; }, [onResume]); }; const useNativeAndroidBackButton = (onBack: () => boolean): void => { React.useEffect(() => { if (!isCapacitorMobileApp()) return; let disposed = false; let remove: (() => void) | null = null; void import('@capacitor/app').then(async ({ App }) => { if (disposed) return; const listener = await App.addListener('backButton', () => { if (onBack()) return; void App.minimizeApp().catch(() => undefined); }); if (disposed) { void listener.remove(); return; } remove = () => void listener.remove(); }).catch(() => undefined); return () => { disposed = true; remove?.(); }; }, [onBack]); }; const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => { if (!limit || typeof limit !== 'object') return undefined; const value = (limit as Partial>)[key]; return typeof value === 'number' && Number.isFinite(value) ? value : undefined; }; const getTokenCount = (value: unknown): number => ( typeof value === 'number' && Number.isFinite(value) ? value : 0 ); const formatTokens = (value: number): string => { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; return String(value); }; const mobileInputKeyboardProps = { autoComplete: 'off', autoCorrect: 'off', spellCheck: false, } as const; const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; const segments = normalized.split('/').filter(Boolean); return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized; }; type OverflowItem = { key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings'; icon?: IconName; iconNode?: React.ReactNode; label: string; badge?: number; onSelect: () => void; }; type ContextDisplay = { percentage: number; tokens: string; colorClass: string; } | null; const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => { if (project) return project.label?.trim() || getProjectLabel(project.path); return getProjectLabel(fallbackDirectory); }; const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConnected }) => { const { t } = useI18n(); const conn = useMobileConnection(onConnected); const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; const [serverUrl, setServerUrl] = React.useState(''); const [connectionName, setConnectionName] = React.useState(''); const [clientToken, setClientToken] = React.useState(''); const [isScanning, setIsScanning] = React.useState(false); const [advancedOpen, setAdvancedOpen] = React.useState(false); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); const [password, setPassword] = React.useState(''); const handleSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void conn.connect({ url: serverUrl, clientToken, label: connectionName }); }, [clientToken, conn, connectionName, serverUrl]); // Accept a pasted pairing link (openchamber://connect?...) in the URL field and // split it back into the server URL + token, revealing the token field when present. const handleUrlChange = React.useCallback((value: string) => { if (/^openchamber:\/\//i.test(value.trim())) { const payload = parseConnectionPayload(value); if (payload) { setServerUrl(payload.url); if (payload.label) setConnectionName(payload.label); if (payload.clientToken) setClientToken(payload.clientToken); if (payload.label || payload.clientToken) setAdvancedOpen(true); return; } } setServerUrl(value); }, []); const handleScanQr = React.useCallback(async () => { if (isScanning || isBusy) return; conn.setError(null); setIsScanning(true); try { const result = await scanConnectionQr(); switch (result.status) { case 'ok': setServerUrl(result.url); if (result.label) setConnectionName(result.label); if (result.clientToken) setClientToken(result.clientToken); if (result.label || result.clientToken) setAdvancedOpen(true); await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label }); break; case 'permission-denied': conn.setError(t('mobile.connect.scan.permissionDenied')); break; case 'invalid': conn.setError(t('mobile.connect.scan.invalid')); break; case 'unsupported': conn.setError(t('mobile.connect.scan.unsupported')); break; case 'failed': conn.setError(t('mobile.connect.scan.failed')); break; case 'cancelled': default: break; } } finally { setIsScanning(false); } }, [conn, isBusy, isScanning, t]); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void conn.submitPassword(password); }, [conn, password]); const cancelPassword = React.useCallback(() => { setPassword(''); conn.cancelPassword(); }, [conn]); return (

{t('mobile.connect.welcome.title')}

{pendingConnection ? (

{pendingConnection.label}

{pendingConnection.url}

setPassword(event.target.value)} placeholder={t('mobile.connect.password.placeholder')} aria-label={t('mobile.connect.password.label')} type="password" autoFocus className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" /> {error ?

{error}

: null}
) : (
setConnectionName(event.target.value)} placeholder={t('mobile.instances.label.placeholder')} aria-label={t('mobile.instances.label.label')} autoComplete="off" autoCapitalize="words" autoCorrect="off" spellCheck={false} className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" /> handleUrlChange(event.target.value)} placeholder={t('mobile.connect.url.placeholder')} aria-label={t('mobile.connect.url.label')} type="url" inputMode="url" autoCapitalize="none" className="h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-center text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20" />

{t('mobile.connect.token.hint')}

{error ?

{error}

: null}
{!qrScanSupported ? (

{t('mobile.connect.scan.unsupported')}

) : null}
)} {!pendingConnection && connections.length > 0 ? (

{t('mobile.connect.saved.title')}

{connections.map((connection) => ( ))}
) : null}
); }; const MobileInstancesSurface: React.FC<{ onConnect: () => void; onActiveConnectionDeleted: () => void; }> = ({ onActiveConnectionDeleted, onConnect }) => { const { t } = useI18n(); const conn = useMobileConnection(onConnect); const { connections, isBusy, isPasswordBusy, error, pendingConnection, connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, } = conn; const [editingId, setEditingId] = React.useState(null); const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); const [url, setUrl] = React.useState(''); const [label, setLabel] = React.useState(''); const [clientToken, setClientToken] = React.useState(''); const [password, setPassword] = React.useState(''); const [isScanning, setIsScanning] = React.useState(false); const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); // Populate/clear the form imperatively (on edit tap / cancel / save) rather than via // an effect keyed on the derived connection object. With an effect, any churn of the // connections list re-fires it and overwrites what the user is typing — the keyboard // "resets" mid-edit. Imperative population is immune to that. const resetForm = React.useCallback(() => { setEditingId(null); setUrl(''); setLabel(''); setClientToken(''); setError(null); }, [setError]); const saveInstance = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void saveConnection({ url, label, clientToken }).then((saved) => { if (saved) resetForm(); }); }, [clientToken, label, resetForm, saveConnection, url]); // Scan a pairing QR into the add/edit form fields (does not change edit mode, so // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. const handleScanInstance = React.useCallback(async () => { if (isScanning) return; setError(null); setIsScanning(true); try { const result = await scanConnectionQr(); switch (result.status) { case 'ok': setUrl(result.url); if (result.label) setLabel(result.label); if (result.clientToken) setClientToken(result.clientToken); break; case 'permission-denied': setError(t('mobile.connect.scan.permissionDenied')); break; case 'invalid': setError(t('mobile.connect.scan.invalid')); break; case 'unsupported': setError(t('mobile.connect.scan.unsupported')); break; case 'failed': setError(t('mobile.connect.scan.failed')); break; case 'cancelled': default: break; } } finally { setIsScanning(false); } }, [isScanning, setError, t]); const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { event.preventDefault(); void submitPassword(password); }, [password, submitPassword]); const cancelPasswordPrompt = React.useCallback(() => { setPassword(''); cancelPassword(); }, [cancelPassword]); // Two-step delete (mirrors the session sheet): the trash icon arms the row, a // second tap on the destructive button confirms, the X disarms. No hover relied on. const toggleConfirmDelete = React.useCallback((id: string) => { setConfirmingDeleteId((current) => (current === id ? null : id)); }, []); const confirmDelete = React.useCallback((id: string) => { setConfirmingDeleteId(null); if (editingId === id) resetForm(); void removeConnection(id).then((removed) => { if (removed && isSameConnectionUrl(removed.url, getRuntimeApiBaseUrl())) { onActiveConnectionDeleted(); } }); }, [editingId, onActiveConnectionDeleted, removeConnection, resetForm]); const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20'; if (pendingConnection) { return (

{pendingConnection.label}

{pendingConnection.url}

setPassword(event.target.value)} placeholder={t('mobile.connect.password.placeholder')} aria-label={t('mobile.connect.password.label')} type="password" autoFocus className={inputClass} /> {error ?

{error}

: null}
); } return (
{connections.length > 0 ? (
{connections.map((connection) => { const confirming = confirmingDeleteId === connection.id; return (
{confirming ? ( ) : ( )}
); })}
) : (

{t('mobile.connect.saved.empty')}

)}

{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}

{editingConnection ? ( ) : null}
{!qrScanSupported ? (

{t('mobile.connect.scan.unsupported')}

) : null}
{error ?

{error}

: null}
); }; type MobileUsageLimitRow = { key: string; label: string; subtitle?: string; window: UsageWindow; }; type MobileUsageProviderGroup = { providerId: QuotaProviderId; providerName: string; rows: MobileUsageLimitRow[]; status: string | null; }; const getWindowValueClass = (window: UsageWindow): string => { const usedPercent = window.usedPercent; if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground'; if (usedPercent >= 80) return 'text-[var(--status-error)]'; if (usedPercent >= 50) return 'text-[var(--status-warning)]'; return 'text-foreground'; }; const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => { const progressPct = clampPercent(percentage) ?? 0; const tone = resolveUsageTone(percentage); const progressColor = tone === 'critical' ? 'var(--status-error)' : tone === 'warn' ? 'var(--status-warning)' : 'var(--status-success)'; const size = 18; const stroke = 3; const radius = (size - stroke) / 2; const circumference = 2 * Math.PI * radius; return ( ); }; const MetadataRow: React.FC<{ icon?: IconName; iconNode?: React.ReactNode; label: string; children: React.ReactNode; }> = ({ icon, iconNode, label, children }) => (
{iconNode ?? (icon ? : null)} {label} {children}
); const SessionMetadataOverlay: React.FC<{ open: boolean; onClose: () => void; anchorRef: React.RefObject; contextDisplay: ContextDisplay; branchLabel: string; usageGroups: MobileUsageProviderGroup[]; usageDisplayMode: 'usage' | 'remaining'; isUsageLoading: boolean; timeFormatPreference: TimeFormatPreference; }> = ({ open, onClose, anchorRef, contextDisplay, branchLabel, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => { const { t } = useI18n(); const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); React.useEffect(() => { if (open) { setShouldRender(true); setIsExiting(false); return; } if (!shouldRender) return; setIsExiting(true); const timeoutId = window.setTimeout(() => { setShouldRender(false); setIsExiting(false); }, 140); return () => window.clearTimeout(timeoutId); }, [open, shouldRender]); React.useEffect(() => { if (!open) return; const handleKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose, open]); React.useEffect(() => { if (!open) return; const closeIfOutside = (event: PointerEvent | WheelEvent) => { const target = event.target; if (!(target instanceof Node)) { onClose(); return; } if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; onClose(); }; document.addEventListener('pointerdown', closeIfOutside, true); document.addEventListener('wheel', closeIfOutside, true); return () => { document.removeEventListener('pointerdown', closeIfOutside, true); document.removeEventListener('wheel', closeIfOutside, true); }; }, [anchorRef, onClose, open]); if (!shouldRender) return null; return (
{branchLabel} {contextDisplay ? ( } label={t('mobile.header.metadata.context')} > {contextDisplay.percentage.toFixed(1)}% {contextDisplay.tokens} ) : null}
); }; const MobileUsageLimits: React.FC<{ groups: MobileUsageProviderGroup[]; displayMode: 'usage' | 'remaining'; isLoading: boolean; timeFormatPreference: TimeFormatPreference; }> = ({ groups, displayMode, isLoading, timeFormatPreference }) => { const { t } = useI18n(); const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used'); if (groups.length === 0) return null; return (
{t('mobile.header.metadata.usage')} {isLoading ? : null} {modeLabel}
{groups.map((group) => (
{group.providerName} {group.status && group.rows.length === 0 ? ( {group.status} ) : null}
{group.rows.length > 0 ? (
{group.rows.map((row) => { const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent; const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); const resetLabel = formatQuotaResetLabel( row.window.resetAt, row.window.resetAfterFormatted ?? row.window.resetAtFormatted, timeFormatPreference, ); return (
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} {resetLabel ? ( {resetLabel} ) : null} {metricLabel === '-' ? '' : metricLabel}
); })}
) : null} {group.status && group.rows.length > 0 ? (
{group.status}
) : null}
))}
); }; const MobileOverflowMenu: React.FC<{ open: boolean; onClose: () => void; items: OverflowItem[]; }> = ({ open, onClose, items }) => { const { t } = useI18n(); React.useEffect(() => { if (!open) return; const handleKey = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); }; document.addEventListener('keydown', handleKey); return () => document.removeEventListener('keydown', handleKey); }, [onClose, open]); if (!open) return null; return (
))}
); }; const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({ open, onOpenChange, currentSessionId, effectiveDirectory, gitDirectory, isNewSessionDraftOpen, primaryLabel, secondaryLabel, }: { open: boolean; onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void; currentSessionId: string | null; effectiveDirectory: string | null; gitDirectory: string | null; isNewSessionDraftOpen: boolean; primaryLabel: string; secondaryLabel: string; }) { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const metadataTriggerRef = React.useRef(null); const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined); const isGitRepo = useIsGitRepo(gitDirectory); const gitStatus = useGitStatus(gitDirectory); const ensureStatus = useGitStore((state) => state.ensureStatus); const fetchStatus = useGitStore((state) => state.fetchStatus); const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); const getModelMetadata = useConfigStore((state) => state.getModelMetadata); useConfigStore((state) => state.modelsMetadata.size); const savedSessionModel = useSelectionStore( React.useCallback( (state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null), [currentSessionId], ), ); const quotaResults = useQuotaStore((state) => state.results); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); const quotaDisplayMode = useQuotaStore((state) => state.displayMode); const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const selectedQuotaModels = useQuotaStore((state) => state.selectedModels); const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); useQuotaAutoRefresh(); React.useEffect(() => { if (!gitDirectory) return; void ensureStatus(gitDirectory, git); }, [ensureStatus, git, gitDirectory]); React.useEffect(() => { if (!gitDirectory) return; return sessionEvents.onGitRefreshHint((hint) => { if (normalizePath(hint.directory) !== gitDirectory) return; void fetchStatus(gitDirectory, git); }); }, [fetchStatus, git, gitDirectory]); React.useEffect(() => { void loadQuotaSettings(); }, [loadQuotaSettings]); React.useEffect(() => { preloadProviderLogos(dropdownProviderIds); }, [dropdownProviderIds]); React.useEffect(() => { if (!open || isQuotaLoading) return; const missingEnabledProvider = dropdownProviderIds.some((providerId) => ( !quotaResults.some((result) => result.providerId === providerId) )); if (!missingEnabledProvider) return; void fetchAllQuotas(); }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]); const latestMessageModel = React.useMemo(() => { for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { model?: { providerID?: string; modelID?: string }; }; if (message.role !== 'user') continue; const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 ? message.model.providerID : undefined; const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 ? message.model.modelID : undefined; if (providerID && modelID) return { providerID, modelID }; } return null; }, [activeSessionMessages]); const modelRef = latestMessageModel ?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null) ?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null); const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined; const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID); const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined; const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context') ?? metadata?.limit?.context ?? 0; const totalTokens = React.useMemo(() => { for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { tokens?: { input?: unknown; output?: unknown; reasoning?: unknown; cache?: { read?: unknown; write?: unknown }; }; }; if (message.role !== 'assistant' || !message.tokens) continue; const total = getTokenCount(message.tokens.input) + getTokenCount(message.tokens.output) + getTokenCount(message.tokens.reasoning) + getTokenCount(message.tokens.cache?.read) + getTokenCount(message.tokens.cache?.write); if (total > 0) return total; } return 0; }, [activeSessionMessages]); const contextPercentage = !isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0 ? Math.min((totalTokens / contextLimit) * 100, 999) : null; const contextTokens = contextPercentage !== null ? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}` : null; const contextColorClass = contextPercentage === null ? '' : contextPercentage >= 90 ? 'text-[var(--status-error)]' : contextPercentage >= 75 ? 'text-[var(--status-warning)]' : 'text-[var(--status-success)]'; const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens ? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass } : null; const branchLabel = isGitRepo === true ? (gitStatus?.current?.trim() || t('gitView.branch.detachedHead')) : t('common.unavailable'); const usageGroups = React.useMemo(() => { const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result])); return QUOTA_PROVIDERS .filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id)) .filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true) .map((providerMeta) => { const result = resultsByProvider.get(providerMeta.id)!; const rows: MobileUsageLimitRow[] = []; for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) { rows.push({ key: `window-${label}`, label: formatWindowLabel(label), window, }); } const modelEntries = Object.entries(result?.usage?.models ?? {}); const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? []; const visibleModelEntries = providerSelectedModels.length > 0 ? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName)) : modelEntries; for (const [modelName, modelUsage] of visibleModelEntries) { const entries = Object.entries(modelUsage.windows ?? {}); if (entries.length === 0) continue; const [label, window] = entries[0]; rows.push({ key: `model-${modelName}-${label}`, label: formatWindowLabel(label), subtitle: getDisplayModelName(modelName), window, }); } const status = !result.ok && result.error ? result.error : rows.length === 0 ? t('header.services.noRateLimitsReported') : null; return { providerId: providerMeta.id, providerName: providerMeta.name, rows, status, }; }); }, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]); React.useEffect(() => { if (!open || usageGroups.length === 0) return; preloadProviderLogos(usageGroups.map((group) => group.providerId)); }, [open, usageGroups]); return ( <>
{primaryLabel} {secondaryLabel ? ( {secondaryLabel} ) : null}
onOpenChange(false)} anchorRef={metadataTriggerRef} contextDisplay={contextDisplay} branchLabel={branchLabel} usageGroups={usageGroups} usageDisplayMode={quotaDisplayMode} isUsageLoading={isQuotaLoading} timeFormatPreference={timeFormatPreference} /> ); }); const MobileHeader: React.FC<{ onOpenSessions: () => void; onOpenMenu: () => void; }> = ({ onOpenSessions, onOpenMenu }) => { const { t } = useI18n(); const [metadataOpen, setMetadataOpen] = React.useState(false); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const currentSessionDirectory = useSessionUIStore( React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]), ); const effectiveDirectory = currentSessionDirectory || currentDirectory; const gitDirectory = normalizePath(effectiveDirectory) || null; const projects = useProjectsStore((state) => state.projects); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const currentWorktreeMetadata = useSessionUIStore( React.useCallback((state) => (currentSessionId ? state.worktreeMetadata.get(currentSessionId) ?? null : null), [currentSessionId]), ); const currentSession = useSession(currentSessionId, effectiveDirectory || undefined); const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); const projectLabel = React.useMemo(() => { const directory = normalizePath(effectiveDirectory); if (!directory) return t('mobile.header.noProject'); const metadataProject = currentWorktreeMetadata?.projectDirectory ? resolveProjectForDirectory(projects, currentWorktreeMetadata.projectDirectory) : null; const project = metadataProject ?? resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); return getProjectDisplayLabel(project, directory) || t('mobile.header.noProject'); }, [availableWorktreesByProject, currentWorktreeMetadata?.projectDirectory, effectiveDirectory, projects, t]); const sessionTitle = currentSession?.title?.trim(); const primaryLabel = sessionTitle || (currentSessionId ? t('mobile.sessions.untitled') : projectLabel); const secondaryLabel = currentSessionId ? projectLabel : ''; React.useEffect(() => { setMetadataOpen(false); }, [currentSessionId, effectiveDirectory]); const handleOpenSessions = React.useCallback(() => { setMetadataOpen(false); onOpenSessions(); }, [onOpenSessions]); const handleOpenMenu = React.useCallback(() => { setMetadataOpen(false); onOpenMenu(); }, [onOpenMenu]); return ( <>
); }; const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); const [mcpOpen, setMcpOpen] = React.useState(false); const [instancesOpen, setInstancesOpen] = React.useState(false); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); const [settingsOpen, setSettingsOpen] = React.useState(false); const [updateOpen, setUpdateOpen] = React.useState(false); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); const [overflowOpen, setOverflowOpen] = React.useState(false); // When set, the Changes surface opens directly into the per-file diff for this path. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setSettingsPage = useUIStore((state) => state.setSettingsPage); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); const refreshMcpStatus = useMcpStore((state) => state.refresh); const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); const gitStatus = useGitStatus(normalizePath(currentDirectory) || null); const dirtyChangeCount = gitStatus?.files?.length ?? 0; const mobileActions = React.useMemo( () => ({ openChanges: ({ diffPath, staged } = {}) => { setPendingChangesDiff(diffPath ? { path: diffPath, staged: staged === true } : null); setChangesOpen(true); }, openFiles: () => setFilesOpen(true), openSettings: () => { setSettingsInitialMobileStage('nav'); setSettingsOpen(true); }, }), [], ); const closeChanges = React.useCallback(() => { setChangesOpen(false); setPendingChangesDiff(null); }, []); // Expose the shell's panel-opening actions to the deep-link layer so openchamber:// URLs // (and notification taps / widgets) can navigate to these surfaces. Session and // new-session intents resolve directly against the store, so they aren't wired here. const deepLinkHandlers = React.useMemo( () => ({ openSessions: () => setSessionsSheetOpen(true), openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { if (target === 'files') setFilesOpen(true); else if (target === 'mcp') setMcpOpen(true); else if (target === 'instances') setInstancesOpen(true); else if (target === 'update') setUpdateOpen(true); }, openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => { setPendingChangesDiff(path ? { path, staged: staged === true } : null); setChangesOpen(true); }, openSettings: (section?: string) => { if (section) setSettingsPage(section as Parameters[0]); setSettingsInitialMobileStage(section ? 'page-content' : 'nav'); setSettingsOpen(true); }, }), [setSettingsPage], ); useDeepLinkHandlers(deepLinkHandlers); // Edge swipe (left/right screen edge → centre) switches between sessions, with a directional // slide+fade on the chat content so it's obvious the session changed. const chatMainRef = React.useRef(null); const chatAnimRef = React.useRef(null); const swipeDirectionRef = React.useRef<'prev' | 'next' | null>(null); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); // Record the swipe direction; the animation itself runs in the layout effect below, once the // new session's content has committed — running it inline in the swipe callback raced the // re-render and dropped the animation on roughly every other switch. const recordSwipeDirection = React.useCallback((direction: 'prev' | 'next') => { swipeDirectionRef.current = direction; }, []); useEdgeSwipeSessionSwitch(chatMainRef, { onSwitch: recordSwipeDirection }); React.useLayoutEffect(() => { const direction = swipeDirectionRef.current; swipeDirectionRef.current = null; if (!direction) return; // only animate swipe-driven switches const element = chatAnimRef.current; if (!element || typeof element.animate !== 'function') return; element.getAnimations().forEach((animation) => animation.cancel()); const fromX = direction === 'prev' ? -70 : 70; element.animate( [ { opacity: 0.1, transform: `translateX(${fromX}px)` }, { opacity: 1, transform: 'translateX(0)' }, ], { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, ); }, [currentSessionId]); const handleNativeBack = React.useCallback(() => { if (overflowOpen) { setOverflowOpen(false); return true; } if (sessionsSheetOpen) { setSessionsSheetOpen(false); return true; } if (filesOpen) { setFilesOpen(false); return true; } if (changesOpen) { closeChanges(); return true; } if (mcpOpen) { setMcpOpen(false); return true; } if (instancesOpen) { setInstancesOpen(false); return true; } if (settingsOpen) { setSettingsOpen(false); return true; } if (updateOpen) { setUpdateOpen(false); return true; } return false; }, [changesOpen, closeChanges, filesOpen, instancesOpen, mcpOpen, overflowOpen, sessionsSheetOpen, settingsOpen, updateOpen]); useNativeAndroidBackButton(handleNativeBack); const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); const openMcpCreateSettings = React.useCallback(() => { const baseName = 'new-mcp-server'; let newName = baseName; let counter = 1; while (mcpServers.some((server) => server.name === newName)) { newName = `${baseName}-${counter}`; counter += 1; } const draft: McpDraft = { name: newName, scope: 'user', type: 'local', command: [], url: '', environment: [], headers: [], oauthEnabled: true, oauthClientId: '', oauthClientSecret: '', oauthScope: '', oauthRedirectUri: '', timeout: '', enabled: true, }; setMcpDraft(draft); setSelectedMcp(newName); setSettingsPage('mcp'); setMcpOpen(false); setSettingsInitialMobileStage('page-content'); setSettingsOpen(true); }, [mcpServers, setMcpDraft, setSelectedMcp, setSettingsPage]); const refreshMcpOverlay = React.useCallback(() => { if (isMcpRefreshing) return; setIsMcpRefreshing(true); const directory = currentDirectory || null; const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500)); void Promise.all([ refreshMcpStatus({ directory, silent: true }), loadMcpConfigs({ force: true }), minSpinPromise, ]).finally(() => setIsMcpRefreshing(false)); }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); const overflowItems: OverflowItem[] = React.useMemo( () => { const items: OverflowItem[] = [ { key: 'files', icon: 'file-text', label: t('mobile.menu.files'), onSelect: () => setFilesOpen(true), }, { key: 'changes', icon: 'git-branch', label: t('mobile.menu.changes'), badge: dirtyChangeCount, onSelect: () => setChangesOpen(true), }, { key: 'mcp', iconNode: , label: t('mobile.menu.mcp'), onSelect: () => setMcpOpen(true), }, ]; if (showCapacitorOnlyFeatures) { items.push({ key: 'instances', icon: 'server', label: t('mobile.menu.instances'), onSelect: () => setInstancesOpen(true), }); } if (showUpdateItem) { items.push({ key: 'update', icon: 'download', label: t('mobile.menu.update'), onSelect: () => setUpdateOpen(true), }); } items.push({ key: 'settings', icon: 'settings-3', label: t('mobile.menu.settings'), onSelect: () => { setSettingsInitialMobileStage('nav'); setSettingsOpen(true); }, }); return items; }, [dirtyChangeCount, showCapacitorOnlyFeatures, showUpdateItem, t], ); return (
setSessionsSheetOpen(true)} onOpenMenu={() => setOverflowOpen(true)} />
setOverflowOpen(false)} items={overflowItems} /> {sessionsSheetOpen ? ( ) : null} {/* Mounted only while open (like the sessions sheet) so each surface computes its safe-area / fixed-position layout fresh on open. Keeping them always-mounted left a stale startup layout, which made the top-inset dimming appear only intermittently on iOS. */} {filesOpen ? ( setFilesOpen(false)} ariaLabel={t('mobile.menu.files')} headerless > setFilesOpen(false)} /> ) : null} {changesOpen ? ( ) : null} {mcpOpen ? ( setMcpOpen(false)} title={t('mcpDropdown.title')} className="h-[72vh]" contentMaxHeightClassName="max-h-full" renderHeader={(closeButton) => (

{t('mcpDropdown.title')}

{closeButton}
)} > ) : null} {instancesOpen && showCapacitorOnlyFeatures ? ( setInstancesOpen(false)} ariaLabel={t('mobile.menu.instances')} title={t('mobile.menu.instances')} > setInstancesOpen(false)} onActiveConnectionDeleted={onActiveConnectionDeleted} /> ) : null} {settingsOpen ? ( setSettingsOpen(false)} ariaLabel={t('mobile.menu.settings')} headerless > setSettingsOpen(false)} /> ) : null} {updateOpen ? ( setUpdateOpen(false)} ariaLabel={t('mobile.menu.update')} title={t('mobile.menu.update')} >
) : null}
); }; export function MobileApp({ apis }: MobileAppProps) { const { t } = useI18n(); const initializeApp = useConfigStore((state) => state.initializeApp); const isInitialized = useConfigStore((state) => state.isInitialized); const isConnected = useConfigStore((state) => state.isConnected); const connectionPhase = useConfigStore((state) => state.connectionPhase); const providersCount = useConfigStore((state) => state.providers.length); const agentsCount = useConfigStore((state) => state.agents.length); const loadProviders = useConfigStore((state) => state.loadProviders); const loadAgents = useConfigStore((state) => state.loadAgents); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const error = useSessionUIStore((state) => state.error); const clearError = useSessionUIStore((state) => state.clearError); const setIsMobile = useUIStore((state) => state.setIsMobile); const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus); const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled); const projects = useProjectsStore((state) => state.projects); const [connectionEpoch, setConnectionEpoch] = React.useState(0); const [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = React.useState(0); const [showConnectionRecovery, setShowConnectionRecovery] = React.useState(false); // Cold-launch auto-connect to the last instance: 'pending'/'attempting' hold the // splash so we don't flash the connect screen; 'done' means we either connected or // exhausted the attempt (then the connect screen shows). const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); const lastNativeResumeSyncEventAtRef = React.useRef(0); const handleNativeResume = React.useCallback(() => { if (!getRuntimeApiBaseUrl()) return; const now = Date.now(); if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { lastNativeResumeSyncEventAtRef.current = now; window.dispatchEvent(new Event('openchamber:system-resume')); } void initializeApp(); void refreshGitHubAuthStatus(apis.github, { force: true }); if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); useNativeMobileChrome(); useNativeMobileLifecycle(handleNativeResume); React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); }, [apis]); // Switching instances (or disconnecting) only changes the runtime endpoint; the // stores still hold the previous instance's data. Mirror the web App.tsx reset // sequence so the UI fully re-bootstraps against the new server instead of going // stale. The SyncProvider is keyed by runtimeEndpointEpoch so it remounts too. React.useEffect(() => { return subscribeRuntimeEndpointChanged((detail) => { resetAppForRuntimeEndpointChange(detail); setRuntimeEndpointEpoch((epoch) => epoch + 1); setConnectionEpoch((epoch) => epoch + 1); }); }, []); // On cold launch, silently reconnect to the most-recent saved instance so a // returning user — and notification deep-links — land in the app instead of the // connect screen. The splash is held while we try (see render below). If there's // no saved instance, it's unreachable, or it needs a (re)login, we fall through // to the connect screen. A successful switchRuntimeEndpoint fires the endpoint- // changed subscription above, which bumps the epochs and bootstraps the app. React.useEffect(() => { if (!isNativeMobileApp || isConnected || getRuntimeApiBaseUrl()) { setAutoConnectPhase('done'); return; } let cancelled = false; setAutoConnectPhase('attempting'); void autoConnectLastInstance() .catch(() => false) .then(() => { if (!cancelled) setAutoConnectPhase('done'); }); return () => { cancelled = true; }; // Run once on mount — auto-connect is a cold-launch concern only. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); React.useEffect(() => { setIsMobile(true); }, [setIsMobile]); React.useEffect(() => { void initializeApp(); }, [connectionEpoch, initializeApp]); React.useEffect(() => { if (!isConnected) return; if (providersCount === 0) void loadProviders({ source: 'mobileApp:recovery' }); if (agentsCount === 0) void loadAgents({ source: 'mobileApp:recovery' }); }, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]); React.useEffect(() => { if (!isConnected) return; opencodeClient.setDirectory(currentDirectory); }, [currentDirectory, isConnected]); React.useEffect(() => { void refreshGitHubAuthStatus(apis.github, { force: true }); }, [apis.github, 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. React.useEffect(() => { if (projects.length === 0) return; let cancelled = false; const run = async () => { const worktreesByProject = new Map(); const allWorktrees: WorktreeMetadata[] = []; await Promise.all( projects.map(async (project) => { const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, ''); if (!projectPath) return; try { const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo; const isGitRepo = 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; worktreesByProject.set(projectPath, worktrees); allWorktrees.push(...worktrees); } catch { // Worktree discovery is best-effort; draft selector falls back to the project root. } }), ); if (cancelled) return; useSessionUIStore.setState({ availableWorktrees: allWorktrees, availableWorktreesByProject: worktreesByProject, }); }; void run(); return () => { cancelled = true; }; }, [projects]); React.useEffect(() => { let cancelled = false; const run = async () => { const res = await runtimeFetch('/health', { method: 'GET' }).catch(() => null); if (!res || !res.ok || cancelled) return; const data = (await res.json().catch(() => null)) as null | { planModeExperimentalEnabled?: unknown }; if (!data || cancelled) return; const raw = data.planModeExperimentalEnabled; setPlanModeEnabled(raw === true || raw === 1 || raw === '1' || raw === 'true'); }; void run(); return () => { cancelled = true; }; }, [setPlanModeEnabled]); React.useEffect(() => { if (!error) return; const timeout = window.setTimeout(() => clearError(), 5000); return () => window.clearTimeout(timeout); }, [clearError, error]); React.useEffect(() => { if (!isNativeMobileApp || isConnected || !getRuntimeApiBaseUrl()) { setShowConnectionRecovery(false); return; } const timeout = window.setTimeout(() => setShowConnectionRecovery(true), 8000); return () => window.clearTimeout(timeout); }, [isConnected, isNativeMobileApp, connectionEpoch, runtimeEndpointEpoch]); useAppFontEffects(); usePushVisibilityBeacon({ enabled: true }); useUpdatePolling(); useWindowTitle(); useRouter(); // APNs is the only notification channel on the native app (background-capable, // focus-suppressed server-side via the visibility beacon). Local notifications are // intentionally disabled — they can't tell foreground from background in a WKWebView // (document.hasFocus() is unreliable) and leaked while the app was open; the in-app SSE // notification dispatch is no-op'd for native in renderMobileApp. useNativePushRegistration({ enabled: isNativeMobileApp && isConnected }); // Single native deep-link entry point: notification taps AND the openchamber:// URL // scheme (widgets, Live Activities, external links). Registered unconditionally so a // cold-launch tap/open isn't lost on the connect/splash screen; intents stash until // the app is ready (connected + initialized) and shell handlers are registered. useDeepLinkSource({ ready: isNativeMobileApp && isConnected && isInitialized }); const fontsReady = useFontsReady(); // `isConnected` is a LIVE flag that flips false on every transient SSE/WS drop and // back true on reconnect. We must NOT blank the whole app to a loader on those — // only on the initial connect / instance switch (connectionPhase 'connecting'). // While 'reconnecting' (we were connected before), keep MobileShell mounted so the // UI doesn't reload on every network blip. const isReconnecting = !isConnected && connectionPhase === 'reconnecting'; // Hold a logo splash until the UI web font is loaded, so the first UI the user sees // already uses the real font instead of flashing the fallback and reflowing (FOUT). if (!fontsReady) { return (
); } if (!isConnected && !isReconnecting && isNativeMobileApp) { // A runtime endpoint is already selected (first connect or switching instances): // show a loader while it re-bootstraps instead of flashing the onboarding screen. if (getRuntimeApiBaseUrl()) { return (
{showConnectionRecovery ? ( <>

{t('sessionAuth.error.networkTitle')}

{t('sessionAuth.error.networkDescription')}

) : null}
); } // Cold-launch auto-connect is still resolving — hold the splash instead of // flashing the connect screen. Only show the connect screen once we've finished // (no saved instance, unreachable, or needs re-login). if (autoConnectPhase !== 'done') { return (
); } return setConnectionEpoch((value) => value + 1)} />; } if (!isConnected && !isReconnecting) { return (

{t('sessionAuth.error.networkTitle')}

{t('sessionAuth.error.networkDescription')}

); } return (
{ switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); setConnectionEpoch((value) => value + 1); }} /> {isInitialized ? : null}
); }