feat(mobile): tablet layout pass and foldable-ready size class (#2569)
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.
This commit is contained in:
committed by
GitHub
parent
34d0ff7383
commit
96c011a8ef
@@ -9,8 +9,11 @@ export const IpadSidebarResizeHandle: React.FC<{
|
||||
handleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
}> = ({ side, isResizing, ariaLabel, handleProps }) => (
|
||||
<div
|
||||
// z-50 AND rendered after the panel content: panes bring their own
|
||||
// full-cover overlays at z-50 (the file editor, for one), and a handle
|
||||
// underneath them is simply not there for the finger.
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-30 w-6 cursor-col-resize touch-none',
|
||||
'absolute inset-y-0 z-50 w-6 cursor-col-resize touch-none',
|
||||
side === 'left' ? 'right-0' : 'left-0',
|
||||
)}
|
||||
role="separator"
|
||||
|
||||
+194
-343
@@ -1,18 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { AboutSettings } from '@/components/sections/openchamber/AboutSettings';
|
||||
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
|
||||
import { MobileAppUpdateToast } from '@/components/update/MobileAppUpdateToast';
|
||||
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { ChatView } from '@/components/views/ChatView';
|
||||
import { PlanView } from '@/components/views/PlanView';
|
||||
import { SettingsView } from '@/components/views/SettingsView';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
@@ -24,9 +20,9 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useOrientation } from '@/lib/device';
|
||||
import { readTabletLayout, useOrientation, useTabletLayout } from '@/lib/device';
|
||||
import { useHardwareKeyboard } from '@/lib/hardwareKeyboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isIPadApp } from '@/lib/platform';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
@@ -36,9 +32,8 @@ import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useGitStatus, useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useMcpConfigStore, type McpDraft } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import {
|
||||
listProjectWorktrees,
|
||||
@@ -51,21 +46,16 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider } from '@/sync/sync-context';
|
||||
|
||||
import { SyncAppEffects } from './AppEffects';
|
||||
import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs';
|
||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
import { BusyDots } from '@/components/chat/message/parts/BusyDots';
|
||||
import { MobileConnectionWelcome, type MobileConnectionNotice } from './MobileConnectionWelcome';
|
||||
import { MobileHeader } from './MobileHeader';
|
||||
import { MobileInstancesSurface } from './MobileInstancesSurface';
|
||||
import { MobileOverflowMenu, type OverflowItem } from './MobileOverflowMenu';
|
||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
|
||||
import { MobileWorkspaceDrawer, type MobileWorkspaceTab } from './MobileWorkspaceDrawer';
|
||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
||||
import { autoConnectLastInstance, getAutoConnectTargetLabel, reprobeActiveConnection, type AutoConnectOutcome } from './mobileConnections';
|
||||
import { isCapacitorMobileApp, useNativeAndroidBackButton, useNativeMobileChrome, useNativeMobileLifecycle } from './mobileNativeChrome';
|
||||
import { normalizePath } from './mobilePaths';
|
||||
import { reconnectAppForTransportSwitch, resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useFontsReady } from './useFontsReady';
|
||||
@@ -76,6 +66,7 @@ import { IpadSidebarResizeHandle } from './IpadSidebarResizeHandle';
|
||||
import {
|
||||
IPAD_LEFT_SIDEBAR_WIDTH,
|
||||
IPAD_RIGHT_SIDEBAR_WIDTH,
|
||||
IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH,
|
||||
useIpadSidebarResize,
|
||||
} from './ipadSidebarResize';
|
||||
|
||||
@@ -101,12 +92,11 @@ type MobileAppProps = {
|
||||
|
||||
const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000;
|
||||
|
||||
/** The fullscreen overlay surfaces reachable from the overflow menu. Exactly
|
||||
one can be open at a time — opening another replaces it, closing returns
|
||||
to the chat. The sessions drawer, the workspace drawer (Changes / Files /
|
||||
Terminal tabs on phones), and the overflow menu are separate layers.
|
||||
'terminal' is iPad-only here — phones get it as a workspace tab. */
|
||||
type MobileSurface = 'terminal' | 'mcp' | 'notes' | 'instances' | 'settings' | 'update';
|
||||
/** The fullscreen app-level surfaces, reachable from the sessions drawer
|
||||
footer. Exactly one can be open at a time — opening another replaces it,
|
||||
closing returns to the chat. The sessions drawer and the workspace drawer
|
||||
(Changes / Files / Terminal / Notes / MCP) are separate layers. */
|
||||
type MobileSurface = 'instances' | 'settings' | 'update';
|
||||
|
||||
const MobileShell: React.FC<{ onActiveConnectionDeleted: () => 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<MobileWorkspaceTab>('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 <body> (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<MobileAppActions>(
|
||||
() => ({
|
||||
@@ -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<HTMLElement>(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: <McpIcon className="size-5 shrink-0 text-muted-foreground" />,
|
||||
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 (
|
||||
<DedicatedMobileAppProvider actions={mobileActions}>
|
||||
<div
|
||||
@@ -434,17 +374,21 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => 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 ? (
|
||||
<aside
|
||||
ref={leftResize.asideRef}
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/70 bg-sidebar will-change-[width] motion-reduce:transition-none',
|
||||
!ipadSidebarOpen && 'border-r-0',
|
||||
// bg-background, not bg-sidebar: the session/worktree/project
|
||||
// rows are opaque bg-background (the swipe actions live under
|
||||
// them), so a tinted sidebar would show through as row-shaped
|
||||
// bands. Same surface as the phone drawer.
|
||||
'relative flex h-full shrink-0 flex-col overflow-hidden border-r border-border/70 bg-background will-change-[width] motion-reduce:transition-none',
|
||||
!sidebarOpen && 'border-r-0',
|
||||
)}
|
||||
style={{
|
||||
width: ipadSidebarOpen ? leftResize.width : 0,
|
||||
minWidth: ipadSidebarOpen ? leftResize.width : 0,
|
||||
maxWidth: ipadSidebarOpen ? leftResize.width : 0,
|
||||
width: sidebarOpen ? leftResize.width : 0,
|
||||
minWidth: sidebarOpen ? leftResize.width : 0,
|
||||
maxWidth: sidebarOpen ? leftResize.width : 0,
|
||||
['--oc-ipad-sidebar-width' as string]: `${leftResize.width}px`,
|
||||
overflowX: 'clip',
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
@@ -452,22 +396,14 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
transitionDuration: '200ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
aria-hidden={!ipadSidebarOpen}
|
||||
aria-hidden={!sidebarOpen}
|
||||
data-page-scroll-lock="true"
|
||||
>
|
||||
{ipadSidebarOpen ? (
|
||||
<IpadSidebarResizeHandle
|
||||
side="left"
|
||||
isResizing={leftResize.isResizing}
|
||||
ariaLabel={t('sidebar.resize.leftPanelAria')}
|
||||
handleProps={leftResize.handleProps}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
leftResize.isResizing && 'pointer-events-none',
|
||||
!ipadSidebarOpen && 'pointer-events-none select-none opacity-0',
|
||||
!sidebarOpen && 'pointer-events-none select-none opacity-0',
|
||||
)}
|
||||
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
||||
>
|
||||
@@ -475,32 +411,34 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
<MobileSessionsSheet
|
||||
open
|
||||
variant="sidebar"
|
||||
// The surface asks to close after picking a session/project or
|
||||
// creating a worktree; the persistent landscape sidebar stays
|
||||
// put, portrait gives the space back to the chat.
|
||||
// The surface asks to close after picking a session/project
|
||||
// or creating a worktree: give the space back to the chat
|
||||
// where the sidebar is a guest, keep it put where it is not.
|
||||
onOpenChange={(value) => {
|
||||
if (!value && isPortrait) setIpadSidebarOpen(false);
|
||||
if (!value && !roomyForPanels) setSidebarOpen(false);
|
||||
}}
|
||||
footer={sessionsFooter}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{/* After the content, not before it: panes stack their own overlays
|
||||
and the handle has to sit above every one of them. */}
|
||||
{sidebarOpen ? (
|
||||
<IpadSidebarResizeHandle
|
||||
side="left"
|
||||
isResizing={leftResize.isResizing}
|
||||
ariaLabel={t('sidebar.resize.leftPanelAria')}
|
||||
handleProps={leftResize.handleProps}
|
||||
/>
|
||||
) : null}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<div className="flex h-full min-w-0 flex-1 flex-col" data-page-scroll-lock="true">
|
||||
<MobileHeader
|
||||
onOpenSessions={() => (isIPad ? toggleIpadSidebar() : setSessionsSheetOpen(true))}
|
||||
// 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}
|
||||
/>
|
||||
<main ref={chatMainRef} className="relative min-h-0 flex-1 overflow-hidden" data-page-scroll-lock="true">
|
||||
<div className="h-full w-full">
|
||||
@@ -511,20 +449,33 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* iPad: Changes/Files live in a full-height right sidebar instead of
|
||||
the phone's fullscreen surfaces. Width animates like the desktop
|
||||
RightSidebar; content stays mounted through the collapse. */}
|
||||
{isIPad ? (
|
||||
{/* 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 ? (
|
||||
<MobileSessionsSheet
|
||||
open={sessionsSheetOpen}
|
||||
onOpenChange={setSessionsSheetOpen}
|
||||
footer={sessionsFooter}
|
||||
/>
|
||||
) : 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 ? (
|
||||
<aside
|
||||
ref={rightResize.asideRef}
|
||||
className={cn(
|
||||
'relative flex h-full shrink-0 flex-col overflow-hidden border-l border-border/70 bg-background will-change-[width] motion-reduce:transition-none',
|
||||
!ipadRightPanel && 'border-l-0',
|
||||
!workspacePanelWidth && 'border-l-0',
|
||||
)}
|
||||
style={{
|
||||
width: ipadRightPanel ? rightResize.width : 0,
|
||||
minWidth: ipadRightPanel ? rightResize.width : 0,
|
||||
maxWidth: ipadRightPanel ? rightResize.width : 0,
|
||||
width: workspacePanelWidth,
|
||||
minWidth: workspacePanelWidth,
|
||||
maxWidth: workspacePanelWidth,
|
||||
['--oc-ipad-sidebar-width' as string]: `${rightResize.width}px`,
|
||||
overflowX: 'clip',
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
@@ -532,10 +483,31 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
transitionDuration: '200ms',
|
||||
transitionTimingFunction: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
}}
|
||||
aria-hidden={!ipadRightPanel}
|
||||
aria-hidden={!workspacePanelWidth}
|
||||
data-page-scroll-lock="true"
|
||||
>
|
||||
{ipadRightPanel ? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-0 shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
rightResize.isResizing && 'pointer-events-none',
|
||||
!workspacePanelWidth && 'pointer-events-none select-none opacity-0',
|
||||
)}
|
||||
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileWorkspaceDrawer
|
||||
open={workspaceOpen}
|
||||
onClose={closeWorkspace}
|
||||
tab={workspaceTab}
|
||||
onTabChange={setWorkspaceTab}
|
||||
pendingChangesDiff={pendingChangesDiff}
|
||||
onOpenPlan={setOpenPlan}
|
||||
onOpenMcpSettings={openMcpCreateSettings}
|
||||
variant={workspaceAsPanel ? 'panel' : 'drawer'}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
{workspacePanelWidth ? (
|
||||
<IpadSidebarResizeHandle
|
||||
side="right"
|
||||
isResizing={rightResize.isResizing}
|
||||
@@ -543,61 +515,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
handleProps={rightResize.handleProps}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full shrink-0 flex-col transition-opacity duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
|
||||
rightResize.isResizing && 'pointer-events-none',
|
||||
!ipadRightPanel && 'pointer-events-none select-none opacity-0',
|
||||
)}
|
||||
style={{ width: 'var(--oc-ipad-sidebar-width)', overflowX: 'hidden' }}
|
||||
>
|
||||
{ipadRightContentMounted ? (
|
||||
<ErrorBoundary>
|
||||
{renderedIpadRightPanel === 'files' ? (
|
||||
<MobileFilesSurface onClose={closeIpadRightPanel} />
|
||||
) : (
|
||||
<MobileChangesSurface
|
||||
onClose={closeIpadRightPanel}
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
/>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{isIPad ? (
|
||||
<MobileOverflowMenu
|
||||
open={overflowOpen}
|
||||
onClose={() => 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 ? (
|
||||
<MobileSessionsSheet
|
||||
open={sessionsSheetOpen}
|
||||
onOpenChange={setSessionsSheetOpen}
|
||||
footer={{
|
||||
instanceLabel: showCapacitorOnlyFeatures ? getAutoConnectTargetLabel() : null,
|
||||
onOpenInstances: showCapacitorOnlyFeatures ? () => 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 ? (
|
||||
) : (
|
||||
<MobileWorkspaceDrawer
|
||||
open={workspaceOpen}
|
||||
onClose={closeWorkspace}
|
||||
@@ -607,87 +526,13 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
onOpenPlan={setOpenPlan}
|
||||
onOpenMcpSettings={openMcpCreateSettings}
|
||||
/>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{activeSurface === 'terminal' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.terminal')}
|
||||
title={t('mobile.menu.terminal')}
|
||||
disableEscapeDismiss
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<TerminalView visible />
|
||||
</ErrorBoundary>
|
||||
</MobileFullscreenSurface>
|
||||
) : null}
|
||||
|
||||
{activeSurface === 'mcp' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mcpDropdown.title')}
|
||||
title={t('mcpDropdown.title')}
|
||||
trailing={(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={openMcpCreateSettings}
|
||||
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={refreshMcpOverlay}
|
||||
disabled={isMcpRefreshing}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
title={t('mcpDropdown.actions.refreshAria')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="refresh" className={cn('size-5', isMcpRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<McpDropdownContent
|
||||
active
|
||||
className="h-full"
|
||||
listClassName="max-h-none"
|
||||
hideHeader
|
||||
mobileListDensity
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileFullscreenSurface>
|
||||
) : null}
|
||||
|
||||
{activeSurface === 'notes' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('contextRail.surface.notes')}
|
||||
title={t('contextRail.surface.notes')}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<ProjectContextPanel
|
||||
onActionComplete={closeSurface}
|
||||
onOpenPlan={setOpenPlan}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</MobileFullscreenSurface>
|
||||
) : 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 ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
onClose={() => setOpenPlan(null)}
|
||||
ariaLabel={openPlan.title}
|
||||
title={openPlan.title}
|
||||
@@ -707,6 +552,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{activeSurface === 'instances' && showCapacitorOnlyFeatures ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
dialogAlign="app"
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.instances')}
|
||||
title={t('mobile.menu.instances')}
|
||||
@@ -721,6 +568,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{activeSurface === 'settings' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
dialogAlign="app"
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.settings')}
|
||||
headerless
|
||||
@@ -744,6 +593,8 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc
|
||||
{activeSurface === 'update' ? (
|
||||
<MobileFullscreenSurface
|
||||
open
|
||||
variant={surfaceVariant}
|
||||
dialogAlign="app"
|
||||
onClose={closeSurface}
|
||||
ariaLabel={t('mobile.menu.update')}
|
||||
title={t('mobile.menu.update')}
|
||||
|
||||
@@ -35,6 +35,18 @@ export type MobileFullscreenSurfaceProps = {
|
||||
/** Drop the header's bottom divider (quiet single-page surfaces). */
|
||||
noHeaderBorder?: boolean;
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* `dialog` packs the same surface into a centered card over a scrim instead
|
||||
* of covering the app. Tablets use it: a settings or instances page stretched
|
||||
* across a 13" screen is mostly empty space, and losing the chat entirely for
|
||||
* an app-level page is a heavier context switch than the content deserves.
|
||||
*/
|
||||
variant?: 'fullscreen' | 'dialog';
|
||||
/**
|
||||
* What the dialog centers on. App-level pages (settings, instances) belong to
|
||||
* the whole window; content that came out of the chat column stays with it.
|
||||
*/
|
||||
dialogAlign?: 'chat' | 'app';
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -51,6 +63,8 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
headerless = false,
|
||||
noHeaderBorder = false,
|
||||
ariaLabel,
|
||||
variant = 'fullscreen',
|
||||
dialogAlign = 'chat',
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -148,15 +162,29 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
|
||||
if (!open || !rootRef.current) return null;
|
||||
|
||||
return createPortal(
|
||||
const isDialog = variant === 'dialog';
|
||||
|
||||
const surface = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
className={cn(
|
||||
'flex flex-col bg-background text-foreground',
|
||||
isDialog
|
||||
? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]'
|
||||
: 'oc-keyboard-inset-surface fixed inset-0 z-50',
|
||||
)}
|
||||
style={isDialog ? {
|
||||
// Scale/fade instead of the push slide: the card is not a navigation
|
||||
// step, and a settled `transform: none` keeps it off its own
|
||||
// compositing layer (iOS clips those to the safe-area viewport).
|
||||
opacity: entered ? 1 : 0,
|
||||
transform: entered ? 'none' : 'scale(0.97)',
|
||||
transition: `opacity ${ENTER_DURATION_MS}ms ease-out, transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
} : {
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Push-style enter: slide in from the right edge; settled state drops
|
||||
// the transform entirely so the surface isn't kept on a compositing
|
||||
@@ -165,7 +193,7 @@ export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = (
|
||||
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<MobileFullscreenSurfaceProps> = (
|
||||
) : null}
|
||||
</div>
|
||||
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||
</section>,
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!isDialog) return createPortal(surface, rootRef.current);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
|
||||
style={{
|
||||
background: 'rgb(0 0 0 / 0.45)',
|
||||
opacity: entered ? 1 : 0,
|
||||
// 'chat' matches every other mobile overlay (the sessions sidebar keeps
|
||||
// its width); 'app' ignores the panels and centers on the window.
|
||||
paddingLeft: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-left, 0px))' : '1rem',
|
||||
paddingRight: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-right, 0px))' : '1rem',
|
||||
paddingTop: 'max(1rem, var(--oc-safe-area-top, 0px))',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex w-full max-w-[720px] justify-center" onClick={(event) => event.stopPropagation()}>
|
||||
{surface}
|
||||
</div>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<{
|
||||
<button
|
||||
ref={titleRef}
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
compactTitle ? 'shrink' : 'flex-1',
|
||||
)}
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={switcherOpen}
|
||||
@@ -122,6 +110,10 @@ export const MobileHeader: React.FC<{
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Compact title: this takes the leftover width so the trailing
|
||||
controls stay pinned to the right edge. */}
|
||||
{compactTitle ? <div className="min-w-0 flex-1" /> : null}
|
||||
|
||||
<MobileSessionMetadataButton
|
||||
open={metadataOpen}
|
||||
onOpenChange={handleMetadataOpenChange}
|
||||
@@ -130,71 +122,19 @@ export const MobileHeader: React.FC<{
|
||||
isNewSessionDraftOpen={isNewSessionDraftOpen}
|
||||
/>
|
||||
|
||||
{surfaceShortcuts ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
surfaceShortcuts.activePanel === 'files'
|
||||
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
|
||||
)}
|
||||
aria-label={t('mobile.menu.files')}
|
||||
aria-pressed={surfaceShortcuts.activePanel === 'files'}
|
||||
onClick={surfaceShortcuts.onToggleFiles}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="file-text" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative flex size-10 shrink-0 items-center justify-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
surfaceShortcuts.activePanel === 'changes'
|
||||
? 'bg-[var(--interactive-selection)] text-[var(--interactive-selectionForeground)]'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover hover:text-foreground',
|
||||
)}
|
||||
aria-label={t('mobile.menu.changes')}
|
||||
aria-pressed={surfaceShortcuts.activePanel === 'changes'}
|
||||
onClick={surfaceShortcuts.onToggleChanges}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="git-branch" className="size-5" />
|
||||
{surfaceShortcuts.changesDirty ? (
|
||||
<span className="absolute right-2 top-2 inline-flex size-2 rounded-full bg-primary" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{onOpenMenu ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMenuAria')}
|
||||
onClick={handleOpenMenu}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="more-2" className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{onOpenWorkspace ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
onClick={() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenWorkspace();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="pencil-ruler-2" className="size-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
onClick={() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenWorkspace();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="pencil-ruler-2" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<MobileSessionSwitcher
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type OverflowItem = {
|
||||
key: 'files' | 'changes' | 'terminal' | 'mcp' | 'notes' | 'instances' | 'update' | 'settings';
|
||||
icon?: IconName;
|
||||
iconNode?: React.ReactNode;
|
||||
label: string;
|
||||
badge?: number;
|
||||
onSelect: () => 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 (
|
||||
<div className="fixed inset-0 z-50" role="dialog" aria-modal="true" aria-label={t('mobile.menu.titleAria')}>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 cursor-default"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-[calc(var(--oc-safe-area-top,0px)+56px+4px)] w-[min(220px,calc(100vw-1rem))] origin-top-right overflow-hidden rounded-2xl border border-border/70 bg-background shadow-[0_18px_60px_rgb(0_0_0_/_0.35)]"
|
||||
role="menu"
|
||||
style={{
|
||||
right: `${8 + rightOffset}px`,
|
||||
animation: 'mobile-menu-in 160ms cubic-bezier(0.32, 0.72, 0, 1)',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
onClick={() => {
|
||||
item.onSelect();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{item.iconNode ?? (item.icon ? <Icon name={item.icon} className="size-5 shrink-0 text-muted-foreground" /> : null)}
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">{item.label}</span>
|
||||
{item.badge && item.badge > 0 ? (
|
||||
<span className="inline-flex size-2 shrink-0 rounded-full bg-primary" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<style>{`@keyframes mobile-menu-in { from { opacity: 0; transform: translateY(-6px) scale(0.96); } to { opacity: 1; transform: translateY(0) scale(1); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>(null);
|
||||
const [ipadAnchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
||||
const [anchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport. Anchor the popover in the
|
||||
// wrapper's own coordinate space — viewport-based lefts would double-count
|
||||
// the sidebar offset.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isIPad || !shouldRender) return;
|
||||
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),
|
||||
}}
|
||||
|
||||
@@ -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<HTMLDivElement>(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<HTMLDivElement>(null);
|
||||
const [anchorLeft, setAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport — anchor 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 (
|
||||
<div className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
className={cn(
|
||||
'mx-3 mt-2 flex flex-col overflow-hidden 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',
|
||||
'flex flex-col overflow-hidden 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',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-switcher-out' : 'session-switcher-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))',
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<div className="oc-hide-scrollbar min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
|
||||
@@ -1437,8 +1437,11 @@ export const MobileSessionsSheet: React.FC<MobileSessionsSheetProps> = ({ 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 = (
|
||||
<div ref={contentRootRef} className="flex h-full flex-col">
|
||||
<div ref={contentRootRef} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-y-auto pb-4">
|
||||
{/* The search bar scrolls WITH the list (iOS-style): the open-time
|
||||
auto-scroll to the current session naturally tucks it away, and
|
||||
|
||||
@@ -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<HTMLElement | null>(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: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
@@ -173,23 +184,8 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
{ id: 'mcp', label: t('mobile.menu.mcp'), icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
aria-hidden={!open}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center">
|
||||
{/* Mounted only while shown; nonCompositedIndicator keeps the active
|
||||
@@ -268,7 +264,36 @@ export const MobileWorkspaceDrawer: React.FC<{
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
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 <div className="flex h-full min-h-0 flex-col bg-background text-foreground">{body}</div>;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
aria-hidden={!open}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</section>,
|
||||
rootRef.current,
|
||||
rootRef.current as HTMLElement,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<HTMLElement | null>(null);
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
if (typeof window === 'undefined') return defaultWidth;
|
||||
const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10);
|
||||
if (!Number.isFinite(stored)) return defaultWidth;
|
||||
return Math.min(IPAD_SIDEBAR_MAX_WIDTH, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
|
||||
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<number | null>(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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user