From 2d305d18c6652a323bf8b8fdd7a3c3805afb6a6b Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 30 Jan 2026 02:46:43 +0200 Subject: [PATCH] feat(desktop): enable macOS version awareness for UI adjustments - Fetch macOS major version from Rust to determine layout - Adjust header padding and height for macOS versions --- packages/desktop/src-tauri/src/main.rs | 18 ++++++++++ packages/desktop/src/main.tsx | 11 ++++++ .../ui/src/components/chat/ChatMessage.tsx | 8 +++-- packages/ui/src/components/layout/Header.tsx | 34 ++++++++++++++----- .../components/multirun/MultiRunLauncher.tsx | 30 ++++++++++------ packages/ui/src/hooks/useKeyboardShortcuts.ts | 4 ++- packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/stores/useUIStore.ts | 7 ++++ 8 files changed, 91 insertions(+), 22 deletions(-) diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index bd2c27f4..8b670920 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -309,6 +309,23 @@ async fn desktop_open_devtools(window: WebviewWindow) -> Result<(), String> { Ok(()) } +#[cfg(target_os = "macos")] +fn get_macos_major_version() -> isize { + use objc2_foundation::NSProcessInfo; + let process_info = NSProcessInfo::processInfo(); + let version = process_info.operatingSystemVersion(); + version.majorVersion +} + +#[cfg(not(target_os = "macos"))] +fn get_macos_major_version() -> isize { + 0 +} + +#[tauri::command] +fn desktop_get_macos_version() -> isize { + get_macos_major_version() +} #[cfg(target_os = "macos")] fn optimize_webview_layer(window: &tauri::WebviewWindow) { @@ -807,6 +824,7 @@ fn main() { .invoke_handler(tauri::generate_handler![ desktop_server_info, desktop_restart_opencode, + desktop_get_macos_version, #[cfg(feature = "devtools")] desktop_open_devtools, load_settings, diff --git a/packages/desktop/src/main.tsx b/packages/desktop/src/main.tsx index c69396c3..82e67106 100644 --- a/packages/desktop/src/main.tsx +++ b/packages/desktop/src/main.tsx @@ -122,6 +122,7 @@ if (homeDirectory) { window.opencodeDesktop = { homeDirectory, + macosMajorVersion: null as number | null, async getServerInfo() { try { const info = await invoke('desktop_server_info'); @@ -262,6 +263,16 @@ window.opencodeDesktop = { } }; +// Fetch macOS version from Rust +try { + const macosVersion = await invoke('desktop_get_macos_version'); + window.opencodeDesktop.macosMajorVersion = macosVersion > 0 ? macosVersion : null; + console.info('[main] macOS version:', macosVersion); +} catch (err) { + console.warn('[main] Failed to get macOS version:', err); + window.opencodeDesktop.macosMajorVersion = null; +} + console.info('[main] window.opencodeDesktop assigned'); if (typeof window !== 'undefined') { diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 8e552e88..fbec6ad4 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -755,16 +755,20 @@ const ChatMessage: React.FC = ({ onContentChange?.('structural'); }, [isUser, onContentChange]); + const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); + const handleShowPopup = React.useCallback((content: ToolPopupContent) => { if (content.image) { setPopupContent(content); + setImagePreviewOpen(true); } - }, []); + }, [setImagePreviewOpen]); const handlePopupChange = React.useCallback((open: boolean) => { setPopupContent((prev) => ({ ...prev, open })); - }, []); + setImagePreviewOpen(open); + }, [setImagePreviewOpen]); const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled')); const isStreamingPhase = streamPhase === 'streaming'; diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 0a583172..093812d2 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -108,15 +108,28 @@ export const Header: React.FC = () => { }, []); const macosMajorVersion = React.useMemo(() => { + if (typeof window === 'undefined') { + return null; + } + // Use Tauri-provided version if available (accurate), otherwise fall back to UA parsing + const desktopApi = (window as typeof window & { opencodeDesktop?: { macosMajorVersion?: number | null } }).opencodeDesktop; + if (desktopApi?.macosMajorVersion != null) { + return desktopApi.macosMajorVersion; + } + // Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix if (typeof navigator === 'undefined') { return null; } - const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._]/); + const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._](\d+)/); if (!match) { return null; } - const parsed = Number.parseInt(match[1], 10); - return Number.isNaN(parsed) ? null : parsed; + const first = Number.parseInt(match[1], 10); + const second = Number.parseInt(match[2], 10); + if (Number.isNaN(first)) { + return null; + } + return first === 10 ? second : first; }, []); useEffect(() => { @@ -296,19 +309,22 @@ export const Header: React.FC = () => { const desktopPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { // Always reserve space for Mac traffic lights since header is always on top - return 'pl-[5.125rem]'; + return 'pl-[5.5rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform]); const macosHeaderSizeClass = React.useMemo(() => { - if (!isDesktopApp || !isMacPlatform) { + if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) { return ''; } - if (macosMajorVersion === null || macosMajorVersion > 15) { - return ''; + if (macosMajorVersion >= 26) { + return 'h-12'; } - return 'h-14'; + if (macosMajorVersion <= 15) { + return 'h-14'; + } + return ''; }, [isDesktopApp, isMacPlatform, macosMajorVersion]); const updateHeaderHeight = React.useCallback(() => { @@ -351,7 +367,7 @@ export const Header: React.FC = () => { useEffect(() => { updateHeaderHeight(); - }, [updateHeaderHeight, isMobile]); + }, [updateHeaderHeight, isMobile, macosHeaderSizeClass]); const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) { diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 699f3809..e571cf8b 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -116,7 +116,7 @@ export const MultiRunLauncher: React.FC = ({ const desktopHeaderPaddingClass = React.useMemo(() => { if (isDesktopApp && isMacPlatform) { // Match main app header: reserve space for Mac traffic lights. - return 'pl-[5.125rem]'; + return 'pl-[5.5rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform]); @@ -139,6 +139,20 @@ export const MultiRunLauncher: React.FC = ({ } }, [isDesktopApp]); + // Handle ESC key to dismiss + React.useEffect(() => { + if (!onCancel) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + onCancel(); + } + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => window.removeEventListener('keydown', handleKeyDown, true); + }, [onCancel]); + // Use the BranchSelector hook for branch state management const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState('HEAD'); const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory); @@ -306,31 +320,27 @@ export const MultiRunLauncher: React.FC = ({
-
-

