diff --git a/packages/mobile/README.md b/packages/mobile/README.md index eb5f98f4..a06a96d8 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -8,8 +8,10 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h - The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server. - On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server. -- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. -- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Connections are saved locally in the app and can be managed from `Instances` in the sessions drawer footer (a persistent left sidebar on tablets). +- The connection screen and the `Instances` entry are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. +- Phones and tablets share one navigation model: a sessions drawer/sidebar on the left, the workspace drawer (Changes / Files / Terminal / Notes / MCP) on the right, and no overflow menu. Tablets differ only in that the sessions list is a resizable persistent sidebar and the header dropdowns are anchored popovers. +- The tablet layout is a live size class (`useTabletLayout`), not a device check: any surface whose short side is at least 600px gets it, and the workspace only becomes a side panel where the width can host the sidebar, the panel and a readable chat at once. Book foldables therefore pick it up when unfolded, keep the portrait layout in both orientations (their long side is barely wider than a tablet's short one), and drop back to the phone layout when folded shut. The Android activity declares the matching `configChanges`, so folding resizes the WebView instead of recreating it. - Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. - The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text. diff --git a/packages/mobile/ios/App/App/AppDelegate.swift b/packages/mobile/ios/App/App/AppDelegate.swift index b46cb7cf..1a8927f9 100644 --- a/packages/mobile/ios/App/App/AppDelegate.swift +++ b/packages/mobile/ios/App/App/AppDelegate.swift @@ -1,5 +1,6 @@ import UIKit import Capacitor +import GameController import UserNotifications import WebKit import WidgetKit @@ -78,22 +79,91 @@ let apnsEnvironment: String = { return profile.range(of: pattern, options: .regularExpression) != nil ? "development" : "production" }() -/// Bridge subclass (referenced from Main.storyboard) whose only job is to expose the APNs -/// environment as a document-start user script. This runs before any page JS, so token -/// registration (useNativePushRegistration) always sees it — injecting later from the scene -/// lifecycle raced the registration call and lost on first launch. +/// Bridge subclass (referenced from Main.storyboard) whose job is to expose native-only +/// facts to the web layer as document-start user scripts. These run before any page JS, +/// so consumers always see them — injecting later from the scene lifecycle raced the +/// consumer (push registration) and lost on first launch. /// -/// The script must be added in capacitorDidLoad(), NOT webViewConfiguration(for:): Capacitor's +/// The scripts must be added in capacitorDidLoad(), NOT webViewConfiguration(for:): Capacitor's /// prepareWebView replaces the configuration's userContentController with its own right after /// calling webViewConfiguration(for:), which silently discards any user script added there. /// capacitorDidLoad() runs after that swap but before loadWebView() starts the initial page load. class BridgeViewController: CAPBridgeViewController { + private var keyboardObservers: [NSObjectProtocol] = [] + override func capacitorDidLoad() { super.capacitorDidLoad() - let source = "window.__OPENCHAMBER_APNS_ENV__ = '\(apnsEnvironment)';" + // GCKeyboard is the only authoritative answer to "is a hardware keyboard + // attached?". The web layer can otherwise only INFER it from a keyboard + // that never appears, which costs the user one focus before the layout + // settles — so the state is stamped at document start and kept live. + // + // At this point GameController has usually NOT finished discovery yet, so + // an already-attached keyboard still reads as nil here. The stamp is only + // the optimistic first answer; refreshHardwareKeyboardState() below is + // what actually settles it once the page exists. + let attached = GCKeyboard.coalesced != nil + let source = """ + window.__OPENCHAMBER_APNS_ENV__ = '\(apnsEnvironment)'; + window.__OPENCHAMBER_HARDWARE_KEYBOARD__ = \(attached ? "true" : "false"); + """ webView?.configuration.userContentController.addUserScript( WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true) ) + observeHardwareKeyboard() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + // Two races make a single early publish unreliable for a keyboard that was + // ALREADY attached at launch, which is why it only ever worked when the + // user plugged one in afterwards: + // - GCKeyboardDidConnect for a pre-attached keyboard fires during launch, + // before the web page exists, so its evaluateJavaScript lands in a + // context the page load then throws away; + // - GameController can populate `coalesced` a beat after launch anyway. + // Re-publishing across the first seconds covers both; the web side adopts + // idempotently, so repeats are free. + refreshHardwareKeyboardState() + for delay in [0.3, 1.0, 2.5] { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + self?.refreshHardwareKeyboardState() + } + } + } + + /// Re-read GameController and push the current answer to the web layer. + /// Also called when the app returns to the foreground — a keyboard can be + /// attached or detached while backgrounded, with no notification delivered. + func refreshHardwareKeyboardState() { + publishHardwareKeyboardState(GCKeyboard.coalesced != nil) + } + + private func observeHardwareKeyboard() { + let center = NotificationCenter.default + keyboardObservers = [ + center.addObserver(forName: .GCKeyboardDidConnect, object: nil, queue: .main) { [weak self] _ in + self?.publishHardwareKeyboardState(true) + }, + center.addObserver(forName: .GCKeyboardDidDisconnect, object: nil, queue: .main) { [weak self] _ in + // A second keyboard may still be attached (Stage Manager, dock swaps). + self?.publishHardwareKeyboardState(GCKeyboard.coalesced != nil) + }, + ] + } + + private func publishHardwareKeyboardState(_ attached: Bool) { + let value = attached ? "true" : "false" + webView?.evaluateJavaScript(""" + window.__OPENCHAMBER_HARDWARE_KEYBOARD__ = \(value); + window.dispatchEvent(new CustomEvent('oc:hardware-keyboard', { detail: { attached: \(value) } })); + """) + } + + deinit { + for observer in keyboardObservers { + NotificationCenter.default.removeObserver(observer) + } } } @@ -137,6 +207,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { UIApplication.shared.applicationIconBadgeNumber = 0 } + // A keyboard can be attached or detached while the app is backgrounded, + // with no GameController notification delivered to it. + (window?.rootViewController as? BridgeViewController)?.refreshHardwareKeyboardState() + // Refresh the widgets' session overview now that the WebView is loaded and state is fresh. writeWidgetSnapshot() } diff --git a/packages/ui/src/apps/IpadSidebarResizeHandle.tsx b/packages/ui/src/apps/IpadSidebarResizeHandle.tsx index 60a87f1e..16518652 100644 --- a/packages/ui/src/apps/IpadSidebarResizeHandle.tsx +++ b/packages/ui/src/apps/IpadSidebarResizeHandle.tsx @@ -9,8 +9,11 @@ export const IpadSidebarResizeHandle: React.FC<{ handleProps: React.HTMLAttributes; }> = ({ side, isResizing, ariaLabel, handleProps }) => (
void }> = ({ onActiveConnectionDeleted }) => { const { t } = useI18n(); @@ -116,26 +106,20 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc // 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); - // A plan opened from the Project notes surface, shown as a second fullscreen + // A plan opened from the workspace drawer's Notes tab, shown as a 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. const [pendingChangesDiff, setPendingChangesDiff] = React.useState<{ path: string; staged: boolean } | null>(null); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled); const updateAvailable = useUpdateStore((state) => state.available); const updateRuntimeType = useUpdateStore((state) => state.runtimeType); const showCapacitorOnlyFeatures = React.useMemo(() => isCapacitorMobileApp(), []); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const setMcpDraft = useMcpConfigStore((state) => state.setMcpDraft); const setSelectedMcp = useMcpConfigStore((state) => state.setSelectedMcp); - const refreshMcpStatus = useMcpStore((state) => state.refresh); - const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); - const gitStatus = useGitStatus(normalizePath(currentDirectory) || null); - const dirtyChangeCount = gitStatus?.files?.length ?? 0; // NOTE: pendingChangesDiff is intentionally NOT cleared on close — it keys // the persistent Changes pane in the workspace drawer, and clearing it would @@ -158,76 +142,93 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc 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(), []); + // Tablet: sessions live in a persistent full-height left sidebar instead of + // the phone's drawer. Everything else — the workspace drawer, the header, the + // app-level surfaces — is shared with phones. + // + // A SIZE class, not a device check: an unfolded book foldable is a tablet + // until it is folded shut, and the shell keeps running across that change. + const { enabled: isTabletLayout, roomyForPanels } = useTabletLayout(); const orientation = useOrientation(); const isPortrait = orientation === 'portrait'; - const [ipadSidebarOpen, setIpadSidebarOpen] = React.useState(isIPad && !isPortrait); - const [ipadRightPanel, setIpadRightPanel] = React.useState<'files' | 'changes' | null>(null); + const hasHardwareKeyboard = useHardwareKeyboard(); + const [sidebarOpen, setSidebarOpen] = React.useState(() => readTabletLayout().roomyForPanels); - const toggleIpadSidebar = React.useCallback(() => { - const willOpen = !ipadSidebarOpen; - // Portrait doesn't fit both side panels next to a usable chat column: - // opening one closes the other (iPadOS behaves the same way). - if (willOpen && isPortrait) setIpadRightPanel(null); - setIpadSidebarOpen(willOpen); - }, [ipadSidebarOpen, isPortrait]); + const toggleSidebar = React.useCallback(() => { + setSidebarOpen((current: boolean) => !current); + }, []); + + // Folding shut (or losing the room for a side-by-side layout) must not leave + // a sidebar open over a phone-width screen. + React.useEffect(() => { + if (!isTabletLayout) setSidebarOpen(false); + }, [isTabletLayout]); const openFilesSurface = React.useCallback(() => { - if (isIPad) { - setPendingChangesDiff(null); - setIpadRightPanel('files'); - if (isPortrait) setIpadSidebarOpen(false); - return; - } + setPendingChangesDiff(null); setWorkspaceTab('files'); setWorkspaceOpen(true); - }, [isIPad, isPortrait]); + }, []); const openChangesSurface = React.useCallback((diff: { path: string; staged: boolean } | null = null) => { setPendingChangesDiff(diff); - if (isIPad) { - setIpadRightPanel('changes'); - if (isPortrait) setIpadSidebarOpen(false); - return; - } setWorkspaceTab('changes'); setWorkspaceOpen(true); - }, [isIPad, isPortrait]); - - const closeIpadRightPanel = React.useCallback(() => { - setIpadRightPanel(null); - setPendingChangesDiff(null); }, []); - const toggleIpadRightPanel = React.useCallback((panel: 'files' | 'changes') => { - if (ipadRightPanel === panel) { - closeIpadRightPanel(); - return; - } - if (panel === 'files') openFilesSurface(); - else openChangesSurface(); - }, [closeIpadRightPanel, ipadRightPanel, openChangesSurface, openFilesSurface]); - - // Keep the right panel's content mounted through the width-collapse - // animation; drop it once the panel is fully closed. - const lastIpadRightPanelRef = React.useRef<'files' | 'changes'>('changes'); - if (ipadRightPanel) lastIpadRightPanelRef.current = ipadRightPanel; - const [ipadRightContentMounted, setIpadRightContentMounted] = React.useState(false); - React.useEffect(() => { - if (!isIPad) return; - if (ipadRightPanel) { - setIpadRightContentMounted(true); - return; - } - const id = window.setTimeout(() => setIpadRightContentMounted(false), 240); - return () => window.clearTimeout(id); - }, [ipadRightPanel, isIPad]); - const renderedIpadRightPanel = ipadRightPanel ?? lastIpadRightPanelRef.current; - const leftResize = useIpadSidebarResize('left', 'openchamber.ipad.leftSidebarWidth', IPAD_LEFT_SIDEBAR_WIDTH); - const rightResize = useIpadSidebarResize('right', 'openchamber.ipad.rightSidebarWidth', IPAD_RIGHT_SIDEBAR_WIDTH); + const rightResize = useIpadSidebarResize( + 'right', + 'openchamber.ipad.rightSidebarWidth', + IPAD_RIGHT_SIDEBAR_WIDTH, + IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH, + ); + // The workspace becomes a real side panel only where the screen can host the + // sidebar, the panel AND a readable chat at once. Everywhere else — a tablet + // in portrait, and an unfolded foldable in EITHER orientation, since its long + // side is barely wider than a tablet's short one — it stays the full-cover + // drawer, which is the layout that actually works at that width. + const workspaceAsPanel = roomyForPanels; + const workspacePanelWidth = workspaceAsPanel && workspaceOpen ? rightResize.width : 0; + const sidebarWidth = isTabletLayout && sidebarOpen ? leftResize.width : 0; + + // Publish the chat column's insets so overlays portaled to (model + // picker, directory picker, every MobileOverlayPanel) can center on the CHAT + // rather than on the window. Zero on phones, where the two are the same. + React.useEffect(() => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + root.style.setProperty('--oc-chat-inset-left', `${sidebarWidth}px`); + root.style.setProperty('--oc-chat-inset-right', `${workspacePanelWidth}px`); + return () => { + root.style.removeProperty('--oc-chat-inset-left'); + root.style.removeProperty('--oc-chat-inset-right'); + }; + }, [sidebarWidth, workspacePanelWidth]); + + // Wide chat layout: the shared chat columns key off this root class, but only + // the desktop App set it — so on a tablet, where the chat column is finally + // wide enough for the setting to mean something, it did nothing. Applied for + // every mobile surface; on a phone the viewport is narrower than even the + // normal clamp, so it is a no-op there. + React.useEffect(() => { + if (typeof document === 'undefined') return; + const root = document.documentElement; + root.classList.toggle('wide-chat-layout', wideChatLayoutEnabled); + return () => root.classList.remove('wide-chat-layout'); + }, [wideChatLayoutEnabled]); + + // The draft screen keeps its starter chips while the keyboard is up when + // there is room for both: a tablet in portrait, or any tablet orientation + // with a hardware keyboard (then no software keyboard eats the screen at + // all). Landscape on the software keyboard still hides them — see mobile.css. + React.useEffect(() => { + if (typeof document === 'undefined') return; + const keep = isTabletLayout && (isPortrait || hasHardwareKeyboard); + const root = document.documentElement; + root.classList.toggle('oc-keep-draft-starters', keep); + return () => root.classList.remove('oc-keep-draft-starters'); + }, [hasHardwareKeyboard, isTabletLayout, isPortrait]); const mobileActions = React.useMemo( () => ({ @@ -246,7 +247,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const deepLinkHandlers = React.useMemo( () => ({ openSessions: () => { - if (isIPad) setIpadSidebarOpen(true); + if (isTabletLayout) setSidebarOpen(true); else setSessionsSheetOpen(true); }, openView: (target: 'files' | 'mcp' | 'instances' | 'update') => { @@ -254,8 +255,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openFilesSurface(); return; } - // Phones host MCP as a workspace tab now; iPad still uses the surface. - if (target === 'mcp' && !isIPad) { + if (target === 'mcp') { setWorkspaceTab('mcp'); setWorkspaceOpen(true); return; @@ -270,37 +270,25 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openSettingsSurface(section ? 'page-content' : 'nav'); }, }), - [isIPad, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage], + [isTabletLayout, openChangesSurface, openFilesSurface, openSettingsSurface, openSurface, setSettingsPage], ); useDeepLinkHandlers(deepLinkHandlers); - // 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). + // Edge swipes on the chat: left edge opens the sessions drawer (the + // persistent sidebar on a tablet), right edge the workspace drawer. const chatMainRef = React.useRef(null); useEdgeSwipe(chatMainRef, { onLeftEdgeSwipe: () => { - if (isIPad) setIpadSidebarOpen(true); + if (isTabletLayout) setSidebarOpen(true); else setSessionsSheetOpen(true); }, - onRightEdgeSwipe: () => { - if (isIPad) { - if (lastIpadRightPanelRef.current === 'files') openFilesSurface(); - else openChangesSurface(); - return; - } - setWorkspaceOpen(true); - }, + onRightEdgeSwipe: () => 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. + // Top-most layer first: a plan or fullscreen surface can 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; @@ -318,7 +306,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc return true; } return false; - }, [activeSurface, closeSurface, closeWorkspace, openPlan, overflowOpen, sessionsSheetOpen, workspaceOpen]); + }, [activeSurface, closeSurface, closeWorkspace, openPlan, sessionsSheetOpen, workspaceOpen]); useNativeAndroidBackButton(handleNativeBack); @@ -329,6 +317,22 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc && updateAvailable && (updateRuntimeType === 'desktop' || updateRuntimeType === 'web'); + // Tablets pack the app-level pages (settings, instances, a plan) into a + // centered dialog instead of covering the whole screen. + const surfaceVariant = isTabletLayout ? 'dialog' as const : 'fullscreen' as const; + + // App-level footer of the sessions list — the same on a phone drawer and a + // tablet sidebar: connected instance, pending web update, settings. + const sessionsFooter = React.useMemo( + () => ({ + instanceLabel: showCapacitorOnlyFeatures ? getAutoConnectTargetLabel() : null, + onOpenInstances: showCapacitorOnlyFeatures ? () => openSurface('instances') : undefined, + onOpenSettings: () => openSettingsSurface('nav'), + onOpenUpdate: showUpdateItem ? () => openSurface('update') : undefined, + }), + [openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem], + ); + const openMcpCreateSettings = React.useCallback(() => { const baseName = 'new-mcp-server'; let newName = baseName; @@ -361,70 +365,6 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc openSettingsSurface('page-content'); }, [mcpServers, openSettingsSurface, setMcpDraft, setSelectedMcp, setSettingsPage]); - const refreshMcpOverlay = React.useCallback(() => { - if (isMcpRefreshing) return; - setIsMcpRefreshing(true); - const directory = currentDirectory || null; - const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500)); - void Promise.all([ - refreshMcpStatus({ directory, silent: true }), - loadMcpConfigs({ force: true }), - minSpinPromise, - ]).finally(() => setIsMcpRefreshing(false)); - }, [currentDirectory, isMcpRefreshing, loadMcpConfigs, refreshMcpStatus]); - - const overflowItems: OverflowItem[] = React.useMemo( - () => { - const items: OverflowItem[] = []; - // 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: 'mcp', - iconNode: , - label: t('mobile.menu.mcp'), - 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: () => openSurface('instances'), - }); - } - if (showUpdateItem) { - items.push({ - key: 'update', - icon: 'download', - label: t('mobile.menu.update'), - onSelect: () => openSurface('update'), - }); - } - items.push({ - key: 'settings', - icon: 'settings-3', - label: t('mobile.menu.settings'), - onSelect: () => openSettingsSurface('nav'), - }); - return items; - }, - [isIPad, openSettingsSurface, openSurface, showCapacitorOnlyFeatures, showUpdateItem, t], - ); - return (
void }> = ({ onAc {/* iPad: persistent full-height sessions sidebar; the chat column and its header butt against it (iPadOS-style split layout). Always mounted so open/close animates width, same as the desktop Sidebar. */} - {isIPad ? ( + {isTabletLayout ? ( ) : null}
(isIPad ? toggleIpadSidebar() : setSessionsSheetOpen(true))} - // Phones dropped the overflow menu: its items live in the sessions - // drawer footer and the workspace tabs now. iPad keeps it until - // the dedicated iPad layout pass. - onOpenMenu={isIPad ? () => setOverflowOpen(true) : undefined} - onOpenWorkspace={isIPad ? undefined : () => setWorkspaceOpen(true)} - surfaceShortcuts={isIPad ? { - activePanel: ipadRightPanel, - changesDirty: dirtyChangeCount > 0, - onToggleFiles: () => toggleIpadRightPanel('files'), - onToggleChanges: () => toggleIpadRightPanel('changes'), - } : undefined} + onOpenSessions={() => (isTabletLayout ? toggleSidebar() : setSessionsSheetOpen(true))} + onOpenWorkspace={() => setWorkspaceOpen(true)} + compactTitle={isTabletLayout} />
@@ -511,20 +449,33 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
- {/* iPad: Changes/Files live in a full-height right sidebar instead of - the phone's fullscreen surfaces. Width animates like the desktop - RightSidebar; content stays mounted through the collapse. */} - {isIPad ? ( + {/* Mounted permanently on phones (parked off-screen while closed) so + the sessions/worktree state stays warm and the drawer opens with + data already on screen — see MobileSessionsDrawerContainer. */} + {!isTabletLayout ? ( + + ) : null} + + {/* Tablet: the workspace lives inside an animated aside so landscape + gets a real sidebar. The drawer element keeps its position in the + tree across rotation — only its `variant` changes — so the mounted + panes (open diff, edited file, attached terminal) survive it. In + portrait the drawer portals itself out and this aside stays at 0. */} + {isTabletLayout ? ( - ) : null} - - {isIPad ? ( - setOverflowOpen(false)} - items={overflowItems} - rightOffset={ipadRightPanel ? rightResize.width : 0} - /> - ) : null} - - {/* Mounted permanently on phones (parked off-screen while closed) so - the sessions/worktree state stays warm and the drawer opens with - data already on screen — see MobileSessionsDrawerContainer. */} - {!isIPad ? ( - openSurface('instances') : undefined, - onOpenSettings: () => openSettingsSurface('nav'), - onOpenUpdate: showUpdateItem ? () => openSurface('update') : undefined, - }} - /> - ) : null} - - {/* Mounted only while open (like the sessions sheet) so each surface - computes its safe-area / fixed-position layout fresh on open. Keeping - them always-mounted left a stale startup layout, which made the - top-inset dimming appear only intermittently on iOS. */} - {!isIPad ? ( + ) : ( void }> = ({ onAc onOpenPlan={setOpenPlan} onOpenMcpSettings={openMcpCreateSettings} /> - ) : null} + )} - {activeSurface === 'terminal' ? ( - - - - - - ) : null} - - {activeSurface === 'mcp' ? ( - - - - - )} - > - - - - - ) : null} - - {activeSurface === 'notes' ? ( - - - - - - ) : null} - - {/* Layered above whichever surface opened it — the notes fullscreen - surface (iPad) or the workspace drawer's Notes tab (phones). */} + {/* Layered above the workspace drawer's Notes tab, which opened it. */} {openPlan ? ( setOpenPlan(null)} ariaLabel={openPlan.title} title={openPlan.title} @@ -707,6 +552,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc {activeSurface === 'instances' && showCapacitorOnlyFeatures ? ( void }> = ({ onAc {activeSurface === 'settings' ? ( void }> = ({ onAc {activeSurface === 'update' ? ( = ( headerless = false, noHeaderBorder = false, ariaLabel, + variant = 'fullscreen', + dialogAlign = 'chat', children, }) => { const { t } = useI18n(); @@ -148,15 +162,29 @@ export const MobileFullscreenSurface: React.FC = ( if (!open || !rootRef.current) return null; - return createPortal( + const isDialog = variant === 'dialog'; + + const surface = (
= ( transition: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`, }} onTransitionEnd={(event) => { - // Reveal content exactly when the enter slide ends — not on a fixed timer. + // Reveal content exactly when the enter transition ends — not on a fixed timer. if (entered && event.target === event.currentTarget && event.propertyName === 'transform') { setContentReady(true); } @@ -214,7 +242,29 @@ export const MobileFullscreenSurface: React.FC = ( ) : null}
- , + + ); + + if (!isDialog) return createPortal(surface, rootRef.current); + + return createPortal( +
+
event.stopPropagation()}> + {surface} +
+
, rootRef.current, ); }; diff --git a/packages/ui/src/apps/MobileHeader.tsx b/packages/ui/src/apps/MobileHeader.tsx index 94cd6427..7660f5e9 100644 --- a/packages/ui/src/apps/MobileHeader.tsx +++ b/packages/ui/src/apps/MobileHeader.tsx @@ -10,23 +10,14 @@ import { useSession } from '@/sync/sync-context'; import { MobileSessionMetadataButton } from './MobileSessionMetadata'; import { MobileSessionSwitcher } from './MobileSessionSwitcher'; -export type MobileHeaderSurfaceShortcuts = { - activePanel: 'files' | 'changes' | null; - changesDirty: boolean; - onToggleFiles: () => void; - onToggleChanges: () => void; -}; - export const MobileHeader: React.FC<{ onOpenSessions: () => void; - /** iPad only for now: the legacy overflow menu. Phones distribute its items - across the sessions drawer footer and the workspace drawer tabs. */ - onOpenMenu?: () => void; - /** Phone only: opens the right workspace drawer (Changes / Files / Terminal). */ - onOpenWorkspace?: () => void; - /** iPad only: Files/Changes header shortcuts that toggle the right sidebar. */ - surfaceShortcuts?: MobileHeaderSurfaceShortcuts; -}> = ({ onOpenSessions, onOpenMenu, onOpenWorkspace, surfaceShortcuts }) => { + /** Opens the right workspace drawer (Changes / Files / Terminal / Notes / MCP). */ + onOpenWorkspace: () => void; + /** Tablet: size the title trigger to its text instead of the free width, so + a wide header doesn't turn the switcher into a full-width tap target. */ + compactTitle?: boolean; +}> = ({ onOpenSessions, onOpenWorkspace, compactTitle = false }) => { const { t } = useI18n(); const [metadataOpen, setMetadataOpen] = React.useState(false); const [switcherOpen, setSwitcherOpen] = React.useState(false); @@ -57,12 +48,6 @@ export const MobileHeader: React.FC<{ onOpenSessions(); }, [onOpenSessions]); - const handleOpenMenu = React.useCallback(() => { - setMetadataOpen(false); - setSwitcherOpen(false); - onOpenMenu?.(); - }, [onOpenMenu]); - // The two header popovers are mutually exclusive. const handleMetadataOpenChange = React.useCallback((value: boolean | ((open: boolean) => boolean)) => { setMetadataOpen((current) => { @@ -101,7 +86,10 @@ export const MobileHeader: React.FC<{ + {/* Compact title: this takes the leftover width so the trailing + controls stay pinned to the right edge. */} + {compactTitle ?
: null} + - {surfaceShortcuts ? ( - <> - - - - ) : null} - - {onOpenMenu ? ( - - ) : null} - - {onOpenWorkspace ? ( - - ) : null} +
void; -}; - -export 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 ( -
- - ))} -
- -
- ); -}; diff --git a/packages/ui/src/apps/MobileSessionMetadata.tsx b/packages/ui/src/apps/MobileSessionMetadata.tsx index c2a42ac1..85a93ff5 100644 --- a/packages/ui/src/apps/MobileSessionMetadata.tsx +++ b/packages/ui/src/apps/MobileSessionMetadata.tsx @@ -4,8 +4,8 @@ import { Icon } from '@/components/icon/Icon'; import type { IconName } from '@/components/icon/icons'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { preloadProviderLogos } from '@/hooks/useProviderLogo'; +import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; -import { isIPadApp } from '@/lib/platform'; import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota'; import { getDisplayModelName } from '@/lib/quota/model-families'; import { cn } from '@/lib/utils'; @@ -16,7 +16,7 @@ import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionMessages } from '@/sync/sync-context'; -const IPAD_METADATA_POPOVER_WIDTH = 380; +const TABLET_METADATA_POPOVER_WIDTH = 380; const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => { if (!limit || typeof limit !== 'object') return undefined; @@ -139,18 +139,18 @@ const SessionMetadataOverlay: React.FC<{ 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 + // Tablet: 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 { enabled: isTabletLayout } = useTabletLayout(); const wrapperRef = React.useRef(null); - const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState(null); + const [anchorLeft, 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; + if (!open || !isTabletLayout || !shouldRender) return; const compute = () => { const anchorRect = anchorRef.current?.getBoundingClientRect(); const wrapperRect = wrapperRef.current?.getBoundingClientRect(); @@ -161,7 +161,7 @@ const SessionMetadataOverlay: React.FC<{ const relativeLeft = anchorRect.left - wrapperRect.left; const left = Math.min( Math.max(relativeLeft, 8), - Math.max(8, wrapperRect.width - IPAD_METADATA_POPOVER_WIDTH - 8), + Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8), ); setIpadAnchorLeft(left); }; @@ -173,9 +173,9 @@ const SessionMetadataOverlay: React.FC<{ const observer = new ResizeObserver(compute); observer.observe(wrapper); return () => observer.disconnect(); - }, [anchorRef, isIPad, open, shouldRender]); + }, [anchorRef, isTabletLayout, open, shouldRender]); - const ipadPopover = isIPad && ipadAnchorLeft !== null; + const isPopover = isTabletLayout && anchorLeft !== null; React.useEffect(() => { if (open) { @@ -233,17 +233,17 @@ const SessionMetadataOverlay: React.FC<{ aria-label={t('mobile.header.openMetadataAria')} className={cn( 'overflow-y-auto overscroll-contain rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform', - ipadPopover ? 'absolute origin-top-left' : 'mx-3 mt-2', + isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2', isExiting ? 'pointer-events-none' : 'pointer-events-auto', )} style={{ animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`, maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))', - ...(ipadPopover + ...(isPopover ? { top: 8, - left: ipadAnchorLeft ?? 8, - width: `min(${IPAD_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`, + left: anchorLeft ?? 8, + width: `min(${TABLET_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`, } : null), }} diff --git a/packages/ui/src/apps/MobileSessionSwitcher.tsx b/packages/ui/src/apps/MobileSessionSwitcher.tsx index 7f13948c..4e2a44a8 100644 --- a/packages/ui/src/apps/MobileSessionSwitcher.tsx +++ b/packages/ui/src/apps/MobileSessionSwitcher.tsx @@ -4,6 +4,7 @@ import type { Session } from '@opencode-ai/sdk/v2'; import { Icon } from '@/components/icon/Icon'; import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils'; import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems'; +import { useTabletLayout } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; import { cn } from '@/lib/utils'; import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; @@ -13,6 +14,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; const RECENT_SESSIONS_LIMIT = 10; +/** Matches the metadata popover's width so both header dropdowns read as a pair. */ +const TABLET_POPOVER_WIDTH = 380; const getSessionTitle = (session: Session, fallback: string): string => session.title?.trim() || fallback; @@ -76,6 +79,42 @@ export const MobileSessionSwitcher: React.FC<{ const panelRef = React.useRef(null); const [shouldRender, setShouldRender] = React.useState(open); const [isExiting, setIsExiting] = React.useState(false); + // Tablet: a phone-width sheet stretched across the whole chat column looks + // broken — anchor a popover under the title instead. Mirror image of the + // metadata/usage popover, which anchors to the ring on the right. + const { enabled: isTabletLayout } = useTabletLayout(); + const wrapperRef = React.useRef(null); + const [anchorLeft, setAnchorLeft] = React.useState(null); + + // The shell has transformed ancestors, so the fixed wrapper's containing + // block is the chat column, NOT the viewport — anchor in the wrapper's own + // coordinate space (see SessionMetadataOverlay for the same reasoning). + React.useLayoutEffect(() => { + if (!open || !isTabletLayout || !shouldRender) return; + const compute = () => { + const anchorRect = anchorRef.current?.getBoundingClientRect(); + const wrapperRect = wrapperRef.current?.getBoundingClientRect(); + if (!anchorRect || !wrapperRect) { + setAnchorLeft(null); + return; + } + const relativeLeft = anchorRect.left - wrapperRect.left; + setAnchorLeft(Math.min( + Math.max(relativeLeft, 8), + Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8), + )); + }; + 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, isTabletLayout, open, shouldRender]); + + const isPopover = isTabletLayout && anchorLeft !== null; const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession); const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly); @@ -132,18 +171,26 @@ export const MobileSessionSwitcher: React.FC<{ if (!shouldRender) return null; return ( -
+
diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 30a5ce90..fb85e607 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -1437,8 +1437,11 @@ export const MobileSessionsSheet: React.FC = ({ open, ) : null; + // flex-1 + min-h-0 rather than h-full: both hosts put a fixed-height header + // above this, so a 100% height overflows by exactly that header — and the + // clipped overflow swallowed the footer. const surfaceContent = ( -
+
{/* The search bar scrolls WITH the list (iOS-style): the open-time auto-scroll to the current session naturally tucks it away, and diff --git a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx index 3d8e6392..b3c94c24 100644 --- a/packages/ui/src/apps/MobileWorkspaceDrawer.tsx +++ b/packages/ui/src/apps/MobileWorkspaceDrawer.tsx @@ -85,10 +85,18 @@ const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenM ); }; -/** 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). */ +/** The workspace surfaces as tabs (Changes / Files / Terminal / Notes / MCP). + + Two hosts, same content and same state: + - `drawer` (default) covers the app and slides in from the right edge — + the phone, and a tablet in portrait where a side panel would leave no + usable chat column; + - `panel` renders inline so the caller can size it as a real sidebar + beside the chat (tablet, landscape). The caller owns the width and the + open/close animation there; this component only fills it. + + 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; @@ -100,7 +108,8 @@ export const MobileWorkspaceDrawer: React.FC<{ 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 }) => { + variant?: 'drawer' | 'panel'; +}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => { const { t } = useI18n(); const rootRef = React.useRef(null); const [entered, setEntered] = React.useState(false); @@ -150,20 +159,22 @@ export const MobileWorkspaceDrawer: React.FC<{ React.useEffect(() => { if (!open) return; + // Only the full-cover drawer owns the page scroll; the inline panel sits + // inside the shell and must leave the chat beside it scrollable. const previousOverflow = document.body.style.overflow; - document.body.style.overflow = 'hidden'; + if (variant === 'drawer') 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; + if (variant === 'drawer') document.body.style.overflow = previousOverflow; document.removeEventListener('keydown', handleKeyDown); }; - }, [open]); + }, [open, variant]); - if (!rootRef.current) return null; + if (variant === 'drawer' && !rootRef.current) return null; const tabItems: SortableTabsStripItem[] = [ { id: 'changes', label: t('mobile.menu.changes'), icon: }, @@ -173,23 +184,8 @@ export const MobileWorkspaceDrawer: React.FC<{ { id: 'mcp', label: t('mobile.menu.mcp'), icon: }, ]; - return createPortal( -
+ const body = ( + <>
{/* Mounted only while shown; nonCompositedIndicator keeps the active @@ -268,7 +264,36 @@ export const MobileWorkspaceDrawer: React.FC<{
) : null}
+ + ); + + if (variant === 'panel') { + // The caller animates the width; the content itself is plain flow so it + // never gets its own compositing layer (iOS clips those to the safe-area + // viewport, which is exactly what the drawer's settled `transform: none` + // avoids on the other host). + return
{body}
; + } + + return createPortal( +
+ {body}
, - rootRef.current, + rootRef.current as HTMLElement, ); }; diff --git a/packages/ui/src/apps/ipadSidebarResize.ts b/packages/ui/src/apps/ipadSidebarResize.ts index 87463186..88a3a529 100644 --- a/packages/ui/src/apps/ipadSidebarResize.ts +++ b/packages/ui/src/apps/ipadSidebarResize.ts @@ -4,17 +4,25 @@ 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; +/** The workspace panel holds diffs, a file editor and a terminal, so it earns + far more room than the sessions list ever needs. */ +export const IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH = 900; /** 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) { +export function useIpadSidebarResize( + side: 'left' | 'right', + storageKey: string, + defaultWidth: number, + maxWidth: number = IPAD_SIDEBAR_MAX_WIDTH, +) { 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)); + return Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored)); }); const [isResizing, setIsResizing] = React.useState(false); const startXRef = React.useRef(0); @@ -23,8 +31,8 @@ export function useIpadSidebarResize(side: 'left' | 'right', storageKey: string, 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))) - ), []); + Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value))) + ), [maxWidth]); const applyLiveWidth = React.useCallback((nextWidth: number) => { const aside = asideRef.current; diff --git a/packages/ui/src/apps/mobileNativeChrome.ts b/packages/ui/src/apps/mobileNativeChrome.ts index 3c4eb92a..a26c6eb4 100644 --- a/packages/ui/src/apps/mobileNativeChrome.ts +++ b/packages/ui/src/apps/mobileNativeChrome.ts @@ -1,5 +1,7 @@ import React from 'react'; +import { observeNativeKeyboardHeight, resetHardwareKeyboardDetection, startHardwareKeyboardBridge } from '@/lib/hardwareKeyboard'; + /** True when running inside the native Capacitor shell (iOS/Android app). */ export const isCapacitorMobileApp = (): boolean => { if (typeof window === 'undefined') return false; @@ -27,6 +29,10 @@ export const useNativeMobileChrome = (): void => { root.classList.add('oc-platform-android'); } + // iOS reports hardware keyboards natively (GCKeyboard); adopting that + // answer switches the layout off its keyboard-event inference entirely. + cleanup.push(startHardwareKeyboardBridge()); + const setInset = (px: number) => { root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`); }; @@ -88,7 +94,8 @@ export const useNativeMobileChrome = (): void => { // 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', () => { + const willShowHandle = await Keyboard.addListener('keyboardWillShow', (info) => { + observeNativeKeyboardHeight(info.keyboardHeight); 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. @@ -178,6 +185,7 @@ export const useNativeMobileChrome = (): void => { const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => { clearSettle(); + observeNativeKeyboardHeight(info.keyboardHeight); keyboardOpen = true; keyboardHeight = info.keyboardHeight; if (!layoutApplied) { @@ -330,6 +338,7 @@ export const useNativeMobileChrome = (): void => { return () => { disposed = true; cleanup.forEach((remove) => remove()); + resetHardwareKeyboardDetection(); 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'); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index b1cda99e..b039d64d 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -52,6 +52,8 @@ import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; import { toast } from '@/components/ui'; // useMessageStore removed — messages now come from sync system import { isVSCodeRuntime } from '@/lib/desktop'; +import { useTabletLayout } from '@/lib/device'; +import { useHardwareKeyboard } from '@/lib/hardwareKeyboard'; import { isIMECompositionEvent } from '@/lib/ime'; import { getCycledPrimaryAgentName, type MobileControlsPanel } from './mobileControlsUtils'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -349,6 +351,8 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); const agents = getVisibleAgents(); const isMobile = useUIStore((state) => state.isMobile); + const hasHardwareKeyboard = useHardwareKeyboard(); + const { enabled: isTabletLayout } = useTabletLayout(); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const inputBarOffset = useUIStore((state) => state.inputBarOffset); const persistChatDraft = useUIStore((state) => state.persistChatDraft); @@ -2233,6 +2237,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo editorRef: composerRef, formRef: composerFormRef, setExpandedInput, + // The pill exists to buy screen back from the soft keyboard. A tablet + // has the room regardless, and with a hardware keyboard there is no + // soft keyboard to buy it back from — keep the real composer up. + alwaysExpanded: hasHardwareKeyboard || isTabletLayout, holders: { controlsPanelOpen: Boolean(mobileControlsPanel), attachMenuOpen: mobileAttachMenuOpen, diff --git a/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts b/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts index 7f46bf66..32b15868 100644 --- a/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts +++ b/packages/ui/src/components/chat/composer/state/useMobileComposerShell.ts @@ -18,6 +18,7 @@ import React from 'react'; import { flushSync } from 'react-dom'; +import { observeEditorFocus } from '@/lib/hardwareKeyboard'; import { isCapacitorApp } from '@/lib/platform'; import type { ComposerEditorHandle } from '../editor/ComposerEditor'; @@ -41,6 +42,13 @@ export interface MobileComposerShellOptions { formRef: React.RefObject; setExpandedInput: (expanded: boolean) => void; holders: MobileComposerHolders; + /** + * Keep the full composer up permanently and never fall back to the pill. + * The pill exists to buy screen back from the soft keyboard; with a + * hardware keyboard on a tablet there is no soft keyboard to hide from, + * and collapsing between keystrokes would only cost the user a tap. + */ + alwaysExpanded?: boolean; } export interface MobileComposerShell { @@ -65,9 +73,9 @@ export interface MobileComposerShell { export function useMobileComposerShell( options: MobileComposerShellOptions, ): MobileComposerShell { - const { isMobile, editorRef, formRef, setExpandedInput, holders } = options; + const { isMobile, editorRef, formRef, setExpandedInput, holders, alwaysExpanded = false } = options; - const [expanded, setExpanded] = React.useState(false); + const [expanded, setExpanded] = React.useState(alwaysExpanded && isMobile); const [focused, setFocused] = React.useState(false); const [overlayHostBusy, setOverlayHostBusy] = React.useState(false); const [dictationActive, setDictationActive] = React.useState(false); @@ -88,6 +96,16 @@ export function useMobileComposerShell( expandedRef.current = expanded; }); + // A hardware keyboard can be attached (or detached) at any moment, so this + // is a live condition rather than a mount-time one. Detaching does NOT + // force a collapse — the normal idle/keyboard-hide paths take over again. + const alwaysExpandedRef = React.useRef(alwaysExpanded); + alwaysExpandedRef.current = alwaysExpanded; + React.useEffect(() => { + if (!isMobile || !alwaysExpanded) return; + setExpanded(true); + }, [alwaysExpanded, isMobile]); + // The draft screen restructures itself around the composer: its starter // chips leave once the full composer is up, and its centered title // re-centers over whatever room remains. Announced as a root class from a @@ -95,12 +113,16 @@ export function useMobileComposerShell( // swap — keyed on the keyboard instead (oc-keyboard-open arrives with the // keyboardWillShow bridge event, ~100ms later), the chips vanished // mid-rise as a second visible jump. + // + // Not announced while `alwaysExpanded`: there the full composer is the + // resting state, not a keyboard takeover, so claiming otherwise would hide + // the starters permanently. The keyboard classes still cover that case. React.useLayoutEffect(() => { if (!isMobile || typeof document === 'undefined') return; const root = document.documentElement; - root.classList.toggle('oc-composer-expanded', expanded); + root.classList.toggle('oc-composer-expanded', expanded && !alwaysExpanded); return () => root.classList.remove('oc-composer-expanded'); - }, [expanded, isMobile]); + }, [alwaysExpanded, expanded, isMobile]); const expand = React.useCallback(() => { expandIntentRef.current = 'focus'; @@ -151,7 +173,7 @@ export function useMobileComposerShell( // insert-and-send) collapse straight back to the pill rather than // parking on the normal composer for the usual grace period. window.setTimeout(() => { - if (!expandedRef.current) return; + if (!expandedRef.current || alwaysExpandedRef.current) return; if (editorRef.current?.isFocused()) return; setExpanded(false); setExpandedInput(false); @@ -288,7 +310,7 @@ export function useMobileComposerShell( || holders.isDragging; React.useEffect(() => { - if (!isMobile || !expanded || busy) return; + if (!isMobile || !expanded || busy || alwaysExpanded) return; const timer = window.setTimeout(() => { // Authoritative DOM check: the React focus state can lag a // programmatic refocus (the overlay-close restore above). @@ -299,7 +321,7 @@ export function useMobileComposerShell( setExpandedInput(false); }, 250); return () => window.clearTimeout(timer); - }, [busy, editorRef, expanded, isMobile, setExpandedInput]); + }, [alwaysExpanded, busy, editorRef, expanded, isMobile, setExpandedInput]); const busyRef = React.useRef(false); busyRef.current = busy; @@ -344,7 +366,7 @@ export function useMobileComposerShell( const handleIntent = (event: Event) => { const detail = (event as CustomEvent<{ open?: boolean }>).detail; if (!detail || detail.open !== false) return; - if (!expandedRef.current) return; + if (!expandedRef.current || alwaysExpandedRef.current) return; // Something still holds the composer open (dictation, an overlay // that closed the keyboard, a drag) — the fallback path handles it. if (busyRef.current) return; @@ -360,6 +382,9 @@ export function useMobileComposerShell( const onEditorFocus = React.useCallback(() => { if (!isMobile) return; + // Focus is the only moment a soft keyboard would be presented, so it is + // also the only moment its ABSENCE tells us a hardware one is attached. + if (isCapacitorApp()) observeEditorFocus(); if (blurTimerRef.current !== null) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index e91d49fd..81d998a4 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -711,7 +711,9 @@ export const DirectoryExplorerDialog: React.FC = ( open={open} onClose={handleClose} title={t('directoryExplorerDialog.title')} - className="h-[88dvh] max-h-[720px] max-w-full" + // Height only — the width stays on MobileOverlayPanel's shared max-w-lg + // so this sheet matches every other mobile overlay on wide screens. + className="h-[88dvh] max-h-[720px]" contentMaxHeightClassName="flex-1" footer={
{renderFooter()}
} > diff --git a/packages/ui/src/components/ui/MobileOverlayPanel.tsx b/packages/ui/src/components/ui/MobileOverlayPanel.tsx index 8151f2d9..c1d56392 100644 --- a/packages/ui/src/components/ui/MobileOverlayPanel.tsx +++ b/packages/ui/src/components/ui/MobileOverlayPanel.tsx @@ -119,6 +119,15 @@ export const MobileOverlayPanel: React.FC = ({ role="dialog" aria-modal="true" onClick={onClose} + // The panel centers over the CHAT column, not the whole app: on a tablet + // the shell keeps a persistent sessions sidebar, and a sheet centered on + // the window reads as belonging to nothing. The shell publishes the + // column's insets; on phones they are 0 and this is a no-op. The scrim + // deliberately still covers everything. + style={{ + paddingLeft: 'var(--oc-chat-inset-left, 0px)', + paddingRight: 'var(--oc-chat-inset-right, 0px)', + }} >
{ + if (typeof window === 'undefined') return { enabled: false, roomyForPanels: false }; + const width = window.innerWidth; + const height = window.innerHeight; + // iPads answer this on identity too: iPadOS reports odd viewports in Slide + // Over / Split View, and a device we KNOW is a tablet should not flip to the + // phone layout because it was given a narrow slice. + const enabled = isIPadApp() || Math.min(width, height) >= TABLET_LAYOUT_MIN_SHORT_SIDE_PX; + return { + enabled, + roomyForPanels: enabled && width > height && width >= WORKSPACE_PANEL_MIN_WIDTH_PX, + }; +}; + +/** + * The tablet layout decision, live. + * + * Deliberately a hook over a one-shot check: foldables change size class while + * the app runs, and the Android shell keeps the WebView alive across the fold + * (`configChanges` covers screenSize), so every consumer has to re-decide + * rather than remember what it saw at mount. + */ +export function useTabletLayout(): TabletLayout { + const [layout, setLayout] = React.useState(readTabletLayout); + + React.useEffect(() => { + if (typeof window === 'undefined') return; + let frame: number | undefined; + const update = () => { + frame = undefined; + const next = readTabletLayout(); + setLayout((current) => ( + current.enabled === next.enabled && current.roomyForPanels === next.roomyForPanels + ? current + : next + )); + }; + const schedule = () => { + if (frame !== undefined) return; + frame = window.requestAnimationFrame(update); + }; + + update(); + window.addEventListener('resize', schedule); + const orientationQuery = window.matchMedia?.('(orientation: landscape)') ?? null; + const detachOrientation = attachMediaQueryListener(orientationQuery, schedule); + return () => { + window.removeEventListener('resize', schedule); + detachOrientation(); + if (frame !== undefined) window.cancelAnimationFrame(frame); + }; + }, []); + + return layout; +} + export function useDeviceInfo(): DeviceInfo { return React.useSyncExternalStore( subscribeDeviceInfo, diff --git a/packages/ui/src/lib/hardwareKeyboard.ts b/packages/ui/src/lib/hardwareKeyboard.ts new file mode 100644 index 00000000..ca7ba92f --- /dev/null +++ b/packages/ui/src/lib/hardwareKeyboard.ts @@ -0,0 +1,157 @@ +/** + * "Is a hardware keyboard attached?" — the input the mobile layout uses to + * decide whether a soft keyboard will ever eat the screen. + * + * Two sources, in priority order: + * + * 1. The native answer. On iOS the shell reads `GCKeyboard` and stamps + * `window.__OPENCHAMBER_HARDWARE_KEYBOARD__` at document start, then keeps it + * live via `oc:hardware-keyboard` (see BridgeViewController). This is + * authoritative and — crucially — known BEFORE the user focuses anything, so + * the draft screen and composer start in the right shape instead of + * re-laying-out after the first focus. + * 2. Inference, for runtimes with no native answer (Android, hosted mobile). + * A `keyboardWillShow` with a real height means there IS a soft keyboard; a + * tiny height means only iOS' shortcut strip; focus with no event at all + * within a short window means nothing was presented. Inference is ignored + * entirely once the native source has spoken. + * + * Everything else stays `false`, which is the safe default: the layout then + * behaves exactly as it does on a phone. + * + * In memory only — a keyboard can be attached and detached while the app runs, + * and both sources re-answer the question continuously. + */ + +import React from 'react'; + +/** Below this the "keyboard" is only iOS' shortcut bar, not a real keyboard. */ +const SOFTWARE_KEYBOARD_MIN_HEIGHT_PX = 120; +/** iOS starts its keyboard animation well inside this window after focus. */ +const KEYBOARD_EVENT_GRACE_MS = 600; + +declare global { + interface Window { + __OPENCHAMBER_HARDWARE_KEYBOARD__?: boolean; + } +} + +// Read at module init, not just from the bridge effect: the stamp exists from +// document start, and the very first render of the draft screen / composer must +// already see it — otherwise the layout still settles one frame late. +const initialNativeAnswer = typeof window !== 'undefined' + && typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean' + ? window.__OPENCHAMBER_HARDWARE_KEYBOARD__ + : null; + +let hardwareKeyboardAttached = initialNativeAnswer === true; +let hasNativeAnswer = initialNativeAnswer !== null; +let focusProbeTimer: number | null = null; +let bridgeStarted = false; +const subscribers = new Set<() => void>(); + +if (hardwareKeyboardAttached && typeof document !== 'undefined') { + document.documentElement.classList.add('oc-hardware-keyboard'); +} + +const clearFocusProbe = (): void => { + if (focusProbeTimer === null) return; + window.clearTimeout(focusProbeTimer); + focusProbeTimer = null; +}; + +const setHardwareKeyboardAttached = (value: boolean): void => { + if (hardwareKeyboardAttached === value) return; + hardwareKeyboardAttached = value; + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('oc-hardware-keyboard', value); + } + for (const listener of subscribers) listener(); +}; + +/** + * Adopt the native shell's answer and stop inferring. Idempotent; safe to call + * before the shell has stamped anything (then it is a no-op and inference + * stays in charge). + */ +export const startHardwareKeyboardBridge = (): (() => void) => { + if (typeof window === 'undefined') return () => {}; + + const adopt = (value: boolean) => { + hasNativeAnswer = true; + clearFocusProbe(); + setHardwareKeyboardAttached(value); + }; + + if (typeof window.__OPENCHAMBER_HARDWARE_KEYBOARD__ === 'boolean') { + adopt(window.__OPENCHAMBER_HARDWARE_KEYBOARD__); + } + + if (bridgeStarted) return () => {}; + bridgeStarted = true; + + const handleNativeChange = (event: Event) => { + const detail = (event as CustomEvent<{ attached?: boolean }>).detail; + adopt(detail?.attached === true); + }; + window.addEventListener('oc:hardware-keyboard', handleNativeChange); + return () => { + window.removeEventListener('oc:hardware-keyboard', handleNativeChange); + bridgeStarted = false; + }; +}; + +/** + * Feed a native `keyboardWillShow` height in. Called by the Capacitor keyboard + * bridge (see `mobileNativeChrome`) on both platforms. An arriving event always + * settles the question, so it cancels any pending focus probe. + */ +export const observeNativeKeyboardHeight = (heightPx: number): void => { + if (hasNativeAnswer || !Number.isFinite(heightPx)) return; + clearFocusProbe(); + setHardwareKeyboardAttached(heightPx > 0 && heightPx < SOFTWARE_KEYBOARD_MIN_HEIGHT_PX); +}; + +/** + * Report that an editor just took focus. If no keyboard event follows, nothing + * was presented — which means a hardware keyboard is attached. + * + * Deliberately one-directional within the window: only the SILENCE concludes + * "hardware". A real `keyboardWillShow` cancels the probe above, so a slow + * keyboard can never be misread. + */ +export const observeEditorFocus = (): void => { + if (hasNativeAnswer || typeof window === 'undefined' || typeof document === 'undefined') return; + // A soft keyboard already up sends no second `keyboardWillShow` — a refocus + // through it (the overlay-close keyboard restore) would look like silence. + if (document.documentElement.classList.contains('oc-keyboard-open')) return; + clearFocusProbe(); + focusProbeTimer = window.setTimeout(() => { + focusProbeTimer = null; + setHardwareKeyboardAttached(true); + }, KEYBOARD_EVENT_GRACE_MS); +}; + +/** Drop the inferred state when the native bridge tears down. */ +export const resetHardwareKeyboardDetection = (): void => { + clearFocusProbe(); + if (hasNativeAnswer) return; + setHardwareKeyboardAttached(false); +}; + +export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached; + +export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => { + subscribers.add(listener); + return () => { + subscribers.delete(listener); + }; +}; + +export function useHardwareKeyboard(): boolean { + return React.useSyncExternalStore( + subscribeHardwareKeyboard, + isHardwareKeyboardAttached, + () => false, + ); +} diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 0f04e76d..8a977fb3 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -90,7 +90,6 @@ export const dict = { 'mobile.nav.changes': 'Changes', 'mobile.nav.settings': 'Settings', 'mobile.surface.closeAria': 'Close', - 'mobile.header.openMenuAria': 'Open menu', 'mobile.header.openWorkspaceAria': 'Open workspace panel', 'mobile.header.openMetadataAria': 'Open session metadata', 'mobile.header.metadata.context': 'Context', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index e6e95889..f4e82a04 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -91,7 +91,6 @@ export const dict: Record = { "mobile.nav.changes": "Cambios", "mobile.nav.settings": "Ajustes", "mobile.surface.closeAria": "Cerrar", - "mobile.header.openMenuAria": "Abrir menú", "mobile.header.openWorkspaceAria": "Abrir panel de trabajo", "mobile.header.openMetadataAria": "Abrir metadatos de la sesión", "mobile.header.metadata.context": "Contexto", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 91f62125..cdf7c8c5 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2699,7 +2699,6 @@ export const dict = { 'mobile.nav.changes': 'Modifications', 'mobile.nav.settings': 'Paramètres', 'mobile.surface.closeAria': 'Fermer', - 'mobile.header.openMenuAria': 'Ouvrir le menu', 'mobile.header.openWorkspaceAria': 'Ouvrir le panneau de travail', 'mobile.header.openMetadataAria': 'Ouvrir les métadonnées de session', 'mobile.header.metadata.context': 'Contexte', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 4c011438..7c0f76eb 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -92,7 +92,6 @@ export const dict: Record = { 'mobile.instances.confirmDeleteAria': '{label} の削除を確定', 'mobile.instances.cancelDeleteAria': '{label} を残す', 'mobile.surface.closeAria': '閉じる', - 'mobile.header.openMenuAria': 'メニューを開く', 'mobile.header.openWorkspaceAria': 'ワークスペースパネルを開く', 'mobile.header.openMetadataAria': 'セッションメタデータを開く', 'mobile.header.metadata.context': 'コンテキスト', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index be7f7374..0bce6f37 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -91,7 +91,6 @@ export const dict: Record = { 'mobile.nav.changes': '변경사항', 'mobile.nav.settings': '설정', 'mobile.surface.closeAria': '닫기', - 'mobile.header.openMenuAria': '메뉴 열기', 'mobile.header.openWorkspaceAria': '작업 공간 패널 열기', 'mobile.header.openMetadataAria': '세션 메타데이터 열기', 'mobile.header.metadata.context': '컨텍스트', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d6aad575..1a78ae00 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -92,7 +92,6 @@ export const dict: Record = { 'mobile.nav.changes': 'Zmiany', 'mobile.nav.settings': 'Ustawienia', 'mobile.surface.closeAria': 'Zamknij', - 'mobile.header.openMenuAria': 'Otwórz menu', 'mobile.header.openWorkspaceAria': 'Otwórz panel roboczy', 'mobile.header.openMetadataAria': 'Otwórz metadane sesji', 'mobile.header.metadata.context': 'Kontekst', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 28bfbbc5..d1c8e309 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -91,7 +91,6 @@ export const dict: Record = { "mobile.nav.changes": "Alterações", "mobile.nav.settings": "Configurações", "mobile.surface.closeAria": "Fechar", - "mobile.header.openMenuAria": "Abrir menu", "mobile.header.openWorkspaceAria": "Abrir painel de trabalho", "mobile.header.openMetadataAria": "Abrir metadados da sessão", "mobile.header.metadata.context": "Contexto", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index a0ef8ea4..2f8f2de8 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -91,7 +91,6 @@ export const dict: Record = { "mobile.nav.changes": "Зміни", "mobile.nav.settings": "Налаштування", "mobile.surface.closeAria": "Закрити", - "mobile.header.openMenuAria": "Відкрити меню", "mobile.header.openWorkspaceAria": "Відкрити робочу панель", "mobile.header.openMetadataAria": "Відкрити метадані сесії", "mobile.header.metadata.context": "Контекст", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f9a36b56..2b0c835c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -91,7 +91,6 @@ export const dict: Record = { 'mobile.nav.changes': '更改', 'mobile.nav.settings': '设置', 'mobile.surface.closeAria': '关闭', - 'mobile.header.openMenuAria': '打开菜单', 'mobile.header.openWorkspaceAria': '打开工作区面板', 'mobile.header.openMetadataAria': '打开会话元数据', 'mobile.header.metadata.context': '上下文', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 862c7df1..598dc9ec 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -91,7 +91,6 @@ export const dict: Record = { 'mobile.nav.changes': '變更', 'mobile.nav.settings': '設定', 'mobile.surface.closeAria': '關閉', - 'mobile.header.openMenuAria': '開啟選單', 'mobile.header.openWorkspaceAria': '開啟工作區面板', 'mobile.header.openMetadataAria': '開啟工作階段中繼資料', 'mobile.header.metadata.context': '上下文', diff --git a/packages/ui/src/lib/tabletLayout.test.ts b/packages/ui/src/lib/tabletLayout.test.ts new file mode 100644 index 00000000..1881b1a0 --- /dev/null +++ b/packages/ui/src/lib/tabletLayout.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, test } from 'bun:test'; + +import { readTabletLayout, type TabletLayout } from './device'; + +// No module mocking here on purpose: mock.module is process-global and would +// leak into every other test file. Outside a Capacitor shell isIPadApp() is +// already false, so a bare viewport stub isolates the geometry rules. +const originalWindow = globalThis.window; + +const setViewport = (width: number, height: number) => { + (globalThis as { window?: unknown }).window = { + innerWidth: width, + innerHeight: height, + // isIPadApp() reaches for the Capacitor markers; a plain web location + // keeps it on its `false` path without mocking the module. + location: { protocol: 'https:', search: '' }, + }; +}; + +const withViewport = (width: number, height: number): TabletLayout => { + setViewport(width, height); + return readTabletLayout(); +}; + +afterEach(() => { + (globalThis as { window?: unknown }).window = originalWindow; +}); + +describe('readTabletLayout', () => { + test('a phone stays a phone in both orientations', () => { + expect(withViewport(390, 844).enabled).toBe(false); + // The long side alone must never qualify — this is the case a plain + // width threshold gets wrong. + expect(withViewport(844, 390).enabled).toBe(false); + }); + + test('a tablet qualifies in both orientations', () => { + expect(withViewport(834, 1194).enabled).toBe(true); + expect(withViewport(1194, 834).enabled).toBe(true); + }); + + test('side panels need real width, so a tablet in portrait keeps the drawer', () => { + expect(withViewport(834, 1194).roomyForPanels).toBe(false); + expect(withViewport(1194, 834).roomyForPanels).toBe(true); + }); + + test('an unfolded foldable is a tablet but never roomy enough for panels', () => { + // Book foldables are near-square: the long side is barely wider than a + // tablet's short one, so both orientations keep the portrait layout. + expect(withViewport(690, 840)).toEqual({ enabled: true, roomyForPanels: false }); + expect(withViewport(840, 690)).toEqual({ enabled: true, roomyForPanels: false }); + }); + + test('folding shut drops back to the phone layout', () => { + expect(withViewport(370, 900).enabled).toBe(false); + }); +}); diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 7170c9c3..f4a165e6 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -619,10 +619,14 @@ the keyboard classes remain as fallbacks for keyboard-up states that do not go through the pill (and for mobile browsers). Instant show/hide (no squish animation); the title's own keyboard compensation (.oc-draft-center below) - carries the smooth motion. */ -:root.oc-composer-expanded .oc-draft-starters, -:root.oc-capacitor-app.oc-keyboard-open .oc-draft-starters, -:root.oc-browser-keyboard-open .oc-draft-starters { + carries the smooth motion. + + A tablet has room to keep them: `oc-keep-draft-starters` is set by the shell + (portrait, or any orientation with a hardware keyboard — see MobileApp) and + opts the whole draft screen out of the hiding below. */ +:root:not(.oc-keep-draft-starters).oc-composer-expanded .oc-draft-starters, +:root:not(.oc-keep-draft-starters).oc-capacitor-app.oc-keyboard-open .oc-draft-starters, +:root:not(.oc-keep-draft-starters).oc-browser-keyboard-open .oc-draft-starters { display: none; } diff --git a/packages/ui/src/sync/session-ordering.ts b/packages/ui/src/sync/session-ordering.ts index 93ca6a24..83bd3f40 100644 --- a/packages/ui/src/sync/session-ordering.ts +++ b/packages/ui/src/sync/session-ordering.ts @@ -140,7 +140,7 @@ export const raiseSessionOrderingBaselines = (sessions: Iterable): void if (liveRank !== undefined) { // A live rank frozen BEFORE this newer authoritative stamp is stale — // the session was active again while this client wasn't watching (its - // transition events never arrived, e.g. другий пристрій + сон). Ranks + // transition events never arrived, e.g. another device + sleep). Ranks // share the epoch-ms scale with `updated`, so raising is well-ordered. if (fresh > liveRank) { nextRanks = nextRanks ?? new Map(currentRanks);