Merge pull request #245 from btriapitsyn/fix-traffic-lights
Adjust macOS traffic lights handling and header padding
This commit is contained in:
@@ -80,7 +80,7 @@ jobs:
|
||||
|
||||
build-desktop-macos:
|
||||
needs: create-release
|
||||
runs-on: macos-26
|
||||
runs-on: macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -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<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
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();
|
||||
|
||||
@@ -122,6 +122,7 @@ if (homeDirectory) {
|
||||
|
||||
window.opencodeDesktop = {
|
||||
homeDirectory,
|
||||
macosMajorVersion: null as number | null,
|
||||
async getServerInfo() {
|
||||
try {
|
||||
const info = await invoke<ServerInfo>('desktop_server_info');
|
||||
@@ -262,6 +263,16 @@ window.opencodeDesktop = {
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch macOS version from Rust
|
||||
try {
|
||||
const macosVersion = await invoke<number>('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') {
|
||||
|
||||
@@ -755,16 +755,20 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -116,7 +116,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
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<MultiRunLauncherProps> = ({
|
||||
}
|
||||
}, [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<string>('HEAD');
|
||||
const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory);
|
||||
@@ -306,31 +320,27 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<header
|
||||
onMouseDown={handleDragStart}
|
||||
className={cn(
|
||||
'flex h-12 items-center justify-between border-b app-region-drag select-none',
|
||||
'relative flex h-12 items-center justify-center border-b app-region-drag select-none',
|
||||
desktopHeaderPaddingClass
|
||||
)}
|
||||
style={{ borderColor: 'var(--interactive-border)' }}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
|
||||
</div>
|
||||
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
|
||||
{onCancel && (
|
||||
<div className="flex items-center pr-3">
|
||||
<div className="absolute right-0 flex items-center pr-3">
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
aria-label="Close"
|
||||
aria-label="Close (Esc)"
|
||||
className="inline-flex h-9 w-9 items-center justify-center p-2 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary app-region-no-drag"
|
||||
>
|
||||
<RiCloseLine className="h-5 w-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Close</p>
|
||||
<p>Close (Esc)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -1234,17 +1234,17 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
|
||||
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',
|
||||
)}
|
||||
>
|
||||
{}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div className="flex w-full items-center gap-2 min-w-0 flex-1 overflow-hidden">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary flex-shrink-0" />
|
||||
) : null}
|
||||
<span className="truncate typography-ui-label font-normal text-foreground">
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
{sessionTitle}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -89,6 +89,7 @@ export type DesktopSettingsApi = {
|
||||
|
||||
export type DesktopApi = {
|
||||
homeDirectory?: string;
|
||||
macosMajorVersion?: number | null;
|
||||
getServerInfo: () => Promise<DesktopServerInfo>;
|
||||
restartOpenCode: () => Promise<{ success: boolean }>;
|
||||
shutdown: () => Promise<{ success: boolean }>;
|
||||
|
||||
@@ -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<UIStore>()(
|
||||
diffWrapLines: false,
|
||||
diffViewMode: 'stacked',
|
||||
isTimelineDialogOpen: false,
|
||||
isImagePreviewOpen: false,
|
||||
nativeNotificationsEnabled: false,
|
||||
notificationMode: 'hidden-only',
|
||||
|
||||
@@ -534,6 +537,10 @@ export const useUIStore = create<UIStore>()(
|
||||
set({ isTimelineDialogOpen: open });
|
||||
},
|
||||
|
||||
setImagePreviewOpen: (open) => {
|
||||
set({ isImagePreviewOpen: open });
|
||||
},
|
||||
|
||||
setNativeNotificationsEnabled: (value) => {
|
||||
set({ nativeNotificationsEnabled: value });
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user