diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6b7f3b8e..a2296a47 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -80,7 +80,7 @@ jobs: build-desktop-macos: needs: create-release - runs-on: macos-26 + runs-on: macos-15 strategy: fail-fast: false matrix: diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 341c6600..8b670920 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -85,13 +85,6 @@ use tokio::{ use tower_http::cors::CorsLayer; use window_state::{load_window_state, persist_window_state, WindowStateManager}; -#[cfg(target_os = "macos")] -use std::sync::atomic::{AtomicBool, Ordering}; - - - -#[cfg(target_os = "macos")] -static NEEDS_TRAFFIC_LIGHT_FIX: AtomicBool = AtomicBool::new(false); const PROXY_BODY_LIMIT: usize = 50 * 1024 * 1024; // 50MB const CLIENT_RELOAD_DELAY_MS: u64 = 800; @@ -324,31 +317,14 @@ fn get_macos_major_version() -> isize { version.majorVersion } -#[cfg(target_os = "macos")] -fn adjust_traffic_lights_position( - window: &tauri::WebviewWindow, - x: f64, - y: f64, -) { - use objc2::msg_send; - use objc2::runtime::AnyObject; - use objc2_foundation::{NSPoint, NSRect}; +#[cfg(not(target_os = "macos"))] +fn get_macos_major_version() -> isize { + 0 +} - if let Ok(ns_window) = window.ns_window() { - unsafe { - let ns_window: *mut AnyObject = ns_window.cast(); - let close_button: *mut AnyObject = msg_send![ns_window, standardWindowButton: 0usize]; - - if !close_button.is_null() { - let superview: *mut AnyObject = msg_send![close_button, superview]; - if !superview.is_null() { - let frame: NSRect = msg_send![superview, frame]; - let new_frame = NSRect::new(NSPoint::new(x, y), frame.size); - let _: () = msg_send![superview, setFrame: new_frame]; - } - } - } - } +#[tauri::command] +fn desktop_get_macos_version() -> isize { + get_macos_major_version() } #[cfg(target_os = "macos")] @@ -712,14 +688,6 @@ fn main() { if let Some(window) = app.get_webview_window("main") { #[cfg(target_os = "macos")] { - let macos_version = get_macos_major_version(); - info!("[macos] Detected macOS version: {}", macos_version); - - if macos_version < 26 { - NEEDS_TRAFFIC_LIGHT_FIX.store(true, Ordering::SeqCst); - adjust_traffic_lights_position(&window, 17.0, 16.0); - } - // Apply layer optimizations for smoother scrolling optimize_webview_layer(&window); } @@ -856,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, @@ -1084,12 +1053,6 @@ fn main() { size.height as f64, is_maximized, ); - #[cfg(target_os = "macos")] - if NEEDS_TRAFFIC_LIGHT_FIX.load(Ordering::SeqCst) { - if let Some(webview) = window.app_handle().get_webview_window("main") { - adjust_traffic_lights_position(&webview, 17.0, 16.0); - } - } } tauri::WindowEvent::CloseRequested { api, .. } => { api.prevent_close(); 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 86191364..093812d2 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -107,6 +107,31 @@ export const Header: React.FC = () => { return /Macintosh|Mac OS X/.test(navigator.userAgent || ''); }, []); + 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+)[._](\d+)/); + if (!match) { + return null; + } + 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(() => { if (typeof window === 'undefined') { return; @@ -284,11 +309,24 @@ 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.75rem]'; + return 'pl-[5.5rem]'; } return 'pl-3'; }, [isDesktopApp, isMacPlatform]); + const macosHeaderSizeClass = React.useMemo(() => { + if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) { + return ''; + } + if (macosMajorVersion >= 26) { + return 'h-12'; + } + if (macosMajorVersion <= 15) { + return 'h-14'; + } + return ''; + }, [isDesktopApp, isMacPlatform, macosMajorVersion]); + const updateHeaderHeight = React.useCallback(() => { if (typeof document === 'undefined') { return; @@ -329,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')) { @@ -463,7 +501,8 @@ export const Header: React.FC = () => { onMouseDown={handleDragStart} className={cn( 'app-region-drag relative flex h-12 select-none items-center', - desktopPaddingClass + desktopPaddingClass, + macosHeaderSizeClass )} role="tablist" aria-label="Main navigation" diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index 78ed41f6..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.75rem]'; + 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/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index caf82c54..d8647a06 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1234,17 +1234,17 @@ export const SessionSidebar: React.FC = ({ disabled={isMissingDirectory} onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)} className={cn( - 'flex min-w-0 flex-1 flex-col gap-0 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none', + 'flex min-w-0 flex-1 flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none', )} > {} -
+
{isStreaming ? ( ) : null} - +
{sessionTitle} - +
{pendingPermissionCount > 0 ? ( { 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 }); },