From 8b7d7803de95933f5c0ab468e68f88fef4c4cef3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 16:15:16 +0300 Subject: [PATCH] refactor(layout): remove the legacy mobile layout from MainLayout and Header Phone viewports run the separate MobileApp shell (and a viewport crossing now reloads into it), so the desktop layout's mobile branch was unreachable: the drawer machinery, the full-screen secondaryView surface switch (including the terminal/diagram desktop carve-out nothing could trigger), the mobile header with its tab bar, the Cmd+number tab shortcuts, the mobile quota panel, and the surface guard that reset non-chat tabs. DrawerContext had no consumers left and is deleted. Header drops from 2630 to 1853 lines; the desktop render is unchanged. --- packages/ui/src/components/layout/Header.tsx | 793 +----------------- .../ui/src/components/layout/MainLayout.tsx | 464 ++-------- packages/ui/src/contexts/DrawerContext.tsx | 29 - 3 files changed, 78 insertions(+), 1208 deletions(-) delete mode 100644 packages/ui/src/contexts/DrawerContext.tsx diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index e6b4ddc0..e9cd8c4a 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -13,10 +13,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; -import { DiffIcon } from '@/components/icons/DiffIcon'; -import { useUIStore, type ContextPanelMode, type MainTab } from '@/stores/useUIStore'; +import { useUIStore, type ContextPanelMode } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; @@ -39,33 +37,17 @@ import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; -import { cn, hasModifier } from '@/lib/utils'; -import { McpDropdownContent } from '@/components/mcp/McpDropdown'; -import { McpIcon } from '@/components/icons/McpIcon'; -import { ProviderLogo } from '@/components/ui/ProviderLogo'; -import { formatQuotaValueLabel, formatQuotaResetLabel, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; -import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; -import { updateDesktopSettings } from '@/lib/persistence'; -import { formatTimeForPreference } from '@/lib/timeFormat'; +import { cn } from '@/lib/utils'; import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { - getAllModelFamilies, - getDisplayModelName, - groupModelsByFamily, - sortModelFamilies, } from '@/lib/quota/model-families'; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, } from '@/components/ui/collapsible'; -import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; -import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopLocalOriginActive, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag, type UpdateInfo } from '@/lib/desktop'; import { desktopHostsGet, redactSensitiveUrl } from '@/lib/desktopHosts'; @@ -90,7 +72,6 @@ import { Button } from '@/components/ui/button'; import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove'; const DESKTOP_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-8 w-8 items-center justify-center gap-2 rounded-md typography-ui-label font-medium text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:bg-interactive-hover transition-colors'; -const MOBILE_HEADER_ICON_BUTTON_CLASS = 'app-region-no-drag inline-flex h-9 w-9 items-center justify-center gap-2 p-2 rounded-md typography-ui-label font-medium text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:pointer-events-none disabled:opacity-50 hover:text-foreground hover:bg-interactive-hover transition-colors'; type HeaderIconActionButtonProps = { visible?: boolean; @@ -416,14 +397,6 @@ const formatCompactHeaderLabel = (value: string): string => { return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed; }; -const formatTime = (timestamp: number | null, timeFormatPreference: 'auto' | '12h' | '24h') => { - if (!timestamp) return '-'; - try { - return formatTimeForPreference(timestamp, timeFormatPreference, { fallback: '-' }); - } catch { - return '-'; - } -}; const normalize = (value: string): string => { if (!value) return ''; @@ -444,32 +417,6 @@ const getActiveContextMode = (panelState: { return activeTab?.mode ?? null; }; -interface TabConfig { - id: MainTab; - label: string; - icon: IconName | 'diff'; - badge?: number; - showDot?: boolean; -} - -interface RateLimitGroup { - providerId: string; - providerName: string; - entries: Array<[string, UsageWindow]>; - error?: string; - modelFamilies?: Array<{ - familyId: string | null; - familyLabel: string; - models: Array<[string, UsageWindow]>; - }>; -} - -interface HeaderProps { - onToggleLeftDrawer?: () => void; - onToggleRightDrawer?: () => void; - leftDrawerOpen?: boolean; - rightDrawerOpen?: boolean; -} type HeaderSessionSnapshot = { title: string | null; @@ -480,24 +427,15 @@ type HeaderSessionSnapshot = { parentId: string | null; }; -export const Header: React.FC = ({ - onToggleLeftDrawer, - onToggleRightDrawer, - leftDrawerOpen, - rightDrawerOpen, -}) => { +export const Header: React.FC = () => { streamPerfCount('ui.header.render'); const { t } = useI18n(); - const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen); - const toggleSidebar = useUIStore((state) => state.toggleSidebar); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const openContextOverview = useUIStore((state) => state.openContextOverview); const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const activeMainTab = useUIStore((state) => state.activeMainTab); - const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const timeFormatPreference = useUIStore((state) => state.timeFormatPreference); const getCurrentModel = useConfigStore((state) => state.getCurrentModel); const runtimeApis = useRuntimeAPIs(); @@ -548,12 +486,7 @@ export const Header: React.FC = ({ }, [activeProject]); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); - const isQuotaLoading = useQuotaStore((state) => state.isLoading); - const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated); - const quotaDisplayMode = useQuotaStore((state) => state.displayMode); - const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); - const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); const { isMobile } = useDeviceInfo(); const githubAuthStatus = useGitHubAuthStore((state) => state.status); @@ -639,14 +572,11 @@ export const Header: React.FC = ({ } }, [contextUsage, currentSessionId, isContextUsageResolvedForSession]); - const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null; const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null; const githubAccounts = githubAuthStatus?.accounts ?? []; const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false); - const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false); const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false); - const [isUsageRefreshSpinning, setIsUsageRefreshSpinning] = React.useState(false); const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local'); const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true); const [remoteUpdateDialogOpen, setRemoteUpdateDialogOpen] = React.useState(false); @@ -654,7 +584,6 @@ export const Header: React.FC = ({ const [remoteUpdateChecking, setRemoteUpdateChecking] = React.useState(false); const [remoteUpdateError, setRemoteUpdateError] = React.useState(null); const compactCurrentInstanceLabel = React.useMemo(() => formatCompactHeaderLabel(currentInstanceLabel), [currentInstanceLabel]); - const [mobileServicesTab, setMobileServicesTab] = React.useState<'usage' | 'mcp'>('usage'); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); // While the work-status panel is on screen it already reports the project, // the branch and the context fill โ€” three paces away in the same window. @@ -817,126 +746,11 @@ export const Header: React.FC = ({ }, [checkRemoteInstanceUpdate, remoteUpdateInfo?.available]); useQuotaAutoRefresh(); - const selectedModels = useQuotaStore((state) => state.selectedModels); - const expandedFamilies = useQuotaStore((state) => state.expandedFamilies); - const toggleFamilyExpanded = useQuotaStore((state) => state.toggleFamilyExpanded); - const rateLimitGroups = React.useMemo(() => { - const groups: RateLimitGroup[] = []; - - for (const provider of QUOTA_PROVIDERS) { - if (!dropdownProviderIds.includes(provider.id)) { - continue; - } - const result = quotaResults.find((entry) => entry.providerId === provider.id); - const windows = (result?.usage?.windows ?? {}) as Record; - const models = result?.usage?.models; - const entries = Object.entries(windows); - - const group: RateLimitGroup = { - providerId: provider.id, - providerName: provider.name, - entries, - error: (result && !result.ok && result.configured) ? result.error : undefined, - }; - - // Add model families if provider has per-model quotas - if (models && Object.keys(models).length > 0) { - const providerSelectedModels = selectedModels[provider.id] ?? []; - // hasExplicitSelection = true means user has selected specific models to show - // If the array exists but is empty, treat as "show all" (user cleared selection) - const hasExplicitSelection = providerSelectedModels.length > 0; - const modelGroups = groupModelsByFamily(models, provider.id); - const families = getAllModelFamilies(provider.id); - const sortedFamilies = sortModelFamilies(families); - - group.modelFamilies = []; - - // Add predefined families first - for (const family of sortedFamilies) { - const modelNames = modelGroups.get(family.id) ?? []; - if (modelNames.length === 0) continue; - - // Filter to selected models only, OR show all if nothing selected - const selectedModelNames = hasExplicitSelection - ? modelNames.filter((m: string) => providerSelectedModels.includes(m)) - : modelNames; - if (selectedModelNames.length === 0) continue; - - const familyModels: Array<[string, UsageWindow]> = []; - for (const modelName of selectedModelNames) { - const modelUsage = models[modelName] as { windows?: Record } | undefined; - if (modelUsage?.windows) { - const windowEntries = Object.entries(modelUsage.windows); - if (windowEntries.length > 0) { - familyModels.push([modelName, windowEntries[0][1]]); - } - } - } - - if (familyModels.length > 0) { - group.modelFamilies.push({ - familyId: family.id, - familyLabel: family.label, - models: familyModels, - }); - } - } - - // Add "Other" family for remaining models - const otherModelNames = modelGroups.get(null) ?? []; - const selectedOtherModels = hasExplicitSelection - ? otherModelNames.filter((m: string) => providerSelectedModels.includes(m)) - : otherModelNames; - if (selectedOtherModels.length > 0) { - const otherModels: Array<[string, UsageWindow]> = []; - for (const modelName of selectedOtherModels) { - const modelUsage = models[modelName] as { windows?: Record } | undefined; - if (modelUsage?.windows) { - const windowEntries = Object.entries(modelUsage.windows); - if (windowEntries.length > 0) { - otherModels.push([modelName, windowEntries[0][1]]); - } - } - } - if (otherModels.length > 0) { - group.modelFamilies.push({ - familyId: null, - familyLabel: t('header.services.modelFamily.other'), - models: otherModels, - }); - } - } - } - - if (entries.length > 0 || (group.modelFamilies && group.modelFamilies.length > 0) || group.error) { - groups.push(group); - } - } - - return groups; - }, [dropdownProviderIds, quotaResults, selectedModels, t]); - const hasRateLimits = rateLimitGroups.length > 0; React.useEffect(() => { void loadQuotaSettings(); }, [loadQuotaSettings]); - const handleDisplayModeChange = React.useCallback(async (mode: 'usage' | 'remaining') => { - setQuotaDisplayMode(mode); - try { - await updateDesktopSettings({ usageDisplayMode: mode }); - } catch (error) { - console.warn('Failed to update usage display mode:', error); - } - }, [setQuotaDisplayMode]); - const handleUsageRefresh = React.useCallback(() => { - if (isUsageRefreshSpinning) return; - setIsUsageRefreshSpinning(true); - const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500)); - Promise.all([fetchAllQuotas(), minSpinPromise]).finally(() => { - setIsUsageRefreshSpinning(false); - }); - }, [fetchAllQuotas, isUsageRefreshSpinning]); const currentSessionSnapshot = currentSessionId ? currentGlobalSession ?? null @@ -1330,17 +1144,10 @@ export const Header: React.FC = ({ }; }, [actionDirectory, activeProjectRef]); - const projectActionsContext = React.useMemo(() => { - if (activeProjectRef && actionDirectory) { - return { projectRef: activeProjectRef, directory: actionDirectory }; - } - return lastProjectActionsContextRef.current; - }, [actionDirectory, activeProjectRef]); const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled); const isSessionPlanAvailable = useSessionUIStore((state) => state.isSessionPlanAvailable); const planTabAvailable = planModeEnabled && currentSessionId ? isSessionPlanAvailable(currentSessionId) : false; - const showPlanTab = planTabAvailable; const lastPlanSessionKeyRef = React.useRef(''); // Reset plan tab availability when session changes @@ -1404,32 +1211,7 @@ export const Header: React.FC = ({ } }, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]); - const blurActiveElement = React.useCallback(() => { - if (typeof document === 'undefined') { - return; - } - const active = document.activeElement as HTMLElement | null; - if (!active) { - return; - } - - const tagName = active.tagName; - const isInput = tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'; - - if (isInput || active.isContentEditable) { - active.blur(); - } - }, []); - - const handleOpenSessionSwitcher = React.useCallback(() => { - if (isMobile) { - blurActiveElement(); - setSessionSwitcherOpen(!isSessionSwitcherOpen); - return; - } - toggleSidebar(); - }, [blurActiveElement, isMobile, isSessionSwitcherOpen, setSessionSwitcherOpen, toggleSidebar]); const handleOpenDraftMiniChat = React.useCallback(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { @@ -1496,47 +1278,6 @@ export const Header: React.FC = ({ const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; - const mobileHeaderIconButtonClass = MOBILE_HEADER_ICON_BUTTON_CLASS; - const mobileActiveHeaderItem = React.useMemo(() => { - if (isMobileRateLimitsOpen) { - return 'services'; - } - if (leftDrawerOpen) { - return 'sessions'; - } - if (rightDrawerOpen) { - return 'git'; - } - return activeMainTab; - }, [activeMainTab, isMobileRateLimitsOpen, leftDrawerOpen, rightDrawerOpen]); - - const closeMobileHeaderPanels = React.useCallback(() => { - setIsMobileRateLimitsOpen(false); - if (leftDrawerOpen && onToggleLeftDrawer) { - onToggleLeftDrawer(); - } - if (rightDrawerOpen && onToggleRightDrawer) { - onToggleRightDrawer(); - } - if (!onToggleLeftDrawer && isSessionSwitcherOpen) { - setSessionSwitcherOpen(false); - } - }, [isSessionSwitcherOpen, leftDrawerOpen, onToggleLeftDrawer, onToggleRightDrawer, rightDrawerOpen, setSessionSwitcherOpen]); - - const handleMobileLeftDrawerToggle = React.useCallback(() => { - if (!leftDrawerOpen) { - setIsMobileRateLimitsOpen(false); - } - onToggleLeftDrawer?.(); - }, [leftDrawerOpen, onToggleLeftDrawer]); - - const handleMobileRightDrawerToggle = React.useCallback(() => { - if (!rightDrawerOpen) { - setIsMobileRateLimitsOpen(false); - } - onToggleRightDrawer?.(); - }, [onToggleRightDrawer, rightDrawerOpen]); - // Left padding the header needs to clear the OS window controls (macOS // traffic lights / window-controls-overlay). When the sidebar is open this // space is owned by the sidebar's top strip instead, so the header drops back @@ -1701,44 +1442,10 @@ export const Header: React.FC = ({ } }, [isDesktopApp]); - const tabs: TabConfig[] = React.useMemo(() => { - if (isMobile) { - const base: TabConfig[] = [ - { id: 'chat', label: t('layout.mainTab.chat'), icon: "chat-4" }, - ]; - - if (showPlanTab) { - base.push({ id: 'plan', label: t('layout.mainTab.plan'), icon: "file-text" }); - } - - base.push( - { id: 'diff', label: t('layout.mainTab.diff'), icon: 'diff' }, - { id: 'files', label: t('layout.mainTab.files'), icon: "folder-6" }, - { id: 'terminal', label: t('layout.mainTab.terminal'), icon: "terminal-box" }, - { id: 'context', label: t('layout.mainTab.context'), icon: "file-list-2" }, - { id: 'diagram', label: t('layout.mainTab.diagram'), icon: 'file' }, - ); - - return base; - } - - // Desktop: no tabs in header - return []; - }, [isMobile, showPlanTab, t]); - const shortcutLabel = React.useCallback((actionId: string) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); - useEffect(() => { - // Project actions may intentionally promote the terminal to the desktop - // main view, and diagram clicks open the diagram viewer; every other - // legacy main tab now lives in the context panel on desktop. - if (!isMobile && activeMainTab !== 'chat' && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') { - setActiveMainTab('chat'); - } - }, [activeMainTab, isMobile, setActiveMainTab]); - // Desktop keeps instances only: quota and MCP now live in the work-status // panel, which reports them per session rather than per window. The mobile // menu below is untouched โ€” it has no panel to defer to. @@ -1751,31 +1458,6 @@ export const Header: React.FC = ({ }, [isDesktopApp, t]); - const mobileServicesTabItems = React.useMemo(() => { - return [ - { id: 'usage', label: t('layout.services.usage'), icon: }, - { id: 'mcp', label: 'MCP', icon: }, - ]; - }, [t]); - - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && !e.shiftKey && !e.altKey) { - const num = parseInt(e.key, 10); - if (num >= 1 && num <= tabs.length) { - e.preventDefault(); - if (isMobile) { - blurActiveElement(); - closeMobileHeaderPanels(); - } - setActiveMainTab(tabs[num - 1].id); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [blurActiveElement, closeMobileHeaderPanels, isMobile, setActiveMainTab, tabs]); - useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides); @@ -1822,55 +1504,6 @@ export const Header: React.FC = ({ handleOpenContextPlan, ]); - const renderTab = (tab: TabConfig) => { - const isActive = activeMainTab === tab.id; - const isDiffTab = tab.icon === 'diff'; - const tabIconName = isDiffTab ? null : (tab.icon as IconName); - const isChatTab = tab.id === 'chat'; - - const renderIcon = (iconSize: number) => { - if (isDiffTab) { - return ; - } - return tabIconName ? : null; - }; - - const tabButton = ( - - ); - - return {tabButton}; - }; - const desktopSidebarActions = ( <> @@ -2097,12 +1730,6 @@ export const Header: React.FC = ({ )} - {tabs.length > 0 && ( -
- {tabs.map((tab) => renderTab(tab))} -
- )} -
@@ -2173,414 +1800,10 @@ export const Header: React.FC = ({
); - const renderMobile = () => ( -
-
- {/* Use drawer toggle when onToggleLeftDrawer is provided, otherwise use legacy session switcher */} - {onToggleLeftDrawer ? ( - - ) : isSessionSwitcherOpen ? ( - - ) : ( - - )} - - {!onToggleLeftDrawer && isSessionSwitcherOpen && ( - {t('header.sessions.title')} - )} -
- - {(!isSessionSwitcherOpen || Boolean(onToggleLeftDrawer)) && ( - <> -
-
-
-
- {tabs.map((tab) => { - const isActive = activeMainTab === tab.id; - const isDiffTab = tab.icon === 'diff'; - const tabIconName = isDiffTab ? null : (tab.icon as IconName); - return ( - - - - - -

{tab.label}

-
-
- ); - })} -
-
-
-
- -
- {projectActionsContext && ( - - )} - - {/* Mobile Services Menu (Usage + MCP) */} - { - if (open) { - if (leftDrawerOpen && onToggleLeftDrawer) { - onToggleLeftDrawer(); - } - if (rightDrawerOpen && onToggleRightDrawer) { - onToggleRightDrawer(); - } - } - setIsMobileRateLimitsOpen(open); - if (open && quotaResults.length === 0) { - fetchAllQuotas(); - } - }} - > - - - - - - - -

{t('header.services.title')}

-
-
- -
-
-
-
- { - const value = tabID as 'usage' | 'mcp'; - setMobileServicesTab(value); - if (value === 'usage' && quotaResults.length === 0) { - fetchAllQuotas(); - } - }} - layoutMode="fit" - variant="active-pill" - activePillInsetClassName="gap-0.5 px-px py-0" - activePillButtonClassName="h-8" - className="h-full" - /> -
- -
-
- - {mobileServicesTab === 'mcp' && ( - - )} - - {mobileServicesTab === 'usage' && ( -
- {/* Mobile usage header */} -
-
-
- {t('header.services.rateLimits')} - - {formatTime(quotaLastUpdated, timeFormatPreference)} - -
-
-
- - ยท - -
- -
-
-
- - {!hasRateLimits && ( -
- {t('header.services.noRateLimits')} -
- )} - - {/* Mobile provider groups */} -
- {rateLimitGroups.map((group, index) => ( - - {index > 0 ? ( -
- ) : null} - - {/* Provider header */} -
- - {group.providerName} -
- - {group.entries.length === 0 && (!group.modelFamilies || group.modelFamilies.length === 0) ? ( -
- - {group.error ?? t('header.services.noRateLimitsReported')} - -
- ) : ( -
- {/* Window-level entries */} - {group.entries.map(([label, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - const resetLabel = formatQuotaResetLabel(window.resetAt, window.resetAfterFormatted ?? window.resetAtFormatted, timeFormatPreference); - return ( -
-
-
- {formatWindowLabel(label)} - {resetLabel ? ( - - {resetLabel} - - ) : null} -
- - {metricLabel === '-' ? '' : metricLabel} - -
- -
- ); - })} - - {/* Model family collapsibles */} - {group.modelFamilies && group.modelFamilies.length > 0 && ( -
- {group.modelFamilies.map((family) => { - const providerExpandedFamilies = expandedFamilies[group.providerId] ?? []; - const isExpanded = providerExpandedFamilies.includes(family.familyId ?? 'other'); - - return ( - toggleFamilyExpanded(group.providerId, family.familyId ?? 'other')} - > - - - {family.familyLabel} - - {isExpanded ? ( - - ) : ( - - )} - - -
- {family.models.map(([modelName, window]) => { - const displayPercent = quotaDisplayMode === 'remaining' - ? window.remainingPercent - : window.usedPercent; - const metricLabel = formatQuotaValueLabel(window.valueLabel, displayPercent); - return ( -
-
- {getDisplayModelName(modelName)} - - {metricLabel === '-' ? '' : metricLabel} - -
- -
- ); - })} -
-
-
- ); - })} -
- )} -
- )} - - ))} -
-
- )} -
- - - - {onToggleRightDrawer ? ( - - - - - -

{rightDrawerOpen ? 'Close git sidebar' : 'Open git sidebar'}

-
-
- ) : null} -
- - )} -
- ); - - const headerClassName = cn( - 'header-safe-area relative z-10 bg-background', - // Mobile keeps a full-width divider. On desktop the divider lives on the chat - // content wrapper instead, so it doesn't run between the header and the right - // sidebar (they read as one continuous surface). - isMobile && 'border-b border-border/50' - ); + // The divider lives on the chat content wrapper instead of the header, so it + // doesn't run between the header and the right sidebar (they read as one + // continuous surface). + const headerClassName = 'header-safe-area relative z-10 bg-background'; return ( <> @@ -2589,7 +1812,7 @@ export const Header: React.FC = ({ className={headerClassName} style={{ ['--padding-scale' as string]: '1' } as React.CSSProperties} > - {isMobile ? renderMobile() : renderDesktop()} + {renderDesktop()} { if (!open) setPendingHeaderRetentionAction(null); }}> diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 1a25e06c..094a69c6 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -1,10 +1,8 @@ -import React, { useRef, useEffect } from 'react'; -import { animate, motion, useMotionValue } from 'motion/react'; +import React from 'react'; import { Header } from './Header'; import { Sidebar } from './Sidebar'; import { SidebarTopBar } from './SidebarTopBar'; import { TitlebarLeftControls } from './TitlebarLeftControls'; -import { ProjectContextPanel } from './RightSidebarTabs'; import { ContextPanel } from './ContextPanel'; import { ContextPanelRail } from './ContextPanelRail'; import { ErrorBoundary } from '../ui/ErrorBoundary'; @@ -18,8 +16,6 @@ import { ArchiveView } from '@/components/views/ArchiveView'; import { WorktreesView } from '@/components/views/WorktreesView'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; -import { TerminalView } from '@/components/views/TerminalView'; -import { DrawerProvider } from '@/contexts/DrawerContext'; import { useUIStore } from '@/stores/useUIStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -30,24 +26,18 @@ import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { ChatView } from '@/components/views/ChatView'; -// Keep TerminalView eager: the bottom dock reserves its height immediately, so -// suspending here leaves a large blank panel on slower machines. -// Other heavy views stay on-demand to reduce initial bundle parse time: -// DiffView/FilesView pull the CodeMirror and @pierre/diffs stacks into the -// startup graph when imported statically. -const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); -const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); -const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); -const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); -const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); -const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); +/** + * Desktop-surface layout: the chat owns the main area, and every other + * surface (git, diff, files, terminal, ...) opens in the ContextPanel via the + * rail. Phone-sized viewports run the separate MobileApp shell โ€” a viewport + * crossing the threshold reloads into it (see watchHostedSurfaceViewport). + */ export const MainLayout: React.FC = () => { const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const activeSurface = useUIStore((state) => state.activeSurface); const setIsMobile = useUIStore((state) => state.setIsMobile); - const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen); // Mount the windowed settings dialog only after its first open: rendering @@ -67,10 +57,9 @@ export const MainLayout: React.FC = () => { const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen); const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen); const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId); - // Any full-page surface replacing the chat area. While open, the chat and - // secondary views are fully hidden (not just covered) so none of their - // floating chrome bleeds through, and selecting a session / draft / main - // tab anywhere closes the surface. + // Any full-page surface replacing the chat area. While open, the chat is + // fully hidden (not just covered) so none of its floating chrome bleeds + // through, and selecting a session or draft anywhere closes the surface. const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen; React.useEffect(() => { @@ -91,157 +80,6 @@ export const MainLayout: React.FC = () => { }; }, []); const { isMobile } = useDeviceInfo(); - const mobilePanelsResetRef = React.useRef(false); - - // Mobile drawer state - const [mobileLeftDrawerOpen, setMobileLeftDrawerOpen] = React.useState(false); - const [mobileRightSidebarOpen, setMobileRightSidebarOpen] = React.useState(false); - const [mobileLeftDrawerVisible, setMobileLeftDrawerVisible] = React.useState(false); - const [mobileRightDrawerVisible, setMobileRightDrawerVisible] = React.useState(false); - const setMobileSessionPanelOpen = React.useCallback((open: boolean) => { - setMobileLeftDrawerOpen(open); - useUIStore.getState().setSessionSwitcherOpen(open); - }, []); - const initialDrawerWidthRef = React.useRef(typeof window === 'undefined' ? 0 : window.innerWidth); - - // Left drawer motion value - const leftDrawerX = useMotionValue(-initialDrawerWidthRef.current); - const leftDrawerWidth = useRef(0); - - // Right drawer motion value - const rightDrawerX = useMotionValue(initialDrawerWidthRef.current); - const rightDrawerWidth = useRef(0); - - // Compute drawer width - useEffect(() => { - if (isMobile) { - leftDrawerWidth.current = window.innerWidth; - rightDrawerWidth.current = window.innerWidth; - } - }, [isMobile]); - - // Sync left drawer state and motion value - useEffect(() => { - if (!isMobile) { - setMobileLeftDrawerVisible(false); - return; - } - if (mobileLeftDrawerOpen) { - setMobileLeftDrawerVisible(true); - } - animate(leftDrawerX, mobileLeftDrawerOpen ? 0 : -leftDrawerWidth.current, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - }, [mobileLeftDrawerOpen, isMobile, leftDrawerX]); - - // Sync right drawer state and motion value - useEffect(() => { - if (!isMobile) { - setMobileRightDrawerVisible(false); - return; - } - if (mobileRightSidebarOpen) { - setMobileRightDrawerVisible(true); - } - animate(rightDrawerX, mobileRightSidebarOpen ? 0 : rightDrawerWidth.current, { - type: 'spring', - stiffness: 400, - damping: 35, - mass: 0.8, - }); - }, [isMobile, mobileRightSidebarOpen, rightDrawerX]); - - useEffect(() => { - if (!isMobile) return; - return leftDrawerX.on('change', (value) => { - const width = leftDrawerWidth.current || initialDrawerWidthRef.current; - const visible = mobileLeftDrawerOpen || value > -width + 0.5; - setMobileLeftDrawerVisible((previous) => previous === visible ? previous : visible); - }); - }, [isMobile, leftDrawerX, mobileLeftDrawerOpen]); - - useEffect(() => { - if (!isMobile) return; - return rightDrawerX.on('change', (value) => { - const width = rightDrawerWidth.current || initialDrawerWidthRef.current; - const visible = mobileRightSidebarOpen || value < width - 0.5; - setMobileRightDrawerVisible((previous) => previous === visible ? previous : visible); - }); - }, [isMobile, mobileRightSidebarOpen, rightDrawerX]); - - // Sync session switcher close events to left drawer. - useEffect(() => { - if (isMobile && !isSessionSwitcherOpen && mobileLeftDrawerOpen) { - setMobileSessionPanelOpen(false); - } - }, [isSessionSwitcherOpen, isMobile, mobileLeftDrawerOpen, setMobileSessionPanelOpen]); - - useEffect(() => { - if (!isMobile) { - mobilePanelsResetRef.current = false; - return; - } - - if (mobilePanelsResetRef.current) { - return; - } - - mobilePanelsResetRef.current = true; - setMobileSessionPanelOpen(false); - setMobileRightSidebarOpen(false); - }, [isMobile, setMobileSessionPanelOpen]); - - useEffect(() => { - if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) { - return; - } - - let disposed = false; - let timeoutId: number | undefined; - - const scheduleDraftOpen = (delayMs: number) => { - timeoutId = window.setTimeout(() => { - if (disposed) { - return; - } - - const sessionState = useSessionUIStore.getState(); - const uiState = useUIStore.getState(); - if (uiState.activeMainTab !== 'chat' || uiState.isSettingsDialogOpen || sessionState.currentSessionId || sessionState.newSessionDraft?.open) { - return; - } - - if (sessionState.isLoading) { - scheduleDraftOpen(250); - return; - } - - sessionState.openNewSessionDraft({ automatic: true }); - }, delayMs); - }; - - scheduleDraftOpen(500); - - return () => { - disposed = true; - if (timeoutId !== undefined) { - window.clearTimeout(timeoutId); - } - }; - }, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]); - - // Ensure mobile drawers are closed when opening full-screen settings - useEffect(() => { - if (!isMobile || !isSettingsDialogOpen) { - return; - } - - setMobileSessionPanelOpen(false); - setMobileRightSidebarOpen(false); - }, [isMobile, isSettingsDialogOpen, setMobileSessionPanelOpen]); useUpdatePolling(); @@ -252,247 +90,85 @@ export const MainLayout: React.FC = () => { } }, [isMobile, setIsMobile]); - const handleToggleMobileRightDrawer = React.useCallback(() => { - if (mobileLeftDrawerOpen) { - setMobileSessionPanelOpen(false); - } - setMobileRightSidebarOpen(!mobileRightSidebarOpen); - }, [mobileLeftDrawerOpen, mobileRightSidebarOpen, setMobileSessionPanelOpen]); - - const secondaryView = React.useMemo(() => { - // Desktop surfaces live in the context panel; the only full-view - // overlays left there are the terminal (promoted by project actions) - // and the diagram viewer. Mobile keeps the full tab set. - if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') { - return null; - } - switch (activeSurface) { - case 'plan': - return ; - case 'git': - return ; - case 'diff': - return ; - case 'terminal': - return ; - case 'files': - return ; - case 'context': - return ; - case 'diagram': - return ; - default: - return null; - } - }, [activeSurface, isMobile, mobileRightSidebarOpen]); - const isChatActive = activeSurface === 'chat'; return (
- {isMobile ? ( - { - const nextOpen = !mobileLeftDrawerOpen; - if (mobileRightSidebarOpen) { - setMobileRightSidebarOpen(false); - } - setMobileSessionPanelOpen(nextOpen); - }, - toggleRightDrawer: handleToggleMobileRightDrawer, - leftDrawerX, - rightDrawerX, - leftDrawerWidth, - rightDrawerWidth, - setMobileLeftDrawerOpen: setMobileSessionPanelOpen, - setRightSidebarOpen: setMobileRightSidebarOpen, - }}> - {/* Mobile: header + drawer mode */} - {!isSettingsDialogOpen &&
{ - const nextOpen = !mobileLeftDrawerOpen; - if (mobileRightSidebarOpen) { - setMobileRightSidebarOpen(false); - } - setMobileSessionPanelOpen(nextOpen); - }} - onToggleRightDrawer={() => { - handleToggleMobileRightDrawer(); - }} - leftDrawerOpen={mobileLeftDrawerOpen} - rightDrawerOpen={mobileRightSidebarOpen} - />} - - {/* Main content area (fixed) */} -
+ {/* Full-height Sidebar beside [Header above (chat | RightSidebar)] */} +
+ } > -
-
- -
- {secondaryView && ( -
- {secondaryView} -
- )} - {isMultiRunLauncherOpen && ( -
- - setMultiRunLauncherOpen(false)} - onCancel={() => setMultiRunLauncherOpen(false)} - /> - -
- )} - - - - {/* Always mount SessionSidebar on mobile to match desktop behavior. - Conditional mount (mobileLeftDrawerVisible && ...) caused a - data-loading cascade on every drawer open: paginated sessions - fetch, worktree discovery, repo status, PR status, and 10+ memo - recomputations. On Android PWA this manifested as a >10s delay - before the drawer became interactive (issue #1695). Visibility is - controlled by the leftDrawerX transform (off-screen when closed). - The invisible class matters when fully hidden: leftDrawerWidth is - not recomputed on resize/rotation, so a closed drawer translated by - the old width could otherwise peek into the viewport; it also keeps - the off-screen sidebar out of the tab order and skips painting it. */} - - - - - - {mobileRightDrawerVisible && ( - - - - - - )} -
-
- - {/* Mobile settings: full screen */} - {isSettingsDialogOpen && ( -
- - - setSettingsDialogOpen(false)} /> - - -
- )} - - ) : ( - <> - {/* Persistent top-left controls (toggle + project actions) that - stay put while the sidebar/header animate beneath them. */} - - {/* Desktop: full-height Sidebar beside [Header above (chat | RightSidebar)] */} -
- } - > - - -
-
-
-
-
- {/* Holds the chat and the context panel together, so its - width does not move when the context panel opens. The - work-status panel measures this rather than the chat, - which the context panel animates. */} -
-
-
- + + +
+
+
+
+
+ {/* Holds the chat and the context panel together, so its + width does not move when the context panel opens. The + work-status panel measures this rather than the chat, + which the context panel animates. */} +
+
+
+ +
+ {isMultiRunLauncherOpen && ( +
+ + {/* isWindowed: the app Header already shows the surface + title, so skip the launcher's own title bar. */} + setMultiRunLauncherOpen(false)} + onCancel={() => setMultiRunLauncherOpen(false)} + /> +
- {secondaryView && ( -
- {secondaryView} -
- )} - {isMultiRunLauncherOpen && ( -
- - {/* isWindowed: the app Header already shows the surface - title, so skip the launcher's own title bar. */} - setMultiRunLauncherOpen(false)} - onCancel={() => setMultiRunLauncherOpen(false)} - /> - -
- )} - - - -
- -
+ )} + + + +
+
-
- -
+
+
+
+
- {/* Desktop settings: windowed dialog with blur */} - {settingsWindowMounted ? ( - - - - ) : null} - - )} - -
- + {/* Settings: windowed dialog with blur */} + {settingsWindowMounted ? ( + + + + ) : null} +
+
); }; diff --git a/packages/ui/src/contexts/DrawerContext.tsx b/packages/ui/src/contexts/DrawerContext.tsx deleted file mode 100644 index 3eafe4f3..00000000 --- a/packages/ui/src/contexts/DrawerContext.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import type { MotionValue } from 'motion/react'; - -export interface DrawerContextValue { - leftDrawerOpen: boolean; - rightDrawerOpen: boolean; - toggleLeftDrawer: () => void; - toggleRightDrawer: () => void; - // Motion values for real-time drawer dragging - leftDrawerX: MotionValue; - rightDrawerX: MotionValue; - leftDrawerWidth: React.MutableRefObject; - rightDrawerWidth: React.MutableRefObject; - setMobileLeftDrawerOpen: (open: boolean) => void; - setRightSidebarOpen: (open: boolean) => void; -} - -const DrawerContext = React.createContext(null); - -export const DrawerProvider: React.FC<{ - children: React.ReactNode; - value: DrawerContextValue; -}> = ({ children, value }) => { - return ( - - {children} - - ); -};