From 86ef96302dda773f1f5de839c1a92568ec0046d3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 1 Aug 2026 21:16:36 +0300 Subject: [PATCH] feat(mobile): mobile app navigation rework and beta-feedback closeout (#2561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Navigation model rebuilt around two full-width drawers and a minimal header (sessions / title-switcher / usage ring / workspace): - Left sessions drawer: cross-project tree with live status indicators, swipe actions on sessions (rename/archive/delete) and on group headers (project edit / two-step close, worktree delete), reorder-only edit mode with collapsible project cards and draggable worktrees, app-level footer (connected instance, settings, pending web update). - Right workspace drawer: Changes / Files / Terminal / Notes / MCP as pill tabs (inactive tabs icon-only); panes stay mounted once visited. The full desktop file editor serves the Files tab; read/skill tool taps in chat open the file there at the requested line. - Header session switcher on title tap: 10 cross-project recents with live busy/attention indicators and project · branch metadata; the usage ring opens a metadata overlay with an explicit loading state. - The overflow menu is gone on phones (its destinations moved into the drawers); iPad keeps it until its dedicated layout pass. Correctness and continuity: - /auth/session answers bearer-first, so a stale WebView cookie can no longer mask a revoked device token; cold launches classify failures fast and land on an explicit connect screen. - Authoritative session snapshots raise frozen ordering baselines and stale live ranks — recents stay truthful after the app slept. - Cold launches reopen the last active session per instance (persisted pointer, confirmed against a sessions snapshot; a user-opened draft clears it), with a logo hold instead of a draft flash. Also: collapsed pill composer gains the stop control; chat tool rows share one 36px rhythm; Task subtool rows truncate; larger bottom safe area so the composer clears big-screen corner radii; Capacitor build hides About/Update (store updates apply there); widgets link to the sessions drawer with a list icon; MobileApp split into focused modules; five mobile-surface detectors unified; translucent borders normalized to 70%; all new strings translated across the 10 locales. iPad and foldable layouts are intentionally untouched - separate next version PR. --- bun.lock | 8 +- .../OpenChamberWidgets.swift | 4 +- .../ui/src/apps/IpadSidebarResizeHandle.tsx | 29 + packages/ui/src/apps/MobileApp.tsx | 2645 +++-------------- packages/ui/src/apps/MobileChangesSurface.tsx | 6 +- .../ui/src/apps/MobileConnectionWelcome.tsx | 301 ++ .../src/apps/MobileDeleteWorktreeDialog.tsx | 2 +- packages/ui/src/apps/MobileFilesSurface.tsx | 297 +- .../ui/src/apps/MobileFullscreenSurface.tsx | 220 ++ packages/ui/src/apps/MobileHeader.tsx | 207 ++ .../ui/src/apps/MobileInstancesSurface.tsx | 362 +++ packages/ui/src/apps/MobileOverflowMenu.tsx | 75 + .../ui/src/apps/MobileProjectEditSurface.tsx | 18 +- .../ui/src/apps/MobileSessionMetadata.tsx | 575 ++++ .../ui/src/apps/MobileSessionSwitcher.tsx | 188 ++ packages/ui/src/apps/MobileSessionsSheet.tsx | 1256 ++++++-- packages/ui/src/apps/MobileSurfaceShell.tsx | 307 -- .../ui/src/apps/MobileWorkspaceDrawer.tsx | 274 ++ packages/ui/src/apps/deepLinkNavigation.ts | 8 +- packages/ui/src/apps/ipadSidebarResize.ts | 89 + packages/ui/src/apps/mobileConnectionUi.ts | 9 + packages/ui/src/apps/mobileConnections.ts | 50 +- packages/ui/src/apps/mobileNativeChrome.ts | 421 +++ packages/ui/src/apps/mobilePaths.ts | 16 + packages/ui/src/apps/renderMobileApp.tsx | 4 + packages/ui/src/apps/useEdgeSwipe.ts | 85 + .../ui/src/apps/useEdgeSwipeSessionSwitch.ts | 128 - .../ui/src/components/chat/ChatContainer.tsx | 4 +- packages/ui/src/components/chat/ChatInput.tsx | 10 +- .../chat/MobileSessionStatusBar.test.ts | 18 - .../chat/MobileSessionStatusBar.tsx | 611 ---- packages/ui/src/components/chat/StatusRow.tsx | 8 +- .../chat/composer/ui/ComposerFooter.tsx | 5 - .../chat/composer/ui/MobilePillComposer.tsx | 41 +- .../chat/message/parts/ProgressiveGroup.tsx | 24 +- .../chat/message/parts/ToolPart.tsx | 23 +- packages/ui/src/components/icon/sprite.ts | 4 +- .../ui/src/components/layout/MainLayout.tsx | 2 +- .../components/layout/RightSidebarTabs.tsx | 7 +- .../ui/src/components/layout/VSCodeLayout.tsx | 2 +- .../session/ProjectNotesTodoPanel.tsx | 10 +- .../session/sidebar/hooks/useSwitcherItems.ts | 41 +- .../src/components/ui/sortable-tabs-strip.tsx | 26 +- .../ui/src/components/views/FilesView.tsx | 15 +- packages/ui/src/components/views/PlanView.tsx | 8 +- .../ui/src/components/views/SettingsView.tsx | 23 +- packages/ui/src/index.css | 24 + packages/ui/src/lib/device.ts | 34 +- packages/ui/src/lib/i18n/messages/en.ts | 30 +- packages/ui/src/lib/i18n/messages/es.ts | 30 +- packages/ui/src/lib/i18n/messages/fr.ts | 30 +- packages/ui/src/lib/i18n/messages/ja.ts | 30 +- packages/ui/src/lib/i18n/messages/ko.ts | 30 +- packages/ui/src/lib/i18n/messages/pl.ts | 30 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 30 +- packages/ui/src/lib/i18n/messages/uk.ts | 30 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 30 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 30 +- packages/ui/src/lib/runtimeSurface.ts | 41 +- .../ui/src/stores/useGlobalSessionsStore.ts | 5 + packages/ui/src/stores/useUIStore.ts | 20 - packages/ui/src/styles/mobile.css | 128 +- .../ui/src/sync/last-session-cache.test.ts | 65 + packages/ui/src/sync/last-session-cache.ts | 91 + packages/ui/src/sync/session-ordering.test.ts | 22 + packages/ui/src/sync/session-ordering.ts | 42 + packages/ui/src/sync/session-ui-store.ts | 18 +- packages/web/server/lib/ui-auth/ui-auth.js | 15 + packages/web/src/main.tsx | 26 +- 69 files changed, 5006 insertions(+), 4291 deletions(-) create mode 100644 packages/ui/src/apps/IpadSidebarResizeHandle.tsx create mode 100644 packages/ui/src/apps/MobileConnectionWelcome.tsx create mode 100644 packages/ui/src/apps/MobileFullscreenSurface.tsx create mode 100644 packages/ui/src/apps/MobileHeader.tsx create mode 100644 packages/ui/src/apps/MobileInstancesSurface.tsx create mode 100644 packages/ui/src/apps/MobileOverflowMenu.tsx create mode 100644 packages/ui/src/apps/MobileSessionMetadata.tsx create mode 100644 packages/ui/src/apps/MobileSessionSwitcher.tsx delete mode 100644 packages/ui/src/apps/MobileSurfaceShell.tsx create mode 100644 packages/ui/src/apps/MobileWorkspaceDrawer.tsx create mode 100644 packages/ui/src/apps/ipadSidebarResize.ts create mode 100644 packages/ui/src/apps/mobileConnectionUi.ts create mode 100644 packages/ui/src/apps/mobileNativeChrome.ts create mode 100644 packages/ui/src/apps/mobilePaths.ts create mode 100644 packages/ui/src/apps/useEdgeSwipe.ts delete mode 100644 packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts delete mode 100644 packages/ui/src/components/chat/MobileSessionStatusBar.test.ts delete mode 100644 packages/ui/src/components/chat/MobileSessionStatusBar.tsx create mode 100644 packages/ui/src/sync/last-session-cache.test.ts create mode 100644 packages/ui/src/sync/last-session-cache.ts diff --git a/bun.lock b/bun.lock index 18e94388..19dcc988 100644 --- a/bun.lock +++ b/bun.lock @@ -97,7 +97,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.17.0", + "version": "1.17.1", "dependencies": { "@openchamber/web": "workspace:*", "better-sqlite3": "^12.10.0", @@ -134,7 +134,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.17.0", + "version": "1.17.1", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -239,7 +239,7 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.17.0", + "version": "1.17.1", "dependencies": { "@openchamber/ui": "workspace:*", "@opencode-ai/sdk": "1.18.9", @@ -262,7 +262,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.17.0", + "version": "1.17.1", "bin": { "openchamber": "./bin/cli.js", }, diff --git a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift index 51122f3c..13745be7 100644 --- a/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift +++ b/packages/mobile/ios/App/OpenChamberWidget/OpenChamberWidgets.swift @@ -61,7 +61,7 @@ struct OverviewWidgetView: View { VStack(spacing: 16) { HStack(spacing: 16) { actionButton(systemImage: "plus", url: WidgetDeepLink.newSession()) - actionButton(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + actionButton(systemImage: "list.bullet", url: WidgetDeepLink.status()) } HStack(spacing: 16) { actionButton(systemImage: "server.rack", url: WidgetDeepLink.instances()) @@ -120,7 +120,7 @@ struct QuickActionsWidgetView: View { // Two round secondary actions. HStack(spacing: 10) { - quickCircle(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status()) + quickCircle(systemImage: "list.bullet", url: WidgetDeepLink.status()) quickCircle(systemImage: "server.rack", url: WidgetDeepLink.instances()) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/packages/ui/src/apps/IpadSidebarResizeHandle.tsx b/packages/ui/src/apps/IpadSidebarResizeHandle.tsx new file mode 100644 index 00000000..60a87f1e --- /dev/null +++ b/packages/ui/src/apps/IpadSidebarResizeHandle.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { cn } from '@/lib/utils'; + +export const IpadSidebarResizeHandle: React.FC<{ + side: 'left' | 'right'; + isResizing: boolean; + ariaLabel: string; + handleProps: React.HTMLAttributes; +}> = ({ side, isResizing, ariaLabel, handleProps }) => ( +
+
+
+); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 7bf5a5e5..e6fff3b6 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -1,7 +1,5 @@ import React from 'react'; -import { Icon } from '@/components/icon/Icon'; -import type { IconName } from '@/components/icon/icons'; import { McpIcon } from '@/components/icons/McpIcon'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { AboutSettings } from '@/components/sections/openchamber/AboutSettings'; @@ -9,68 +7,73 @@ import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; import { MobileAppUpdateToast } from '@/components/update/MobileAppUpdateToast'; import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; -import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; +import { PlanView } from '@/components/views/PlanView'; import { SettingsView } from '@/components/views/SettingsView'; import { TerminalView } from '@/components/views/TerminalView'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; -import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon'; -import { preloadProviderLogos } from '@/hooks/useProviderLogo'; -import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useRouter } from '@/hooks/useRouter'; import { useUpdatePolling } from '@/hooks/useUpdatePolling'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { opencodeClient } from '@/lib/opencode/client'; -import type { ProjectEntry, RuntimeAPIs } from '@/lib/api/types'; +import type { RuntimeAPIs } from '@/lib/api/types'; import { useOrientation } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; import { isIPadApp } from '@/lib/platform'; -import { resolveProjectForDirectory, resolveProjectForSessionDirectory } from '@/lib/projectResolution'; -import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; -import { getDisplayModelName } from '@/lib/quota/model-families'; import { runtimeFetch } from '@/lib/runtime-fetch'; -import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; -import { sessionEvents } from '@/lib/sessionEvents'; +import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; +import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { clearLastActiveSession, readLastActiveSession } from '@/sync/last-session-cache'; import { cn } from '@/lib/utils'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; -import { useGitStatus, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; +import { useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore'; import { useMcpStore } from '@/stores/useMcpStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; -import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager'; -import type { QuotaProviderId, UsageWindow } from '@/types'; -import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; +import { useUIStore } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; -import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { SyncProvider, useSession, useSessionMessages } from '@/sync/sync-context'; +import { SyncProvider } from '@/sync/sync-context'; import { SyncAppEffects } from './AppEffects'; +import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs'; import { MobileChangesSurface } from './MobileChangesSurface'; import { MobileFilesSurface } from './MobileFilesSurface'; import { BusyDots } from '@/components/chat/message/parts/BusyDots'; +import { MobileConnectionWelcome, type MobileConnectionNotice } from './MobileConnectionWelcome'; +import { MobileHeader } from './MobileHeader'; +import { MobileInstancesSurface } from './MobileInstancesSurface'; +import { MobileOverflowMenu, type OverflowItem } from './MobileOverflowMenu'; import { MobileSessionsSheet } from './MobileSessionsSheet'; -import { MobileSurfaceShell } from './MobileSurfaceShell'; +import { MobileFullscreenSurface } from './MobileFullscreenSurface'; +import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, connectionDisplayUrl, getAutoConnectTargetLabel, isActiveRuntimeConnection, reprobeActiveConnection, useMobileConnection } from './mobileConnections'; -import { isRelayModeActive } from '@/lib/relay/runtime-tunnel'; -import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; +import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections'; +import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome'; +import { normalizePath } from './mobilePaths'; import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; import { useFontsReady } from './useFontsReady'; import { useDeepLinkHandlers, useDeepLinkSource } from './deepLinkNavigation'; -import { useEdgeSwipeSessionSwitch } from './useEdgeSwipeSessionSwitch'; +import { useEdgeSwipe } from './useEdgeSwipe'; import { useNativePushRegistration } from './useNativePushRegistration'; +import { IpadSidebarResizeHandle } from './IpadSidebarResizeHandle'; +import { + IPAD_LEFT_SIDEBAR_WIDTH, + IPAD_RIGHT_SIDEBAR_WIDTH, + useIpadSidebarResize, +} from './ipadSidebarResize'; const MOBILE_SETTINGS_PAGES = [ 'general', @@ -92,1986 +95,27 @@ type MobileAppProps = { apis: RuntimeAPIs; }; -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(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(null); - const pointerIdRef = React.useRef(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; -}> = ({ side, isResizing, ariaLabel, handleProps }) => ( -
-
-
-); - -const isCapacitorMobileApp = (): boolean => { - if (typeof window === 'undefined') return false; - const maybeCapacitor = (window as typeof window & { - Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string }; - }).Capacitor; - if (maybeCapacitor?.isNativePlatform?.() === true) return true; - return window.location.protocol === 'capacitor:'; -}; - -const useNativeMobileChrome = (): void => { - React.useEffect(() => { - if (!isCapacitorMobileApp()) return; - - let disposed = false; - const cleanup: Array<() => void> = []; - const root = document.documentElement; - // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in - // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). - root.classList.add('oc-capacitor-app'); - // Platform marker: Android resizes the window for the keyboard natively (no manual - // inset/choreography — the keyboard listeners below skip Android entirely). - 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') { - // 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). - const isDark = document.documentElement.classList.contains('dark'); - const themeBg = - (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || - (isDark ? '#171515' : '#fffdf4'); - await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); - await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); - // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), - // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. - await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); - await StatusBar.show().catch(() => undefined); - return; - } - await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); - await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); - await StatusBar.show().catch(() => undefined); - }; - await applyStatusBar(); - const retry = window.setTimeout(() => void applyStatusBar(), 400); - cleanup.push(() => window.clearTimeout(retry)); - - const { App } = await import('@capacitor/app'); - const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { - if (isActive) void applyStatusBar(); - }); - if (disposed) { - void stateHandle.remove(); - return; - } - cleanup.push(() => void stateHandle.remove()); - }).catch(() => undefined); - - void import('@capacitor/keyboard').then(async ({ Keyboard }) => { - if (disposed) return; - // iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard - // overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the - // window for the keyboard (dvh already shrinks), so applying the inset on top would - // double-count — Android gets only the class/event signals below. - const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); - if (platform === 'android') { - // Android resizes the WebView natively, so no inset/transform - // choreography — but the UI still needs the open/closed signal: - // oc-keyboard-open drives CSS (draft starters, composer padding), and - // the settled event gives the chat its one deterministic re-pin after - // the native resize (the auto-follow idle gate ignores it otherwise). - const willShowHandle = await Keyboard.addListener('keyboardWillShow', () => { - root.classList.add('oc-keyboard-open'); - // The composer already expanded on tap — re-pin the chat to it now, - // so the native resize that follows is the only remaining movement. - window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } })); - }); - const didShowHandle = await Keyboard.addListener('keyboardDidShow', () => { - window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } })); - }); - const willHideHandle = await Keyboard.addListener('keyboardWillHide', () => { - // Same single-motion trick as iOS: collapse the composer into the - // pill synchronously (flushSync in ChatInput) so the native window - // growth and the composer shrink land together, not as two steps. - window.dispatchEvent(new CustomEvent('oc:keyboard-intent', { detail: { open: false } })); - root.classList.remove('oc-keyboard-open'); - }); - const didHideHandle = await Keyboard.addListener('keyboardDidHide', () => { - window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: false } })); - }); - const removeAll = () => { - void willShowHandle.remove(); - void didShowHandle.remove(); - void willHideHandle.remove(); - void didHideHandle.remove(); - }; - if (disposed) { - removeAll(); - return; - } - cleanup.push(removeAll); - return; - } - // No WebKit form accessory bar (prev/next arrows + Done) above the keyboard — - // there's a single input, so it only eats vertical space. - await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined); - - // 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 - // visible motion is transform-only (inline styles on the kb-movers), and the shell's layout - // 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; - let caretTimer: number | null = null; - 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; - } - }; - const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record) => { - window.dispatchEvent(new CustomEvent(type, { detail })); - }; - // 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('.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('.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 = ''; - } - }; - - const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { - 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'); - // WKWebView renders the caret as a native layer that doesn't ride CSS - // transforms — after the rise it visibly "flies" from the pre-keyboard - // position to the final one. Hide it for the transition (plus the lag - // window where UIKit animates it into place) and pop it back in. - if (caretTimer !== null) { - window.clearTimeout(caretTimer); - caretTimer = null; - } - root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold'); - setInset(keyboardHeight); - 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 }); - 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; - clearKbMovers(); - dispatchKb('oc:keyboard-settled', { open: true }); - // Reveal the caret only after UIKit's own caret reposition window. - caretTimer = window.setTimeout(() => { - caretTimer = null; - root.classList.remove('oc-kb-caret-hold'); - }, 250); - }, KB_ANIM_MS + 20); - }); - - // 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(); - // Fired BEFORE any layout change: lets the composer collapse into its - // pill synchronously (flushSync in ChatInput), so the keyboard hide - // compensation below measures keyboard + composer shrink as ONE delta - // instead of two staggered steps. - dispatchKb('oc:keyboard-intent', { open: false }); - if (caretTimer !== null) { - window.clearTimeout(caretTimer); - caretTimer = null; - } - root.classList.remove('oc-kb-caret-hold'); - const slide = Math.max(0, keyboardHeight - safeBottomPx); - root.classList.remove('oc-keyboard-open'); - setInset(0); - setVar('--oc-kb-scroll-inset', 0); - if (layoutApplied) { - // Settled-open → restore the full-height layout NOW (still hidden behind - // the keyboard) and FLIP the movers to their raised position without - // transitioning, so the next frame looks unchanged. - root.classList.remove('oc-kb-animating'); - setVar('--oc-kb-layout', 0); - layoutApplied = false; - for (const { el, factor } of getKbMovers()) { - el.style.transition = 'none'; - el.style.transform = `translateY(${-slide * factor}px)`; - } - // 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), - // the movers transition back down from wherever they currently are. - dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING }); - root.classList.add('oc-kb-animating', 'oc-kb-hide'); - for (const { el } of getKbMovers()) { - el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`; - el.style.transform = 'translateY(0px)'; - } - settleTimer = window.setTimeout(() => { - settleTimer = null; - root.classList.remove('oc-kb-animating', 'oc-kb-hide'); - clearKbMovers(); - 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); - - if (disposed) { - clearSettle(); - document.removeEventListener('focusout', handleFocusOut, true); - void showHandle.remove(); - void hideHandle.remove(); - return; - } - cleanup.push( - clearSettle, - () => { - if (caretTimer !== null) { - window.clearTimeout(caretTimer); - caretTimer = null; - } - }, - () => document.removeEventListener('focusout', handleFocusOut, true), - () => void showHandle.remove(), - () => void hideHandle.remove(), - ); - }).catch(() => undefined); - - return () => { - disposed = true; - cleanup.forEach((remove) => remove()); - root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android'); - root.style.removeProperty('--oc-keyboard-inset'); - root.style.removeProperty('--oc-kb-shift'); - root.style.removeProperty('--oc-kb-layout'); - root.style.removeProperty('--oc-kb-scroll-inset'); - }; - }, []); -}; - -const useNativeMobileLifecycle = (onResume: () => void): void => { - const wasInactiveRef = React.useRef(false); - - React.useEffect(() => { - if (!isCapacitorMobileApp()) return; - - let disposed = false; - const cleanup: Array<() => void> = []; - const resumeAfterInactive = () => { - if (!wasInactiveRef.current) return; - wasInactiveRef.current = false; - onResume(); - }; - - // 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)); - - void import('@capacitor/app').then(async ({ App }) => { - if (disposed) return; - const state = await App.addListener('appStateChange', ({ isActive }) => { - document.documentElement.classList.toggle('oc-native-app-active', isActive); - if (!isActive) { - wasInactiveRef.current = true; - return; - } - resumeAfterInactive(); - }); - const resume = await App.addListener('resume', resumeAfterInactive); - if (disposed) { - void state.remove(); - void resume.remove(); - return; - } - cleanup.push(() => void state.remove(), () => void resume.remove()); - }).catch(() => undefined); - - return () => { - disposed = true; - cleanup.forEach((remove) => remove()); - }; - }, [onResume]); -}; - -const useNativeAndroidBackButton = (onBack: () => boolean): void => { - React.useEffect(() => { - if (!isCapacitorMobileApp()) return; - - let disposed = false; - let remove: (() => void) | null = null; - - void import('@capacitor/app').then(async ({ App }) => { - if (disposed) return; - const listener = await App.addListener('backButton', () => { - if (onBack()) return; - void App.minimizeApp().catch(() => undefined); - }); - if (disposed) { - void listener.remove(); - return; - } - remove = () => void listener.remove(); - }).catch(() => undefined); - - return () => { - disposed = true; - remove?.(); - }; - }, [onBack]); -}; - -const normalizePath = (value?: string | null): string => - (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); - -const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => { - if (!limit || typeof limit !== 'object') return undefined; - const value = (limit as Partial>)[key]; - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -}; - -const getTokenCount = (value: unknown): number => ( - typeof value === 'number' && Number.isFinite(value) ? value : 0 -); - -const formatTokens = (value: number): string => { - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; - return String(value); -}; - -const mobileInputKeyboardProps = { - autoComplete: 'off', - autoCorrect: 'off', - spellCheck: false, -} as const; - const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; -const getProjectLabel = (path: string): string => { - const normalized = normalizePath(path); - if (!normalized) return ''; - const segments = normalized.split('/').filter(Boolean); - return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized; -}; - -type OverflowItem = { - key: 'files' | 'changes' | 'terminal' | 'mcp' | 'instances' | 'update' | 'settings'; - icon?: IconName; - iconNode?: React.ReactNode; - label: string; - badge?: number; - onSelect: () => void; -}; - -type ContextDisplay = { - percentage: number; - tokens: string; - colorClass: string; -} | null; - -const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => { - if (project) return project.label?.trim() || getProjectLabel(project.path); - return getProjectLabel(fallbackDirectory); -}; - -const MobileConnectionWelcome: React.FC<{ onConnected: () => void }> = ({ onConnected }) => { - const { t } = useI18n(); - const conn = useMobileConnection(onConnected); - const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn; - const [serverUrl, setServerUrl] = React.useState(''); - const [connectionName, setConnectionName] = React.useState(''); - const [clientToken, setClientToken] = React.useState(''); - const [isScanning, setIsScanning] = React.useState(false); - const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); - // 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(null); - const [password, setPassword] = React.useState(''); - - const handleSubmit = React.useCallback((event: React.FormEvent) => { - event.preventDefault(); - void conn.connect({ url: serverUrl, clientToken, label: connectionName }); - }, [clientToken, conn, connectionName, serverUrl]); - - // Accept a pasted pairing link (openchamber://connect?...) in the URL field and - // split it back into the server URL + token. - const handleUrlChange = React.useCallback((value: string) => { - if (/^openchamber:\/\//i.test(value.trim())) { - const payload = parseConnectionPayload(value); - if (payload) { - if ('pairing' in payload) { - void conn.redeemPairingConnection(payload.pairing); - return; - } - setServerUrl(payload.url); - if (payload.label) setConnectionName(payload.label); - if (payload.clientToken) setClientToken(payload.clientToken); - return; - } - } - setServerUrl(value); - }, [conn]); - - 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; - case 'pairing': - await conn.redeemPairingConnection(result.pairing); - break; - case 'permission-denied': - conn.setError(t('mobile.connect.scan.permissionDenied')); - break; - case 'invalid': - conn.setError(t('mobile.connect.scan.invalid')); - break; - case 'unsupported': - conn.setError(t('mobile.connect.scan.unsupported')); - break; - case 'failed': - conn.setError(t('mobile.connect.scan.failed')); - break; - case 'cancelled': - default: - break; - } - } finally { - setIsScanning(false); - } - }, [conn, isBusy, isScanning, t]); - - const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { - event.preventDefault(); - void conn.submitPassword(password); - }, [conn, password]); - - const cancelPassword = React.useCallback(() => { - setPassword(''); - conn.cancelPassword(); - }, [conn]); - - return ( -
-
-
- -

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

-
- - {pendingConnection ? ( -
-
- - - -
-

{pendingConnection.label}

-

- {pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')} -

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

{error}

: null} - - -
- ) : ( -
- {/* Primary path: scan the pairing QR from "Add a device" on the server. */} - {qrScanSupported ? ( -
- -

- {t('mobile.connect.welcome.scanHint')} -

-
- ) : null} - - {error && !manualOpen ?

{error}

: null} - - {connections.length > 0 ? ( -
-

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

-
- {connections.map((connection) => { - const isConnectingRow = connectingId === connection.id; - return ( - - ); - })} -
-
- ) : null} - - {/* Manual URL entry, collapsed by default — most people pair by QR. */} -
- {qrScanSupported ? ( - - ) : null} -
-
-
- 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" - /> - 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" - /> - 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" - /> -

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

- {error ?

{error}

: null} - -
-
-
-
-
- )} -
-
- ); -}; - -const MobileInstancesSurface: React.FC<{ - onConnect: () => void; - onActiveConnectionDeleted: () => void; -}> = ({ onActiveConnectionDeleted, onConnect }) => { - const { t } = useI18n(); - const conn = useMobileConnection(onConnect); - const { - connections, isBusy, isPasswordBusy, error, pendingConnection, - connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError, - } = conn; - const [editingId, setEditingId] = React.useState(null); - const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null; - const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); - const [url, setUrl] = React.useState(''); - const [label, setLabel] = React.useState(''); - const [clientToken, setClientToken] = React.useState(''); - const [password, setPassword] = React.useState(''); - const [isScanning, setIsScanning] = React.useState(false); - const qrScanSupported = React.useMemo(() => isQrScanSupported(), []); - // 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(null); - - // 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); - setFormOpen(false); - }, [setError]); - - const saveInstance = React.useCallback((event: React.FormEvent) => { - event.preventDefault(); - // 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) => { - if (saved) resetForm(); - }); - }, [clientToken, editingId, label, resetForm, saveConnection, url]); - - // Scan a pairing QR into the add/edit form fields (does not change edit mode, so - // the form-reset effect doesn't wipe the scanned values). The user reviews + saves. - const handleScanInstance = React.useCallback(async () => { - if (isScanning) return; - setError(null); - setIsScanning(true); - try { - const result = await scanConnectionQr(); - switch (result.status) { - case 'ok': - // Legacy token QR: prefill the manual form for review before saving. - setUrl(result.url); - if (result.label) setLabel(result.label); - if (result.clientToken) setClientToken(result.clientToken); - setFormOpen(true); - break; - case 'pairing': - await conn.redeemPairingConnection(result.pairing); - break; - case 'permission-denied': - setError(t('mobile.connect.scan.permissionDenied')); - break; - case 'invalid': - setError(t('mobile.connect.scan.invalid')); - break; - case 'unsupported': - setError(t('mobile.connect.scan.unsupported')); - break; - case 'failed': - setError(t('mobile.connect.scan.failed')); - break; - case 'cancelled': - default: - break; - } - } finally { - setIsScanning(false); - } - }, [conn, isScanning, setError, t]); - - const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => { - event.preventDefault(); - void submitPassword(password); - }, [password, submitPassword]); - - const cancelPasswordPrompt = React.useCallback(() => { - setPassword(''); - cancelPassword(); - }, [cancelPassword]); - - // Two-step delete (mirrors the session sheet): the trash icon arms the row, a - // second tap on the destructive button confirms, the X disarms. No hover relied on. - const toggleConfirmDelete = React.useCallback((id: string) => { - setConfirmingDeleteId((current) => (current === id ? null : id)); - }, []); - - const confirmDelete = React.useCallback((id: string) => { - setConfirmingDeleteId(null); - if (editingId === id) resetForm(); - // 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; - void removeConnection(id).then((removed) => { - if (!removed) return; - if (wasLast || isActiveRuntimeConnection(removed)) { - onActiveConnectionDeleted(); - } - }); - }, [connections.length, editingId, onActiveConnectionDeleted, removeConnection, resetForm]); - - const inputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20'; - - if (pendingConnection) { - return ( -
-
-
-
- - - -
-

{pendingConnection.label}

-

- {pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')} -

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

{error}

: null} - - -
-
-
- ); - } - - return ( -
-
-
- {connections.length > 0 ? ( -
- {connections.map((connection) => { - const confirming = confirmingDeleteId === connection.id; - 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'); - return ( -
- -
- {confirming ? ( - - ) : !connection.candidates.some((c) => c.kind === 'direct') ? null : ( - - )} - -
-
- ); - })} -
- ) : ( -

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

- )} - - {/* 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 ? ( -
- {qrScanSupported ? ( - - ) : null} - - {error ?

{error}

: null} -
- ) : ( -
-
-

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

- -
- - - - {error ?

{error}

: null} - -
- )} -
-
-
- ); -}; - -type MobileUsageLimitRow = { - key: string; - label: string; - subtitle?: string; - window: UsageWindow; -}; - -type MobileUsageProviderGroup = { - providerId: QuotaProviderId; - providerName: string; - rows: MobileUsageLimitRow[]; - status: string | null; -}; - -const getWindowValueClass = (window: UsageWindow): string => { - const usedPercent = window.usedPercent; - if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground'; - if (usedPercent >= 80) return 'text-[var(--status-error)]'; - if (usedPercent >= 50) return 'text-[var(--status-warning)]'; - return 'text-foreground'; -}; - -const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => { - const progressPct = clampPercent(percentage) ?? 0; - const tone = resolveUsageTone(percentage); - const progressColor = tone === 'critical' - ? 'var(--status-error)' - : tone === 'warn' - ? 'var(--status-warning)' - : 'var(--status-success)'; - const size = 18; - const stroke = 3; - const radius = (size - stroke) / 2; - const circumference = 2 * Math.PI * radius; - - return ( - - - - - ); -}; - -const MetadataRow: React.FC<{ - icon?: IconName; - iconNode?: React.ReactNode; - label: string; - children: React.ReactNode; -}> = ({ icon, iconNode, label, children }) => ( -
- - {iconNode ?? (icon ? : null)} - - {label} - - {children} - -
-); - -const SessionMetadataOverlay: React.FC<{ - open: boolean; - onClose: () => void; - anchorRef: React.RefObject; - contextDisplay: ContextDisplay; - branchLabel: string; - usageGroups: MobileUsageProviderGroup[]; - usageDisplayMode: 'usage' | 'remaining'; - isUsageLoading: boolean; - timeFormatPreference: TimeFormatPreference; -}> = ({ open, onClose, anchorRef, contextDisplay, branchLabel, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => { - const { t } = useI18n(); - const panelRef = React.useRef(null); - const [shouldRender, setShouldRender] = React.useState(open); - const [isExiting, setIsExiting] = React.useState(false); - // 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(null); - const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState(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; - - React.useEffect(() => { - if (open) { - setShouldRender(true); - setIsExiting(false); - return; - } - - if (!shouldRender) return; - setIsExiting(true); - const timeoutId = window.setTimeout(() => { - setShouldRender(false); - setIsExiting(false); - }, 140); - return () => window.clearTimeout(timeoutId); - }, [open, shouldRender]); - - React.useEffect(() => { - if (!open) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', handleKey); - return () => document.removeEventListener('keydown', handleKey); - }, [onClose, open]); - - React.useEffect(() => { - if (!open) return; - - const closeIfOutside = (event: PointerEvent | WheelEvent) => { - const target = event.target; - if (!(target instanceof Node)) { - onClose(); - return; - } - if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return; - onClose(); - }; - - document.addEventListener('pointerdown', closeIfOutside, true); - document.addEventListener('wheel', closeIfOutside, true); - return () => { - document.removeEventListener('pointerdown', closeIfOutside, true); - document.removeEventListener('wheel', closeIfOutside, true); - }; - }, [anchorRef, onClose, open]); - - if (!shouldRender) return null; - - return ( -
-
-
- - {branchLabel} - - {contextDisplay ? ( - } - label={t('mobile.header.metadata.context')} - > - - {contextDisplay.percentage.toFixed(1)}% - {contextDisplay.tokens} - - - ) : null} - -
-
- -
- ); -}; - -const MobileUsageLimits: React.FC<{ - groups: MobileUsageProviderGroup[]; - displayMode: 'usage' | 'remaining'; - isLoading: boolean; - timeFormatPreference: TimeFormatPreference; -}> = ({ groups, displayMode, isLoading, timeFormatPreference }) => { - const { t } = useI18n(); - const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used'); - - if (groups.length === 0) return null; - - return ( -
-
- - - - - {t('mobile.header.metadata.usage')} - - - {isLoading ? : null} - {modeLabel} - -
- -
- {groups.map((group) => ( -
-
- - - {group.providerName} - - {group.status && group.rows.length === 0 ? ( - - {group.status} - - ) : null} -
- {group.rows.length > 0 ? ( -
- {group.rows.map((row) => { - const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent; - const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel( - row.window.resetAt, - row.window.resetAfterFormatted ?? row.window.resetAtFormatted, - timeFormatPreference, - ); - return ( -
- - - {row.subtitle ? `${row.subtitle} · ${row.label}` : row.label} - - {resetLabel ? ( - {resetLabel} - ) : null} - - - {metricLabel === '-' ? '' : metricLabel} - -
- ); - })} -
- ) : null} - {group.status && group.rows.length > 0 ? ( -
{group.status}
- ) : null} -
- ))} -
-
- ); -}; - -const MobileOverflowMenu: React.FC<{ - open: boolean; - onClose: () => void; - items: OverflowItem[]; - /** 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 }) => { - const { t } = useI18n(); - React.useEffect(() => { - if (!open) return; - const handleKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') onClose(); - }; - document.addEventListener('keydown', handleKey); - return () => document.removeEventListener('keydown', handleKey); - }, [onClose, open]); - - if (!open) return null; - - return ( -
- - ))} -
- -
- ); -}; - -const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({ - open, - onOpenChange, - currentSessionId, - effectiveDirectory, - gitDirectory, - isNewSessionDraftOpen, - primaryLabel, - secondaryLabel, -}: { - open: boolean; - onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void; - currentSessionId: string | null; - effectiveDirectory: string | null; - gitDirectory: string | null; - isNewSessionDraftOpen: boolean; - primaryLabel: string; - secondaryLabel: string; -}) { - const { t } = useI18n(); - const { git } = useRuntimeAPIs(); - const metadataTriggerRef = React.useRef(null); - const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined); - const isGitRepo = useIsGitRepo(gitDirectory); - const gitStatus = useGitStatus(gitDirectory); - const ensureStatus = useGitStore((state) => state.ensureStatus); - const fetchStatus = useGitStore((state) => state.fetchStatus); - const providers = useConfigStore((state) => state.providers); - const currentProviderId = useConfigStore((state) => state.currentProviderId); - const currentModelId = useConfigStore((state) => state.currentModelId); - const getModelMetadata = useConfigStore((state) => state.getModelMetadata); - useConfigStore((state) => state.modelsMetadata.size); - const savedSessionModel = useSelectionStore( - React.useCallback( - (state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null), - [currentSessionId], - ), - ); - const quotaResults = useQuotaStore((state) => state.results); - const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); - const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); - const isQuotaLoading = useQuotaStore((state) => state.isLoading); - const quotaDisplayMode = useQuotaStore((state) => state.displayMode); - const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); - const selectedQuotaModels = useQuotaStore((state) => state.selectedModels); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); - - useQuotaAutoRefresh(); - - React.useEffect(() => { - if (!gitDirectory) return; - void ensureStatus(gitDirectory, git); - }, [ensureStatus, git, gitDirectory]); - - React.useEffect(() => { - if (!gitDirectory) return; - return sessionEvents.onGitRefreshHint((hint) => { - if (normalizePath(hint.directory) !== gitDirectory) return; - void fetchStatus(gitDirectory, git); - }); - }, [fetchStatus, git, gitDirectory]); - - React.useEffect(() => { - void loadQuotaSettings(); - }, [loadQuotaSettings]); - - React.useEffect(() => { - preloadProviderLogos(dropdownProviderIds); - }, [dropdownProviderIds]); - - React.useEffect(() => { - if (!open || isQuotaLoading) return; - const missingEnabledProvider = dropdownProviderIds.some((providerId) => ( - !quotaResults.some((result) => result.providerId === providerId) - )); - if (!missingEnabledProvider) return; - void fetchAllQuotas(); - }, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]); - - const latestMessageModel = React.useMemo(() => { - for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { - const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { - model?: { providerID?: string; modelID?: string }; - }; - if (message.role !== 'user') continue; - const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 - ? message.model.providerID - : undefined; - const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 - ? message.model.modelID - : undefined; - if (providerID && modelID) return { providerID, modelID }; - } - return null; - }, [activeSessionMessages]); - - const modelRef = latestMessageModel - ?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null) - ?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null); - const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined; - const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID); - const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined; - const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context') - ?? metadata?.limit?.context - ?? 0; - const totalTokens = React.useMemo(() => { - for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) { - const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & { - tokens?: { - input?: unknown; - output?: unknown; - reasoning?: unknown; - cache?: { read?: unknown; write?: unknown }; - }; - }; - if (message.role !== 'assistant' || !message.tokens) continue; - const total = getTokenCount(message.tokens.input) - + getTokenCount(message.tokens.output) - + getTokenCount(message.tokens.reasoning) - + getTokenCount(message.tokens.cache?.read) - + getTokenCount(message.tokens.cache?.write); - if (total > 0) return total; - } - return 0; - }, [activeSessionMessages]); - - const contextPercentage = - !isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0 - ? Math.min((totalTokens / contextLimit) * 100, 999) - : null; - const contextTokens = contextPercentage !== null - ? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}` - : null; - const contextColorClass = - contextPercentage === null - ? '' - : contextPercentage >= 90 - ? 'text-[var(--status-error)]' - : contextPercentage >= 75 - ? 'text-[var(--status-warning)]' - : 'text-[var(--status-success)]'; - const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens - ? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass } - : null; - - const branchLabel = isGitRepo === true - ? (gitStatus?.current?.trim() || t('gitView.branch.detachedHead')) - : t('common.unavailable'); - - const usageGroups = React.useMemo(() => { - const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result])); - return QUOTA_PROVIDERS - .filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id)) - .filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true) - .map((providerMeta) => { - const result = resultsByProvider.get(providerMeta.id)!; - const rows: MobileUsageLimitRow[] = []; - - for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) { - rows.push({ - key: `window-${label}`, - label: formatWindowLabel(label), - window, - }); - } - - const modelEntries = Object.entries(result?.usage?.models ?? {}); - const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? []; - const visibleModelEntries = providerSelectedModels.length > 0 - ? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName)) - : modelEntries; - for (const [modelName, modelUsage] of visibleModelEntries) { - const entries = Object.entries(modelUsage.windows ?? {}); - if (entries.length === 0) continue; - const [label, window] = entries[0]; - rows.push({ - key: `model-${modelName}-${label}`, - label: formatWindowLabel(label), - subtitle: getDisplayModelName(modelName), - window, - }); - } - - const status = !result.ok && result.error - ? result.error - : rows.length === 0 - ? t('header.services.noRateLimitsReported') - : null; - - return { - providerId: providerMeta.id, - providerName: providerMeta.name, - rows, - status, - }; - }); - }, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]); - - React.useEffect(() => { - if (!open || usageGroups.length === 0) return; - preloadProviderLogos(usageGroups.map((group) => group.providerId)); - }, [open, usageGroups]); - - return ( - <> -
- - {primaryLabel} - {secondaryLabel ? ( - {secondaryLabel} - ) : null} - -
- - onOpenChange(false)} - anchorRef={metadataTriggerRef} - contextDisplay={contextDisplay} - branchLabel={branchLabel} - usageGroups={usageGroups} - usageDisplayMode={quotaDisplayMode} - isUsageLoading={isQuotaLoading} - timeFormatPreference={timeFormatPreference} - /> - - ); -}); - -type MobileHeaderSurfaceShortcuts = { - activePanel: 'files' | 'changes' | null; - changesDirty: boolean; - onToggleFiles: () => void; - onToggleChanges: () => void; -}; - -const MobileHeader: React.FC<{ - onOpenSessions: () => void; - onOpenMenu: () => void; - /** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */ - surfaceShortcuts?: MobileHeaderSurfaceShortcuts; -}> = ({ onOpenSessions, onOpenMenu, surfaceShortcuts }) => { - const { t } = useI18n(); - const [metadataOpen, setMetadataOpen] = React.useState(false); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const currentSessionDirectory = useSessionUIStore( - React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]), - ); - const effectiveDirectory = currentSessionDirectory || currentDirectory; - const gitDirectory = normalizePath(effectiveDirectory) || null; - const projects = useProjectsStore((state) => state.projects); - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const currentWorktreeMetadata = useSessionUIStore( - React.useCallback((state) => (currentSessionId ? state.worktreeMetadata.get(currentSessionId) ?? null : null), [currentSessionId]), - ); - const currentSession = useSession(currentSessionId, effectiveDirectory || undefined); - const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); - - const projectLabel = React.useMemo(() => { - const directory = normalizePath(effectiveDirectory); - if (!directory) return t('mobile.header.noProject'); - const metadataProject = currentWorktreeMetadata?.projectDirectory - ? resolveProjectForDirectory(projects, currentWorktreeMetadata.projectDirectory) - : null; - const project = metadataProject ?? resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); - return getProjectDisplayLabel(project, directory) || t('mobile.header.noProject'); - }, [availableWorktreesByProject, currentWorktreeMetadata?.projectDirectory, effectiveDirectory, projects, t]); - - const sessionTitle = currentSession?.title?.trim(); - const primaryLabel = sessionTitle || (currentSessionId ? t('mobile.sessions.untitled') : projectLabel); - const secondaryLabel = currentSessionId ? projectLabel : ''; - - React.useEffect(() => { - setMetadataOpen(false); - }, [currentSessionId, effectiveDirectory]); - - const handleOpenSessions = React.useCallback(() => { - setMetadataOpen(false); - onOpenSessions(); - }, [onOpenSessions]); - - const handleOpenMenu = React.useCallback(() => { - setMetadataOpen(false); - onOpenMenu(); - }, [onOpenMenu]); - - return ( - <> -
-
- - - - - {surfaceShortcuts ? ( - <> - - - - ) : null} - - -
-
- - ); -}; +/** The fullscreen overlay surfaces reachable from the overflow menu. Exactly + one can be open at a time — opening another replaces it, closing returns + to the chat. The sessions drawer, the workspace drawer (Changes / Files / + Terminal tabs on phones), and the overflow menu are separate layers. + 'terminal' is iPad-only here — phones get it as a workspace tab. */ +type MobileSurface = 'terminal' | 'mcp' | 'notes' | 'instances' | 'settings' | 'update'; const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); - const [filesOpen, setFilesOpen] = React.useState(false); - const [changesOpen, setChangesOpen] = React.useState(false); - const [terminalOpen, setTerminalOpen] = React.useState(false); - const [mcpOpen, setMcpOpen] = React.useState(false); - const [instancesOpen, setInstancesOpen] = React.useState(false); + const [activeSurface, setActiveSurface] = React.useState(null); + // Phone right drawer with the workspace tabs; the tab persists across + // open/close so the right-edge swipe reopens where the user left off. + const [workspaceOpen, setWorkspaceOpen] = React.useState(false); + const [workspaceTab, setWorkspaceTab] = React.useState('changes'); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); - const [settingsOpen, setSettingsOpen] = React.useState(false); - const [updateOpen, setUpdateOpen] = React.useState(false); + // A plan opened from the Project notes surface, shown as a second fullscreen + // layer on top of it (back returns to the notes). + const [openPlan, setOpenPlan] = React.useState<{ path: string; title: string } | null>(null); const [settingsInitialMobileStage, setSettingsInitialMobileStage] = React.useState<'nav' | 'page-content'>('nav'); const [overflowOpen, setOverflowOpen] = React.useState(false); // When set, the Changes surface opens directly into the per-file diff for this path. @@ -2089,6 +133,27 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const gitStatus = useGitStatus(normalizePath(currentDirectory) || null); const dirtyChangeCount = gitStatus?.files?.length ?? 0; + // NOTE: pendingChangesDiff is intentionally NOT cleared on close — it keys + // the persistent Changes pane in the workspace drawer, and clearing it would + // remount the pane (losing its navigation) on every close. + const closeSurface = React.useCallback(() => { + setActiveSurface(null); + setOpenPlan(null); + }, []); + + const openSurface = React.useCallback((surface: MobileSurface) => { + setActiveSurface(surface); + }, []); + + const closeWorkspace = React.useCallback(() => { + setWorkspaceOpen(false); + }, []); + + const openSettingsSurface = React.useCallback((stage: 'nav' | 'page-content') => { + setSettingsInitialMobileStage(stage); + openSurface('settings'); + }, [openSurface]); + // 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(), []); @@ -2112,7 +177,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc if (isPortrait) setIpadSidebarOpen(false); return; } - setFilesOpen(true); + setWorkspaceTab('files'); + setWorkspaceOpen(true); }, [isIPad, isPortrait]); const openChangesSurface = React.useCallback((diff: { path: string; staged: boolean } | null = null) => { @@ -2122,7 +188,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc if (isPortrait) setIpadSidebarOpen(false); return; } - setChangesOpen(true); + setWorkspaceTab('changes'); + setWorkspaceOpen(true); }, [isIPad, isPortrait]); const closeIpadRightPanel = React.useCallback(() => { @@ -2164,19 +231,11 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openChangesSurface(diffPath ? { path: diffPath, staged: staged === true } : null); }, openFiles: () => openFilesSurface(), - openSettings: () => { - setSettingsInitialMobileStage('nav'); - setSettingsOpen(true); - }, + openSettings: () => openSettingsSurface('nav'), }), - [openChangesSurface, openFilesSurface], + [openChangesSurface, openFilesSurface, openSettingsSurface], ); - const closeChanges = React.useCallback(() => { - setChangesOpen(false); - setPendingChangesDiff(null); - }, []); - // Expose the shell's panel-opening actions to the deep-link layer so openchamber:// URLs // (and notification taps / widgets) can navigate to these surfaces. Session and // new-session intents resolve directly against the store, so they aren't wired here. @@ -2187,94 +246,84 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc else setSessionsSheetOpen(true); }, openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { - if (target === 'files') openFilesSurface(); - else if (target === 'mcp') setMcpOpen(true); - else if (target === 'instances') setInstancesOpen(true); - else if (target === 'update') setUpdateOpen(true); + if (target === 'files') { + openFilesSurface(); + return; + } + // Phones host MCP as a workspace tab now; iPad still uses the surface. + if (target === 'mcp' && !isIPad) { + setWorkspaceTab('mcp'); + setWorkspaceOpen(true); + return; + } + openSurface(target); }, openChanges: ({ path, staged }: { path?: string; staged?: boolean } = {}) => { openChangesSurface(path ? { path, staged: staged === true } : null); }, openSettings: (section?: string) => { if (section) setSettingsPage(section as Parameters[0]); - setSettingsInitialMobileStage(section ? 'page-content' : 'nav'); - setSettingsOpen(true); + openSettingsSurface(section ? 'page-content' : 'nav'); }, }), - [isIPad, openChangesSurface, openFilesSurface, setSettingsPage], + [isIPad, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage], ); useDeepLinkHandlers(deepLinkHandlers); - // Edge swipe (left/right screen edge → centre) switches between sessions, with a directional - // slide+fade on the chat content so it's obvious the session changed. + // Edge swipes on the chat: left edge opens the sessions drawer (the iPad + // sidebar on iPad), right edge reopens the most recent overflow surface + // (the last right panel on iPad). const chatMainRef = React.useRef(null); - const chatAnimRef = React.useRef(null); - const swipeDirectionRef = React.useRef<'prev' | 'next' | null>(null); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - // Record the swipe direction; the animation itself runs in the layout effect below, once the - // new session's content has committed — running it inline in the swipe callback raced the - // re-render and dropped the animation on roughly every other switch. - const recordSwipeDirection = React.useCallback((direction: 'prev' | 'next') => { - swipeDirectionRef.current = direction; - }, []); - useEdgeSwipeSessionSwitch(chatMainRef, { onSwitch: recordSwipeDirection }); - - React.useLayoutEffect(() => { - const direction = swipeDirectionRef.current; - swipeDirectionRef.current = null; - if (!direction) return; // only animate swipe-driven switches - const element = chatAnimRef.current; - if (!element || typeof element.animate !== 'function') return; - element.getAnimations().forEach((animation) => animation.cancel()); - const fromX = direction === 'prev' ? -70 : 70; - element.animate( - [ - { opacity: 0.1, transform: `translateX(${fromX}px)` }, - { opacity: 1, transform: 'translateX(0)' }, - ], - { duration: 300, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }, - ); - }, [currentSessionId]); + useEdgeSwipe(chatMainRef, { + onLeftEdgeSwipe: () => { + if (isIPad) setIpadSidebarOpen(true); + else setSessionsSheetOpen(true); + }, + onRightEdgeSwipe: () => { + if (isIPad) { + if (lastIpadRightPanelRef.current === 'files') openFilesSurface(); + else openChangesSurface(); + return; + } + setWorkspaceOpen(true); + }, + }); + // Top-most layer first: a plan or fullscreen surface can now sit ABOVE a + // drawer (opened from the drawer footer / workspace tabs), so they close + // before the drawers underneath. const handleNativeBack = React.useCallback(() => { if (overflowOpen) { setOverflowOpen(false); return true; } + if (openPlan) { + setOpenPlan(null); + return true; + } + if (activeSurface) { + closeSurface(); + return true; + } + if (workspaceOpen) { + closeWorkspace(); + 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]); + }, [activeSurface, closeSurface, closeWorkspace, openPlan, overflowOpen, sessionsSheetOpen, workspaceOpen]); useNativeAndroidBackButton(handleNativeBack); - const showUpdateItem = updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); + // Server updates are actionable from a browser (hosted mobile) but not from + // the Capacitor shell — the native app updates through the store, and the + // server it CONNECTS to is updated elsewhere. + const showUpdateItem = !showCapacitorOnlyFeatures + && updateAvailable + && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); const openMcpCreateSettings = React.useCallback(() => { const baseName = 'new-mcp-server'; @@ -2305,10 +354,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc setMcpDraft(draft); setSelectedMcp(newName); setSettingsPage('mcp'); - setMcpOpen(false); - setSettingsInitialMobileStage('page-content'); - setSettingsOpen(true); - }, [mcpServers, setMcpDraft, setSelectedMcp, setSettingsPage]); + openSettingsSurface('page-content'); + }, [mcpServers, openSettingsSurface, setMcpDraft, setSelectedMcp, setSettingsPage]); const refreshMcpOverlay = React.useCallback(() => { if (isMcpRefreshing) return; @@ -2325,42 +372,34 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const overflowItems: OverflowItem[] = React.useMemo( () => { 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(), - }, - ); + // Phones get Files/Changes/Terminal as workspace-drawer tabs; the iPad + // exposes Files/Changes as header shortcuts and keeps Terminal here. + if (isIPad) { + items.push({ + key: 'terminal', + icon: 'terminal', + label: t('mobile.menu.terminal'), + onSelect: () => openSurface('terminal'), + }); } - items.push({ - key: 'terminal', - icon: 'terminal', - label: t('mobile.menu.terminal'), - onSelect: () => setTerminalOpen(true), - }); items.push({ key: 'mcp', iconNode: , label: t('mobile.menu.mcp'), - onSelect: () => setMcpOpen(true), + onSelect: () => openSurface('mcp'), + }); + items.push({ + key: 'notes', + icon: 'sticky-note', + label: t('contextRail.surface.notes'), + onSelect: () => openSurface('notes'), }); if (showCapacitorOnlyFeatures) { items.push({ key: 'instances', icon: 'server', label: t('mobile.menu.instances'), - onSelect: () => setInstancesOpen(true), + onSelect: () => openSurface('instances'), }); } if (showUpdateItem) { @@ -2368,21 +407,18 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc key: 'update', icon: 'download', label: t('mobile.menu.update'), - onSelect: () => setUpdateOpen(true), + onSelect: () => openSurface('update'), }); } items.push({ key: 'settings', icon: 'settings-3', label: t('mobile.menu.settings'), - onSelect: () => { - setSettingsInitialMobileStage('nav'); - setSettingsOpen(true); - }, + onSelect: () => openSettingsSurface('nav'), }); return items; }, - [dirtyChangeCount, isIPad, openChangesSurface, openFilesSurface, showCapacitorOnlyFeatures, showUpdateItem, t], + [isIPad, openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem, t], ); return ( @@ -2398,7 +434,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
); }; -export const MobileSessionsSheet: React.FC = ({ open, onOpenChange, variant = 'sheet' }) => { +/** Reorder-mode project card: drag handle reorders projects globally; tapping + the rest of the row collapses/expands its worktrees, which reorder within + the project through their own nested DndContext. */ +const SortableProjectRow: React.FC<{ + project: ProjectMeta; + totalSessions: number; + expanded: boolean; + onToggleExpanded: () => void; + onReorderWorktrees: (orderedPaths: string[]) => void; +}> = ({ project, totalSessions, expanded, onToggleExpanded, onReorderWorktrees }) => { + const { t } = useI18n(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: project.id }); + const worktreeSensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const hasWorktrees = project.worktrees.length > 0; + + const handleWorktreeDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const paths = project.worktrees.map((worktree) => normalizePath(worktree.path)); + const fromIndex = paths.indexOf(String(active.id)); + const toIndex = paths.indexOf(String(over.id)); + if (fromIndex < 0 || toIndex < 0) return; + const next = [...paths]; + const [moved] = next.splice(fromIndex, 1); + next.splice(toIndex, 0, moved); + onReorderWorktrees(next); + }; + + return ( +
+
+ + +
+ {expanded && hasWorktrees ? ( + + normalizePath(worktree.path))} + strategy={verticalListSortingStrategy} + > +
+ {project.worktrees.map((worktree) => ( + + ))} +
+
+
+ ) : null} +
+ ); +}; + +export const MobileSessionsSheet: React.FC = ({ open, onOpenChange, variant = 'drawer', footer }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const liveSessions = useAllLiveSessions(); @@ -541,6 +850,8 @@ export const MobileSessionsSheet: React.FC = ({ open, const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const archiveSession = useSessionUIStore((state) => state.archiveSession); + const deleteSession = useSessionUIStore((state) => state.deleteSession); + const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const setActiveProject = useProjectsStore((state) => state.setActiveProject); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); @@ -551,20 +862,47 @@ export const MobileSessionsSheet: React.FC = ({ open, const setProjectExpanded = useMobileSessionTreeStore((state) => state.setProjectExpanded); const setWorktreeExpanded = useMobileSessionTreeStore((state) => state.setWorktreeExpanded); const worktreeOrderByProject = useWorktreeOrderStore((state) => state.orderByProject); + const setWorktreeOrder = useWorktreeOrderStore((state) => state.setWorktreeOrder); const expandedParents = useMobileSessionExpansionStore((state) => state.expandedParents); const toggleParent = useMobileSessionExpansionStore((state) => state.toggleParent); const [query, setQuery] = React.useState(''); const [editingProjectId, setEditingProjectId] = React.useState(null); - const [confirmingArchiveSessionId, setConfirmingArchiveSessionId] = React.useState(null); + // Swipe-left actions: which row has its actions revealed, and whether its + // delete button is armed (two-step). One row at a time. + const [revealedSessionId, setRevealedSessionId] = React.useState(null); + const [confirmingDeleteSessionId, setConfirmingDeleteSessionId] = React.useState(null); + const [renamingSessionId, setRenamingSessionId] = React.useState(null); + // Swipe-left actions on group headers (`project:{id}` / `wt:{bucketKey}`) — + // separate from session rows, but mutually exclusive with them. + const [revealedRowId, setRevealedRowId] = React.useState(null); + const [confirmingRemoveProjectId, setConfirmingRemoveProjectId] = React.useState(null); + const [worktreeToDelete, setWorktreeToDelete] = React.useState<{ + project: ProjectMeta; + worktree: WorktreeMetadata; + } | null>(null); // Bumped to force a re-list of worktrees (e.g. after one is deleted in the editor). const [worktreeRefreshKey, setWorktreeRefreshKey] = React.useState(0); const [directoryDialogOpen, setDirectoryDialogOpen] = React.useState(false); const [newWorktreeDialogOpen, setNewWorktreeDialogOpen] = React.useState(false); const [worktreeDialogProjectId, setWorktreeDialogProjectId] = React.useState(null); - const [worktreesByProject, setWorktreesByProject] = React.useState>(new Map()); - const [gitProjectPaths, setGitProjectPaths] = React.useState>(new Set()); + // Seeded from the app-level worktree discovery (MobileApp populates + // availableWorktreesByProject on connect) so the FIRST open already shows + // worktrees; the per-open refresh below keeps them fresh without ever + // blanking the list. + const [worktreesByProject, setWorktreesByProject] = React.useState>( + () => new Map(useSessionUIStore.getState().availableWorktreesByProject), + ); + const [gitProjectPaths, setGitProjectPaths] = React.useState>(() => { + const seeded = new Set(); + for (const [path, worktrees] of useSessionUIStore.getState().availableWorktreesByProject) { + if (worktrees.length > 0) seeded.add(path); + } + return seeded; + }); const [editingOrder, setEditingOrder] = React.useState(false); - const [confirmingDeleteId, setConfirmingDeleteId] = React.useState(null); + // Reorder mode collapses projects by default (dragging past 40 worktrees is + // painful); tap outside the drag handle to expand one. + const [reorderExpandedProjects, setReorderExpandedProjects] = React.useState>(new Set()); // Per-bucket count of sessions revealed past the default page. Ephemeral — // resets when the sheet closes or when a group/project is toggled. Expand // state itself lives in useMobileSessionTreeStore (persisted). @@ -575,10 +913,14 @@ export const MobileSessionsSheet: React.FC = ({ open, if (!open) { setQuery(''); setEditingOrder(false); - setConfirmingDeleteId(null); + setReorderExpandedProjects(new Set()); setVisibleCountByBucket(new Map()); setEditingProjectId(null); - setConfirmingArchiveSessionId(null); + setRevealedSessionId(null); + setConfirmingDeleteSessionId(null); + setRenamingSessionId(null); + setRevealedRowId(null); + setConfirmingRemoveProjectId(null); return; } void refreshGlobalSessions(liveSessions); @@ -587,7 +929,7 @@ export const MobileSessionsSheet: React.FC = ({ open, }, [open]); React.useEffect(() => { - if (!editingOrder) setConfirmingDeleteId(null); + if (!editingOrder) setReorderExpandedProjects(new Set()); }, [editingOrder]); React.useEffect(() => { @@ -657,11 +999,30 @@ export const MobileSessionsSheet: React.FC = ({ open, for (const session of liveSessions) { if (!seenIds.has(session.id)) merged.push(session); } - return merged; + // Archived sessions never show on mobile (no archived view here): the live + // overlay can carry them for the active directory, and they'd otherwise + // surface in search and then "disappear" once the overlay refreshes. + return merged.filter((session) => !session.time?.archived); }, [globalActiveSessions, liveSessions]); const normalizedQuery = query.trim().toLowerCase(); + // On open, bring the current session (or at least its project) into view — + // the list keeps its scroll position between opens, so a long project list + // otherwise lands wherever it was left. Rows carry data-active-* markers. + const contentRootRef = React.useRef(null); + React.useEffect(() => { + if (!open) return; + const frame = window.requestAnimationFrame(() => { + const root = contentRootRef.current; + if (!root) return; + const target = root.querySelector('[data-active-session="true"]') + ?? root.querySelector('[data-active-project="true"]'); + target?.scrollIntoView({ block: 'center' }); + }); + return () => window.cancelAnimationFrame(frame); + }, [open]); + const projectNodes = React.useMemo(() => { const nodes: ProjectNode[] = projectsMeta.map((project) => ({ project, @@ -731,8 +1092,10 @@ export const MobileSessionsSheet: React.FC = ({ open, const isProjectExpanded = (node: ProjectNode): boolean => projectExpandedMap[node.project.id] ?? true; + // Worktrees default to EXPANDED (desktop parity): their sessions ARE the + // content; the header still toggles for users who want them tucked away. const isWorktreeExpanded = (node: ProjectNode, bucket: WorktreeBucket): boolean => - worktreeExpandedMap[`${node.project.id}::${bucket.key}`] ?? false; + worktreeExpandedMap[`${node.project.id}::${bucket.key}`] ?? true; const resetBucketVisibleCount = (bucketKey: string) => { setVisibleCountByBucket((previous) => { @@ -807,10 +1170,17 @@ export const MobileSessionsSheet: React.FC = ({ open, hasChildren={hasChildren} expanded={expanded} onToggleChildren={hasChildren ? () => toggleParent(session.id) : undefined} - confirmingArchive={confirmingArchiveSessionId === session.id} onSelect={() => handleSelectSession(session)} - onRequestArchive={() => handleRequestArchive(session.id)} - onConfirmArchive={() => void handleConfirmArchive(session)} + revealed={revealedSessionId === session.id} + onRevealedChange={(nextRevealed) => handleRowRevealedChange(session.id, nextRevealed)} + confirmingDelete={confirmingDeleteSessionId === session.id} + onArchive={() => void handleArchive(session)} + onRequestDelete={() => setConfirmingDeleteSessionId(session.id)} + onConfirmDelete={() => void handleConfirmDelete(session)} + renaming={renamingSessionId === session.id} + onRequestRename={() => handleRequestRename(session.id)} + onSubmitRename={(nextTitle) => void handleSubmitRename(session.id, nextTitle)} + onCancelRename={() => setRenamingSessionId(null)} /> {hasChildren && expanded ? children.map((child) => renderNode(child, rowIndent + CHILD_INDENT_STEP)) @@ -850,24 +1220,67 @@ export const MobileSessionsSheet: React.FC = ({ open, // setCurrentSession) — also move the active project so the rest of the app // and the active highlight follow the selected session, not just the draft. const project = findExactProjectMatch(projectsMeta, directory ?? ''); - if (project) setActiveProjectIdOnly(project.id); + if (project) { + setActiveProjectIdOnly(project.id); + // Expand the session's project (and worktree group) in the tree, so a + // session picked from search is actually visible — and the open-time + // auto-scroll can land on it — the next time the drawer opens. + setProjectExpanded(project.id, true); + const worktree = findExactWorktreeMatch(project, normalizePath(directory ?? '')); + if (worktree) setWorktreeExpanded(`${project.id}::${normalizePath(worktree.path)}`, true); + } void setCurrentSession(session.id, directory); onOpenChange(false); }; - // Two-step archive: first tap arms the confirm on that row, second confirms. - // Only one row can be in the confirming state at a time. - const handleRequestArchive = (sessionId: string) => { - setConfirmingArchiveSessionId((current) => (current === sessionId ? null : sessionId)); + // Swipe actions. Revealing a row disarms any pending delete confirm; archive + // fires immediately (the swipe itself is the intent), delete stays two-step. + const handleRowRevealedChange = (sessionId: string, nextRevealed: boolean) => { + setRevealedSessionId(nextRevealed ? sessionId : null); + setConfirmingDeleteSessionId(null); + setRevealedRowId(null); + setConfirmingRemoveProjectId(null); }; - const handleConfirmArchive = async (session: Session) => { - setConfirmingArchiveSessionId(null); + // Same contract for group headers (project / worktree rows). + const handleRowKeyRevealedChange = (rowKey: string, nextRevealed: boolean) => { + setRevealedRowId(nextRevealed ? rowKey : null); + setConfirmingRemoveProjectId(null); + setRevealedSessionId(null); + setConfirmingDeleteSessionId(null); + }; + + const handleArchive = async (session: Session) => { + setRevealedSessionId(null); + setConfirmingDeleteSessionId(null); const ok = await archiveSession(session.id); if (ok) toast.success(t('sessions.sidebar.session.archive.success')); else toast.error(t('sessions.sidebar.session.archive.error')); }; + const handleConfirmDelete = async (session: Session) => { + setRevealedSessionId(null); + setConfirmingDeleteSessionId(null); + const ok = await deleteSession(session.id); + if (ok) toast.success(t('sessions.sidebar.session.delete.success')); + else toast.error(t('sessions.sidebar.session.delete.error')); + }; + + const handleRequestRename = (sessionId: string) => { + setRevealedSessionId(null); + setConfirmingDeleteSessionId(null); + setRenamingSessionId(sessionId); + }; + + const handleSubmitRename = async (sessionId: string, title: string) => { + setRenamingSessionId(null); + try { + await updateSessionTitle(sessionId, title); + } catch { + toast.error(t('mobile.sessions.renameError')); + } + }; + const handleStartNewChat = () => { openNewSessionDraft(); onOpenChange(false); @@ -886,7 +1299,6 @@ export const MobileSessionsSheet: React.FC = ({ open, const handleReorderDragEnd = (event: DragEndEvent) => { const { active, over } = event; - setConfirmingDeleteId(null); if (!over || active.id === over.id) return; const fromIndex = projectsMeta.findIndex((p) => p.id === active.id); const toIndex = projectsMeta.findIndex((p) => p.id === over.id); @@ -894,14 +1306,13 @@ export const MobileSessionsSheet: React.FC = ({ open, reorderProjects(fromIndex, toIndex); }; - const handleRequestRemoveProject = (projectId: string) => { - setConfirmingDeleteId((current) => (current === projectId ? null : projectId)); - }; - - const handleConfirmRemoveProject = (project: ProjectMeta) => { - removeProject(project.id); - setConfirmingDeleteId(null); - toast.success(t('mobile.sessions.toast.projectRemoved', { label: project.label })); + const toggleReorderProjectExpanded = (projectId: string) => { + setReorderExpandedProjects((current) => { + const next = new Set(current); + if (next.has(projectId)) next.delete(projectId); + else next.add(projectId); + return next; + }); }; /** Short "Project · branch" string shown under the session title in search results. */ @@ -941,6 +1352,9 @@ export const MobileSessionsSheet: React.FC = ({ open, if (!normalizedQuery) return [] as Session[]; return orderSessionsByLifecycleScopes( sessions.filter((session) => { + // Subsessions are implementation noise in a flat search list — only + // top-level sessions are searchable. + if (getParentId(session)) return false; const directory = getSessionDirectory(session); const project = findExactProjectMatch(projectsMeta, directory); return sessionMatchesQuery(session, project?.label ?? '', normalizedQuery); @@ -1021,31 +1435,33 @@ export const MobileSessionsSheet: React.FC = ({ open, ) : null; const surfaceContent = ( -
-
-
- - setQuery(event.target.value)} - placeholder={t('mobile.sessions.search.placeholder')} - className={cn('h-11 pl-9', query && 'pr-10')} - /> - {query ? ( - - ) : null} -
-
- +
+ {/* The search bar scrolls WITH the list (iOS-style): the open-time + auto-scroll to the current session naturally tucks it away, and + scrolling to the very top brings it back. */} +
+
+ + setQuery(event.target.value)} + placeholder={t('mobile.sessions.search.placeholder')} + className={cn('h-11 pl-9', query && 'pr-10')} + /> + {query ? ( + + ) : null} +
+
{projectsMeta.length === 0 ? ( = ({ open, {searchSessionMatches.length}
-
+
{searchSessionMatches.map((session, index) => ( -
0 && 'border-t border-border/30')}> +
0 && 'border-t border-border/70')}> = ({ open, {searchProjectMatches.length}
-
+
{searchProjectMatches.map((project, index) => (
0 && 'border-t border-border/30')} + className={cn('flex items-center', index > 0 && 'border-t border-border/70')} > - {node.project.isGitRepo ? ( - handleNewWorktree(node.project.id)} - /> - ) : null} -
+ handleRowKeyRevealedChange(`project:${node.project.id}`, nextRevealed)} + actions={( + <> + + + + )} + > +
+ + {node.project.isGitRepo ? ( + handleNewWorktree(node.project.id)} + /> + ) : null} +
+
{projectExpanded ? (
@@ -1230,10 +1700,38 @@ export const MobileSessionsSheet: React.FC = ({ open, const isActiveWt = activeWorktreePath === bucket.path; return (
+ handleRowKeyRevealedChange(`wt:${bucket.key}`, nextRevealed)} + actions={( + + )} + > + {worktreeExpanded - ? renderBucketSessions(node, bucket, WORKTREE_SESSION_INDENT) + ? renderBucketSessions(node, bucket, PROJECT_SESSION_INDENT) : null}
); @@ -1283,6 +1785,58 @@ export const MobileSessionsSheet: React.FC = ({ open, )} + {/* App-level footer: instance on the left (Capacitor), settings — + plus a pending web update — on the right. Bottom placement keeps + the header for list actions and stays thumb-reachable. */} + {footer ? ( +
+ {footer.instanceLabel && footer.onOpenInstances ? ( + + ) : ( +
+ )} +
+ {footer.onOpenUpdate ? ( + + ) : null} + +
+
+ ) : null} + = ({ open, onClose={() => setEditingProjectId(null)} onWorktreesChanged={() => setWorktreeRefreshKey((value) => value + 1)} /> + {worktreeToDelete ? ( + setWorktreeToDelete(null)} + onDeleted={() => setWorktreeRefreshKey((value) => value + 1)} + /> + ) : null}
); @@ -1314,7 +1877,7 @@ export const MobileSessionsSheet: React.FC = ({ open, if (!open) return null; return (
-
+

{t('mobile.sessions.sheet.title')}

@@ -1328,15 +1891,122 @@ export const MobileSessionsSheet: React.FC = ({ open, } return ( - onOpenChange(false)} ariaLabel={t('mobile.sessions.sheet.title')} - title={t('mobile.sessions.sheet.title')} - trailing={trailingActions} > +
+ +

+ {t('mobile.sessions.sheet.title')} +

+ {trailingActions ? ( +
{trailingActions}
+ ) : null} +
{surfaceContent} -
+ + ); +}; + +const DRAWER_ROOT_ID = 'mobile-surface-root'; +const DRAWER_ENTER_DELAY_MS = 16; +// Slightly long, decelerating slide — matches the workspace drawer so both +// sides feel like the same piece of chrome. +const DRAWER_ENTER_DURATION_MS = 320; +const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +/** Full-width left drawer for the phone sessions list: covers the whole app + and slides in from the left edge. Closes via the header X, Escape, or the + Android back button (handled by MobileShell). + + Stays MOUNTED while closed (parked off-screen, hidden): the sessions + sheet's project/worktree state stays warm, so reopening shows the tree + instantly instead of refetching from scratch — and the close slide can + actually play instead of the drawer vanishing on unmount. */ +const MobileSessionsDrawerContainer: React.FC<{ + open: boolean; + onClose: () => void; + ariaLabel: string; + children: React.ReactNode; +}> = ({ open, onClose, ariaLabel, children }) => { + const rootRef = React.useRef(null); + const [entered, setEntered] = React.useState(false); + // Kept visible through the exit slide; flipped to hidden once it finishes. + const [visible, setVisible] = React.useState(open); + const onCloseRef = React.useRef(onClose); + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + if (typeof document !== 'undefined' && !rootRef.current) { + let root = document.getElementById(DRAWER_ROOT_ID); + if (!root) { + root = document.createElement('div'); + root.id = DRAWER_ROOT_ID; + document.body.appendChild(root); + } + rootRef.current = root; + } + + React.useEffect(() => { + if (open) { + setVisible(true); + const id = window.setTimeout(() => setEntered(true), DRAWER_ENTER_DELAY_MS); + return () => window.clearTimeout(id); + } + setEntered(false); + const id = window.setTimeout(() => setVisible(false), DRAWER_ENTER_DURATION_MS + 40); + return () => window.clearTimeout(id); + }, [open]); + + React.useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onCloseRef.current(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + if (!rootRef.current) return null; + + return createPortal( +
+
+ {children} +
+
, + rootRef.current, ); }; diff --git a/packages/ui/src/apps/MobileSurfaceShell.tsx b/packages/ui/src/apps/MobileSurfaceShell.tsx deleted file mode 100644 index 756322c5..00000000 --- a/packages/ui/src/apps/MobileSurfaceShell.tsx +++ /dev/null @@ -1,307 +0,0 @@ -import React from 'react'; -import { createPortal } from 'react-dom'; -import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'; - -import { useI18n } from '@/lib/i18n'; -import { cn } from '@/lib/utils'; - -const SURFACE_ROOT_ID = 'mobile-surface-root'; -const DISMISS_THRESHOLD_PX = 90; -const ENTER_DELAY_MS = 16; -// Enter-slide duration. Heavy content is revealed when this transition actually -// ends (transitionend); this also feeds the fallback timer. -const ENTER_DURATION_MS = 100; -// How far below its resting position the sheet starts the enter slide. Small -// offset → a short "rise + fade" rather than a full slide up from the bottom. -const ENTER_OFFSET_PX = 48; -// Extra gap above the sheet (below the top safe area) so it doesn't sit flush -// against the very top of the app. -const TOP_GAP_PX = 8; - -const ensureSurfaceRoot = (): HTMLElement | null => { - if (typeof document === 'undefined') return null; - let root = document.getElementById(SURFACE_ROOT_ID); - if (!root) { - root = document.createElement('div'); - root.id = SURFACE_ROOT_ID; - document.body.appendChild(root); - } - return root; -}; - -export type MobileSurfaceShellProps = { - open: boolean; - onClose: () => void; - title?: React.ReactNode; - subtitle?: React.ReactNode; - trailing?: React.ReactNode; - /** When set, the leading icon becomes a back arrow that calls this. Otherwise it's a close X bound to onClose. */ - onBack?: () => void; - /** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */ - disableSwipeDismiss?: boolean; - /** If true, leave Escape available to nested content instead of dismissing the surface. */ - disableEscapeDismiss?: boolean; - /** If true, render only the drag handle and let the child render its own header. */ - headerless?: boolean; - ariaLabel?: string; - children: React.ReactNode; -}; - -export const MobileSurfaceShell: React.FC = ({ - open, - onClose, - title, - subtitle, - trailing, - onBack, - disableSwipeDismiss = false, - disableEscapeDismiss = false, - headerless = false, - ariaLabel, - children, -}) => { - const { t } = useI18n(); - const rootRef = React.useRef(null); - const [mounted, setMounted] = React.useState(false); - const [entered, setEntered] = React.useState(false); - const [contentReady, setContentReady] = React.useState(false); - const [dragOffset, setDragOffset] = React.useState(0); - const dragStartYRef = React.useRef(null); - const isDraggingRef = React.useRef(false); - const surfaceRef = React.useRef(null); - const previousFocusRef = React.useRef(null); - // Keep onClose in a ref so the focus/keydown effect below depends only on `open`. - // The parent passes a fresh inline onClose on every render; if the effect depended - // on it, each parent re-render (e.g. an SSE store update) would re-run it and - // refocus the first element — stealing focus from whatever input the user is in - // and collapsing the keyboard mid-edit. - const onCloseRef = React.useRef(onClose); - React.useEffect(() => { - onCloseRef.current = onClose; - }, [onClose]); - - if (typeof document !== 'undefined' && !rootRef.current) { - rootRef.current = ensureSurfaceRoot(); - } - - React.useEffect(() => { - if (open) { - setMounted(true); - const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS); - return () => window.clearTimeout(id); - } - setEntered(false); - const id = window.setTimeout(() => setMounted(false), 300); - return () => window.clearTimeout(id); - }, [open]); - - // Defer mounting heavy children until the enter slide finishes, so the - // animation stays smooth instead of competing with a large content render. - // Primary trigger is the slide's transitionend (below); this is just a - // fallback in case it never fires (reduced motion / interrupted transition). - React.useEffect(() => { - if (!open) { - setContentReady(false); - return; - } - const id = window.setTimeout(() => setContentReady(true), ENTER_DELAY_MS + ENTER_DURATION_MS + 80); - return () => window.clearTimeout(id); - }, [open]); - - React.useEffect(() => { - if (!open) return; - const previousOverflow = document.body.style.overflow; - previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; - document.body.style.overflow = 'hidden'; - const focusFirstElement = () => { - const surface = surfaceRef.current; - if (!surface) return; - const focusable = surface.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - (focusable ?? surface).focus({ preventScroll: true }); - }; - const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS); - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !disableEscapeDismiss) { - onCloseRef.current(); - return; - } - if (event.key !== 'Tab') return; - const surface = surfaceRef.current; - if (!surface) return; - const focusable = Array.from(surface.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - )).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true'); - if (focusable.length === 0) { - event.preventDefault(); - surface.focus({ preventScroll: true }); - return; - } - const first = focusable[0]; - const last = focusable[focusable.length - 1]; - const active = document.activeElement; - if (event.shiftKey && active === first) { - event.preventDefault(); - last.focus({ preventScroll: true }); - } else if (!event.shiftKey && active === last) { - event.preventDefault(); - first.focus({ preventScroll: true }); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => { - window.clearTimeout(focusTimer); - document.body.style.overflow = previousOverflow; - document.removeEventListener('keydown', handleKeyDown); - previousFocusRef.current?.focus?.({ preventScroll: true }); - previousFocusRef.current = null; - }; - }, [disableEscapeDismiss, open]); - - const handleDragStart = (event: React.TouchEvent) => { - if (disableSwipeDismiss) return; - dragStartYRef.current = event.touches[0]?.clientY ?? null; - isDraggingRef.current = true; - }; - - const handleDragMove = (event: React.TouchEvent) => { - if (!isDraggingRef.current || dragStartYRef.current == null) return; - const currentY = event.touches[0]?.clientY ?? dragStartYRef.current; - const delta = currentY - dragStartYRef.current; - setDragOffset(delta > 0 ? delta : 0); - }; - - const handleDragEnd = () => { - if (!isDraggingRef.current) return; - isDraggingRef.current = false; - dragStartYRef.current = null; - if (dragOffset >= DISMISS_THRESHOLD_PX) { - setDragOffset(0); - onClose(); - } else { - setDragOffset(0); - } - }; - - if (!mounted || !rootRef.current) return null; - - const leading = onBack ? ( - - ) : ( - - ); - - // When settled, use `none` (not translateY(0)) so the sheet isn't kept on a - // compositing layer — that layer is clipped to the safe-area viewport on iOS, - // leaving a scrim gap below it over the home-indicator inset. - const visualTransform = !entered - ? `translateY(${ENTER_OFFSET_PX}px)` - : dragOffset > 0 - ? `translateY(${dragOffset}px)` - : 'none'; - - return createPortal( -
- {/* Sheet is a normal flex child — mirroring MobileOverlayPanel. */} -
event.stopPropagation()} - onTransitionEnd={(event) => { - // Reveal content exactly when the enter slide ends — not on a fixed timer. - if (entered && event.target === event.currentTarget && event.propertyName === 'transform') { - setContentReady(true); - } - }} - style={{ - // Sized to leave the top safe area (plus a small gap) uncovered so the - // scrim dims it and the sheet sits a few px below the very top. - height: `calc(100% - var(--oc-safe-area-top, 0px) - ${TOP_GAP_PX}px)`, - transform: visualTransform, - transition: isDraggingRef.current - ? 'none' - : `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`, - }} - > -
- {disableSwipeDismiss ? ( -
- ) : ( -
- -
- )} - {!headerless ? ( -
- {leading} -
- {title ? ( - typeof title === 'string' ? ( -

{title}

- ) : ( - title - ) - ) : null} - {subtitle ? ( - typeof subtitle === 'string' ? ( -

{subtitle}

- ) : ( - subtitle - ) - ) : null} -
- {trailing ?
{trailing}
: null} -
- ) : null} -
-
- {contentReady ? ( -
- {children} -
- ) : null} -
-
- -
, - rootRef.current, - ); -}; diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx new file mode 100644 index 00000000..3d8e6392 --- /dev/null +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -0,0 +1,274 @@ +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { Icon } from '@/components/icon/Icon'; +import { McpIcon } from '@/components/icons/McpIcon'; +import { McpDropdownContent } from '@/components/mcp/McpDropdown'; +import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs'; +import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; +import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; +import { TerminalView } from '@/components/views/TerminalView'; +import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useMcpConfigStore } from '@/stores/useMcpConfigStore'; +import { useMcpStore } from '@/stores/useMcpStore'; + +import { MobileChangesSurface } from './MobileChangesSurface'; +import { MobileFilesSurface } from './MobileFilesSurface'; + +const DRAWER_ROOT_ID = 'mobile-surface-root'; +const ENTER_DELAY_MS = 16; +// Slightly long, decelerating slide — matches the sessions drawer so both +// sides feel like the same piece of chrome. +const ENTER_DURATION_MS = 320; +const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +export type MobileWorkspaceTab = 'changes' | 'files' | 'terminal' | 'notes' | 'mcp'; + +/** Quick MCP enable/disable toggles as a workspace pane, with its own slim + action row (add server → settings, refresh) replacing the old fullscreen + surface's header actions. */ +const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenMcpSettings }) => { + const { t } = useI18n(); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const refreshMcpStatus = useMcpStore((state) => state.refresh); + const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); + + const refresh = () => { + if (isRefreshing) return; + setIsRefreshing(true); + const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500)); + void Promise.all([ + refreshMcpStatus({ directory: currentDirectory || null, silent: true }), + loadMcpConfigs({ force: true }), + minSpinPromise, + ]).finally(() => setIsRefreshing(false)); + }; + + return ( +
+
+ + +
+
+ +
+
+ ); +}; + +/** Full-width right drawer with the phone workspace surfaces as tabs + (Changes / Files / Terminal / Notes / MCP). Slides in from the right edge; + closes via the header X, Escape (unless the terminal tab owns the keys), + or the Android back button (handled by MobileShell). */ +export const MobileWorkspaceDrawer: React.FC<{ + open: boolean; + onClose: () => void; + tab: MobileWorkspaceTab; + onTabChange: (tab: MobileWorkspaceTab) => void; + /** When set, the Changes tab opens directly into the per-file diff. */ + pendingChangesDiff: { path: string; staged: boolean } | null; + /** Notes tab: opens a plan fullscreen (layered above the drawer). */ + onOpenPlan: (plan: { path: string; title: string }) => void; + /** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */ + onOpenMcpSettings: () => void; +}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings }) => { + const { t } = useI18n(); + const rootRef = React.useRef(null); + const [entered, setEntered] = React.useState(false); + // Kept visible through the exit slide; flipped to hidden once it finishes. + const [visible, setVisible] = React.useState(open); + const onCloseRef = React.useRef(onClose); + React.useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + const tabRef = React.useRef(tab); + React.useEffect(() => { + tabRef.current = tab; + }, [tab]); + + // Tabs the user has actually opened — their panes stay mounted afterwards. + const [visitedTabs, setVisitedTabs] = React.useState>(() => new Set()); + React.useEffect(() => { + if (!open) return; + setVisitedTabs((current) => { + if (current.has(tab)) return current; + const next = new Set(current); + next.add(tab); + return next; + }); + }, [open, tab]); + + if (typeof document !== 'undefined' && !rootRef.current) { + let root = document.getElementById(DRAWER_ROOT_ID); + if (!root) { + root = document.createElement('div'); + root.id = DRAWER_ROOT_ID; + document.body.appendChild(root); + } + rootRef.current = root; + } + + React.useEffect(() => { + if (open) { + setVisible(true); + const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS); + return () => window.clearTimeout(id); + } + setEntered(false); + const id = window.setTimeout(() => setVisible(false), ENTER_DURATION_MS + 40); + return () => window.clearTimeout(id); + }, [open]); + + React.useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const handleKeyDown = (event: KeyboardEvent) => { + // The terminal owns Escape (it goes to the PTY) — don't hijack it. + if (event.key === 'Escape' && tabRef.current !== 'terminal') onCloseRef.current(); + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.body.style.overflow = previousOverflow; + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open]); + + if (!rootRef.current) return null; + + const tabItems: SortableTabsStripItem[] = [ + { id: 'changes', label: t('mobile.menu.changes'), icon: }, + { id: 'files', label: t('mobile.menu.files'), icon: }, + { id: 'terminal', label: t('mobile.menu.terminal'), icon: }, + { id: 'notes', label: t('contextRail.surface.notes'), icon: }, + { id: 'mcp', label: t('mobile.menu.mcp'), icon: }, + ]; + + return createPortal( +
+
+
+ {/* Mounted only while shown; nonCompositedIndicator keeps the active + pill off its own compositing layer — creating one inside the + drawer's slide flickers in WKWebView. */} + {visible ? ( + onTabChange(id as MobileWorkspaceTab)} + layoutMode="fit" + variant="active-pill" + nonCompositedIndicator + // Five tabs don't fit with labels — the active tab keeps + // icon + label, the rest collapse to icons. + inactiveTabsIconOnly + className="h-full" + /> + ) : null} +
+ +
+
+ {/* Panes stay MOUNTED once visited (hidden when inactive/closed), so + reopening the drawer lands exactly where the user left off — an + open diff, an edited file, an attached terminal. */} + {visitedTabs.has('changes') ? ( +
+ + + +
+ ) : null} + {visitedTabs.has('files') ? ( +
+ + + +
+ ) : null} + {visitedTabs.has('terminal') ? ( +
+ + + +
+ ) : null} + {visitedTabs.has('notes') ? ( +
+ + + +
+ ) : null} + {visitedTabs.has('mcp') ? ( +
+ + + +
+ ) : null} +
+
, + rootRef.current, + ); +}; diff --git a/packages/ui/src/apps/deepLinkNavigation.ts b/packages/ui/src/apps/deepLinkNavigation.ts index cff08359..80bd2de0 100644 --- a/packages/ui/src/apps/deepLinkNavigation.ts +++ b/packages/ui/src/apps/deepLinkNavigation.ts @@ -2,7 +2,6 @@ import React from 'react'; import { isCapacitorApp } from '@/lib/platform'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { useUIStore } from '@/stores/useUIStore'; import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks'; @@ -59,9 +58,10 @@ const execute = (intent: DeepLinkIntent): boolean => { return true; case 'status': - // The session status panel is store-backed (useUIStore.mobileSessionPanelOpen), - // so it opens without a shell handler — like session/new-session. - useUIStore.getState().setMobileSessionPanelOpen(true); + // The old input-bar status panel is gone — recent sessions with statuses + // now live in the sessions drawer, so route status links there. + if (!handlers.openSessions) return false; + handlers.openSessions(); return true; case 'view': diff --git a/packages/ui/src/apps/ipadSidebarResize.ts b/packages/ui/src/apps/ipadSidebarResize.ts new file mode 100644 index 00000000..87463186 --- /dev/null +++ b/packages/ui/src/apps/ipadSidebarResize.ts @@ -0,0 +1,89 @@ +import React from 'react'; + +export const IPAD_LEFT_SIDEBAR_WIDTH = 320; +export const IPAD_RIGHT_SIDEBAR_WIDTH = 380; +const IPAD_SIDEBAR_MIN_WIDTH = 280; +const IPAD_SIDEBAR_MAX_WIDTH = 560; + +/** 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. */ +export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, defaultWidth: number) { + const asideRef = React.useRef(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(null); + const pointerIdRef = React.useRef(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 }; +} + diff --git a/packages/ui/src/apps/mobileConnectionUi.ts b/packages/ui/src/apps/mobileConnectionUi.ts new file mode 100644 index 00000000..f1ddb06f --- /dev/null +++ b/packages/ui/src/apps/mobileConnectionUi.ts @@ -0,0 +1,9 @@ +/** Kills autocorrect/autocomplete on URL/token/password fields — mobile keyboards + mangle those values otherwise. */ +export const mobileInputKeyboardProps = { + autoComplete: 'off', + autoCorrect: 'off', + spellCheck: false, +} as const; + +export const mobileConnectionInputClass = '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'; diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 7b50cf45..1783e48a 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -844,7 +844,12 @@ const probeConnectionCandidates = async ( continue; } } - const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions); + // With a bearer token, probe EXACTLY the way the runtime authenticates: + // bearer-only, no cookies. A leftover valid oc_ui_session cookie in the + // WebView otherwise answers "authenticated" for a revoked/expired token, + // the probe passes, and the app dies later on bootstrap's bearer-only + // requests. Cookie auth stays for the token-less (browser) flow. + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions); if (session?.status === 401) return { status: 'needs-login' }; if (!session || (!session.ok && session.status !== 404)) continue; const status = await readSessionStatus(session); @@ -976,28 +981,45 @@ export const getAutoConnectTargetLabel = (): string | null => { // the runtime endpoint when reachable AND we already have a usable bearer token; // returns false — caller shows the connect screen — when there is no saved // instance, it's unreachable, or it needs a (re)login. No prompts or UI state. -export const autoConnectLastInstance = async (): Promise => { +export type AutoConnectOutcome = + | { status: 'connected' } + /** No saved instance / no saved token — nothing to report to the user. */ + | { status: 'no-candidate' } + | { status: 'unreachable'; label: string } + /** The saved token was rejected (expired/revoked) — the user must sign in again. */ + | { status: 'needs-login'; label: string }; + +export const autoConnectLastInstance = async (): Promise => { await migrateLegacyInlineTokens(); const candidate = readConnections()[0]; // sorted most-recent-first - if (!candidate) return false; + if (!candidate) return { status: 'no-candidate' }; // The runtime transport needs a bearer token; only auto-connect when one is // already saved. A missing/expired token must go through the login UI. let token: string | undefined; if (isCapacitorApp()) { - if (!candidate.hasToken) return false; + if (!candidate.hasToken) { + return { status: 'no-candidate' }; + } token = await readSecureToken(secureTokenKeyOf(candidate)); - if (!token) return false; + if (!token) { + return { status: 'no-candidate' }; + } } else { token = candidate.clientToken; - if (!token) return false; + if (!token) return { status: 'no-candidate' }; } - const result = await probeConnectionCandidates(candidate.candidates, token); - if (result.status !== 'ok') return false; + // Fast probe: the cold-launch splash should decide in a couple of seconds, + // not sit through the full connect timeouts on a dead LAN candidate. A slow + // network that fails the fast probe still lands on the connect screen where + // a manual tap retries with the full budget. + const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true }); + if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label }; + if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label }; await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token) switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) }); - return true; + return { status: 'connected' }; }; export const validateMobileConnectionSession = async (input: { @@ -1019,7 +1041,9 @@ export const validateMobileConnectionSession = async (input: { const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }, requestOptions); if (!health?.ok) return false; - const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions); + // Bearer-only when a token is present — see the probe note about stale + // session cookies masking a revoked token. + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions); if (!session || (!session.ok && session.status !== 404)) return false; const status = await readSessionStatus(session); @@ -1129,7 +1153,7 @@ export const isActiveRuntimeConnection = (connection: MobileSavedConnection): bo return Boolean(runtimeKey) && secureTokenKeyOf(connection) === runtimeKey; }; -export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'no-connection'; +export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-login' | 'no-connection'; // App-resume re-probe: when the app wakes (Capacitor `isActive`), the network may // have changed while it slept, so re-select the active device's transport and @@ -1163,7 +1187,8 @@ export const reprobeActiveConnection = async (): Promise => { switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) }); return 'switched'; } - if (better.status === 'needs-login') return 'unreachable'; + // The shared token was explicitly rejected — no transport will accept it. + if (better.status === 'needs-login') return 'needs-login'; // 2. No better transport — is the current one still alive on its live channel? if (currentIndex >= 0) { @@ -1186,6 +1211,7 @@ export const reprobeActiveConnection = async (): Promise => { switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) }); return 'switched'; } + if (fallback.status === 'needs-login') return 'needs-login'; return 'unreachable'; }; diff --git a/packages/ui/src/apps/mobileNativeChrome.ts b/packages/ui/src/apps/mobileNativeChrome.ts new file mode 100644 index 00000000..3c4eb92a --- /dev/null +++ b/packages/ui/src/apps/mobileNativeChrome.ts @@ -0,0 +1,421 @@ +import React from 'react'; + +/** True when running inside the native Capacitor shell (iOS/Android app). */ +export 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:'; +}; + +export const useNativeMobileChrome = (): void => { + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const root = document.documentElement; + // Marks the Capacitor shell so keyboard-inset CSS only applies here, not in + // the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget). + root.classList.add('oc-capacitor-app'); + // Platform marker: Android resizes the window for the keyboard natively (no manual + // inset/choreography — the keyboard listeners below skip Android entirely). + 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') { + // 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). + const isDark = document.documentElement.classList.contains('dark'); + const themeBg = + (isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) || + (isDark ? '#171515' : '#fffdf4'); + await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined); + await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined); + // Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg), + // Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light. + await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + return; + } + await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined); + await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined); + await StatusBar.show().catch(() => undefined); + }; + await applyStatusBar(); + const retry = window.setTimeout(() => void applyStatusBar(), 400); + cleanup.push(() => window.clearTimeout(retry)); + + const { App } = await import('@capacitor/app'); + const stateHandle = await App.addListener('appStateChange', ({ isActive }) => { + if (isActive) void applyStatusBar(); + }); + if (disposed) { + void stateHandle.remove(); + return; + } + cleanup.push(() => void stateHandle.remove()); + }).catch(() => undefined); + + void import('@capacitor/keyboard').then(async ({ Keyboard }) => { + if (disposed) return; + // iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard + // overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the + // window for the keyboard (dvh already shrinks), so applying the inset on top would + // double-count — Android gets only the class/event signals below. + const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.(); + if (platform === 'android') { + // Android resizes the WebView natively, so no inset/transform + // choreography — but the UI still needs the open/closed signal: + // oc-keyboard-open drives CSS (draft starters, composer padding), and + // the settled event gives the chat its one deterministic re-pin after + // the native resize (the auto-follow idle gate ignores it otherwise). + const willShowHandle = await Keyboard.addListener('keyboardWillShow', () => { + root.classList.add('oc-keyboard-open'); + // The composer already expanded on tap — re-pin the chat to it now, + // so the native resize that follows is the only remaining movement. + window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } })); + }); + const didShowHandle = await Keyboard.addListener('keyboardDidShow', () => { + window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } })); + }); + const willHideHandle = await Keyboard.addListener('keyboardWillHide', () => { + // Same single-motion trick as iOS: collapse the composer into the + // pill synchronously (flushSync in ChatInput) so the native window + // growth and the composer shrink land together, not as two steps. + window.dispatchEvent(new CustomEvent('oc:keyboard-intent', { detail: { open: false } })); + root.classList.remove('oc-keyboard-open'); + }); + const didHideHandle = await Keyboard.addListener('keyboardDidHide', () => { + window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: false } })); + }); + const removeAll = () => { + void willShowHandle.remove(); + void didShowHandle.remove(); + void willHideHandle.remove(); + void didHideHandle.remove(); + }; + if (disposed) { + removeAll(); + return; + } + cleanup.push(removeAll); + return; + } + // No WebKit form accessory bar (prev/next arrows + Done) above the keyboard — + // there's a single input, so it only eats vertical space. + await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined); + + // 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 + // visible motion is transform-only (inline styles on the kb-movers), and the shell's layout + // 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; + let caretTimer: number | null = null; + 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; + } + }; + const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record) => { + window.dispatchEvent(new CustomEvent(type, { detail })); + }; + // 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('.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('.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 = ''; + } + }; + + const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + 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'); + // WKWebView renders the caret as a native layer that doesn't ride CSS + // transforms — after the rise it visibly "flies" from the pre-keyboard + // position to the final one. Hide it for the transition (plus the lag + // window where UIKit animates it into place) and pop it back in. + if (caretTimer !== null) { + window.clearTimeout(caretTimer); + caretTimer = null; + } + root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold'); + setInset(keyboardHeight); + 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 }); + 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; + clearKbMovers(); + dispatchKb('oc:keyboard-settled', { open: true }); + // Reveal the caret only after UIKit's own caret reposition window. + caretTimer = window.setTimeout(() => { + caretTimer = null; + root.classList.remove('oc-kb-caret-hold'); + }, 250); + }, KB_ANIM_MS + 20); + }); + + // 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(); + // Fired BEFORE any layout change: lets the composer collapse into its + // pill synchronously (flushSync in ChatInput), so the keyboard hide + // compensation below measures keyboard + composer shrink as ONE delta + // instead of two staggered steps. + dispatchKb('oc:keyboard-intent', { open: false }); + if (caretTimer !== null) { + window.clearTimeout(caretTimer); + caretTimer = null; + } + root.classList.remove('oc-kb-caret-hold'); + const slide = Math.max(0, keyboardHeight - safeBottomPx); + root.classList.remove('oc-keyboard-open'); + setInset(0); + setVar('--oc-kb-scroll-inset', 0); + if (layoutApplied) { + // Settled-open → restore the full-height layout NOW (still hidden behind + // the keyboard) and FLIP the movers to their raised position without + // transitioning, so the next frame looks unchanged. + root.classList.remove('oc-kb-animating'); + setVar('--oc-kb-layout', 0); + layoutApplied = false; + for (const { el, factor } of getKbMovers()) { + el.style.transition = 'none'; + el.style.transform = `translateY(${-slide * factor}px)`; + } + // 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), + // the movers transition back down from wherever they currently are. + dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING }); + root.classList.add('oc-kb-animating', 'oc-kb-hide'); + for (const { el } of getKbMovers()) { + el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`; + el.style.transform = 'translateY(0px)'; + } + settleTimer = window.setTimeout(() => { + settleTimer = null; + root.classList.remove('oc-kb-animating', 'oc-kb-hide'); + clearKbMovers(); + 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); + + if (disposed) { + clearSettle(); + document.removeEventListener('focusout', handleFocusOut, true); + void showHandle.remove(); + void hideHandle.remove(); + return; + } + cleanup.push( + clearSettle, + () => { + if (caretTimer !== null) { + window.clearTimeout(caretTimer); + caretTimer = null; + } + }, + () => document.removeEventListener('focusout', handleFocusOut, true), + () => void showHandle.remove(), + () => void hideHandle.remove(), + ); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android'); + root.style.removeProperty('--oc-keyboard-inset'); + root.style.removeProperty('--oc-kb-shift'); + root.style.removeProperty('--oc-kb-layout'); + root.style.removeProperty('--oc-kb-scroll-inset'); + }; + }, []); +}; + +export const useNativeMobileLifecycle = (onResume: () => void): void => { + const wasInactiveRef = React.useRef(false); + + React.useEffect(() => { + if (!isCapacitorMobileApp()) return; + + let disposed = false; + const cleanup: Array<() => void> = []; + const resumeAfterInactive = () => { + if (!wasInactiveRef.current) return; + wasInactiveRef.current = false; + onResume(); + }; + + // 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)); + + void import('@capacitor/app').then(async ({ App }) => { + if (disposed) return; + const state = await App.addListener('appStateChange', ({ isActive }) => { + document.documentElement.classList.toggle('oc-native-app-active', isActive); + if (!isActive) { + wasInactiveRef.current = true; + return; + } + resumeAfterInactive(); + }); + const resume = await App.addListener('resume', resumeAfterInactive); + if (disposed) { + void state.remove(); + void resume.remove(); + return; + } + cleanup.push(() => void state.remove(), () => void resume.remove()); + }).catch(() => undefined); + + return () => { + disposed = true; + cleanup.forEach((remove) => remove()); + }; + }, [onResume]); +}; + +export 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]); +}; diff --git a/packages/ui/src/apps/mobilePaths.ts b/packages/ui/src/apps/mobilePaths.ts new file mode 100644 index 00000000..98d49719 --- /dev/null +++ b/packages/ui/src/apps/mobilePaths.ts @@ -0,0 +1,16 @@ +import type { ProjectEntry } from '@/lib/api/types'; + +export const normalizePath = (value?: string | null): string => + (value || '').replace(/\\/g, '/').replace(/\/+$/g, ''); + +export 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; +}; + +export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => { + if (project) return project.label?.trim() || getProjectLabel(project.path); + return getProjectLabel(fallbackDirectory); +}; diff --git a/packages/ui/src/apps/renderMobileApp.tsx b/packages/ui/src/apps/renderMobileApp.tsx index a665d2d5..559886db 100644 --- a/packages/ui/src/apps/renderMobileApp.tsx +++ b/packages/ui/src/apps/renderMobileApp.tsx @@ -44,6 +44,10 @@ const initializeSharedPreferences = () => { }; export function renderMobileApp(apis: RuntimeAPIs) { + // Stamp the surface before anything else reads it: perf tuning, sync paging, + // and device info all key off isMobileSurfaceRuntime(), and without the stamp + // a wide native device (iPad landscape) would fall out of the mobile branch. + window.__OPENCHAMBER_SURFACE__ = 'mobile'; preloadMarkdownRenderer(); initializeSharedPreferences(); diff --git a/packages/ui/src/apps/useEdgeSwipe.ts b/packages/ui/src/apps/useEdgeSwipe.ts new file mode 100644 index 00000000..5247c00f --- /dev/null +++ b/packages/ui/src/apps/useEdgeSwipe.ts @@ -0,0 +1,85 @@ +import React from 'react'; + +/** + * Native-feeling edge swipes on the mobile chat: start a horizontal swipe from + * the very left/right screen edge and drag toward the centre. + * + * - Left edge → centre = open the sessions drawer + * - Right edge → centre = open the most recent overflow surface + * + * Only `touchstart`/`touchend` are observed (both passive), so this never + * interferes with vertical chat scrolling or the horizontal scroll inside code + * blocks — it just reads where the gesture began and ended. The edge zone + * keeps it clear of in-content horizontal scroll, which lives away from the + * screen edges. + */ + +const EDGE_ZONE = 32; // px from a side where the swipe must begin +const MIN_DISTANCE = 64; // px of horizontal travel required to commit +const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal) + +export interface EdgeSwipeOptions { + /** Swipe that started at the left edge and travelled right. */ + onLeftEdgeSwipe?: () => void; + /** Swipe that started at the right edge and travelled left. */ + onRightEdgeSwipe?: () => void; +} + +export const useEdgeSwipe = ( + ref: React.RefObject, + options: EdgeSwipeOptions, +): void => { + // Keep callbacks in a ref so changing identities don't re-attach the listeners. + const optionsRef = React.useRef(options); + optionsRef.current = options; + + React.useEffect(() => { + const element = ref.current; + if (!element) return; + + let tracking = false; + let fromLeftEdge = false; + let startX = 0; + let startY = 0; + + const onTouchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) { + tracking = false; + return; + } + const touch = event.touches[0]; + const width = element.clientWidth; + const nearLeft = touch.clientX <= EDGE_ZONE; + const nearRight = touch.clientX >= width - EDGE_ZONE; + tracking = nearLeft || nearRight; + fromLeftEdge = nearLeft; + startX = touch.clientX; + startY = touch.clientY; + }; + + const onTouchEnd = (event: TouchEvent) => { + if (!tracking) return; + tracking = false; + const touch = event.changedTouches[0]; + if (!touch) return; + + const dx = touch.clientX - startX; + const dy = touch.clientY - startY; + if (Math.abs(dx) < MIN_DISTANCE) return; + if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return; + // Must travel toward the centre: left edge → rightward, right edge → leftward. + if (fromLeftEdge && dx <= 0) return; + if (!fromLeftEdge && dx >= 0) return; + + if (fromLeftEdge) optionsRef.current.onLeftEdgeSwipe?.(); + else optionsRef.current.onRightEdgeSwipe?.(); + }; + + element.addEventListener('touchstart', onTouchStart, { passive: true }); + element.addEventListener('touchend', onTouchEnd, { passive: true }); + return () => { + element.removeEventListener('touchstart', onTouchStart); + element.removeEventListener('touchend', onTouchEnd); + }; + }, [ref]); +}; diff --git a/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts b/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts deleted file mode 100644 index bb32dca9..00000000 --- a/packages/ui/src/apps/useEdgeSwipeSessionSwitch.ts +++ /dev/null @@ -1,128 +0,0 @@ -import React from 'react'; -import type { Session } from '@opencode-ai/sdk/v2'; - -import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; -import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore'; -import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; -import { useSessionUIStore } from '@/sync/session-ui-store'; - -/** - * Native-feeling edge swipe to switch sessions in the mobile chat: start a horizontal swipe - * from the very left/right edge and drag toward the centre to step through sessions. - * - * - Left edge → centre = previous session (the more-recent one in the list) - * - Right edge → centre = next session (the older one) - * - * Navigation walks the same ranked list the rest of the mobile UI uses: top-level sessions - * (no subtasks) across all projects, lifecycle-ranked with timestamp fallback. The order is computed at - * gesture time from the store (not subscribed) so it's always fresh and never re-attaches. - * - * Only `touchstart`/`touchend` are observed (both passive), so this never interferes with - * vertical chat scrolling or the horizontal scroll inside code blocks — it just reads where the - * gesture began and ended. The edge zone keeps it clear of in-content horizontal scroll, which - * lives away from the screen edges. - */ - -const EDGE_ZONE = 32; // px from a side where the swipe must begin -const MIN_DISTANCE = 64; // px of horizontal travel required to commit a switch -const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal) - -const parentIdOf = (session: Session): string | null => - (session as Session & { parentID?: string | null }).parentID ?? null; - -/** Top-level sessions across all projects in shared display order. */ -const orderedTopLevelSessions = (): Session[] => { - const pinnedSessionIds = useSessionPinnedStore.getState().ids; - const sessionOrderRanks = useSessionOrderingStore.getState().rankById; - return useGlobalSessionsStore - .getState() - .activeSessions.filter((session) => parentIdOf(session) === null) - .slice() - .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); -}; - -/** - * Switch to the session `step` positions away from the current one (clamped — no wrap). - * Returns true if a switch actually happened. - */ -const switchByStep = (step: number): boolean => { - const ordered = orderedTopLevelSessions(); - if (ordered.length < 2) return false; - - const currentId = useSessionUIStore.getState().currentSessionId; - const index = ordered.findIndex((session) => session.id === currentId); - if (index < 0) return false; - - const targetIndex = index + step; - if (targetIndex < 0 || targetIndex >= ordered.length) return false; - - const target = ordered[targetIndex]; - useSessionUIStore.getState().setCurrentSession(target.id, resolveGlobalSessionDirectory(target)); - return true; -}; - -export interface EdgeSwipeSessionSwitchOptions { - /** Called after a successful switch, with the travel direction, so the caller can animate. */ - onSwitch?: (direction: 'prev' | 'next') => void; -} - -export const useEdgeSwipeSessionSwitch = ( - ref: React.RefObject, - options?: EdgeSwipeSessionSwitchOptions, -): void => { - // Keep onSwitch in a ref so a changing callback identity doesn't re-attach the listeners. - const onSwitchRef = React.useRef(options?.onSwitch); - onSwitchRef.current = options?.onSwitch; - - React.useEffect(() => { - const element = ref.current; - if (!element) return; - - let tracking = false; - let fromLeftEdge = false; - let startX = 0; - let startY = 0; - - const onTouchStart = (event: TouchEvent) => { - if (event.touches.length !== 1) { - tracking = false; - return; - } - const touch = event.touches[0]; - const width = element.clientWidth; - const nearLeft = touch.clientX <= EDGE_ZONE; - const nearRight = touch.clientX >= width - EDGE_ZONE; - tracking = nearLeft || nearRight; - fromLeftEdge = nearLeft; - startX = touch.clientX; - startY = touch.clientY; - }; - - const onTouchEnd = (event: TouchEvent) => { - if (!tracking) return; - tracking = false; - const touch = event.changedTouches[0]; - if (!touch) return; - - const dx = touch.clientX - startX; - const dy = touch.clientY - startY; - if (Math.abs(dx) < MIN_DISTANCE) return; - if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return; - // Must travel toward the centre: left edge → rightward, right edge → leftward. - if (fromLeftEdge && dx <= 0) return; - if (!fromLeftEdge && dx >= 0) return; - - const step = fromLeftEdge ? -1 : 1; - if (switchByStep(step)) { - onSwitchRef.current?.(step < 0 ? 'prev' : 'next'); - } - }; - - element.addEventListener('touchstart', onTouchStart, { passive: true }); - element.addEventListener('touchend', onTouchEnd, { passive: true }); - return () => { - element.removeEventListener('touchstart', onTouchStart); - element.removeEventListener('touchend', onTouchEnd); - }; - }, [ref]); -}; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index f6c66922..17aee5c4 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -771,7 +771,9 @@ export const ChatContainer: React.FC = ({ active = true, aut React.useEffect(() => { if (autoOpenDraft && !currentSessionId && !draftOpen) { - openNewSessionDraft(); + // Programmatic fallback, not user navigation — must not clear the + // persisted last-session pointer the cold-launch restore reads. + openNewSessionDraft({ automatic: true }); } }, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index cbb4e972..b1cda99e 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -48,7 +48,6 @@ import { PendingChangesBar } from './PendingChangesBar'; import { useChatSurfaceMode } from './useChatSurfaceMode'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; -import { MobileSessionStatusBar } from './MobileSessionStatusBar'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; // useMessageStore removed — messages now come from sync system @@ -2482,8 +2481,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo newSessionDraftOpen={newSessionDraftOpen} hasContent={Boolean(hasContent)} isVSCode={isVSCode} + canAbort={canAbort} footerIconButtonClass={footerIconButtonClass} iconSizeClass={iconSizeClass} + stopIconSizeClass={stopIconSizeClass} theme={currentTheme} onExpand={mobileShell.expand} onApplySuggestion={applyAssistSuggestion} @@ -2493,6 +2494,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onOpenPrPicker={openPrPicker} onOpenAttachSheet={openMobileAttachSheet} onStartDictation={toggleDictation} + onAbort={handleAbort} /> ) : ( <> @@ -2569,7 +2571,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo onClose={closeAutocomplete} /> {/* Positioning context for the dictation overlay: covers the - text area + footer exactly, excluding MobileSessionStatusBar. */} + text area + footer exactly. */}
{isMobile ? ( @@ -2704,10 +2706,6 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo /> ) : null}
- {/* Mobile session panel: slide-up overlay toggled by - MobileSessionPanelTrigger. Mounted outside the pill - conditional so the pill's trigger works too. */} - {isMobile && } {/* Hidden host for the model/agent/variant bottom sheets. Kept outside the pill conditional so an open panel survives (and stays visible over) the collapsed composer. */} diff --git a/packages/ui/src/components/chat/MobileSessionStatusBar.test.ts b/packages/ui/src/components/chat/MobileSessionStatusBar.test.ts deleted file mode 100644 index a0814fbd..00000000 --- a/packages/ui/src/components/chat/MobileSessionStatusBar.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { readFileSync } from 'node:fs'; - -const source = readFileSync(new URL('./MobileSessionStatusBar.tsx', import.meta.url), 'utf8'); - -describe('MobileSessionStatusBar hidden work', () => { - test('does not mount session grouping and project derivation while the panel is closed', () => { - const wrapperStart = source.indexOf('export const MobileSessionStatusBar'); - const openPanelStart = source.indexOf('const MobileSessionStatusOpenPanel'); - const closedGuard = source.indexOf('if (!isMobile || !open) return null;', wrapperStart); - const openPanelMount = source.indexOf(' void; -} - -interface SessionWithStatus extends Session { - _statusType?: 'busy' | 'retry' | 'idle'; - _runningChildrenCount?: number; -} - -// Cross-project session source. Mirrors the dedicated MobileSessionsSheet: -// global sessions cover all directories (even unbootstrapped ones), while the -// live aggregate (`useAllLiveSessions`) surfaces fresher data and every -// bootstrapped directory. Merging both makes other projects' sessions appear. -function useAllProjectSessions(): Session[] { - const liveSessions = useAllLiveSessions(); - const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions); - return React.useMemo(() => { - const liveById = new Map(liveSessions.map((session) => [session.id, session])); - const merged = globalActiveSessions.map((session) => { - const liveSession = liveById.get(session.id); - return liveSession ? mergeLiveSessionWithGlobalSession(liveSession, session) : session; - }); - const seen = new Set(merged.map((session) => session.id)); - for (const session of liveSessions) { - if (!seen.has(session.id)) merged.push(session); - } - return merged; - }, [globalActiveSessions, liveSessions]); -} - -// Max sessions shown per (filtered) project list - a "recent" cap applied -// after filtering, so each project view shows at most this many. -const MAX_RECENT_SESSIONS = 25; - -// Normalize path for comparison -const normalize = (value: string): string => { - if (!value) return ''; - const replaced = value.replace(/\\/g, '/'); - return replaced === '/' ? '/' : replaced.replace(/\/+$/, ''); -}; - -// A session's directory, mirroring the store's canonical resolution. -const sessionDirectory = (session: Session): string => { - const record = session as Session & { - directory?: string | null; - project?: { worktree?: string | null } | null; - }; - return normalize(record.directory ?? record.project?.worktree ?? ''); -}; - -// Prefix-match used to group a session under a project root or worktree. -const pathBelongsToRoot = (path: string, root: string): boolean => { - const p = normalize(path); - const r = normalize(root); - return Boolean(p && r && (p === r || p.startsWith(`${r}/`))); -}; - -function useSessionGrouping( - sessions: Session[], - sessionStatus: Record | undefined -) { - const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); - const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); - const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById); - - const parentChildMap = React.useMemo(() => { - const map = new Map(); - const allIds = new Set(sessions.map((s) => s.id)); - - for (const session of sessions) { - const parentID = (session as { parentID?: string }).parentID; - if (parentID && allIds.has(parentID)) { - const children = map.get(parentID); - if (children) children.push(session); - else map.set(parentID, [session]); - } - } - return map; - }, [sessions]); - - const getStatusType = React.useCallback((sessionId: string): 'busy' | 'retry' | 'idle' => { - const status = sessionStatus?.[sessionId]; - if (status?.type === 'busy' || status?.type === 'retry') return status.type; - return 'idle'; - }, [sessionStatus]); - - const processedSessions = React.useMemo(() => { - const sessionIds = new Set(sessions.map((s) => s.id)); - const topLevel = sessions.filter((session) => { - const parentID = (session as { parentID?: string }).parentID; - return !parentID || !sessionIds.has(parentID); - }); - - const ordered = topLevel.map((session): SessionWithStatus => { - const statusType = getStatusType(session.id); - const runningChildrenCount = (parentChildMap.get(session.id) ?? []) - .filter((child) => getStatusType(child.id) !== 'idle') - .length; - return { - ...session, - _statusType: statusType, - _runningChildrenCount: runningChildrenCount, - }; - }); - - const compare = (a: Session, b: Session) => ( - compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks) - ); - return ordered.sort(compare); - }, [sessions, getStatusType, parentChildMap, pinnedSessionIds, sessionOrderRanks]); - - const totalRunning = processedSessions.reduce((sum, s) => { - const selfRunning = s._statusType !== 'idle' ? 1 : 0; - return sum + selfRunning + (s._runningChildrenCount ?? 0); - }, 0); - - const totalUnread = processedSessions.filter((s) => (unseenCounts[s.id] ?? 0) > 0).length; - - return { sessions: processedSessions, totalRunning, totalUnread, totalCount: processedSessions.length }; -} - -function useSessionHelpers() { - const getSessionTitle = React.useCallback((session: Session): string => { - const title = session.title; - if (title && title.trim()) return title; - return 'New session'; - }, []); - - const unseenCounts = useNotificationStore((s) => s.index.session.unseenCount); - const needsAttention = React.useCallback((sessionId: string): boolean => { - return (unseenCounts[sessionId] ?? 0) > 0; - }, [unseenCounts]); - - return { getSessionTitle, needsAttention }; -} - -// Per-project status indicators (running / unread) for the filter chips. -function useProjectStatus( - sessionStatus: Record | undefined, - currentSessionId: string | null -) { - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const getSessionsByDirectory = useSessionUIStore((state) => state.getSessionsByDirectory); - const notifUnseenCounts = useNotificationStore((s) => s.index.session.unseenCount); - - return React.useCallback((projectPath: string): { hasRunning: boolean; hasUnread: boolean } => { - const getStatusType = (sessionId: string): 'busy' | 'retry' | 'idle' => { - const status = sessionStatus?.[sessionId]; - if (status?.type === 'busy' || status?.type === 'retry') return status.type; - return 'idle'; - }; - - const projectRoot = normalize(projectPath); - if (!projectRoot) return { hasRunning: false, hasUnread: false }; - - const dirs: string[] = [projectRoot]; - const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; - for (const meta of worktrees) { - const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; - if (typeof p === 'string' && p.trim()) { - const normalized = normalize(p); - if (normalized && normalized !== projectRoot) dirs.push(normalized); - } - } - - const seen = new Set(); - let hasRunning = false; - let hasUnread = false; - - for (const dir of dirs) { - for (const session of getSessionsByDirectory(dir)) { - if (!session?.id || seen.has(session.id)) continue; - seen.add(session.id); - - if (getStatusType(session.id) !== 'idle') hasRunning = true; - if (session.id !== currentSessionId && (notifUnseenCounts[session.id] ?? 0) > 0) hasUnread = true; - if (hasRunning && hasUnread) break; - } - if (hasRunning && hasUnread) break; - } - - return { hasRunning, hasUnread }; - }, [getSessionsByDirectory, availableWorktreesByProject, sessionStatus, notifUnseenCounts, currentSessionId]); -} - -// Resolves the project's root directories (root + known worktrees) for -// prefix-matching sessions, mirroring the dedicated MobileSessionsSheet. -function useProjectRootsResolver() { - const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - - return React.useCallback((project: ProjectEntry): string[] => { - const projectRoot = normalize(project.path); - const roots = [projectRoot]; - const worktrees = availableWorktreesByProject.get(projectRoot) ?? []; - for (const meta of worktrees) { - const p = (meta && typeof meta === 'object' && 'path' in meta) ? (meta as { path?: unknown }).path : null; - if (typeof p === 'string' && p.trim()) { - const normalized = normalize(p); - if (normalized) roots.push(normalized); - } - } - return roots; - }, [availableWorktreesByProject]); -} - -function StatusIndicator({ isRunning, needsAttention }: { isRunning: boolean; needsAttention: boolean }) { - if (isRunning) { - return ; - } - if (needsAttention) { - return
; - } - return
; -} - -function RunningIndicator({ count }: { count: number }) { - if (count === 0) return null; - return ( - - - {count} - - ); -} - -function UnreadIndicator({ count }: { count: number }) { - if (count === 0) return null; - return ( - -
- {count} - - ); -} - -// A single session row sized for comfortable touch. -function SessionItem({ - session, - isCurrent, - getSessionTitle, - onClick, - needsAttention, -}: { - session: SessionWithStatus; - isCurrent: boolean; - getSessionTitle: (s: Session) => string; - onClick: () => void; - needsAttention: (sessionId: string) => boolean; -}) { - const attention = needsAttention(session.id); - - return ( - - ); -} - -// A project filter pill sized for touch. Selecting it filters -// the session list; it does NOT switch the active project. -interface ProjectFilterChipProps { - label: string; - icon?: string | null; - project?: Pick | null; - iconOptions?: React.ComponentProps['options']; - iconBackground?: string | null; - colorVar?: string | null; - isActive: boolean; - status?: { hasRunning: boolean; hasUnread: boolean }; - onClick: () => void; -} - -function ProjectFilterChip({ - label, - icon, - project, - iconOptions, - iconBackground, - colorVar, - isActive, - status, - onClick, -}: ProjectFilterChipProps) { - const projectIconName = icon ? PROJECT_ICON_MAP[icon] : null; - const fallbackIcon = projectIconName ? ( - - ) : null; - - return ( - - ); -} - -// The chip that lives in the composer footer and toggles the slide-up sheet. -// This is the only persistent affordance; there is no longer a permanent bar. -interface MobileSessionPanelTriggerProps { - footerIconButtonClass: string; - iconSizeClass: string; -} - -export const MobileSessionPanelTrigger: React.FC = ({ - footerIconButtonClass, - iconSizeClass, -}) => { - const { t } = useI18n(); - const isMobile = useUIStore((state) => state.isMobile); - const open = useUIStore((state) => state.mobileSessionPanelOpen); - const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen); - - // Ensure the cross-project session list is loaded once, so the panel reflects - // every project, not just the active directory. - React.useEffect(() => { - if (isMobile) { - void ensureGlobalSessionsLoaded(); - } - }, [isMobile]); - - if (!isMobile) { - return null; - } - - return ( - - ); -}; - -const MobileSessionStatusOpenPanel: React.FC = ({ - onSessionSwitch, -}) => { - const { t } = useI18n(); - const { currentTheme } = useThemeSystem(); - const sessions = useAllProjectSessions(); - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const sessionStatus = useAllSessionStatuses(); - const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); - const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); - const open = useUIStore((state) => state.mobileSessionPanelOpen); - const setOpen = useUIStore((state) => state.setMobileSessionPanelOpen); - - const projects = useProjectsStore((state) => state.projects); - const homeDirectory = useDirectoryStore((state) => state.homeDirectory); - - const { sessions: sortedSessions, totalRunning, totalUnread } = useSessionGrouping(sessions, sessionStatus); - const { getSessionTitle, needsAttention } = useSessionHelpers(); - const getProjectStatus = useProjectStatus(sessionStatus, currentSessionId); - const resolveProjectRoots = useProjectRootsResolver(); - - // Project filter, persisted in the UI store so the choice survives closing and - // reopening the sheet. Defaults to "All" so sessions from every project are - // visible regardless of which session is currently selected. - const filterProjectId = useUIStore((state) => state.mobileSessionFilterProjectId); - const setFilterProjectId = useUIStore((state) => state.setMobileSessionFilterProjectId); - - // Refresh the cross-project session list when the panel opens (mirrors the - // dedicated MobileSessionsSheet). The active-directory sync only upserts the - // current project's sessions, so other projects need this global load. - React.useEffect(() => { - if (open) { - void refreshGlobalSessions(sessions); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open]); - - const formatProjectLabel = React.useCallback((project: ProjectEntry): string => { - return project.label?.trim() - || formatDirectoryName(project.path, homeDirectory) - || project.path; - }, [homeDirectory]); - - // Filter sessions by the selected project (root + worktrees), using the - // store's canonical directory keying. - const filteredSessions = React.useMemo(() => { - if (!filterProjectId) return sortedSessions; - const project = projects.find((p) => p.id === filterProjectId); - if (!project) return sortedSessions; - const roots = resolveProjectRoots(project); - return sortedSessions.filter((session) => { - const dir = sessionDirectory(session); - return roots.some((root) => pathBelongsToRoot(dir, root)); - }); - }, [sortedSessions, filterProjectId, projects, resolveProjectRoots]); - - // Cap to the most recent N (already sorted running-first, then by updated). - const visibleSessions = React.useMemo( - () => filteredSessions.slice(0, MAX_RECENT_SESSIONS), - [filteredSessions], - ); - - const handleSessionClick = (session: SessionWithStatus) => { - setCurrentSession(session.id, sessionDirectory(session) || null); - onSessionSwitch?.(session.id); - setOpen(false); - }; - - // "+" — start a new session draft. Target the project selected in the filter; - // for "All", use the most recently active session's directory, falling back to - // the store's own default target when there are no sessions. - const handleNewChat = React.useCallback(() => { - setOpen(false); - if (filterProjectId) { - const project = projects.find((p) => p.id === filterProjectId); - if (project) { - openNewSessionDraft({ selectedProjectId: project.id, directoryOverride: project.path }); - return; - } - } - const mostRecent = [...sessions].sort((a, b) => compareSessionsByLifecycleOrder( - a, - b, - useSessionPinnedStore.getState().ids, - useSessionOrderingStore.getState().rankById, - ))[0]; - const directory = mostRecent ? sessionDirectory(mostRecent) : ''; - openNewSessionDraft(directory ? { directoryOverride: directory } : undefined); - }, [filterProjectId, projects, sessions, openNewSessionDraft, setOpen]); - - const renderHeader = React.useCallback(() => ( -
-
-
-
- -
-

- {t('mobile.sessions.search.section.sessions')} -

-
- - - - -
-
- - {projects.length > 1 && ( -
- setFilterProjectId(null)} - /> - {projects.map((project) => ( - setFilterProjectId(project.id)} - /> - ))} -
- )} -
- ), [t, totalRunning, totalUnread, projects, filterProjectId, setFilterProjectId, formatProjectLabel, currentTheme, getProjectStatus, handleNewChat, setOpen]); - - return ( - setOpen(false)} - title={t('mobile.sessions.search.section.sessions')} - renderHeader={renderHeader} - className="h-[72vh]" - contentMaxHeightClassName="max-h-full" - > -
- {visibleSessions.length === 0 ? ( -
- {t('chat.mobileStatus.noSessionsInProject')} -
- ) : ( - visibleSessions.map((session) => ( - handleSessionClick(session)} - needsAttention={needsAttention} - /> - )) - )} -
-
- ); -}; - -export const MobileSessionStatusBar: React.FC = (props) => { - const isMobile = useUIStore((state) => state.isMobile); - const open = useUIStore((state) => state.mobileSessionPanelOpen); - - if (!isMobile || !open) return null; - return ; -}; diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 1469e1cc..66ecde5f 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -305,7 +305,13 @@ export const StatusRow: React.FC = ({ } return ( -
+
is running…" row sits flush against + // the message above. + className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")} + style={STATUS_ROW_CONTAINER_STYLE} + >
{/* Left: Abort status | Working placeholder | leftAccessory */}
diff --git a/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx b/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx index 4f651b05..cc52fdc9 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerFooter.tsx @@ -19,7 +19,6 @@ import { Icon } from '@/components/icon/Icon'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { ModelControls } from '../../ModelControls'; -import { MobileSessionPanelTrigger } from '../../MobileSessionStatusBar'; import { ComposerActionButtons } from './ComposerActionButtons'; import { ComposerAttachmentControls } from './ComposerAttachmentControls'; import { FocusModeButton } from './FocusModeButton'; @@ -124,10 +123,6 @@ export function ComposerFooter(props: ComposerFooterProps) { <>
- void; onApplySuggestion: (text: string) => void; @@ -40,6 +40,7 @@ export interface MobilePillComposerProps { onOpenPrPicker: () => void; onOpenAttachSheet: () => void; onStartDictation: () => void; + onAbort: () => void; } export function MobilePillComposer(props: MobilePillComposerProps) { @@ -51,8 +52,10 @@ export function MobilePillComposer(props: MobilePillComposerProps) { newSessionDraftOpen, hasContent, isVSCode, + canAbort, footerIconButtonClass, iconSizeClass, + stopIconSizeClass, theme: currentTheme, onExpand, onApplySuggestion, @@ -62,6 +65,7 @@ export function MobilePillComposer(props: MobilePillComposerProps) { onOpenPrPicker, onOpenAttachSheet, onStartDictation, + onAbort, } = props; return ( @@ -83,10 +87,6 @@ export function MobilePillComposer(props: MobilePillComposerProps) { className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1 shadow-[0_4px_16px_-4px_rgb(0_0_0_/_0.12)]" style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }} > - + {/* Same visibility rule as the full composer's stop control: + while a turn is running the stop button takes the mic's + end slot and the mic shifts one slot left. Instant swap — + no shape animation (WKWebView). */} + {canAbort ? ( + + ) : null}
{/* New-session button: fades/shrinks away when the draft is already open, letting the pill expand into its place. */} diff --git a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx index d0e9681a..07d6c9f5 100644 --- a/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx +++ b/packages/ui/src/components/chat/message/parts/ProgressiveGroup.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useMobileAppActions } from '@/apps/mobileAppContext'; import { cn } from '@/lib/utils'; import type { TurnActivityRecord as TurnActivityPart } from '../../lib/turns/types'; import type { ToolPart as ToolPartType } from '@opencode-ai/sdk/v2'; @@ -573,6 +574,7 @@ const StaticToolRowInner: React.FC<{ const icon = getToolIcon(toolName); const isReadGroup = toolName.toLowerCase() === 'read'; const runtime = React.useContext(RuntimeAPIContext); + const mobileActions = useMobileAppActions(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const skills = useSkillsStore((state) => state.skills); const hasRunningActivity = React.useMemo(() => activities.some((activity) => isActivityRunning(activity)), [activities]); @@ -634,6 +636,21 @@ const StaticToolRowInner: React.FC<{ return; } + // Dedicated mobile app: stage the same pending file focus/navigation + // desktop uses, then surface the Files pane (workspace drawer tab), + // which consumes it. Desktop grant flows don't apply here. + if (mobileActions) { + const uiStore = useUIStore.getState(); + const contextDirectory = currentDirectory || getDirectoryForFilePath(currentDirectory, absolutePath); + if (offset && Number.isFinite(offset)) { + uiStore.openContextFileAtLine(contextDirectory, absolutePath, Math.max(1, Math.trunc(offset)), 1); + } else { + uiStore.openContextFile(contextDirectory, absolutePath); + } + mobileActions.openFiles(); + return; + } + if (!isFilePathWithinDirectory(absolutePath, currentDirectory)) { void ensureOutsideFileGrantForDesktop(absolutePath, currentDirectory).then(() => { const uiStore = useUIStore.getState(); @@ -654,7 +671,7 @@ const StaticToolRowInner: React.FC<{ return; } uiStore.openContextFile(contextDirectory, absolutePath); - }, [currentDirectory, runtime]); + }, [currentDirectory, mobileActions, runtime]); const normalizedToolName = toolName.toLowerCase(); const isSearchGroup = normalizedToolName === 'grep' @@ -667,8 +684,11 @@ const StaticToolRowInner: React.FC<{ return (
diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 0a3cfc61..39b08d9d 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { useMobileAppActions } from '@/apps/mobileAppContext'; import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; @@ -1152,7 +1153,11 @@ const TaskSummaryEntryRow = React.memo(({ return ( -
+ {/* Single-line rows everywhere: the old mobile break-words mode + wrapped long shell commands into a hanging column and floated + the icon to the top of the block. Errors still wrap — they must + stay readable. */} +
{getToolIcon(toolName)} @@ -1587,6 +1589,7 @@ const ToolExpandedContent: React.FC = React.memo(({ }) => { const { t } = useI18n(); const runtime = React.useContext(RuntimeAPIContext); + const mobileActions = useMobileAppActions(); const { pierreTheme, pierreThemeType } = usePierreThemeConfig(); const [diffViewMode, setDiffViewMode] = React.useState('unified'); const stateWithData = state as ToolStateWithMetadata; @@ -1694,6 +1697,9 @@ const ToolExpandedContent: React.FC = React.memo(({ return; } useUIStore.getState().openContextFileAtLine(currentDirectory, absolutePath, line ?? 1, 1); + // Dedicated mobile app: the pending file navigation is consumed by + // the FilesView pane — surface it (workspace drawer Files tab). + mobileActions?.openFiles(); }; const openEntryDiff = (entry: DiffPatchEntry, event: React.MouseEvent) => { event.stopPropagation(); @@ -2421,13 +2427,16 @@ const ToolPartContent: React.FC = ({
{}
{ event.stopPropagation(); onToggle(part.id); }} > {}
`, "align-justify": ``, "apple": ``, - "apps-2-ai": ``, "archive": ``, "archive-stack": ``, "arrow-down": ``, @@ -18,7 +17,6 @@ export const iconSpriteData = { "arrow-go-back": ``, "arrow-go-forward": ``, "arrow-left": ``, - "arrow-left-long": ``, "arrow-left-right": ``, "arrow-left-s": ``, "arrow-right": ``, @@ -176,6 +174,7 @@ export const iconSpriteData = { "pencil": ``, "pencil-ai": ``, "pencil-ai-2": ``, + "pencil-ruler-2": ``, "picture-in-picture-2": ``, "pie-chart": ``, "play": ``, @@ -211,7 +210,6 @@ export const iconSpriteData = { "shuffle": ``, "slash-commands-2": ``, "smartphone": ``, - "sort-desc": ``, "sparkling": ``, "split-cells-horizontal": ``, "stack": ``, diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index ba827b95..b089d4e9 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -206,7 +206,7 @@ export const MainLayout: React.FC = () => { return; } - sessionState.openNewSessionDraft(); + sessionState.openNewSessionDraft({ automatic: true }); }, delayMs); }; diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index 70fbdb12..dbfb46f4 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -6,7 +6,10 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { formatDirectoryName } from '@/lib/utils'; -export const ProjectContextPanel: React.FC = () => { +export const ProjectContextPanel: React.FC<{ + onActionComplete?: () => void; + onOpenPlan?: (plan: { path: string; title: string }) => void; +}> = ({ onActionComplete, onOpenPlan }) => { const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); @@ -51,6 +54,8 @@ export const ProjectContextPanel: React.FC = () => { projectRef={projectRef} projectLabel={projectLabel} canCreateWorktree={canCreateWorktree} + onActionComplete={onActionComplete} + onOpenPlan={onOpenPlan} />
); diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index a944725f..23ebcc3f 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -446,7 +446,7 @@ export const VSCodeLayout: React.FC = () => { // No initialSessionId means open a new session draft if (!initialSessionId) { hasAppliedInitialSession.current = true; - openNewSessionDraft(); + openNewSessionDraft({ automatic: true }); return; } diff --git a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx index d0c0ae17..5f912444 100644 --- a/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx +++ b/packages/ui/src/components/session/ProjectNotesTodoPanel.tsx @@ -78,6 +78,9 @@ interface ProjectNotesTodoPanelProps { projectLabel?: string | null; canCreateWorktree?: boolean; onActionComplete?: () => void; + /** When provided, opening a plan calls this instead of the desktop context + panel tab — hosts without ContextPanel (mobile) render their own viewer. */ + onOpenPlan?: (plan: { path: string; title: string }) => void; className?: string; } @@ -162,6 +165,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ projectLabel, canCreateWorktree = false, onActionComplete, + onOpenPlan, className, }) => { const { t } = useI18n(); @@ -725,6 +729,10 @@ export const ProjectNotesTodoPanel: React.FC = ({ const handleOpenPlan = React.useCallback( (plan: ProjectPlanListItem) => { + if (onOpenPlan) { + onOpenPlan({ path: plan.path, title: plan.title }); + return; + } const projectPath = projectRef?.path?.trim(); const panelDirectory = currentDirectory?.trim() || projectPath; if (!panelDirectory) { @@ -737,7 +745,7 @@ export const ProjectNotesTodoPanel: React.FC = ({ label: plan.title, }); }, - [currentDirectory, openContextPanelTab, projectRef] + [currentDirectory, onOpenPlan, openContextPanelTab, projectRef] ); if (!projectRef) { diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index ef49400f..1329daab 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -8,6 +8,7 @@ import { useGitAllBranches } from '@/stores/useGitStore'; import type { SessionNode } from '../types'; import { isPathWithinProject } from '../utils'; import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; +import { useSessionUIStore } from '@/sync/session-ui-store'; export type SwitcherItem = { node: SessionNode; @@ -23,6 +24,8 @@ const MAX_PARENT_SESSIONS = 7; type SwitcherItemsOptions = { scopeProjectId?: string | null; + /** How many parent sessions to return (default 7 — the desktop dropdown). */ + maxParents?: number; }; const normalize = (value: string | null | undefined): string | null => { @@ -41,12 +44,30 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n }; export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => { - const { scopeProjectId = null } = options; + const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options; const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); const projects = useProjectsStore((state) => state.projects); const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById); const branchesByDirectory = useGitAllBranches(); + const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + + // Worktree sessions live OUTSIDE their project's path, so prefix matching + // can't resolve their project — and their branch is known from worktree + // discovery long before any git status is fetched for that directory. + const worktreeInfoByPath = React.useMemo(() => { + const map = new Map(); + for (const [projectPath, worktrees] of availableWorktreesByProject) { + const normalizedProjectPath = normalize(projectPath); + if (!normalizedProjectPath) continue; + for (const worktree of worktrees) { + const worktreePath = normalize(worktree.path); + if (!worktreePath) continue; + map.set(worktreePath, { projectPath: normalizedProjectPath, branch: worktree.branch?.trim() || null }); + } + } + return map; + }, [availableWorktreesByProject]); const normalizedProjects = React.useMemo( () => projects @@ -58,12 +79,18 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const findProjectForDirectory = React.useCallback( (directory: string | null) => { if (!directory) return null; + // Known worktree → its project, regardless of where the worktree lives. + const worktreeInfo = worktreeInfoByPath.get(normalize(directory) ?? directory); + if (worktreeInfo) { + const byPath = normalizedProjects.find((project) => project.normalizedPath === worktreeInfo.projectPath); + if (byPath) return byPath; + } const matches = normalizedProjects .filter((project) => isPathWithinProject(directory, project.normalizedPath)) .sort((a, b) => (b.normalizedPath?.length ?? 0) - (a.normalizedPath?.length ?? 0)); return matches[0] ?? null; }, - [normalizedProjects], + [normalizedProjects, worktreeInfoByPath], ); const items = React.useMemo(() => { @@ -94,7 +121,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions return findProjectForDirectory(directory)?.id === scopeProjectId; }) .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)) - .slice(0, MAX_PARENT_SESSIONS); + .slice(0, maxParents); const buildNode = (session: Session): SessionNode => { const childSessions = childrenByParent.get(session.id) ?? []; @@ -109,7 +136,11 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const directory = resolveGlobalSessionDirectory(session); const matchedProject = findProjectForDirectory(directory); const projectLabel = formatProjectLabel(matchedProject); - const branchLabel = directory ? branchesByDirectory.get(directory) ?? null : null; + // Live git branch when available; the discovered worktree branch fills + // in for directories whose git status hasn't been fetched yet. + const worktreeInfo = directory ? worktreeInfoByPath.get(normalize(directory) ?? directory) : null; + const liveBranch = directory ? branchesByDirectory.get(directory) : undefined; + const branchLabel = liveBranch ?? worktreeInfo?.branch ?? null; return { node: buildNode(session), projectId: matchedProject?.id ?? null, @@ -120,7 +151,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions }, }; }); - }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, pinnedSessionIds, scopeProjectId, sessionOrderRanks]); + }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); return items; }; diff --git a/packages/ui/src/components/ui/sortable-tabs-strip.tsx b/packages/ui/src/components/ui/sortable-tabs-strip.tsx index c00110bf..e7474c59 100644 --- a/packages/ui/src/components/ui/sortable-tabs-strip.tsx +++ b/packages/ui/src/components/ui/sortable-tabs-strip.tsx @@ -44,6 +44,11 @@ type SortableTabsStripProps = { inactiveTabsIconOnly?: boolean; animateActivePill?: boolean; activePillLowercase?: boolean; + /** Position the active-pill indicator with left/top instead of translate3d. + Use when the strip lives inside an ancestor that transform-animates + (e.g. a sliding mobile drawer): creating a composited layer mid-slide + flickers in WKWebView. Tab-switch animation stays (layout transition). */ + nonCompositedIndicator?: boolean; className?: string; }; @@ -100,6 +105,7 @@ export const SortableTabsStrip: React.FC = ({ inactiveTabsIconOnly = false, animateActivePill, activePillLowercase = true, + nonCompositedIndicator = false, className, }) => { const { t } = useI18n(); @@ -409,13 +415,21 @@ export const SortableTabsStrip: React.FC = ({ // than a hard border, so the pill reads as raised above the track. 'border border-[color-mix(in_srgb,var(--foreground)_7%,transparent)]', 'shadow-[0_1px_2px_color-mix(in_srgb,var(--foreground)_10%,transparent),0_2px_6px_color-mix(in_srgb,var(--foreground)_6%,transparent)]', - shouldAnimateActivePill && pillTransitionEnabled && 'pill-tabs__indicator--is-animated' + shouldAnimateActivePill && pillTransitionEnabled + && (nonCompositedIndicator ? 'pill-tabs__indicator--is-animated-layout' : 'pill-tabs__indicator--is-animated') )} - style={{ - transform: `translate3d(${pillRect.left + pillNudge}px, ${pillRect.top}px, 0)`, - width: `${pillRect.width}px`, - height: `${pillRect.height}px`, - }} + style={nonCompositedIndicator + ? { + left: `${pillRect.left + pillNudge}px`, + top: `${pillRect.top}px`, + width: `${pillRect.width}px`, + height: `${pillRect.height}px`, + } + : { + transform: `translate3d(${pillRect.left + pillNudge}px, ${pillRect.top}px, 0)`, + width: `${pillRect.width}px`, + height: `${pillRect.height}px`, + }} /> ) : null} {useUnderlineIndicator && pillRect ? ( diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index fec12654..de607acf 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -725,7 +725,9 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const currentDirectory = useEffectiveDirectory() ?? ''; const root = normalizePath(currentDirectory.trim()); - const showEditorTabsRow = isMobile || mode !== 'editor-only'; + // editor-only hosts (desktop context panel, the mobile Files surface) bring + // their own chrome — the open-file tabs row is redundant there. + const showEditorTabsRow = mode !== 'editor-only'; const suppressFileLoadingIndicator = mode === 'editor-only' && !isMobile; const searchFiles = useFileSearchStore((state) => state.searchFiles); const gitStatus = useGitStatus(currentDirectory); @@ -3753,10 +3755,13 @@ export const FilesView: React.FC = ({ mode = 'full' }) => {
) : null} - {/* Row 2: Docked editor toolbar (expanded). Desktop-only opt-in. */} - {settingsExpandedEditorToolbar && !isMobile && selectedFile ? ( + {/* Row 2: Docked editor toolbar (expanded). Desktop opt-in; ALWAYS on + for mobile — floating hover controls don't work with touch. */} + {(settingsExpandedEditorToolbar || isMobile) && selectedFile ? (
- {displaySelectedPath ? ( + {/* Mobile hosts already show the file name in their own header; + a truncated duplicate here just eats toolbar width. */} + {displaySelectedPath && !isMobile ? ( = ({ mode = 'full' }) => {
- {selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar && !isMobile) && ( + {selectedFile && !isSearchOpen && !(settingsExpandedEditorToolbar || isMobile) && (
void; }; type PlanSendAction = 'improve' | 'implement'; @@ -147,7 +150,7 @@ type SelectedLineRange = { end: number; }; -export const PlanView: React.FC = ({ targetPath = null }) => { +export const PlanView: React.FC = ({ targetPath = null, onNavigatedToChat }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const createSession = useSessionUIStore((state) => state.createSession); @@ -526,7 +529,8 @@ export const PlanView: React.FC = ({ targetPath = null }) => { const routeToChat = React.useCallback(() => { setActiveMainTab('chat'); setSessionSwitcherOpen(false); - }, [setActiveMainTab, setSessionSwitcherOpen]); + onNavigatedToChat?.(); + }, [onNavigatedToChat, setActiveMainTab, setSessionSwitcherOpen]); const handleConfirmPlanSend = React.useCallback( async (execution: TodoSendExecution) => { diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 3cdc5d5e..74505195 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -242,7 +242,11 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const settingsSlug = resolveSettingsSlug(settingsPageRaw); const [mobileStage, setMobileStage] = React.useState(initialMobileStage); - const autoNavSlugRef = React.useRef(null); + // Seed with the mount-time slug when opening at the nav stage: the slug + // persists across opens, and the deep-link auto-jump below must react only + // to slug CHANGES after mount — not re-enter the previously visited page + // every time settings reopen. + const autoNavSlugRef = React.useRef(initialMobileStage === 'nav' ? settingsSlug : null); // No starter page on desktop: 'home' (fresh state) resolves to General. // settingsPage persists in the UI store, so subsequent opens restore the @@ -924,7 +928,10 @@ export const SettingsView: React.FC = ({ onClose, forceMobile {t(`settings.view.nav.group.${group}`)}
{pages.map((page) => { - const selected = settingsSlug === page.slug; + // On the mobile nav STAGE nothing is "current" — the user is + // choosing, and settingsSlug only remembers the last visited + // page. Keeping it highlighted read as a stuck selection. + const selected = settingsSlug === page.slug && !(isMobile && mobileStage === 'nav'); const iconName = getSettingsNavIcon(page.slug); if (!iconName && page.slug !== 'mcp') return null; @@ -1069,15 +1076,19 @@ export const SettingsView: React.FC = ({ onClose, forceMobile {isMobile ? (
- {(showBackButton || onClose) ? ( + {showBackButton ? (