2026-06-02 00:43:05 +03:00
|
|
|
import React from 'react';
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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';
|
2026-07-07 13:49:26 +03:00
|
|
|
import { MobileAppUpdateToast } from '@/components/update/MobileAppUpdateToast';
|
2026-06-10 15:55:54 +03:00
|
|
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
2026-07-01 09:55:41 +03:00
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { ChatView } from '@/components/views/ChatView';
|
|
|
|
|
import { SettingsView } from '@/components/views/SettingsView';
|
2026-07-17 13:17:21 +03:00
|
|
|
import { TerminalView } from '@/components/views/TerminalView';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
2026-06-02 00:43:05 +03:00
|
|
|
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';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useRouter } from '@/hooks/useRouter';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
|
|
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
2026-06-10 12:00:10 +03:00
|
|
|
import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types';
|
2026-07-08 21:52:18 +03:00
|
|
|
import { useOrientation } from '@/lib/device';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-07-08 21:52:18 +03:00
|
|
|
import { isIPadApp } from '@/lib/platform';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
2026-06-16 14:34:02 +03:00
|
|
|
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { getDisplayModelName } from '@/lib/quota/model-families';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
2026-07-10 00:12:33 +03:00
|
|
|
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { sessionEvents } from '@/lib/sessionEvents';
|
2026-06-02 00:43:05 +03:00
|
|
|
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';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore';
|
|
|
|
|
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
|
|
|
|
import { useMcpStore } from '@/stores/useMcpStore';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
2026-07-12 00:06:58 +11:00
|
|
|
import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager';
|
2026-06-10 12:00:10 +03:00
|
|
|
import type { QuotaProviderId, UsageWindow } from '@/types';
|
|
|
|
|
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
|
|
|
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
|
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
2026-06-10 12:00:10 +03:00
|
|
|
import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-context';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
import { SyncAppEffects } from './AppEffects';
|
|
|
|
|
import { MobileChangesSurface } from './MobileChangesSurface';
|
|
|
|
|
import { MobileFilesSurface } from './MobileFilesSurface';
|
2026-07-13 01:29:35 +03:00
|
|
|
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
|
|
|
|
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
|
|
|
|
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
2026-07-13 01:29:35 +03:00
|
|
|
import { autoConnectLastInstance, connectionDisplayUrl, getAutoConnectTargetLabel, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections';
|
2026-07-10 01:44:42 +03:00
|
|
|
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
2026-07-01 09:55:41 +03:00
|
|
|
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
2026-07-10 00:12:33 +03:00
|
|
|
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
2026-06-02 00:43:05 +03:00
|
|
|
import { useAppFontEffects } from './useAppFontEffects';
|
2026-07-01 09:55:41 +03:00
|
|
|
import { useFontsReady } from './useFontsReady';
|
|
|
|
|
import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation';
|
|
|
|
|
import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch';
|
|
|
|
|
import { useNativePushRegistration } from './useNativePushRegistration';
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
const MOBILE_SETTINGS_PAGES = [
|
|
|
|
|
'appearance',
|
|
|
|
|
'chat',
|
|
|
|
|
'notifications',
|
|
|
|
|
'sessions',
|
|
|
|
|
'git',
|
|
|
|
|
'magic-prompts',
|
|
|
|
|
'behavior',
|
|
|
|
|
'mcp',
|
|
|
|
|
'providers',
|
|
|
|
|
'usage',
|
|
|
|
|
'voice',
|
2026-06-10 12:00:10 +03:00
|
|
|
'about',
|
2026-06-02 00:43:05 +03:00
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
type MobileAppProps = {
|
|
|
|
|
apis: RuntimeAPIs;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-08 21:52:18 +03:00
|
|
|
const IPAD_LEFT_SIDEBAR_WIDTH = 320;
|
|
|
|
|
const IPAD_RIGHT_SIDEBAR_WIDTH = 380;
|
|
|
|
|
const IPAD_SIDEBAR_MIN_WIDTH = 280;
|
|
|
|
|
const IPAD_SIDEBAR_MAX_WIDTH = 560;
|
|
|
|
|
const IPAD_METADATA_POPOVER_WIDTH = 380;
|
|
|
|
|
|
|
|
|
|
/** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop
|
|
|
|
|
Sidebar (imperative styles during the drag, committed to state at the end),
|
|
|
|
|
but with a finger-sized grab strip instead of a 3px hover handle. */
|
|
|
|
|
function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, defaultWidth: number) {
|
|
|
|
|
const asideRef = React.useRef<HTMLElement | null>(null);
|
|
|
|
|
const [width, setWidth] = React.useState(() => {
|
|
|
|
|
if (typeof window === 'undefined') return defaultWidth;
|
|
|
|
|
const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10);
|
|
|
|
|
if (!Number.isFinite(stored)) return defaultWidth;
|
|
|
|
|
return Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
|
|
|
|
|
});
|
|
|
|
|
const [isResizing, setIsResizing] = React.useState(false);
|
|
|
|
|
const startXRef = React.useRef(0);
|
|
|
|
|
const startWidthRef = React.useRef(width);
|
|
|
|
|
const liveWidthRef = React.useRef<number | null>(null);
|
|
|
|
|
const pointerIdRef = React.useRef<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const clampWidth = React.useCallback((value: number) => (
|
|
|
|
|
Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
|
|
|
|
|
), []);
|
|
|
|
|
|
|
|
|
|
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
|
|
|
|
const aside = asideRef.current;
|
|
|
|
|
if (!aside) return;
|
|
|
|
|
aside.style.width = `${nextWidth}px`;
|
|
|
|
|
aside.style.minWidth = `${nextWidth}px`;
|
|
|
|
|
aside.style.maxWidth = `${nextWidth}px`;
|
|
|
|
|
aside.style.setProperty('--oc-ipad-sidebar-width', `${nextWidth}px`);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
|
|
|
|
|
try {
|
|
|
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
pointerIdRef.current = event.pointerId;
|
|
|
|
|
startXRef.current = event.clientX;
|
|
|
|
|
startWidthRef.current = width;
|
|
|
|
|
liveWidthRef.current = width;
|
|
|
|
|
setIsResizing(true);
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
}, [width]);
|
|
|
|
|
|
|
|
|
|
const handlePointerMove = React.useCallback((event: React.PointerEvent) => {
|
|
|
|
|
if (pointerIdRef.current !== event.pointerId) return;
|
|
|
|
|
const delta = event.clientX - startXRef.current;
|
|
|
|
|
const next = clampWidth(startWidthRef.current + (side === 'left' ? delta : -delta));
|
|
|
|
|
if (liveWidthRef.current === next) return;
|
|
|
|
|
liveWidthRef.current = next;
|
|
|
|
|
applyLiveWidth(next);
|
|
|
|
|
}, [applyLiveWidth, clampWidth, side]);
|
|
|
|
|
|
|
|
|
|
const handlePointerEnd = React.useCallback((event: React.PointerEvent) => {
|
|
|
|
|
if (pointerIdRef.current !== event.pointerId) return;
|
|
|
|
|
try {
|
|
|
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
const finalWidth = clampWidth(liveWidthRef.current ?? startWidthRef.current);
|
|
|
|
|
pointerIdRef.current = null;
|
|
|
|
|
liveWidthRef.current = null;
|
|
|
|
|
setIsResizing(false);
|
|
|
|
|
setWidth(finalWidth);
|
|
|
|
|
try {
|
|
|
|
|
window.localStorage.setItem(storageKey, String(finalWidth));
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
}, [clampWidth, storageKey]);
|
|
|
|
|
|
|
|
|
|
const handleProps = React.useMemo(() => ({
|
|
|
|
|
onPointerDown: handlePointerDown,
|
|
|
|
|
onPointerMove: handlePointerMove,
|
|
|
|
|
onPointerUp: handlePointerEnd,
|
|
|
|
|
onPointerCancel: handlePointerEnd,
|
|
|
|
|
}), [handlePointerDown, handlePointerEnd, handlePointerMove]);
|
|
|
|
|
|
|
|
|
|
return { asideRef, width, isResizing, handleProps };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const IpadSidebarResizeHandle: React.FC<{
|
|
|
|
|
side: 'left' | 'right';
|
|
|
|
|
isResizing: boolean;
|
|
|
|
|
ariaLabel: string;
|
|
|
|
|
handleProps: React.HTMLAttributes<HTMLDivElement>;
|
|
|
|
|
}> = ({ side, isResizing, ariaLabel, handleProps }) => (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-y-0 z-30 w-6 cursor-col-resize touch-none',
|
|
|
|
|
side === 'left' ? 'right-0' : 'left-0',
|
|
|
|
|
)}
|
|
|
|
|
role="separator"
|
|
|
|
|
aria-orientation="vertical"
|
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
{...handleProps}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'absolute inset-y-0 w-[3px] transition-colors',
|
|
|
|
|
side === 'left' ? 'right-0' : 'left-0',
|
|
|
|
|
isResizing && 'bg-[var(--interactive-border)]',
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
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');
|
2026-07-04 13:55:12 +03:00
|
|
|
// Platform marker: Android resizes the window for the keyboard natively (no manual
|
|
|
|
|
// inset/choreography — the keyboard listeners below skip Android entirely).
|
2026-07-01 09:55:41 +03:00
|
|
|
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') {
|
2026-07-10 20:25:48 +03:00
|
|
|
// Inset the WebView below the bar and paint it with the resolved theme background
|
|
|
|
|
// (the splash colours the theme system persists). On Android 15+ edge-to-edge is
|
|
|
|
|
// enforced and both calls are no-ops — there the app pads itself via the
|
|
|
|
|
// Capacitor-injected --safe-area-inset-* CSS vars (see mobile.css, oc-platform-android).
|
2026-07-01 09:55:41 +03:00
|
|
|
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
|
2026-07-05 00:05:38 +03:00
|
|
|
// window for the keyboard (dvh already shrinks), so applying the inset on top would
|
|
|
|
|
// double-count — Android gets only the class/event signals below.
|
2026-07-01 09:55:41 +03:00
|
|
|
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
2026-07-05 00:05:38 +03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-04 13:55:12 +03:00
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
// Keyboard slide choreography (see the "Native (Capacitor) keyboard handling"
|
|
|
|
|
// block in mobile.css for the full picture). `keyboardWillShow` fires at the
|
|
|
|
|
// START of the iOS keyboard animation and carries the final height; the
|
2026-07-05 01:41:44 +03:00
|
|
|
// visible motion is transform-only (inline styles on the kb-movers), and the shell's layout
|
2026-07-04 13:55:12 +03:00
|
|
|
// height (--oc-kb-layout) snaps exactly once per open/close at the moment the
|
|
|
|
|
// resize is invisible. visualViewport tracking was tried but doesn't shrink
|
|
|
|
|
// under WKWebView's `resize: 'none'`, so these events are the reliable signal.
|
|
|
|
|
const KB_ANIM_MS = 250;
|
|
|
|
|
// Dismissal reads faster than the rise — run the hide leg shorter (kept in
|
|
|
|
|
// sync with the .oc-kb-hide transition-duration override in mobile.css).
|
|
|
|
|
const KB_HIDE_MS = 200;
|
|
|
|
|
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
|
|
|
|
let settleTimer: number | null = null;
|
2026-07-05 00:05:38 +03:00
|
|
|
let caretTimer: number | null = null;
|
2026-07-04 13:55:12 +03:00
|
|
|
let keyboardHeight = 0;
|
|
|
|
|
let layoutApplied = false;
|
|
|
|
|
let safeBottomPx = 0;
|
|
|
|
|
let keyboardOpen = false;
|
|
|
|
|
|
|
|
|
|
const setVar = (name: string, px: number) => {
|
|
|
|
|
root.style.setProperty(name, `${Math.max(0, Math.round(px))}px`);
|
|
|
|
|
};
|
|
|
|
|
const clearSettle = () => {
|
|
|
|
|
if (settleTimer !== null) {
|
|
|
|
|
window.clearTimeout(settleTimer);
|
|
|
|
|
settleTimer = null;
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-07-05 00:05:38 +03:00
|
|
|
const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
2026-07-04 13:55:12 +03:00
|
|
|
window.dispatchEvent(new CustomEvent(type, { detail }));
|
|
|
|
|
};
|
2026-07-05 01:41:44 +03:00
|
|
|
// Elements that ride the keyboard slide, with their travel factor. Driven
|
|
|
|
|
// by INLINE styles from here: WebKit does not reliably start a transition
|
|
|
|
|
// when the transform's value changes via a CSS custom property, which
|
|
|
|
|
// left the composer parked until the keyboard finished.
|
|
|
|
|
const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => {
|
|
|
|
|
const movers: Array<{ el: HTMLElement; factor: number }> = [];
|
|
|
|
|
const composer = document.querySelector<HTMLElement>('.oc-mobile-composer');
|
|
|
|
|
if (composer) movers.push({ el: composer, factor: 1 });
|
|
|
|
|
// The centered draft title moves half the shift — exactly where the
|
|
|
|
|
// center lands after the shell snap (see mobile.css notes).
|
|
|
|
|
const draftCenter = document.querySelector<HTMLElement>('.oc-draft-center');
|
|
|
|
|
if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 });
|
|
|
|
|
return movers;
|
|
|
|
|
};
|
|
|
|
|
const clearKbMovers = () => {
|
|
|
|
|
for (const { el } of getKbMovers()) {
|
|
|
|
|
el.style.transition = '';
|
|
|
|
|
el.style.transform = '';
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-07-04 13:55:12 +03:00
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
2026-07-04 13:55:12 +03:00
|
|
|
clearSettle();
|
|
|
|
|
keyboardOpen = true;
|
|
|
|
|
keyboardHeight = info.keyboardHeight;
|
|
|
|
|
if (!layoutApplied) {
|
|
|
|
|
// The shell's resolved padding-bottom while the keyboard is down IS the
|
|
|
|
|
// bottom safe padding it gives up when open — measure it so the slide
|
|
|
|
|
// distance lands the composer exactly where the final layout puts it.
|
|
|
|
|
const shell = document.querySelector('.oc-mobile-app-shell');
|
|
|
|
|
safeBottomPx = shell ? parseFloat(getComputedStyle(shell).paddingBottom) || 0 : 0;
|
|
|
|
|
}
|
|
|
|
|
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
|
|
|
|
root.classList.remove('oc-kb-hide');
|
2026-07-05 00:05:38 +03:00
|
|
|
// 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');
|
2026-07-04 13:55:12 +03:00
|
|
|
setInset(keyboardHeight);
|
2026-07-05 01:41:44 +03:00
|
|
|
for (const { el, factor } of getKbMovers()) {
|
|
|
|
|
el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`;
|
|
|
|
|
el.style.transform = `translateY(${-slide * factor}px)`;
|
|
|
|
|
}
|
|
|
|
|
// Reserve the keyboard strip inside the chat scroller NOW and re-pin
|
|
|
|
|
// immediately (settled = one cheap scrollTop write over already-mounted
|
|
|
|
|
// rows), so the chat bottom moves as the keyboard STARTS rising instead
|
|
|
|
|
// of waiting for it to finish. `slide` (keyboard minus the safe inset
|
|
|
|
|
// the shell gives up) is exactly the strip the scroller loses at
|
|
|
|
|
// settle, so pin position and settle stay geometry-neutral.
|
|
|
|
|
setVar('--oc-kb-scroll-inset', slide);
|
|
|
|
|
dispatchKb('oc:keyboard-settled', { open: true });
|
2026-07-04 13:55:12 +03:00
|
|
|
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
|
|
|
|
settleTimer = window.setTimeout(() => {
|
|
|
|
|
settleTimer = null;
|
|
|
|
|
// Invisible swap: transition off, layout takes the keyboard height (one
|
|
|
|
|
// reflow), shift returns to 0 in the same frame.
|
|
|
|
|
root.classList.remove('oc-kb-animating');
|
|
|
|
|
setVar('--oc-kb-layout', keyboardHeight);
|
|
|
|
|
layoutApplied = true;
|
2026-07-05 01:41:44 +03:00
|
|
|
clearKbMovers();
|
2026-07-04 13:55:12 +03:00
|
|
|
dispatchKb('oc:keyboard-settled', { open: true });
|
2026-07-05 00:05:38 +03:00
|
|
|
// Reveal the caret only after UIKit's own caret reposition window.
|
|
|
|
|
caretTimer = window.setTimeout(() => {
|
|
|
|
|
caretTimer = null;
|
|
|
|
|
root.classList.remove('oc-kb-caret-hold');
|
|
|
|
|
}, 250);
|
2026-07-04 13:55:12 +03:00
|
|
|
}, KB_ANIM_MS + 20);
|
2026-07-01 09:55:41 +03:00
|
|
|
});
|
2026-07-04 13:55:12 +03:00
|
|
|
|
|
|
|
|
// Shared hide choreography. The bridge's `keyboardWillHide` can arrive a
|
|
|
|
|
// beat AFTER the native dismiss animation has already started (WKWebView +
|
|
|
|
|
// resize: 'none'), which made the composer begin its down-slide only once
|
|
|
|
|
// the keyboard was gone. The earliest reliable signal for the common
|
|
|
|
|
// dismissal path (tap outside the input) is the textarea's focusout — so
|
|
|
|
|
// both trigger this, and `keyboardOpen` makes the second call a no-op.
|
|
|
|
|
const runHide = () => {
|
|
|
|
|
if (!keyboardOpen) return;
|
|
|
|
|
keyboardOpen = false;
|
|
|
|
|
clearSettle();
|
2026-07-05 00:05:38 +03:00
|
|
|
// 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');
|
2026-07-04 13:55:12 +03:00
|
|
|
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
2026-07-01 09:55:41 +03:00
|
|
|
root.classList.remove('oc-keyboard-open');
|
|
|
|
|
setInset(0);
|
2026-07-05 01:41:44 +03:00
|
|
|
setVar('--oc-kb-scroll-inset', 0);
|
2026-07-04 13:55:12 +03:00
|
|
|
if (layoutApplied) {
|
|
|
|
|
// Settled-open → restore the full-height layout NOW (still hidden behind
|
2026-07-05 01:41:44 +03:00
|
|
|
// the keyboard) and FLIP the movers to their raised position without
|
2026-07-04 13:55:12 +03:00
|
|
|
// transitioning, so the next frame looks unchanged.
|
|
|
|
|
root.classList.remove('oc-kb-animating');
|
|
|
|
|
setVar('--oc-kb-layout', 0);
|
|
|
|
|
layoutApplied = false;
|
2026-07-05 01:41:44 +03:00
|
|
|
for (const { el, factor } of getKbMovers()) {
|
|
|
|
|
el.style.transition = 'none';
|
|
|
|
|
el.style.transform = `translateY(${-slide * factor}px)`;
|
|
|
|
|
}
|
2026-07-04 13:55:12 +03:00
|
|
|
// Force the style/layout flush so the transition below starts from the
|
|
|
|
|
// FLIP position instead of coalescing both writes into one frame.
|
|
|
|
|
void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight;
|
|
|
|
|
}
|
|
|
|
|
// If the hide interrupted a show mid-animation (layout not applied yet),
|
2026-07-05 01:41:44 +03:00
|
|
|
// the movers transition back down from wherever they currently are.
|
2026-07-04 13:55:12 +03:00
|
|
|
dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING });
|
|
|
|
|
root.classList.add('oc-kb-animating', 'oc-kb-hide');
|
2026-07-05 01:41:44 +03:00
|
|
|
for (const { el } of getKbMovers()) {
|
|
|
|
|
el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`;
|
|
|
|
|
el.style.transform = 'translateY(0px)';
|
|
|
|
|
}
|
2026-07-04 13:55:12 +03:00
|
|
|
settleTimer = window.setTimeout(() => {
|
|
|
|
|
settleTimer = null;
|
|
|
|
|
root.classList.remove('oc-kb-animating', 'oc-kb-hide');
|
2026-07-05 01:41:44 +03:00
|
|
|
clearKbMovers();
|
2026-07-04 13:55:12 +03:00
|
|
|
dispatchKb('oc:keyboard-settled', { open: false });
|
|
|
|
|
}, KB_HIDE_MS + 20);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hideHandle = await Keyboard.addListener('keyboardWillHide', runHide);
|
|
|
|
|
|
|
|
|
|
// Early hide trigger: blurring the focused text field is what starts the
|
|
|
|
|
// native dismiss animation, and it happens in-page — no bridge latency.
|
|
|
|
|
// Deferred a task so a synchronous refocus (focus moving to another text
|
|
|
|
|
// input, or a control that restores focus) doesn't false-trigger; in that
|
|
|
|
|
// case the keyboard never hides and `keyboardWillHide` never fires either.
|
|
|
|
|
const isTextInput = (node: unknown): boolean =>
|
|
|
|
|
node instanceof HTMLElement
|
|
|
|
|
&& (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable);
|
|
|
|
|
const handleFocusOut = (event: FocusEvent) => {
|
|
|
|
|
if (!keyboardOpen) return;
|
|
|
|
|
if (!isTextInput(event.target)) return;
|
|
|
|
|
if (isTextInput(event.relatedTarget)) return;
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (!keyboardOpen) return;
|
|
|
|
|
if (isTextInput(document.activeElement)) return;
|
|
|
|
|
runHide();
|
|
|
|
|
}, 0);
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('focusout', handleFocusOut, true);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
if (disposed) {
|
2026-07-04 13:55:12 +03:00
|
|
|
clearSettle();
|
|
|
|
|
document.removeEventListener('focusout', handleFocusOut, true);
|
2026-07-01 09:55:41 +03:00
|
|
|
void showHandle.remove();
|
|
|
|
|
void hideHandle.remove();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-04 13:55:12 +03:00
|
|
|
cleanup.push(
|
|
|
|
|
clearSettle,
|
2026-07-05 00:05:38 +03:00
|
|
|
() => {
|
|
|
|
|
if (caretTimer !== null) {
|
|
|
|
|
window.clearTimeout(caretTimer);
|
|
|
|
|
caretTimer = null;
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-07-04 13:55:12 +03:00
|
|
|
() => document.removeEventListener('focusout', handleFocusOut, true),
|
|
|
|
|
() => void showHandle.remove(),
|
|
|
|
|
() => void hideHandle.remove(),
|
|
|
|
|
);
|
2026-07-01 09:55:41 +03:00
|
|
|
}).catch(() => undefined);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
disposed = true;
|
|
|
|
|
cleanup.forEach((remove) => remove());
|
2026-07-05 00:05:38 +03:00
|
|
|
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android');
|
2026-07-01 09:55:41 +03:00
|
|
|
root.style.removeProperty('--oc-keyboard-inset');
|
2026-07-04 13:55:12 +03:00
|
|
|
root.style.removeProperty('--oc-kb-shift');
|
|
|
|
|
root.style.removeProperty('--oc-kb-layout');
|
2026-07-05 01:41:44 +03:00
|
|
|
root.style.removeProperty('--oc-kb-scroll-inset');
|
2026-07-01 09:55:41 +03:00
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const useNativeMobileLifecycle = (onResume: () => void): void => {
|
2026-07-02 22:44:00 +03:00
|
|
|
const wasInactiveRef = React.useRef(false);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isCapacitorMobileApp()) return;
|
|
|
|
|
|
|
|
|
|
let disposed = false;
|
|
|
|
|
const cleanup: Array<() => void> = [];
|
2026-07-02 22:44:00 +03:00
|
|
|
const resumeAfterInactive = () => {
|
|
|
|
|
if (!wasInactiveRef.current) return;
|
|
|
|
|
wasInactiveRef.current = false;
|
|
|
|
|
onResume();
|
|
|
|
|
};
|
2026-07-01 09:55:41 +03:00
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
// Belt-and-suspenders resume detection. Capacitor's `appStateChange` is the
|
|
|
|
|
// primary signal, but on iOS it can be missed after a long suspend, so the
|
|
|
|
|
// webview's own `visibilitychange` is a second trigger — either one flips
|
|
|
|
|
// wasInactiveRef and fires onResume exactly once per background→foreground.
|
|
|
|
|
const handleVisibility = () => {
|
|
|
|
|
if (document.visibilityState === 'hidden') {
|
|
|
|
|
wasInactiveRef.current = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
resumeAfterInactive();
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('visibilitychange', handleVisibility);
|
|
|
|
|
cleanup.push(() => document.removeEventListener('visibilitychange', handleVisibility));
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
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);
|
2026-07-02 22:44:00 +03:00
|
|
|
if (!isActive) {
|
|
|
|
|
wasInactiveRef.current = true;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
resumeAfterInactive();
|
2026-07-01 09:55:41 +03:00
|
|
|
});
|
2026-07-02 22:44:00 +03:00
|
|
|
const resume = await App.addListener('resume', resumeAfterInactive);
|
2026-07-01 09:55:41 +03:00
|
|
|
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]);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const normalizePath = (value?: string | null): string =>
|
|
|
|
|
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
|
|
|
|
|
if (!limit || typeof limit !== 'object') return undefined;
|
|
|
|
|
const value = (limit as Partial<Record<'context' | 'output', unknown>>)[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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
const mobileInputKeyboardProps = {
|
|
|
|
|
autoComplete: 'off',
|
|
|
|
|
autoCorrect: 'off',
|
|
|
|
|
spellCheck: false,
|
|
|
|
|
} as const;
|
|
|
|
|
|
2026-07-01 18:32:16 +03:00
|
|
|
const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000;
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
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 = {
|
2026-07-17 13:17:21 +03:00
|
|
|
key: 'files' | 'changes' | 'terminal' | 'mcp' | 'instances' | 'update' | 'settings';
|
2026-06-10 12:00:10 +03:00
|
|
|
icon?: IconName;
|
|
|
|
|
iconNode?: React.ReactNode;
|
2026-06-02 00:43:05 +03:00
|
|
|
label: string;
|
|
|
|
|
badge?: number;
|
|
|
|
|
onSelect: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
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 qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
2026-07-10 01:44:42 +03:00
|
|
|
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
|
|
|
|
// scanning is unavailable (web build) or the user asks for it.
|
|
|
|
|
const [manualOpen, setManualOpen] = React.useState(() => !isQrScanSupported());
|
|
|
|
|
// Which saved connection is being connected to, for the per-row spinner.
|
|
|
|
|
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
2026-07-01 09:55:41 +03:00
|
|
|
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
|
2026-07-10 01:44:42 +03:00
|
|
|
// split it back into the server URL + token.
|
2026-07-01 09:55:41 +03:00
|
|
|
const handleUrlChange = React.useCallback((value: string) => {
|
|
|
|
|
if (/^openchamber:\/\//i.test(value.trim())) {
|
|
|
|
|
const payload = parseConnectionPayload(value);
|
|
|
|
|
if (payload) {
|
2026-07-10 00:12:33 +03:00
|
|
|
if ('pairing' in payload) {
|
|
|
|
|
void conn.redeemPairingConnection(payload.pairing);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-01 09:55:41 +03:00
|
|
|
setServerUrl(payload.url);
|
|
|
|
|
if (payload.label) setConnectionName(payload.label);
|
|
|
|
|
if (payload.clientToken) setClientToken(payload.clientToken);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setServerUrl(value);
|
2026-07-10 00:12:33 +03:00
|
|
|
}, [conn]);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
|
|
|
|
break;
|
2026-07-10 00:12:33 +03:00
|
|
|
case 'pairing':
|
|
|
|
|
await conn.redeemPairingConnection(result.pairing);
|
|
|
|
|
break;
|
2026-07-01 09:55:41 +03:00
|
|
|
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 (
|
2026-07-10 20:25:48 +03:00
|
|
|
<main className="oc-keyboard-fill-screen flex min-h-dvh flex-col overflow-y-auto bg-background px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+28px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+28px)] text-foreground">
|
2026-07-01 09:55:41 +03:00
|
|
|
<div className="m-auto flex w-full max-w-[360px] shrink-0 flex-col items-center gap-9 py-8">
|
|
|
|
|
<div className="flex flex-col items-center gap-5 text-center">
|
|
|
|
|
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
|
|
|
|
|
<h1 className="typography-h2 text-foreground">{t('mobile.connect.welcome.title')}</h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{pendingConnection ? (
|
|
|
|
|
<form className="flex w-full flex-col gap-3" onSubmit={handlePasswordSubmit}>
|
|
|
|
|
<div className="flex items-center gap-3 rounded-[18px] border border-border/70 bg-surface-elevated px-3.5 py-3">
|
|
|
|
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
|
|
|
|
<Icon name="lock" className="size-[18px]" />
|
|
|
|
|
</span>
|
|
|
|
|
<div className="min-w-0 text-left">
|
|
|
|
|
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
2026-07-08 03:44:02 +03:00
|
|
|
<p className="truncate typography-small text-muted-foreground">
|
2026-07-10 00:12:33 +03:00
|
|
|
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
2026-07-08 03:44:02 +03:00
|
|
|
</p>
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
{...mobileInputKeyboardProps}
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(event) => 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 ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
|
|
|
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isPasswordBusy || !password.trim()}>
|
|
|
|
|
{isPasswordBusy ? t('mobile.connect.connecting') : t('mobile.connect.unlockButton')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="w-full"
|
|
|
|
|
onClick={cancelPassword}
|
|
|
|
|
>
|
|
|
|
|
{t('mobile.connect.cancelPassword')}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
) : (
|
2026-07-10 01:44:42 +03:00
|
|
|
<div className="flex w-full flex-col gap-6">
|
|
|
|
|
{/* Primary path: scan the pairing QR from "Add a device" on the server. */}
|
|
|
|
|
{qrScanSupported ? (
|
|
|
|
|
<div className="flex w-full flex-col gap-2">
|
|
|
|
|
<Button
|
2026-07-01 09:55:41 +03:00
|
|
|
type="button"
|
2026-07-10 01:44:42 +03:00
|
|
|
size="lg"
|
|
|
|
|
className="h-12 w-full"
|
|
|
|
|
onClick={() => void handleScanQr()}
|
|
|
|
|
disabled={isScanning || isBusy}
|
2026-07-01 09:55:41 +03:00
|
|
|
>
|
2026-07-10 01:44:42 +03:00
|
|
|
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
|
|
|
|
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.scanQr')}
|
|
|
|
|
</Button>
|
|
|
|
|
<p className="px-2 text-center typography-small text-muted-foreground">
|
|
|
|
|
{t('mobile.connect.welcome.scanHint')}
|
|
|
|
|
</p>
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
2026-07-10 01:44:42 +03:00
|
|
|
) : null}
|
2026-07-01 09:55:41 +03:00
|
|
|
|
2026-07-10 01:44:42 +03:00
|
|
|
{error && !manualOpen ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
|
|
|
|
|
|
|
|
{connections.length > 0 ? (
|
|
|
|
|
<section className="flex w-full flex-col gap-2.5">
|
|
|
|
|
<h2 className="text-center typography-micro uppercase tracking-[0.14em] text-muted-foreground">
|
|
|
|
|
{t('mobile.connect.saved.title')}
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
|
|
|
|
{connections.map((connection) => {
|
|
|
|
|
const isConnectingRow = connectingId === connection.id;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={connection.id}
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isBusy}
|
|
|
|
|
className="flex min-h-14 w-full items-center gap-3 border-b border-border/60 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-70"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setConnectingId(connection.id);
|
|
|
|
|
void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
|
|
|
|
.finally(() => setConnectingId(null));
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
|
|
|
|
<Icon name="server" className="size-[18px]" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
|
|
|
|
<span className={cn('block truncate typography-small', isConnectingRow ? 'text-foreground' : 'text-muted-foreground')}>
|
|
|
|
|
{isConnectingRow
|
|
|
|
|
? t('mobile.connect.connecting')
|
|
|
|
|
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
|
|
|
|
</span>
|
|
|
|
|
</span>
|
|
|
|
|
{isConnectingRow
|
|
|
|
|
? <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />
|
|
|
|
|
: <Icon name="arrow-right-s" className="size-5 text-muted-foreground" />}
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2026-07-01 09:55:41 +03:00
|
|
|
) : null}
|
|
|
|
|
|
2026-07-10 01:44:42 +03:00
|
|
|
{/* Manual URL entry, collapsed by default — most people pair by QR. */}
|
|
|
|
|
<div className="flex w-full flex-col">
|
|
|
|
|
{qrScanSupported ? (
|
2026-07-01 09:55:41 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-07-10 01:44:42 +03:00
|
|
|
onClick={() => setManualOpen((value) => !value)}
|
|
|
|
|
aria-expanded={manualOpen}
|
|
|
|
|
className="mx-auto flex items-center gap-1 rounded-full px-2 py-1 typography-small text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
2026-07-01 09:55:41 +03:00
|
|
|
>
|
2026-07-10 01:44:42 +03:00
|
|
|
<span>{t('mobile.connect.manual.toggle')}</span>
|
|
|
|
|
<Icon name="arrow-down-s" className={cn('size-4 transition-transform duration-200', manualOpen && 'rotate-180')} />
|
2026-07-01 09:55:41 +03:00
|
|
|
</button>
|
2026-07-10 01:44:42 +03:00
|
|
|
) : null}
|
|
|
|
|
<div
|
|
|
|
|
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
|
|
|
|
style={{ gridTemplateRows: manualOpen ? '1fr' : '0fr' }}
|
|
|
|
|
>
|
|
|
|
|
<div className="min-h-0 overflow-hidden">
|
|
|
|
|
<form className="flex w-full flex-col gap-3 pt-3" onSubmit={handleSubmit}>
|
|
|
|
|
<input
|
|
|
|
|
{...mobileInputKeyboardProps}
|
|
|
|
|
value={serverUrl}
|
|
|
|
|
onChange={(event) => handleUrlChange(event.target.value)}
|
|
|
|
|
placeholder={t('mobile.connect.url.placeholder')}
|
|
|
|
|
aria-label={t('mobile.connect.url.label')}
|
|
|
|
|
type="url"
|
|
|
|
|
inputMode="url"
|
|
|
|
|
autoCapitalize="none"
|
|
|
|
|
tabIndex={manualOpen ? undefined : -1}
|
|
|
|
|
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"
|
|
|
|
|
/>
|
|
|
|
|
<input
|
|
|
|
|
value={connectionName}
|
|
|
|
|
onChange={(event) => 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}
|
|
|
|
|
tabIndex={manualOpen ? undefined : -1}
|
|
|
|
|
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"
|
|
|
|
|
/>
|
|
|
|
|
<input
|
|
|
|
|
{...mobileInputKeyboardProps}
|
|
|
|
|
value={clientToken}
|
|
|
|
|
onChange={(event) => setClientToken(event.target.value)}
|
|
|
|
|
placeholder={t('mobile.connect.token.placeholder')}
|
|
|
|
|
aria-label={t('mobile.connect.token.label')}
|
|
|
|
|
tabIndex={manualOpen ? undefined : -1}
|
|
|
|
|
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"
|
|
|
|
|
/>
|
|
|
|
|
<p className="px-1 text-center typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
|
|
|
|
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
|
|
|
<Button type="submit" variant={qrScanSupported ? 'outline' : 'default'} size="lg" className="h-12 w-full" disabled={isBusy || isScanning || !serverUrl.trim()}>
|
|
|
|
|
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.connectButton')}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
2026-07-10 01:44:42 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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<string | null>(null);
|
|
|
|
|
const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null;
|
|
|
|
|
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(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(), []);
|
2026-07-10 01:44:42 +03:00
|
|
|
// The manual add/edit form is hidden until asked for — the sheet leads with
|
|
|
|
|
// the list of instances (with live status), not a wall of inputs.
|
|
|
|
|
const [formOpen, setFormOpen] = React.useState(false);
|
|
|
|
|
// Which row is being connected to, for the per-row spinner.
|
|
|
|
|
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
// 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);
|
2026-07-10 01:44:42 +03:00
|
|
|
setFormOpen(false);
|
2026-07-01 09:55:41 +03:00
|
|
|
}, [setError]);
|
|
|
|
|
|
|
|
|
|
const saveInstance = React.useCallback((event: React.FormEvent) => {
|
|
|
|
|
event.preventDefault();
|
2026-07-13 01:29:35 +03:00
|
|
|
// The id is what makes this an EDIT: saveConnection uses it to preserve the
|
|
|
|
|
// existing relay/https candidates (and the Keychain token they key) instead
|
|
|
|
|
// of rebuilding the instance from the single URL field.
|
|
|
|
|
void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => {
|
2026-07-01 09:55:41 +03:00
|
|
|
if (saved) resetForm();
|
|
|
|
|
});
|
2026-07-13 01:29:35 +03:00
|
|
|
}, [clientToken, editingId, label, resetForm, saveConnection, url]);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
// 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':
|
2026-07-10 01:44:42 +03:00
|
|
|
// Legacy token QR: prefill the manual form for review before saving.
|
2026-07-01 09:55:41 +03:00
|
|
|
setUrl(result.url);
|
|
|
|
|
if (result.label) setLabel(result.label);
|
|
|
|
|
if (result.clientToken) setClientToken(result.clientToken);
|
2026-07-10 01:44:42 +03:00
|
|
|
setFormOpen(true);
|
2026-07-01 09:55:41 +03:00
|
|
|
break;
|
2026-07-10 00:12:33 +03:00
|
|
|
case 'pairing':
|
|
|
|
|
await conn.redeemPairingConnection(result.pairing);
|
|
|
|
|
break;
|
2026-07-01 09:55:41 +03:00
|
|
|
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);
|
|
|
|
|
}
|
2026-07-10 00:12:33 +03:00
|
|
|
}, [conn, isScanning, setError, t]);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
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();
|
2026-07-10 01:44:42 +03:00
|
|
|
// Removing the ACTIVE instance — or the LAST one — must drop the user back
|
|
|
|
|
// to the connect screen instead of leaving them in a stale, unbacked UI.
|
|
|
|
|
const wasLast = connections.length === 1;
|
2026-07-01 09:55:41 +03:00
|
|
|
void removeConnection(id).then((removed) => {
|
2026-07-08 03:44:02 +03:00
|
|
|
if (!removed) return;
|
2026-07-10 01:44:42 +03:00
|
|
|
if (wasLast || isActiveRuntimeConnection(removed)) {
|
2026-07-01 09:55:41 +03:00
|
|
|
onActiveConnectionDeleted();
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-07-10 01:44:42 +03:00
|
|
|
}, [connections.length, editingId, onActiveConnectionDeleted, removeConnection, resetForm]);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
|
|
|
<form className="flex-1 overflow-y-auto px-5 py-4" onSubmit={handlePasswordSubmit}>
|
|
|
|
|
<div className="flex flex-col gap-3">
|
|
|
|
|
<div className="flex items-center gap-3 rounded-[18px] border border-border/70 bg-surface-elevated px-3.5 py-3">
|
|
|
|
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
|
|
|
|
<Icon name="lock" className="size-[18px]" />
|
|
|
|
|
</span>
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
2026-07-08 03:44:02 +03:00
|
|
|
<p className="truncate typography-small text-muted-foreground">
|
2026-07-10 00:12:33 +03:00
|
|
|
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
2026-07-08 03:44:02 +03:00
|
|
|
</p>
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
{...mobileInputKeyboardProps}
|
|
|
|
|
value={password}
|
|
|
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
|
|
|
placeholder={t('mobile.connect.password.placeholder')}
|
|
|
|
|
aria-label={t('mobile.connect.password.label')}
|
|
|
|
|
type="password"
|
|
|
|
|
autoFocus
|
|
|
|
|
className={inputClass}
|
|
|
|
|
/>
|
|
|
|
|
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
|
|
|
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isPasswordBusy || !password.trim()}>
|
|
|
|
|
{isPasswordBusy ? t('mobile.connect.connecting') : t('mobile.connect.unlockButton')}
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="button" variant="ghost" size="sm" className="w-full" onClick={cancelPasswordPrompt}>
|
|
|
|
|
{t('mobile.connect.cancelPassword')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
|
|
|
<div className="flex-1 overflow-y-auto px-5 py-4">
|
2026-07-10 01:44:42 +03:00
|
|
|
<div className="space-y-6">
|
2026-07-01 09:55:41 +03:00
|
|
|
{connections.length > 0 ? (
|
|
|
|
|
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
|
|
|
|
{connections.map((connection) => {
|
|
|
|
|
const confirming = confirmingDeleteId === connection.id;
|
2026-07-10 01:44:42 +03:00
|
|
|
const isActive = isActiveRuntimeConnection(connection);
|
|
|
|
|
const isConnectingRow = connectingId === connection.id;
|
|
|
|
|
// Status line: the active instance says HOW it is connected right
|
|
|
|
|
// now (direct vs relay); others show their address.
|
|
|
|
|
const statusText = isConnectingRow
|
|
|
|
|
? t('mobile.connect.connecting')
|
|
|
|
|
: isActive
|
|
|
|
|
? (isRelayModeActive() ? t('mobile.instances.status.connectedRelay') : t('mobile.instances.status.connectedDirect'))
|
|
|
|
|
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge');
|
2026-07-01 09:55:41 +03:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
key={connection.id}
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex items-center border-b border-border/60 transition-colors last:border-b-0',
|
|
|
|
|
confirming && 'bg-[color-mix(in_srgb,var(--destructive)_8%,transparent)]',
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
2026-07-10 01:44:42 +03:00
|
|
|
onClick={() => {
|
|
|
|
|
if (isActive) return;
|
|
|
|
|
setConnectingId(connection.id);
|
|
|
|
|
void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
|
|
|
|
.finally(() => setConnectingId(null));
|
|
|
|
|
}}
|
|
|
|
|
disabled={(isBusy && !isConnectingRow) || confirming}
|
2026-07-01 09:55:41 +03:00
|
|
|
>
|
2026-07-10 01:44:42 +03:00
|
|
|
<span className="relative flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
2026-07-01 09:55:41 +03:00
|
|
|
<Icon name="server" className="size-[18px]" />
|
2026-07-10 01:44:42 +03:00
|
|
|
{isActive ? (
|
|
|
|
|
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-[var(--surface-elevated)] bg-[var(--status-success)]" aria-hidden />
|
|
|
|
|
) : null}
|
2026-07-01 09:55:41 +03:00
|
|
|
</span>
|
|
|
|
|
<span className="min-w-0 flex-1">
|
|
|
|
|
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
2026-07-10 01:44:42 +03:00
|
|
|
<span className={cn(
|
|
|
|
|
'block truncate typography-small',
|
|
|
|
|
isActive && !isConnectingRow ? 'text-[var(--status-success)]' : 'text-muted-foreground',
|
|
|
|
|
)}>
|
|
|
|
|
{statusText}
|
2026-07-08 03:44:02 +03:00
|
|
|
</span>
|
2026-07-01 09:55:41 +03:00
|
|
|
</span>
|
2026-07-10 01:44:42 +03:00
|
|
|
{isConnectingRow ? <Icon name="loader-4" className="size-5 shrink-0 animate-spin text-muted-foreground" /> : null}
|
2026-07-01 09:55:41 +03:00
|
|
|
</button>
|
|
|
|
|
<div className="flex items-center gap-0.5 pr-2">
|
|
|
|
|
{confirming ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={t('mobile.instances.confirmDeleteAria', { label: connection.label })}
|
|
|
|
|
className="flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-destructive-foreground transition-opacity active:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
|
|
|
|
|
onClick={() => confirmDelete(connection.id)}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="delete-bin" className="size-[18px]" />
|
|
|
|
|
<span className="typography-ui-label">{t('mobile.instances.delete')}</span>
|
|
|
|
|
</button>
|
2026-07-10 00:12:33 +03:00
|
|
|
) : !connection.candidates.some((c) => c.kind === 'direct') ? null : (
|
2026-07-01 09:55:41 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={t('mobile.instances.edit')}
|
|
|
|
|
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setEditingId(connection.id);
|
2026-07-10 00:12:33 +03:00
|
|
|
setUrl(connectionDisplayUrl(connection));
|
2026-07-01 09:55:41 +03:00
|
|
|
setLabel(connection.label);
|
|
|
|
|
setClientToken(connection.clientToken || '');
|
|
|
|
|
setError(null);
|
|
|
|
|
}}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="edit" className="size-[18px]" />
|
|
|
|
|
</button>
|
|
|
|
|
)}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
aria-label={confirming
|
|
|
|
|
? t('mobile.instances.cancelDeleteAria', { label: connection.label })
|
|
|
|
|
: t('mobile.instances.deleteAria', { label: connection.label })}
|
|
|
|
|
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
onClick={() => toggleConfirmDelete(connection.id)}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name={confirming ? 'close' : 'delete-bin'} className="size-[18px]" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<p className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
|
|
|
|
|
{t('mobile.connect.saved.empty')}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-07-10 01:44:42 +03:00
|
|
|
{/* Add actions: QR pairing is the primary path; the manual form stays
|
|
|
|
|
hidden until asked for (or until a row's edit button opens it). */}
|
|
|
|
|
{!formOpen && !editingConnection ? (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{qrScanSupported ? (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
size="lg"
|
|
|
|
|
className="h-12 w-full"
|
|
|
|
|
onClick={() => void handleScanInstance()}
|
|
|
|
|
disabled={isScanning}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
|
|
|
|
{t('mobile.connect.scanQr')}
|
2026-07-01 09:55:41 +03:00
|
|
|
</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
2026-07-10 01:44:42 +03:00
|
|
|
variant={qrScanSupported ? 'ghost' : 'outline'}
|
2026-07-01 09:55:41 +03:00
|
|
|
size="lg"
|
|
|
|
|
className="h-12 w-full"
|
2026-07-10 01:44:42 +03:00
|
|
|
onClick={() => { setError(null); setFormOpen(true); }}
|
2026-07-01 09:55:41 +03:00
|
|
|
>
|
2026-07-10 01:44:42 +03:00
|
|
|
<Icon name="add" className="size-[18px]" />
|
|
|
|
|
{t('mobile.instances.addManual')}
|
2026-07-01 09:55:41 +03:00
|
|
|
</Button>
|
2026-07-10 01:44:42 +03:00
|
|
|
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
2026-07-10 01:44:42 +03:00
|
|
|
) : (
|
|
|
|
|
<form className="space-y-3" onSubmit={saveInstance}>
|
|
|
|
|
<div className="flex h-8 items-center justify-between gap-3 px-1">
|
|
|
|
|
<h3 className="typography-ui-label text-foreground">
|
|
|
|
|
{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}
|
|
|
|
|
</h3>
|
|
|
|
|
<Button type="button" variant="ghost" size="xs" onClick={resetForm}>
|
|
|
|
|
{t('mobile.instances.cancelEdit')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.url.label')}</span>
|
|
|
|
|
<input
|
|
|
|
|
{...mobileInputKeyboardProps}
|
|
|
|
|
value={url}
|
|
|
|
|
onChange={(event) => setUrl(event.target.value)}
|
|
|
|
|
placeholder={t('mobile.connect.url.placeholder')}
|
|
|
|
|
type="url"
|
|
|
|
|
inputMode="url"
|
|
|
|
|
autoCapitalize="none"
|
|
|
|
|
className={inputClass}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.instances.label.label')}</span>
|
|
|
|
|
<input
|
|
|
|
|
value={label}
|
|
|
|
|
onChange={(event) => setLabel(event.target.value)}
|
|
|
|
|
placeholder={t('mobile.instances.label.placeholder')}
|
|
|
|
|
autoComplete="off"
|
|
|
|
|
autoCapitalize="words"
|
|
|
|
|
autoCorrect="off"
|
|
|
|
|
spellCheck={false}
|
|
|
|
|
className={inputClass}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="block space-y-1.5">
|
|
|
|
|
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.token.label')}</span>
|
|
|
|
|
<input
|
|
|
|
|
{...mobileInputKeyboardProps}
|
|
|
|
|
value={clientToken}
|
|
|
|
|
onChange={(event) => setClientToken(event.target.value)}
|
|
|
|
|
placeholder={t('mobile.connect.token.placeholder')}
|
|
|
|
|
autoCapitalize="none"
|
|
|
|
|
className={inputClass}
|
|
|
|
|
/>
|
|
|
|
|
<p className="px-1 typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
|
|
|
|
</label>
|
|
|
|
|
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
|
|
|
|
<Button type="submit" size="lg" className="mt-1 h-12 w-full">
|
|
|
|
|
{editingConnection ? t('mobile.instances.saveEdit') : t('mobile.instances.saveNew')}
|
|
|
|
|
</Button>
|
|
|
|
|
</form>
|
|
|
|
|
)}
|
2026-07-01 09:55:41 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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';
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-16 14:34:02 +03:00
|
|
|
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 (
|
|
|
|
|
<svg
|
|
|
|
|
viewBox={`0 0 ${size} ${size}`}
|
|
|
|
|
className="size-[18px] -rotate-90"
|
|
|
|
|
role="progressbar"
|
|
|
|
|
aria-valuenow={Math.round(progressPct)}
|
|
|
|
|
aria-valuemin={0}
|
|
|
|
|
aria-valuemax={100}
|
|
|
|
|
>
|
|
|
|
|
<circle
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
r={radius}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="var(--interactive-border)"
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
/>
|
|
|
|
|
<circle
|
|
|
|
|
cx={size / 2}
|
|
|
|
|
cy={size / 2}
|
|
|
|
|
r={radius}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke={progressColor}
|
|
|
|
|
strokeWidth={stroke}
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeDasharray={circumference}
|
|
|
|
|
strokeDashoffset={circumference * (1 - progressPct / 100)}
|
|
|
|
|
className="transition-[stroke-dashoffset,stroke] duration-300"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
const MetadataRow: React.FC<{
|
2026-06-16 14:34:02 +03:00
|
|
|
icon?: IconName;
|
|
|
|
|
iconNode?: React.ReactNode;
|
2026-06-10 12:00:10 +03:00
|
|
|
label: string;
|
|
|
|
|
children: React.ReactNode;
|
2026-06-16 14:34:02 +03:00
|
|
|
}> = ({ icon, iconNode, label, children }) => (
|
2026-06-10 12:00:10 +03:00
|
|
|
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
|
|
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
2026-06-16 14:34:02 +03:00
|
|
|
{iconNode ?? (icon ? <Icon name={icon} className="size-[18px]" /> : null)}
|
2026-06-10 12:00:10 +03:00
|
|
|
</span>
|
|
|
|
|
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
|
|
|
|
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
|
|
|
|
{children}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const SessionMetadataOverlay: React.FC<{
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
anchorRef: React.RefObject<HTMLElement | null>;
|
|
|
|
|
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<HTMLDivElement>(null);
|
|
|
|
|
const [shouldRender, setShouldRender] = React.useState(open);
|
|
|
|
|
const [isExiting, setIsExiting] = React.useState(false);
|
2026-07-08 21:52:18 +03:00
|
|
|
// iPad: a phone-width sheet stretched across the whole chat column looks
|
|
|
|
|
// broken — render a popover anchored to the metadata button instead.
|
|
|
|
|
const isIPad = React.useMemo(() => isIPadApp(), []);
|
|
|
|
|
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
|
|
|
|
const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
// The shell has transformed ancestors, so the fixed wrapper's containing
|
|
|
|
|
// block is the chat column, NOT the viewport. Anchor the popover in the
|
|
|
|
|
// wrapper's own coordinate space — viewport-based lefts would double-count
|
|
|
|
|
// the sidebar offset.
|
|
|
|
|
React.useLayoutEffect(() => {
|
|
|
|
|
if (!open || !isIPad || !shouldRender) return;
|
|
|
|
|
const compute = () => {
|
|
|
|
|
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
|
|
|
|
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
|
|
|
|
if (!anchorRect || !wrapperRect) {
|
|
|
|
|
setIpadAnchorLeft(null);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const relativeLeft = anchorRect.left - wrapperRect.left;
|
|
|
|
|
const left = Math.min(
|
|
|
|
|
Math.max(relativeLeft, 8),
|
|
|
|
|
Math.max(8, wrapperRect.width - IPAD_METADATA_POPOVER_WIDTH - 8),
|
|
|
|
|
);
|
|
|
|
|
setIpadAnchorLeft(left);
|
|
|
|
|
};
|
|
|
|
|
compute();
|
|
|
|
|
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
|
|
|
|
// toggle/resize, orientation change) — the header buttons move with it.
|
|
|
|
|
const wrapper = wrapperRef.current;
|
|
|
|
|
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
|
|
|
|
const observer = new ResizeObserver(compute);
|
|
|
|
|
observer.observe(wrapper);
|
|
|
|
|
return () => observer.disconnect();
|
|
|
|
|
}, [anchorRef, isIPad, open, shouldRender]);
|
|
|
|
|
|
|
|
|
|
const ipadPopover = isIPad && ipadAnchorLeft !== null;
|
2026-06-10 12:00:10 +03:00
|
|
|
|
|
|
|
|
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 (
|
2026-07-08 21:52:18 +03:00
|
|
|
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
2026-06-10 12:00:10 +03:00
|
|
|
<div
|
|
|
|
|
ref={panelRef}
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-label={t('mobile.header.openMetadataAria')}
|
|
|
|
|
className={cn(
|
2026-07-08 21:52:18 +03:00
|
|
|
'overflow-y-auto overscroll-contain rounded-[20px] border border-border/40 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
|
|
|
|
ipadPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
2026-06-10 12:00:10 +03:00
|
|
|
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
|
|
|
|
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
2026-07-08 21:52:18 +03:00
|
|
|
...(ipadPopover
|
|
|
|
|
? {
|
|
|
|
|
top: 8,
|
|
|
|
|
left: ipadAnchorLeft ?? 8,
|
|
|
|
|
width: `min(${IPAD_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
|
|
|
|
}
|
|
|
|
|
: null),
|
2026-06-10 12:00:10 +03:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<MetadataRow icon="git-branch" label={t('mobile.header.metadata.branch')}>
|
|
|
|
|
{branchLabel}
|
|
|
|
|
</MetadataRow>
|
|
|
|
|
{contextDisplay ? (
|
2026-06-16 14:34:02 +03:00
|
|
|
<MetadataRow
|
|
|
|
|
iconNode={<ContextProgressIcon percentage={contextDisplay.percentage} />}
|
|
|
|
|
label={t('mobile.header.metadata.context')}
|
|
|
|
|
>
|
2026-06-10 12:00:10 +03:00
|
|
|
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
|
|
|
|
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
|
|
|
|
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</MetadataRow>
|
|
|
|
|
) : null}
|
|
|
|
|
<MobileUsageLimits
|
|
|
|
|
groups={usageGroups}
|
|
|
|
|
displayMode={usageDisplayMode}
|
|
|
|
|
isLoading={isUsageLoading}
|
|
|
|
|
timeFormatPreference={timeFormatPreference}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<style>{`
|
|
|
|
|
@keyframes session-metadata-in {
|
|
|
|
|
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
|
|
|
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
|
|
|
}
|
|
|
|
|
@keyframes session-metadata-out {
|
|
|
|
|
from { opacity: 1; transform: translateY(0) scale(1); }
|
|
|
|
|
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
|
|
|
|
}
|
|
|
|
|
`}</style>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<div className="pt-2.5">
|
|
|
|
|
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
|
|
|
|
|
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
|
|
|
|
<Icon name="timer" className="size-[18px]" />
|
|
|
|
|
</span>
|
|
|
|
|
<span className="shrink-0 typography-ui-label text-muted-foreground">
|
|
|
|
|
{t('mobile.header.metadata.usage')}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
|
|
|
|
|
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
|
|
|
|
|
<span className="truncate">{modeLabel}</span>
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
{groups.map((group) => (
|
|
|
|
|
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
|
|
|
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
|
|
|
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
|
|
|
|
|
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
|
|
|
|
|
{group.providerName}
|
|
|
|
|
</span>
|
|
|
|
|
{group.status && group.rows.length === 0 ? (
|
|
|
|
|
<span className="shrink-0 truncate typography-micro text-muted-foreground">
|
|
|
|
|
{group.status}
|
|
|
|
|
</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
{group.rows.length > 0 ? (
|
|
|
|
|
<div className="mt-1.5 space-y-1">
|
|
|
|
|
{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 (
|
|
|
|
|
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
|
|
|
|
|
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
|
|
|
|
|
<span className="truncate typography-ui-label text-muted-foreground">
|
|
|
|
|
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
|
|
|
|
|
</span>
|
|
|
|
|
{resetLabel ? (
|
|
|
|
|
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
|
|
|
|
|
{metricLabel === '-' ? '' : metricLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{group.status && group.rows.length > 0 ? (
|
|
|
|
|
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const MobileOverflowMenu: React.FC<{
|
|
|
|
|
open: boolean;
|
|
|
|
|
onClose: () => void;
|
|
|
|
|
items: OverflowItem[];
|
2026-07-08 21:52:18 +03:00
|
|
|
/** Extra viewport-right inset so the dropdown stays anchored to the
|
|
|
|
|
three-dots button when the iPad right sidebar shifts the header. */
|
|
|
|
|
rightOffset?: number;
|
|
|
|
|
}> = ({ open, onClose, items, rightOffset = 0 }) => {
|
2026-06-02 00:43:05 +03:00
|
|
|
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 (
|
|
|
|
|
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-06-02 19:00:14 +03:00
|
|
|
className="absolute inset-0 cursor-default"
|
2026-06-02 00:43:05 +03:00
|
|
|
aria-label={t('mobile.surface.closeAria')}
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
/>
|
|
|
|
|
<div
|
2026-07-08 21:52:18 +03:00
|
|
|
className="absolute top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/40 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
|
2026-06-02 00:43:05 +03:00
|
|
|
role="menu"
|
2026-07-08 21:52:18 +03:00
|
|
|
style={{
|
|
|
|
|
right: `${8 + rightOffset}px`,
|
|
|
|
|
animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)',
|
|
|
|
|
}}
|
2026-06-02 00:43:05 +03:00
|
|
|
>
|
|
|
|
|
{items.map((item, index) => (
|
|
|
|
|
<button
|
|
|
|
|
key={item.key}
|
|
|
|
|
type="button"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset',
|
|
|
|
|
index > 0 && 'border-t border-border/30',
|
|
|
|
|
)}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
item.onSelect();
|
|
|
|
|
onClose();
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-06-10 12:00:10 +03:00
|
|
|
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
|
2026-06-02 00:43:05 +03:00
|
|
|
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
|
|
|
|
|
{item.badge && item.badge > 0 ? (
|
|
|
|
|
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
|
|
|
|
|
) : null}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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<HTMLButtonElement>(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<MobileUsageProviderGroup[]>(() => {
|
|
|
|
|
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 (
|
|
|
|
|
<>
|
2026-07-01 20:13:53 +03:00
|
|
|
<div className="flex min-w-0 flex-1 items-center px-2 py-1.5 text-left">
|
|
|
|
|
<span className="flex min-w-0 flex-1 flex-col leading-tight">
|
|
|
|
|
<span className="block truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
|
|
|
|
{secondaryLabel ? (
|
|
|
|
|
<span className="block truncate typography-micro text-muted-foreground">{secondaryLabel}</span>
|
|
|
|
|
) : null}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-06-10 12:00:10 +03:00
|
|
|
<button
|
|
|
|
|
ref={metadataTriggerRef}
|
|
|
|
|
type="button"
|
2026-07-01 20:13:53 +03:00
|
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
2026-06-10 12:00:10 +03:00
|
|
|
aria-label={t('mobile.header.openMetadataAria')}
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
2026-07-01 20:13:53 +03:00
|
|
|
<Icon name="apps-2-ai" className="size-5" />
|
2026-06-10 12:00:10 +03:00
|
|
|
</button>
|
|
|
|
|
<SessionMetadataOverlay
|
|
|
|
|
open={open}
|
|
|
|
|
onClose={() => onOpenChange(false)}
|
|
|
|
|
anchorRef={metadataTriggerRef}
|
|
|
|
|
contextDisplay={contextDisplay}
|
|
|
|
|
branchLabel={branchLabel}
|
|
|
|
|
usageGroups={usageGroups}
|
|
|
|
|
usageDisplayMode={quotaDisplayMode}
|
|
|
|
|
isUsageLoading={isQuotaLoading}
|
|
|
|
|
timeFormatPreference={timeFormatPreference}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-08 21:52:18 +03:00
|
|
|
type MobileHeaderSurfaceShortcuts = {
|
|
|
|
|
activePanel: 'files' | 'changes' | null;
|
|
|
|
|
changesDirty: boolean;
|
|
|
|
|
onToggleFiles: () => void;
|
|
|
|
|
onToggleChanges: () => void;
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const MobileHeader: React.FC<{
|
|
|
|
|
onOpenSessions: () => void;
|
|
|
|
|
onOpenMenu: () => void;
|
2026-07-08 21:52:18 +03:00
|
|
|
/** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */
|
|
|
|
|
surfaceShortcuts?: MobileHeaderSurfaceShortcuts;
|
|
|
|
|
}> = ({ onOpenSessions, onOpenMenu, surfaceShortcuts }) => {
|
2026-06-02 00:43:05 +03:00
|
|
|
const { t } = useI18n();
|
2026-06-10 12:00:10 +03:00
|
|
|
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
2026-06-02 00:43:05 +03:00
|
|
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
|
|
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
2026-06-10 12:00:10 +03:00
|
|
|
const currentSessionDirectory = useSessionUIStore(
|
|
|
|
|
React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]),
|
|
|
|
|
);
|
|
|
|
|
const effectiveDirectory = currentSessionDirectory || currentDirectory;
|
|
|
|
|
const gitDirectory = normalizePath(effectiveDirectory) || null;
|
2026-06-02 00:43:05 +03:00
|
|
|
const projects = useProjectsStore((state) => state.projects);
|
2026-06-10 12:00:10 +03:00
|
|
|
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));
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
const projectLabel = React.useMemo(() => {
|
2026-06-10 12:00:10 +03:00
|
|
|
const directory = normalizePath(effectiveDirectory);
|
2026-06-02 00:43:05 +03:00
|
|
|
if (!directory) return t('mobile.header.noProject');
|
2026-06-10 12:00:10 +03:00
|
|
|
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]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
const sessionTitle = currentSession?.title?.trim();
|
2026-06-10 12:00:10 +03:00
|
|
|
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]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
return (
|
2026-06-10 12:00:10 +03:00
|
|
|
<>
|
|
|
|
|
<header
|
2026-07-05 09:14:55 +03:00
|
|
|
className="oc-mobile-header relative z-30 flex shrink-0 items-center gap-1 border-b border-border/30 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
2026-06-10 12:00:10 +03:00
|
|
|
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
aria-label={t('mobile.sessions.openSheetAria')}
|
|
|
|
|
onClick={handleOpenSessions}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
2026-07-01 20:13:53 +03:00
|
|
|
<Icon name="folders" className="size-5" />
|
2026-06-10 12:00:10 +03:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<MobileSessionMetadataButton
|
|
|
|
|
open={metadataOpen}
|
|
|
|
|
onOpenChange={setMetadataOpen}
|
|
|
|
|
currentSessionId={currentSessionId}
|
|
|
|
|
effectiveDirectory={effectiveDirectory}
|
|
|
|
|
gitDirectory={gitDirectory}
|
|
|
|
|
isNewSessionDraftOpen={isNewSessionDraftOpen}
|
|
|
|
|
primaryLabel={primaryLabel}
|
|
|
|
|
secondaryLabel={secondaryLabel}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-07-08 21:52:18 +03:00
|
|
|
{surfaceShortcuts ? (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
|
|
|
|
surfaceShortcuts.activePanel === 'files'
|
|
|
|
|
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
|
|
|
|
|
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
|
|
|
|
|
)}
|
|
|
|
|
aria-label={t('mobile.menu.files')}
|
|
|
|
|
aria-pressed={surfaceShortcuts.activePanel === 'files'}
|
|
|
|
|
onClick={surfaceShortcuts.onToggleFiles}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="file-text" className="size-5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
|
|
|
|
surfaceShortcuts.activePanel === 'changes'
|
|
|
|
|
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
|
|
|
|
|
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
|
|
|
|
|
)}
|
|
|
|
|
aria-label={t('mobile.menu.changes')}
|
|
|
|
|
aria-pressed={surfaceShortcuts.activePanel === 'changes'}
|
|
|
|
|
onClick={surfaceShortcuts.onToggleChanges}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="git-branch" className="size-5" />
|
|
|
|
|
{surfaceShortcuts.changesDirty ? (
|
|
|
|
|
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
|
|
|
|
|
) : null}
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
aria-label={t('mobile.header.openMenuAria')}
|
|
|
|
|
onClick={handleOpenMenu}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="more-2" className="size-5" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
</>
|
2026-06-02 00:43:05 +03:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => {
|
2026-06-02 00:43:05 +03:00
|
|
|
const { t } = useI18n();
|
|
|
|
|
const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false);
|
|
|
|
|
const [filesOpen, setFilesOpen] = React.useState(false);
|
|
|
|
|
const [changesOpen, setChangesOpen] = React.useState(false);
|
2026-07-17 13:17:21 +03:00
|
|
|
const [terminalOpen, setTerminalOpen] = React.useState(false);
|
2026-06-10 12:00:10 +03:00
|
|
|
const [mcpOpen, setMcpOpen] = React.useState(false);
|
2026-07-01 09:55:41 +03:00
|
|
|
const [instancesOpen, setInstancesOpen] = React.useState(false);
|
2026-06-10 12:00:10 +03:00
|
|
|
const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false);
|
2026-06-02 00:43:05 +03:00
|
|
|
const [settingsOpen, setSettingsOpen] = React.useState(false);
|
2026-06-10 12:00:10 +03:00
|
|
|
const [updateOpen, setUpdateOpen] = React.useState(false);
|
|
|
|
|
const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav');
|
2026-06-02 00:43:05 +03:00
|
|
|
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);
|
2026-06-10 12:00:10 +03:00
|
|
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
|
|
|
|
const updateAvailable = useUpdateStore((state) => state.available);
|
|
|
|
|
const updateRuntimeType = useUpdateStore((state) => state.runtimeType);
|
2026-07-01 09:55:41 +03:00
|
|
|
const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []);
|
2026-06-10 12:00:10 +03:00
|
|
|
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);
|
2026-06-02 00:43:05 +03:00
|
|
|
const gitStatus = useGitStatus(normalizePath(currentDirectory) || null);
|
|
|
|
|
const dirtyChangeCount = gitStatus?.files?.length ?? 0;
|
|
|
|
|
|
2026-07-08 21:52:18 +03:00
|
|
|
// iPad (Capacitor): sessions live in a persistent full-height left sidebar
|
|
|
|
|
// and Changes/Files in a right sidebar, instead of phone sheets/surfaces.
|
|
|
|
|
const isIPad = React.useMemo(() => isIPadApp(), []);
|
|
|
|
|
const orientation = useOrientation();
|
|
|
|
|
const isPortrait = orientation === 'portrait';
|
|
|
|
|
const [ipadSidebarOpen, setIpadSidebarOpen] = React.useState(isIPad && !isPortrait);
|
|
|
|
|
const [ipadRightPanel, setIpadRightPanel] = React.useState<'files' | 'changes' | null>(null);
|
|
|
|
|
|
|
|
|
|
const toggleIpadSidebar = React.useCallback(() => {
|
|
|
|
|
const willOpen = !ipadSidebarOpen;
|
|
|
|
|
// Portrait doesn't fit both side panels next to a usable chat column:
|
|
|
|
|
// opening one closes the other (iPadOS behaves the same way).
|
|
|
|
|
if (willOpen && isPortrait) setIpadRightPanel(null);
|
|
|
|
|
setIpadSidebarOpen(willOpen);
|
|
|
|
|
}, [ipadSidebarOpen, isPortrait]);
|
|
|
|
|
|
|
|
|
|
const openFilesSurface = React.useCallback(() => {
|
|
|
|
|
if (isIPad) {
|
|
|
|
|
setPendingChangesDiff(null);
|
|
|
|
|
setIpadRightPanel('files');
|
|
|
|
|
if (isPortrait) setIpadSidebarOpen(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setFilesOpen(true);
|
|
|
|
|
}, [isIPad, isPortrait]);
|
|
|
|
|
|
|
|
|
|
const openChangesSurface = React.useCallback((diff: { path: string; staged: boolean } | null = null) => {
|
|
|
|
|
setPendingChangesDiff(diff);
|
|
|
|
|
if (isIPad) {
|
|
|
|
|
setIpadRightPanel('changes');
|
|
|
|
|
if (isPortrait) setIpadSidebarOpen(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setChangesOpen(true);
|
|
|
|
|
}, [isIPad, isPortrait]);
|
|
|
|
|
|
|
|
|
|
const closeIpadRightPanel = React.useCallback(() => {
|
|
|
|
|
setIpadRightPanel(null);
|
|
|
|
|
setPendingChangesDiff(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const toggleIpadRightPanel = React.useCallback((panel: 'files' | 'changes') => {
|
|
|
|
|
if (ipadRightPanel === panel) {
|
|
|
|
|
closeIpadRightPanel();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (panel === 'files') openFilesSurface();
|
|
|
|
|
else openChangesSurface();
|
|
|
|
|
}, [closeIpadRightPanel, ipadRightPanel, openChangesSurface, openFilesSurface]);
|
|
|
|
|
|
|
|
|
|
// Keep the right panel's content mounted through the width-collapse
|
|
|
|
|
// animation; drop it once the panel is fully closed.
|
|
|
|
|
const lastIpadRightPanelRef = React.useRef<'files' | 'changes'>('changes');
|
|
|
|
|
if (ipadRightPanel) lastIpadRightPanelRef.current = ipadRightPanel;
|
|
|
|
|
const [ipadRightContentMounted, setIpadRightContentMounted] = React.useState(false);
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isIPad) return;
|
|
|
|
|
if (ipadRightPanel) {
|
|
|
|
|
setIpadRightContentMounted(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const id = window.setTimeout(() => setIpadRightContentMounted(false), 240);
|
|
|
|
|
return () => window.clearTimeout(id);
|
|
|
|
|
}, [ipadRightPanel, isIPad]);
|
|
|
|
|
const renderedIpadRightPanel = ipadRightPanel ?? lastIpadRightPanelRef.current;
|
|
|
|
|
|
|
|
|
|
const leftResize = useIpadSidebarResize('left', 'openchamber.ipad.leftSidebarWidth', IPAD_LEFT_SIDEBAR_WIDTH);
|
|
|
|
|
const rightResize = useIpadSidebarResize('right', 'openchamber.ipad.rightSidebarWidth', IPAD_RIGHT_SIDEBAR_WIDTH);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const mobileActions = React.useMemo<MobileAppActions>(
|
|
|
|
|
() => ({
|
|
|
|
|
openChanges: ({ diffPath, staged } = {}) => {
|
2026-07-08 21:52:18 +03:00
|
|
|
openChangesSurface(diffPath ? { path: diffPath, staged: staged === true } : null);
|
2026-06-02 00:43:05 +03:00
|
|
|
},
|
2026-07-08 21:52:18 +03:00
|
|
|
openFiles: () => openFilesSurface(),
|
2026-06-10 12:00:10 +03:00
|
|
|
openSettings: () => {
|
|
|
|
|
setSettingsInitialMobileStage('nav');
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
},
|
2026-06-02 00:43:05 +03:00
|
|
|
}),
|
2026-07-08 21:52:18 +03:00
|
|
|
[openChangesSurface, openFilesSurface],
|
2026-06-02 00:43:05 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const closeChanges = React.useCallback(() => {
|
|
|
|
|
setChangesOpen(false);
|
|
|
|
|
setPendingChangesDiff(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
// 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(
|
|
|
|
|
() => ({
|
2026-07-08 21:52:18 +03:00
|
|
|
openSessions: () => {
|
|
|
|
|
if (isIPad) setIpadSidebarOpen(true);
|
|
|
|
|
else setSessionsSheetOpen(true);
|
|
|
|
|
},
|
2026-07-01 09:55:41 +03:00
|
|
|
openView: (target: 'files' | 'mcp' | 'instances' | 'update') => {
|
2026-07-08 21:52:18 +03:00
|
|
|
if (target === 'files') openFilesSurface();
|
2026-07-01 09:55:41 +03:00
|
|
|
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 } = {}) => {
|
2026-07-08 21:52:18 +03:00
|
|
|
openChangesSurface(path ? { path, staged: staged === true } : null);
|
2026-07-01 09:55:41 +03:00
|
|
|
},
|
|
|
|
|
openSettings: (section?: string) => {
|
|
|
|
|
if (section) setSettingsPage(section as Parameters<typeof setSettingsPage>[0]);
|
|
|
|
|
setSettingsInitialMobileStage(section ? 'page-content' : 'nav');
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
},
|
|
|
|
|
}),
|
2026-07-08 21:52:18 +03:00
|
|
|
[isIPad, openChangesSurface, openFilesSurface, setSettingsPage],
|
2026-07-01 09:55:41 +03:00
|
|
|
);
|
|
|
|
|
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<HTMLElement>(null);
|
|
|
|
|
const chatAnimRef = React.useRef<HTMLDivElement>(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);
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
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]);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
const overflowItems: OverflowItem[] = React.useMemo(
|
2026-07-01 09:55:41 +03:00
|
|
|
() => {
|
2026-07-08 21:52:18 +03:00
|
|
|
const items: OverflowItem[] = [];
|
|
|
|
|
// iPad exposes Files/Changes as header shortcuts instead of menu items.
|
|
|
|
|
if (!isIPad) {
|
|
|
|
|
items.push(
|
|
|
|
|
{
|
|
|
|
|
key: 'files',
|
|
|
|
|
icon: 'file-text',
|
|
|
|
|
label: t('mobile.menu.files'),
|
|
|
|
|
onSelect: () => openFilesSurface(),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'changes',
|
|
|
|
|
icon: 'git-branch',
|
|
|
|
|
label: t('mobile.menu.changes'),
|
|
|
|
|
badge: dirtyChangeCount,
|
|
|
|
|
onSelect: () => openChangesSurface(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
items.push({
|
|
|
|
|
key: 'terminal',
|
|
|
|
|
icon: 'terminal',
|
|
|
|
|
label: t('mobile.menu.terminal'),
|
|
|
|
|
onSelect: () => setTerminalOpen(true),
|
|
|
|
|
});
|
2026-07-08 21:52:18 +03:00
|
|
|
items.push({
|
2026-06-10 12:00:10 +03:00
|
|
|
key: 'mcp',
|
|
|
|
|
iconNode: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
|
|
|
|
|
label: t('mobile.menu.mcp'),
|
|
|
|
|
onSelect: () => setMcpOpen(true),
|
2026-07-08 21:52:18 +03:00
|
|
|
});
|
2026-07-01 09:55:41 +03:00
|
|
|
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({
|
2026-06-02 00:43:05 +03:00
|
|
|
key: 'settings',
|
2026-06-10 12:00:10 +03:00
|
|
|
icon: 'settings-3',
|
2026-06-02 00:43:05 +03:00
|
|
|
label: t('mobile.menu.settings'),
|
2026-06-10 12:00:10 +03:00
|
|
|
onSelect: () => {
|
|
|
|
|
setSettingsInitialMobileStage('nav');
|
|
|
|
|
setSettingsOpen(true);
|
|
|
|
|
},
|
2026-07-01 09:55:41 +03:00
|
|
|
});
|
|
|
|
|
return items;
|
|
|
|
|
},
|
2026-07-08 21:52:18 +03:00
|
|
|
[dirtyChangeCount, isIPad, openChangesSurface, openFilesSurface, showCapacitorOnlyFeatures, showUpdateItem, t],
|
2026-06-02 00:43:05 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DedicatedMobileAppProvider actions={mobileActions}>
|
|
|
|
|
<div
|
2026-07-08 21:52:18 +03:00
|
|
|
className="oc-mobile-app-shell main-content-safe-area flex h-[100dvh] flex-row bg-background text-foreground"
|
2026-06-02 00:43:05 +03:00
|
|
|
data-page-scroll-lock="true"
|
|
|
|
|
>
|
2026-07-08 21:52:18 +03:00
|
|
|
{/* iPad: persistent full-height sessions sidebar; the chat column and
|
|
|
|
|
its header butt against it (iPadOS-style split layout). Always
|
|
|
|
|
mounted so open/close animates width, same as the desktop Sidebar. */}
|
|
|
|
|
{isIPad ? (
|
|
|
|
|
<aside
|
|
|
|
|
ref={leftResize.asideRef}
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/50 bg-sidebar will-change-[width] motion-reduce:transition-none',
|
|
|
|
|
!ipadSidebarOpen && 'border-r-0',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
width: ipadSidebarOpen ? leftResize.width : 0,
|
|
|
|
|
minWidth: ipadSidebarOpen ? leftResize.width : 0,
|
|
|
|
|
maxWidth: ipadSidebarOpen ? leftResize.width : 0,
|
|
|
|
|
['--oc-ipad-sidebar-width' as string]: `${leftResize.width}px`,
|
|
|
|
|
overflowX: 'clip',
|
|
|
|
|
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
|
|
|
|
transitionProperty: leftResize.isResizing ? 'none' : 'width, min-width, max-width',
|
|
|
|
|
transitionDuration: '200ms',
|
|
|
|
|
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
|
|
|
}}
|
|
|
|
|
aria-hidden={!ipadSidebarOpen}
|
|
|
|
|
data-page-scroll-lock="true"
|
|
|
|
|
>
|
|
|
|
|
{ipadSidebarOpen ? (
|
|
|
|
|
<IpadSidebarResizeHandle
|
|
|
|
|
side="left"
|
|
|
|
|
isResizing={leftResize.isResizing}
|
|
|
|
|
ariaLabel={t('sidebar.resize.leftPanelAria')}
|
|
|
|
|
handleProps={leftResize.handleProps}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
|
|
|
|
leftResize.isResizing && 'pointer-events-none',
|
|
|
|
|
!ipadSidebarOpen && 'pointer-events-none select-none opacity-0',
|
|
|
|
|
)}
|
|
|
|
|
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<MobileSessionsSheet
|
|
|
|
|
open
|
|
|
|
|
variant="sidebar"
|
|
|
|
|
// The surface asks to close after picking a session/project or
|
|
|
|
|
// creating a worktree; the persistent landscape sidebar stays
|
|
|
|
|
// put, portrait gives the space back to the chat.
|
|
|
|
|
onOpenChange={(value) => {
|
|
|
|
|
if (!value && isPortrait) setIpadSidebarOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="flex h-full min-w-0 flex-1 flex-col" data-page-scroll-lock="true">
|
|
|
|
|
<MobileHeader
|
|
|
|
|
onOpenSessions={() => (isIPad ? toggleIpadSidebar() : setSessionsSheetOpen(true))}
|
|
|
|
|
onOpenMenu={() => setOverflowOpen(true)}
|
|
|
|
|
surfaceShortcuts={isIPad ? {
|
|
|
|
|
activePanel: ipadRightPanel,
|
|
|
|
|
changesDirty: dirtyChangeCount > 0,
|
|
|
|
|
onToggleFiles: () => toggleIpadRightPanel('files'),
|
|
|
|
|
onToggleChanges: () => toggleIpadRightPanel('changes'),
|
|
|
|
|
} : undefined}
|
|
|
|
|
/>
|
|
|
|
|
<main ref={chatMainRef} className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
|
|
|
|
<div ref={chatAnimRef} className="h-full w-full">
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<ChatView />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* iPad: Changes/Files live in a full-height right sidebar instead of
|
|
|
|
|
the phone's fullscreen surfaces. Width animates like the desktop
|
|
|
|
|
RightSidebar; content stays mounted through the collapse. */}
|
|
|
|
|
{isIPad ? (
|
|
|
|
|
<aside
|
|
|
|
|
ref={rightResize.asideRef}
|
|
|
|
|
className={cn(
|
|
|
|
|
'relative flex h-full shrink-0 flex-col overflow-hidden border-l border-border/50 bg-background will-change-[width] motion-reduce:transition-none',
|
|
|
|
|
!ipadRightPanel && 'border-l-0',
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
width: ipadRightPanel ? rightResize.width : 0,
|
|
|
|
|
minWidth: ipadRightPanel ? rightResize.width : 0,
|
|
|
|
|
maxWidth: ipadRightPanel ? rightResize.width : 0,
|
|
|
|
|
['--oc-ipad-sidebar-width' as string]: `${rightResize.width}px`,
|
|
|
|
|
overflowX: 'clip',
|
|
|
|
|
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
|
|
|
|
transitionProperty: rightResize.isResizing ? 'none' : 'width, min-width, max-width',
|
|
|
|
|
transitionDuration: '200ms',
|
|
|
|
|
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
|
|
|
}}
|
|
|
|
|
aria-hidden={!ipadRightPanel}
|
|
|
|
|
data-page-scroll-lock="true"
|
|
|
|
|
>
|
|
|
|
|
{ipadRightPanel ? (
|
|
|
|
|
<IpadSidebarResizeHandle
|
|
|
|
|
side="right"
|
|
|
|
|
isResizing={rightResize.isResizing}
|
|
|
|
|
ariaLabel={t('sidebar.resize.rightPanelAria')}
|
|
|
|
|
handleProps={rightResize.handleProps}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
|
|
|
|
rightResize.isResizing && 'pointer-events-none',
|
|
|
|
|
!ipadRightPanel && 'pointer-events-none select-none opacity-0',
|
|
|
|
|
)}
|
|
|
|
|
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
|
|
|
|
>
|
|
|
|
|
{ipadRightContentMounted ? (
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
{renderedIpadRightPanel === 'files' ? (
|
|
|
|
|
<MobileFilesSurface onClose={closeIpadRightPanel} />
|
|
|
|
|
) : (
|
|
|
|
|
<MobileChangesSurface
|
|
|
|
|
onClose={closeIpadRightPanel}
|
|
|
|
|
initialDiffPath={pendingChangesDiff?.path ?? null}
|
|
|
|
|
initialDiffStaged={pendingChangesDiff?.staged === true}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
) : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
<MobileOverflowMenu
|
|
|
|
|
open={overflowOpen}
|
|
|
|
|
onClose={() => setOverflowOpen(false)}
|
|
|
|
|
items={overflowItems}
|
2026-07-08 21:52:18 +03:00
|
|
|
rightOffset={isIPad && ipadRightPanel ? rightResize.width : 0}
|
2026-06-02 00:43:05 +03:00
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{sessionsSheetOpen ? (
|
|
|
|
|
<MobileSessionsSheet open={sessionsSheetOpen} onOpenChange={setSessionsSheetOpen} />
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-02 19:00:14 +03:00
|
|
|
{/* 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 ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setFilesOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.files')}
|
|
|
|
|
headerless
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<MobileFilesSurface onClose={() => setFilesOpen(false)} />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
2026-06-02 19:00:14 +03:00
|
|
|
{changesOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={closeChanges}
|
|
|
|
|
ariaLabel={t('mobile.menu.changes')}
|
|
|
|
|
headerless
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<MobileChangesSurface
|
|
|
|
|
onClose={closeChanges}
|
|
|
|
|
initialDiffPath={pendingChangesDiff?.path ?? null}
|
|
|
|
|
initialDiffStaged={pendingChangesDiff?.staged === true}
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
{terminalOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setTerminalOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.terminal')}
|
|
|
|
|
title={t('mobile.menu.terminal')}
|
|
|
|
|
disableSwipeDismiss
|
|
|
|
|
disableEscapeDismiss
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<TerminalView visible />
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-10 12:00:10 +03:00
|
|
|
{mcpOpen ? (
|
|
|
|
|
<MobileOverlayPanel
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setMcpOpen(false)}
|
|
|
|
|
title={t('mcpDropdown.title')}
|
|
|
|
|
className="h-[72vh]"
|
|
|
|
|
contentMaxHeightClassName="max-h-full"
|
|
|
|
|
renderHeader={(closeButton) => (
|
|
|
|
|
<div className="shrink-0">
|
|
|
|
|
<div className="flex justify-center pt-2.5 pb-1">
|
|
|
|
|
<div className="h-1 w-9 rounded-full bg-[color-mix(in_srgb,var(--surface-mutedForeground)_40%,transparent)]" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center justify-between gap-2 px-4 pb-2">
|
|
|
|
|
<h2 className="text-[16px] font-semibold text-[var(--surface-foreground)]">
|
|
|
|
|
{t('mcpDropdown.title')}
|
|
|
|
|
</h2>
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
|
|
|
|
|
onClick={openMcpCreateSettings}
|
|
|
|
|
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
|
|
|
|
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="add" className="h-5 w-5" />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="flex size-8 items-center justify-center rounded-full text-[var(--surface-mutedForeground)] transition-colors hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)] disabled:opacity-60"
|
|
|
|
|
onClick={refreshMcpOverlay}
|
|
|
|
|
disabled={isMcpRefreshing}
|
|
|
|
|
aria-label={t('mcpDropdown.actions.refreshAria')}
|
|
|
|
|
title={t('mcpDropdown.actions.refreshAria')}
|
|
|
|
|
style={{ touchAction: 'manipulation' }}
|
|
|
|
|
>
|
|
|
|
|
<Icon name="refresh" className={cn('h-5 w-5', isMcpRefreshing && 'animate-spin')} />
|
|
|
|
|
</button>
|
|
|
|
|
{closeButton}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<McpDropdownContent
|
|
|
|
|
active
|
|
|
|
|
className="h-full"
|
|
|
|
|
listClassName="max-h-none"
|
|
|
|
|
hideHeader
|
|
|
|
|
mobileListDensity
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileOverlayPanel>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
{instancesOpen && showCapacitorOnlyFeatures ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setInstancesOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.instances')}
|
|
|
|
|
title={t('mobile.menu.instances')}
|
|
|
|
|
>
|
|
|
|
|
<MobileInstancesSurface
|
|
|
|
|
onConnect={() => setInstancesOpen(false)}
|
|
|
|
|
onActiveConnectionDeleted={onActiveConnectionDeleted}
|
|
|
|
|
/>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-06-02 19:00:14 +03:00
|
|
|
{settingsOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.settings')}
|
|
|
|
|
headerless
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<SettingsView
|
|
|
|
|
forceMobile
|
|
|
|
|
isWindowed
|
2026-06-10 12:00:10 +03:00
|
|
|
initialMobileStage={settingsInitialMobileStage}
|
2026-06-02 19:00:14 +03:00
|
|
|
visiblePageSlugs={[...MOBILE_SETTINGS_PAGES]}
|
|
|
|
|
onClose={() => setSettingsOpen(false)}
|
|
|
|
|
/>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-10 12:00:10 +03:00
|
|
|
|
|
|
|
|
{updateOpen ? (
|
|
|
|
|
<MobileSurfaceShell
|
|
|
|
|
open
|
|
|
|
|
onClose={() => setUpdateOpen(false)}
|
|
|
|
|
ariaLabel={t('mobile.menu.update')}
|
|
|
|
|
title={t('mobile.menu.update')}
|
|
|
|
|
>
|
|
|
|
|
<ErrorBoundary>
|
|
|
|
|
<div className="h-full overflow-auto px-5 py-4">
|
|
|
|
|
<AboutSettings initialUpdateDialogOpen />
|
|
|
|
|
</div>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
</MobileSurfaceShell>
|
|
|
|
|
) : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
</div>
|
|
|
|
|
</DedicatedMobileAppProvider>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function MobileApp({ apis }: MobileAppProps) {
|
2026-07-01 09:55:41 +03:00
|
|
|
const { t } = useI18n();
|
2026-06-02 00:43:05 +03:00
|
|
|
const initializeApp = useConfigStore((state) => state.initializeApp);
|
|
|
|
|
const isInitialized = useConfigStore((state) => state.isInitialized);
|
|
|
|
|
const isConnected = useConfigStore((state) => state.isConnected);
|
2026-07-01 09:55:41 +03:00
|
|
|
const connectionPhase = useConfigStore((state) => state.connectionPhase);
|
2026-06-02 00:43:05 +03:00
|
|
|
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);
|
2026-07-01 09:55:41 +03:00
|
|
|
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');
|
2026-07-13 01:29:35 +03:00
|
|
|
// The instance the splash says we are connecting to. Read once on mount —
|
|
|
|
|
// auto-connect targets the most-recent saved connection from the same list.
|
|
|
|
|
const autoConnectLabel = React.useMemo(() => getAutoConnectTargetLabel(), []);
|
2026-07-10 00:12:33 +03:00
|
|
|
// Bumped to force a re-render (and thus a fresh `sdk` prop for SyncProvider)
|
|
|
|
|
// after a same-device transport swap — reconnects the sync layer in place with
|
|
|
|
|
// no remount. The value itself is unused; only the re-render matters.
|
|
|
|
|
const [, bumpTransportSwitch] = React.useReducer((count: number) => count + 1, 0);
|
2026-07-01 09:55:41 +03:00
|
|
|
const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []);
|
2026-07-01 18:32:16 +03:00
|
|
|
const lastNativeResumeSyncEventAtRef = React.useRef(0);
|
2026-07-05 23:51:11 +03:00
|
|
|
const nativeResumeValidationSeqRef = React.useRef(0);
|
2026-07-01 09:55:41 +03:00
|
|
|
|
|
|
|
|
const handleNativeResume = React.useCallback(() => {
|
2026-07-05 23:51:11 +03:00
|
|
|
const apiBaseUrl = getRuntimeApiBaseUrl();
|
|
|
|
|
const validationSeq = nativeResumeValidationSeqRef.current + 1;
|
|
|
|
|
nativeResumeValidationSeqRef.current = validationSeq;
|
|
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
if (!apiBaseUrl) {
|
|
|
|
|
// Already disconnected — e.g. a previous re-probe ran mid network flux
|
|
|
|
|
// (Android Wi-Fi switch with no cellular fallback) and found nothing
|
|
|
|
|
// reachable. When a resume/online signal arrives, silently retry the last
|
|
|
|
|
// saved instance instead of dead-ending on the connect screen until the
|
|
|
|
|
// user restarts the app. Success fires runtime-endpoint-changed, which
|
|
|
|
|
// re-bootstraps everything.
|
|
|
|
|
void autoConnectLastInstance();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-05 23:51:11 +03:00
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
// Re-probe the active device's transports on resume: the network may have
|
|
|
|
|
// changed while the app slept, so hot-switch LAN⇄relay if a better transport
|
|
|
|
|
// is now reachable — no re-pairing. A 'switched' outcome already fired the
|
|
|
|
|
// runtime-endpoint-changed subscription (which re-bootstraps the app), so we
|
|
|
|
|
// only refresh in place when the transport is 'unchanged'.
|
|
|
|
|
const refreshInPlace = () => {
|
2026-07-05 23:51:11 +03:00
|
|
|
void initializeApp();
|
|
|
|
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
|
|
|
|
if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' });
|
|
|
|
|
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' });
|
2026-07-10 00:12:33 +03:00
|
|
|
};
|
|
|
|
|
const disconnect = () => {
|
|
|
|
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
|
|
|
|
setConnectionEpoch((value) => value + 1);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void reprobeActiveConnection().then((outcome) => {
|
|
|
|
|
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
|
|
|
|
if (outcome === 'no-connection') {
|
|
|
|
|
disconnect();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (outcome === 'unreachable') {
|
|
|
|
|
// Right after a resume or Wi-Fi switch the network is often still
|
|
|
|
|
// settling (on Android without a SIM there is NO connectivity at all for
|
|
|
|
|
// a few seconds), so a single fast probe races the network coming up.
|
|
|
|
|
// Retry once after a grace period before tearing the connection down.
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
|
|
|
|
void reprobeActiveConnection().then((retry) => {
|
|
|
|
|
if (nativeResumeValidationSeqRef.current !== validationSeq) return;
|
|
|
|
|
if (retry === 'switched') return;
|
|
|
|
|
if (retry === 'unchanged') {
|
|
|
|
|
refreshInPlace();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
disconnect();
|
|
|
|
|
});
|
|
|
|
|
}, 4000);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (outcome === 'switched') return;
|
|
|
|
|
|
|
|
|
|
refreshInPlace();
|
2026-07-05 23:51:11 +03:00
|
|
|
});
|
|
|
|
|
|
2026-07-01 18:32:16 +03:00
|
|
|
const now = Date.now();
|
|
|
|
|
if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) {
|
|
|
|
|
lastNativeResumeSyncEventAtRef.current = now;
|
|
|
|
|
window.dispatchEvent(new Event('openchamber:system-resume'));
|
|
|
|
|
}
|
2026-07-01 09:55:41 +03:00
|
|
|
}, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]);
|
|
|
|
|
|
|
|
|
|
useNativeMobileChrome();
|
|
|
|
|
useNativeMobileLifecycle(handleNativeResume);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
2026-07-10 00:12:33 +03:00
|
|
|
// Network-change re-probe. The resume hook only fires on background→foreground,
|
|
|
|
|
// but on Android switching Wi-Fi (quick-settings tile) does NOT background the
|
|
|
|
|
// app — no visibility/appState event ever fires, so the app would sit on a dead
|
|
|
|
|
// LAN transport instead of hot-switching to relay. The webview's `online` event
|
|
|
|
|
// fires on connectivity changes (new Wi-Fi, cellular back, airplane off), so
|
|
|
|
|
// run the same re-probe then. Debounced: the first seconds after `online` the
|
|
|
|
|
// route is often not usable yet, and rapid offline/online flaps must collapse
|
|
|
|
|
// into one probe. iOS also gets this (harmless — same seq-guarded operation the
|
|
|
|
|
// resume path runs; a concurrent duplicate supersedes via the seq ref).
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isNativeMobileApp) return;
|
|
|
|
|
let timer: number | undefined;
|
|
|
|
|
const handleOnline = () => {
|
|
|
|
|
window.clearTimeout(timer);
|
|
|
|
|
timer = window.setTimeout(() => handleNativeResume(), 1500);
|
|
|
|
|
};
|
|
|
|
|
window.addEventListener('online', handleOnline);
|
|
|
|
|
return () => {
|
|
|
|
|
window.removeEventListener('online', handleOnline);
|
|
|
|
|
window.clearTimeout(timer);
|
|
|
|
|
};
|
|
|
|
|
}, [isNativeMobileApp, handleNativeResume]);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
registerRuntimeAPIs(apis);
|
|
|
|
|
return () => registerRuntimeAPIs(null);
|
|
|
|
|
}, [apis]);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
// 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) => {
|
2026-07-10 00:12:33 +03:00
|
|
|
// A LAN⇄relay swap for the SAME device keeps the runtime key stable. Treat
|
|
|
|
|
// that as a transport-only change: rebind the sync layer to the new
|
|
|
|
|
// transport but keep the user's session/connection state — no reconnecting
|
|
|
|
|
// screen, no bounce back to the draft. Only a real instance switch (key
|
|
|
|
|
// change) does the full reset.
|
|
|
|
|
const sameDevice = Boolean(detail.runtimeKey) && detail.runtimeKey === detail.previousRuntimeKey;
|
|
|
|
|
if (sameDevice) {
|
|
|
|
|
// Transport-only swap for the same device: rebind the SDK to the new
|
|
|
|
|
// transport and force a re-render so SyncProvider receives the new `sdk`
|
|
|
|
|
// prop. Its event-pipeline + bootstrap effects (keyed on `sdk`) then
|
|
|
|
|
// reconnect over the new transport WITHOUT remounting — so the message
|
|
|
|
|
// pagination refs, the open session, and the whole view are preserved.
|
|
|
|
|
// No key bump, no flash, no bounce to the draft.
|
|
|
|
|
reconnectAppForTransportSwitch();
|
|
|
|
|
bumpTransportSwitch();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-01 09:55:41 +03:00
|
|
|
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
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
setIsMobile(true);
|
|
|
|
|
}, [setIsMobile]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-07-10 01:44:42 +03:00
|
|
|
// Never bootstrap without a runtime endpoint on native: with apiBaseUrl ''
|
|
|
|
|
// the resolver falls back to the webview's own origin, where Capacitor's
|
|
|
|
|
// static server answers every request with index.html — the bootstrap
|
|
|
|
|
// "succeeds" against a fake backend and flips isConnected back on, leaving
|
|
|
|
|
// the user in an empty shell after a disconnect.
|
|
|
|
|
if (isNativeMobileApp && !getRuntimeApiBaseUrl()) return;
|
2026-06-02 00:43:05 +03:00
|
|
|
void initializeApp();
|
2026-07-10 01:44:42 +03:00
|
|
|
}, [connectionEpoch, initializeApp, isNativeMobileApp]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isConnected) return;
|
2026-06-05 15:09:24 +03:00
|
|
|
if (providersCount === 0) void loadProviders({ source: 'mobileApp:recovery' });
|
|
|
|
|
if (agentsCount === 0) void loadAgents({ source: 'mobileApp:recovery' });
|
2026-06-02 00:43:05 +03:00
|
|
|
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isConnected) return;
|
|
|
|
|
opencodeClient.setDirectory(currentDirectory);
|
|
|
|
|
}, [currentDirectory, isConnected]);
|
|
|
|
|
|
2026-07-05 00:05:38 +03:00
|
|
|
// 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.
|
2026-06-02 00:43:05 +03:00
|
|
|
React.useEffect(() => {
|
2026-07-05 00:05:38 +03:00
|
|
|
if (!isConnected) return;
|
2026-06-02 00:43:05 +03:00
|
|
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
2026-07-05 00:05:38 +03:00
|
|
|
}, [apis.github, isConnected, refreshGitHubAuthStatus]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
// 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.
|
2026-07-05 00:05:38 +03:00
|
|
|
// 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).
|
2026-06-02 00:43:05 +03:00
|
|
|
React.useEffect(() => {
|
2026-07-05 00:05:38 +03:00
|
|
|
if (!isConnected || projects.length === 0) return;
|
2026-06-02 00:43:05 +03:00
|
|
|
let cancelled = false;
|
|
|
|
|
|
|
|
|
|
const run = async () => {
|
2026-07-05 00:05:38 +03:00
|
|
|
const worktreesByProject = new Map(useSessionUIStore.getState().availableWorktreesByProject);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
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 });
|
2026-07-05 00:05:38 +03:00
|
|
|
if (cancelled) return;
|
2026-06-02 00:43:05 +03:00
|
|
|
worktreesByProject.set(projectPath, worktrees);
|
|
|
|
|
} catch {
|
2026-07-05 00:05:38 +03:00
|
|
|
// Worktree discovery is best-effort per project: a failed probe keeps
|
|
|
|
|
// that project's previously known (persisted) worktrees instead of
|
|
|
|
|
// wiping the whole map.
|
2026-06-02 00:43:05 +03:00
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (cancelled) return;
|
2026-07-12 00:06:58 +11:00
|
|
|
|
2026-07-05 00:05:38 +03:00
|
|
|
const allWorktrees = Array.from(worktreesByProject.values()).flat();
|
2026-07-12 00:06:58 +11:00
|
|
|
|
|
|
|
|
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
|
|
|
|
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
|
|
|
|
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
|
|
|
|
useSessionUIStore.setState({
|
|
|
|
|
availableWorktrees: allWorktrees,
|
|
|
|
|
availableWorktreesByProject: worktreesByProject,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
void run();
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
cancelled = true;
|
|
|
|
|
};
|
2026-07-05 00:05:38 +03:00
|
|
|
}, [isConnected, projects]);
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-07-01 09:55:41 +03:00
|
|
|
React.useEffect(() => {
|
2026-07-05 03:08:18 +03:00
|
|
|
// Native: only while an instance is selected and reconnecting. Browser: the
|
|
|
|
|
// runtime is same-origin (no explicit base URL), so any not-connected spell
|
|
|
|
|
// counts — the splash holds until this fires, then the error screen shows.
|
|
|
|
|
const waitingOnConnection = !isConnected && (isNativeMobileApp ? Boolean(getRuntimeApiBaseUrl()) : true);
|
|
|
|
|
if (!waitingOnConnection) {
|
2026-07-01 09:55:41 +03:00
|
|
|
setShowConnectionRecovery(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const timeout = window.setTimeout(() => setShowConnectionRecovery(true), 8000);
|
|
|
|
|
return () => window.clearTimeout(timeout);
|
|
|
|
|
}, [isConnected, isNativeMobileApp, connectionEpoch, runtimeEndpointEpoch]);
|
|
|
|
|
|
2026-06-02 00:43:05 +03:00
|
|
|
useAppFontEffects();
|
|
|
|
|
usePushVisibilityBeacon({ enabled: true });
|
2026-06-10 12:00:10 +03:00
|
|
|
useUpdatePolling();
|
2026-06-02 00:43:05 +03:00
|
|
|
useWindowTitle();
|
|
|
|
|
useRouter();
|
2026-07-01 09:55:41 +03:00
|
|
|
// 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 (
|
|
|
|
|
<main className="flex min-h-dvh items-center justify-center bg-background text-foreground">
|
2026-07-05 03:08:18 +03:00
|
|
|
<OpenChamberLogo width={120} height={120} isAnimated />
|
2026-07-01 09:55:41 +03:00
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-10 01:44:42 +03:00
|
|
|
// No runtime endpoint on native = explicitly disconnected (last instance
|
|
|
|
|
// deleted, revoked token, unreachable). The connect screen is the only valid
|
|
|
|
|
// UI then — regardless of what a stale isConnected flag claims (the store can
|
|
|
|
|
// be poisoned by a bootstrap that ran against the webview's own origin).
|
|
|
|
|
const hasRuntimeEndpoint = Boolean(getRuntimeApiBaseUrl());
|
|
|
|
|
|
|
|
|
|
if (isNativeMobileApp && (!hasRuntimeEndpoint || (!isConnected && !isReconnecting))) {
|
2026-07-01 09:55:41 +03:00
|
|
|
// A runtime endpoint is already selected (first connect or switching instances):
|
|
|
|
|
// show a loader while it re-bootstraps instead of flashing the onboarding screen.
|
2026-07-10 01:44:42 +03:00
|
|
|
if (hasRuntimeEndpoint) {
|
2026-07-01 09:55:41 +03:00
|
|
|
return (
|
|
|
|
|
<main className="flex min-h-dvh items-center justify-center bg-background px-6 text-center text-foreground">
|
|
|
|
|
<div className="flex max-w-sm flex-col items-center gap-4">
|
2026-07-05 03:08:18 +03:00
|
|
|
<OpenChamberLogo width={120} height={120} isAnimated={!showConnectionRecovery} />
|
2026-07-01 09:55:41 +03:00
|
|
|
{showConnectionRecovery ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<h1 className="typography-h3 text-foreground">{t('sessionAuth.error.networkTitle')}</h1>
|
|
|
|
|
<p className="typography-body text-muted-foreground">{t('sessionAuth.error.networkDescription')}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
|
|
|
|
setConnectionEpoch((value) => value + 1);
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{t('mobile.connect.cancelPassword')}
|
|
|
|
|
</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// 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 (
|
2026-07-13 01:29:35 +03:00
|
|
|
<main className="relative flex min-h-dvh items-center justify-center bg-background text-foreground">
|
2026-07-05 03:08:18 +03:00
|
|
|
<OpenChamberLogo width={120} height={120} isAnimated />
|
2026-07-13 01:29:35 +03:00
|
|
|
{/* Absolutely positioned below the (still perfectly centered) logo so
|
|
|
|
|
the text never pushes it up. 50% + half the 120px logo + a gap. */}
|
|
|
|
|
{autoConnectLabel ? (
|
|
|
|
|
<div className="absolute inset-x-0 top-[calc(50%+84px)] flex flex-col items-center gap-0.5 px-6 text-center">
|
|
|
|
|
<p className="typography-small text-muted-foreground">{t('mobile.connect.splash.connectingTo')}</p>
|
|
|
|
|
<p className="typography-small text-foreground">
|
|
|
|
|
{autoConnectLabel}
|
|
|
|
|
<BusyDots />
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-07-01 09:55:41 +03:00
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return <MobileConnectionWelcome onConnected={() => setConnectionEpoch((value) => value + 1)} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isConnected && !isReconnecting) {
|
2026-07-05 03:08:18 +03:00
|
|
|
// Browser: the initial connect takes a beat — hold the logo splash instead
|
|
|
|
|
// of flashing the unreachable-server error while it resolves. The error
|
|
|
|
|
// only shows once the recovery delay has expired (genuinely unreachable).
|
|
|
|
|
if (!showConnectionRecovery) {
|
|
|
|
|
return (
|
|
|
|
|
<main className="flex min-h-dvh items-center justify-center bg-background text-foreground">
|
|
|
|
|
<OpenChamberLogo width={120} height={120} isAnimated />
|
|
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-01 09:55:41 +03:00
|
|
|
return (
|
|
|
|
|
<main className="flex min-h-dvh items-center justify-center bg-background px-6 text-center text-foreground">
|
|
|
|
|
<div className="max-w-sm space-y-3">
|
|
|
|
|
<h1 className="typography-h3 text-foreground">{t('sessionAuth.error.networkTitle')}</h1>
|
|
|
|
|
<p className="typography-body text-muted-foreground">{t('sessionAuth.error.networkDescription')}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-02 00:43:05 +03:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<ErrorBoundary>
|
2026-07-01 09:55:41 +03:00
|
|
|
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
2026-06-02 00:43:05 +03:00
|
|
|
<RuntimeAPIProvider apis={apis}>
|
|
|
|
|
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
|
|
|
|
<div className="h-full bg-background text-foreground">
|
|
|
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={isInitialized} />
|
2026-06-10 12:00:10 +03:00
|
|
|
<OpenCodeUpdateToast />
|
2026-07-07 13:49:26 +03:00
|
|
|
<MobileAppUpdateToast />
|
2026-07-01 09:55:41 +03:00
|
|
|
<MobileShell onActiveConnectionDeleted={() => {
|
|
|
|
|
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
|
|
|
|
|
setConnectionEpoch((value) => value + 1);
|
|
|
|
|
}} />
|
2026-07-01 20:13:53 +03:00
|
|
|
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
|
2026-06-10 15:55:54 +03:00
|
|
|
{isInitialized ? <ConfigUpdateOverlay /> : null}
|
2026-06-02 00:43:05 +03:00
|
|
|
</div>
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
</RuntimeAPIProvider>
|
|
|
|
|
</SyncProvider>
|
|
|
|
|
</ErrorBoundary>
|
|
|
|
|
);
|
|
|
|
|
}
|