New Multi-Run

-
+

New Multi-Run

{onCancel && ( -
+
-

Close

+

Close (Esc)

diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 74287442..d5a899e8 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -226,6 +226,8 @@ export const useKeyboardShortcuts = () => { isHelpDialogOpen, isSessionSwitcherOpen, isAboutDialogOpen, + isMultiRunLauncherOpen, + isImagePreviewOpen, activeMainTab, } = useUIStore.getState(); @@ -238,7 +240,7 @@ export const useKeyboardShortcuts = () => { } // Check if any overlay is open or not on chat tab - don't process abort - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; + const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen; const isChatActive = activeMainTab === 'chat'; if (hasOverlay || !isChatActive) { diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 2450a1e2..8e19cd27 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -89,6 +89,7 @@ export type DesktopSettingsApi = { export type DesktopApi = { homeDirectory?: string; + macosMajorVersion?: number | null; getServerInfo: () => Promise; restartOpenCode: () => Promise<{ success: boolean }>; shutdown: () => Promise<{ success: boolean }>; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 26da7228..4fb1459c 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -62,6 +62,7 @@ interface UIStore { diffWrapLines: boolean; diffViewMode: 'single' | 'stacked'; isTimelineDialogOpen: boolean; + isImagePreviewOpen: boolean; nativeNotificationsEnabled: boolean; notificationMode: 'always' | 'hidden-only'; @@ -113,6 +114,7 @@ interface UIStore { setDiffViewMode: (mode: 'single' | 'stacked') => void; setMultiRunLauncherOpen: (open: boolean) => void; setTimelineDialogOpen: (open: boolean) => void; + setImagePreviewOpen: (open: boolean) => void; setNativeNotificationsEnabled: (value: boolean) => void; setNotificationMode: (mode: 'always' | 'hidden-only') => void; openMultiRunLauncher: () => void; @@ -165,6 +167,7 @@ export const useUIStore = create()( diffWrapLines: false, diffViewMode: 'stacked', isTimelineDialogOpen: false, + isImagePreviewOpen: false, nativeNotificationsEnabled: false, notificationMode: 'hidden-only', @@ -534,6 +537,10 @@ export const useUIStore = create()( set({ isTimelineDialogOpen: open }); }, + setImagePreviewOpen: (open) => { + set({ isImagePreviewOpen: open }); + }, + setNativeNotificationsEnabled: (value) => { set({ nativeNotificationsEnabled: value }); },