From 96c011a8ef81c3faf2d5d7ebd633c2e37704eb10 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 2 Aug 2026 16:25:16 +0300 Subject: [PATCH] feat(mobile): tablet layout pass and foldable-ready size class (#2569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tablet ran the phone layout with a half-finished iPad draft on top: two custom sidebars, a leftover overflow menu, split Files/Changes header buttons, and phone-width sheets stretched across a 13" screen. This brings it onto the phone's navigation model and keeps only the differences a large screen earns. - Sessions are a persistent resizable left sidebar; the overflow menu is gone and its destinations moved into that sidebar's footer (connected instance, settings, pending web update) and into the workspace drawer. - The workspace (Changes / Files / Terminal / Notes / MCP) is the phone's drawer everywhere: a resizable right sidebar where the screen can host one (up to 900px) and the full-cover drawer otherwise, with its mounted panes — an open diff, an edited file, an attached terminal — surviving rotation. - Header dropdowns are anchored popovers: the recents switcher mirrors the usage overlay on the left, and its trigger is sized to the title rather than to the free width. - App-level pages (settings, instances, update, an opened plan) render as centered dialogs instead of covering the screen. - Overlays center on the chat column through published insets, so the model and directory pickers no longer sit off-centre; the directory picker also stops overriding the shared width clamp. - Wide chat layout applies to mobile surfaces, where a tablet chat column is finally wide enough for the setting to mean anything. The layout gate is a live size class rather than a device check, so Android tablets and foldables are covered by the same code: - `enabled` when the shortest viewport side is at le sw600dp). The short side is what makes this a size question instead of a device question — a phone reports ~360-430 whichev unfolded book foldable ~600+, and folding shut drops back under it. iPads also answer on identity, since iPadOS hands out od - `roomyForPanels` when landscape and at least 1000px wide, which is what it takes to host the sidebar, the panel and a readabl foldables miss it in BOTH orientations — their long side is barely wider than a tablet's short one — so they keep the portr Every consumer re-decides instead of remembering wha open sidebar closes if the device folds shut under it. iPad behaviour is unchanged: its landscape widths all clear the panel ones do not, exactly as the previous orientation check did. Hardware keyboards are read natively. iOS reports them through GCKeyboard, published to the web layer at document start and kep disconnect and foregrounding; the layer stops inferring once that answers. A single early publish was not enough — the connect no already-attached keyboard fires before the page exists, and GameController can populate late — so the state is re-published across resume. With a keyboard attached the draft screen keeps its starter chips and the composer never collapses; tablets skip the colla Runtimes with no native answer fall back to inferring it from the keyboard bridge, and only ever conclude "hardware" from silen Also: sidebar rows no longer sit on a differently ti footer is no longer clipped by an over-tall content box, the resize handles moved above the panes' own overlays so they can actu now-unreachable overflow menu, fullscreen terminal/MCP/notes surfaces and their locale key are deleted. Device behaviour is unverified — the tablet layout, keyboard bridge and the foldable size class have not been exercised on hardware, and the 600/1000 thresholds are derived fr rather than measured on a foldable. --- packages/mobile/README.md | 6 +- packages/mobile/ios/App/App/AppDelegate.swift | 86 ++- .../ui/src/apps/IpadSidebarResizeHandle.tsx | 5 +- packages/ui/src/apps/MobileApp.tsx | 537 +++++++----------- .../ui/src/apps/MobileFullscreenSurface.tsx | 60 +- packages/ui/src/apps/MobileHeader.tsx | 114 +--- packages/ui/src/apps/MobileOverflowMenu.tsx | 75 --- .../ui/src/apps/MobileSessionMetadata.tsx | 26 +- .../ui/src/apps/MobileSessionSwitcher.tsx | 51 +- packages/ui/src/apps/MobileSessionsSheet.tsx | 5 +- .../ui/src/apps/MobileWorkspaceDrawer.tsx | 79 ++- packages/ui/src/apps/ipadSidebarResize.ts | 16 +- packages/ui/src/apps/mobileNativeChrome.ts | 11 +- packages/ui/src/components/chat/ChatInput.tsx | 8 + .../composer/state/useMobileComposerShell.ts | 41 +- .../session/DirectoryExplorerDialog.tsx | 4 +- .../src/components/ui/MobileOverlayPanel.tsx | 9 + packages/ui/src/lib/device.ts | 84 ++- packages/ui/src/lib/hardwareKeyboard.ts | 157 +++++ packages/ui/src/lib/i18n/messages/en.ts | 1 - packages/ui/src/lib/i18n/messages/es.ts | 1 - packages/ui/src/lib/i18n/messages/fr.ts | 1 - packages/ui/src/lib/i18n/messages/ja.ts | 1 - packages/ui/src/lib/i18n/messages/ko.ts | 1 - packages/ui/src/lib/i18n/messages/pl.ts | 1 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 - packages/ui/src/lib/i18n/messages/uk.ts | 1 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 - packages/ui/src/lib/tabletLayout.test.ts | 57 ++ packages/ui/src/styles/mobile.css | 12 +- packages/ui/src/sync/session-ordering.ts | 2 +- 32 files changed, 863 insertions(+), 592 deletions(-) delete mode 100644 packages/ui/src/apps/MobileOverflowMenu.tsx create mode 100644 packages/ui/src/lib/hardwareKeyboard.ts create mode 100644 packages/ui/src/lib/tabletLayout.test.ts 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);