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
This commit is contained in:
@@ -309,6 +309,23 @@ async fn desktop_open_devtools(window: WebviewWindow) -> Result<(), String> {
|
|||||||
Ok(())
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
fn optimize_webview_layer<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
|
fn optimize_webview_layer<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
|
||||||
@@ -807,6 +824,7 @@ fn main() {
|
|||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
desktop_server_info,
|
desktop_server_info,
|
||||||
desktop_restart_opencode,
|
desktop_restart_opencode,
|
||||||
|
desktop_get_macos_version,
|
||||||
#[cfg(feature = "devtools")]
|
#[cfg(feature = "devtools")]
|
||||||
desktop_open_devtools,
|
desktop_open_devtools,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ if (homeDirectory) {
|
|||||||
|
|
||||||
window.opencodeDesktop = {
|
window.opencodeDesktop = {
|
||||||
homeDirectory,
|
homeDirectory,
|
||||||
|
macosMajorVersion: null as number | null,
|
||||||
async getServerInfo() {
|
async getServerInfo() {
|
||||||
try {
|
try {
|
||||||
const info = await invoke<ServerInfo>('desktop_server_info');
|
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');
|
console.info('[main] window.opencodeDesktop assigned');
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -755,16 +755,20 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
onContentChange?.('structural');
|
onContentChange?.('structural');
|
||||||
}, [isUser, onContentChange]);
|
}, [isUser, onContentChange]);
|
||||||
|
|
||||||
|
const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen);
|
||||||
|
|
||||||
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
const handleShowPopup = React.useCallback((content: ToolPopupContent) => {
|
||||||
|
|
||||||
if (content.image) {
|
if (content.image) {
|
||||||
setPopupContent(content);
|
setPopupContent(content);
|
||||||
|
setImagePreviewOpen(true);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setImagePreviewOpen]);
|
||||||
|
|
||||||
const handlePopupChange = React.useCallback((open: boolean) => {
|
const handlePopupChange = React.useCallback((open: boolean) => {
|
||||||
setPopupContent((prev) => ({ ...prev, open }));
|
setPopupContent((prev) => ({ ...prev, open }));
|
||||||
}, []);
|
setImagePreviewOpen(open);
|
||||||
|
}, [setImagePreviewOpen]);
|
||||||
|
|
||||||
const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
|
const isAnimationSettled = Boolean(getMessageInfoProp(message.info, 'animationSettled'));
|
||||||
const isStreamingPhase = streamPhase === 'streaming';
|
const isStreamingPhase = streamPhase === 'streaming';
|
||||||
|
|||||||
@@ -108,15 +108,28 @@ export const Header: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const macosMajorVersion = React.useMemo(() => {
|
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') {
|
if (typeof navigator === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._]/);
|
const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._](\d+)/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const parsed = Number.parseInt(match[1], 10);
|
const first = Number.parseInt(match[1], 10);
|
||||||
return Number.isNaN(parsed) ? null : parsed;
|
const second = Number.parseInt(match[2], 10);
|
||||||
|
if (Number.isNaN(first)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return first === 10 ? second : first;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -296,19 +309,22 @@ export const Header: React.FC = () => {
|
|||||||
const desktopPaddingClass = React.useMemo(() => {
|
const desktopPaddingClass = React.useMemo(() => {
|
||||||
if (isDesktopApp && isMacPlatform) {
|
if (isDesktopApp && isMacPlatform) {
|
||||||
// Always reserve space for Mac traffic lights since header is always on top
|
// Always reserve space for Mac traffic lights since header is always on top
|
||||||
return 'pl-[5.125rem]';
|
return 'pl-[5.5rem]';
|
||||||
}
|
}
|
||||||
return 'pl-3';
|
return 'pl-3';
|
||||||
}, [isDesktopApp, isMacPlatform]);
|
}, [isDesktopApp, isMacPlatform]);
|
||||||
|
|
||||||
const macosHeaderSizeClass = React.useMemo(() => {
|
const macosHeaderSizeClass = React.useMemo(() => {
|
||||||
if (!isDesktopApp || !isMacPlatform) {
|
if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
if (macosMajorVersion === null || macosMajorVersion > 15) {
|
if (macosMajorVersion >= 26) {
|
||||||
return '';
|
return 'h-12';
|
||||||
}
|
}
|
||||||
return 'h-14';
|
if (macosMajorVersion <= 15) {
|
||||||
|
return 'h-14';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
|
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
|
||||||
|
|
||||||
const updateHeaderHeight = React.useCallback(() => {
|
const updateHeaderHeight = React.useCallback(() => {
|
||||||
@@ -351,7 +367,7 @@ export const Header: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
updateHeaderHeight();
|
updateHeaderHeight();
|
||||||
}, [updateHeaderHeight, isMobile]);
|
}, [updateHeaderHeight, isMobile, macosHeaderSizeClass]);
|
||||||
|
|
||||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
|
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
const desktopHeaderPaddingClass = React.useMemo(() => {
|
const desktopHeaderPaddingClass = React.useMemo(() => {
|
||||||
if (isDesktopApp && isMacPlatform) {
|
if (isDesktopApp && isMacPlatform) {
|
||||||
// Match main app header: reserve space for Mac traffic lights.
|
// Match main app header: reserve space for Mac traffic lights.
|
||||||
return 'pl-[5.125rem]';
|
return 'pl-[5.5rem]';
|
||||||
}
|
}
|
||||||
return 'pl-3';
|
return 'pl-3';
|
||||||
}, [isDesktopApp, isMacPlatform]);
|
}, [isDesktopApp, isMacPlatform]);
|
||||||
@@ -139,6 +139,20 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
}
|
}
|
||||||
}, [isDesktopApp]);
|
}, [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
|
// Use the BranchSelector hook for branch state management
|
||||||
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
const [worktreeBaseBranch, setWorktreeBaseBranch] = React.useState<string>('HEAD');
|
||||||
const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory);
|
const { isLoading: isLoadingWorktreeBaseBranches, isGitRepository } = useBranchOptions(currentDirectory);
|
||||||
@@ -306,31 +320,27 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
<header
|
<header
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
className={cn(
|
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
|
desktopHeaderPaddingClass
|
||||||
)}
|
)}
|
||||||
style={{ borderColor: 'var(--interactive-border)' }}
|
style={{ borderColor: 'var(--interactive-border)' }}
|
||||||
>
|
>
|
||||||
<div
|
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<h1 className="typography-ui-label font-medium">New Multi-Run</h1>
|
|
||||||
</div>
|
|
||||||
{onCancel && (
|
{onCancel && (
|
||||||
<div className="flex items-center pr-3">
|
<div className="absolute right-0 flex items-center pr-3">
|
||||||
<Tooltip delayDuration={500}>
|
<Tooltip delayDuration={500}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
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"
|
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" />
|
<RiCloseLine className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>Close</p>
|
<p>Close (Esc)</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -226,6 +226,8 @@ export const useKeyboardShortcuts = () => {
|
|||||||
isHelpDialogOpen,
|
isHelpDialogOpen,
|
||||||
isSessionSwitcherOpen,
|
isSessionSwitcherOpen,
|
||||||
isAboutDialogOpen,
|
isAboutDialogOpen,
|
||||||
|
isMultiRunLauncherOpen,
|
||||||
|
isImagePreviewOpen,
|
||||||
activeMainTab,
|
activeMainTab,
|
||||||
} = useUIStore.getState();
|
} = useUIStore.getState();
|
||||||
|
|
||||||
@@ -238,7 +240,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if any overlay is open or not on chat tab - don't process abort
|
// 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';
|
const isChatActive = activeMainTab === 'chat';
|
||||||
|
|
||||||
if (hasOverlay || !isChatActive) {
|
if (hasOverlay || !isChatActive) {
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export type DesktopSettingsApi = {
|
|||||||
|
|
||||||
export type DesktopApi = {
|
export type DesktopApi = {
|
||||||
homeDirectory?: string;
|
homeDirectory?: string;
|
||||||
|
macosMajorVersion?: number | null;
|
||||||
getServerInfo: () => Promise<DesktopServerInfo>;
|
getServerInfo: () => Promise<DesktopServerInfo>;
|
||||||
restartOpenCode: () => Promise<{ success: boolean }>;
|
restartOpenCode: () => Promise<{ success: boolean }>;
|
||||||
shutdown: () => Promise<{ success: boolean }>;
|
shutdown: () => Promise<{ success: boolean }>;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ interface UIStore {
|
|||||||
diffWrapLines: boolean;
|
diffWrapLines: boolean;
|
||||||
diffViewMode: 'single' | 'stacked';
|
diffViewMode: 'single' | 'stacked';
|
||||||
isTimelineDialogOpen: boolean;
|
isTimelineDialogOpen: boolean;
|
||||||
|
isImagePreviewOpen: boolean;
|
||||||
nativeNotificationsEnabled: boolean;
|
nativeNotificationsEnabled: boolean;
|
||||||
notificationMode: 'always' | 'hidden-only';
|
notificationMode: 'always' | 'hidden-only';
|
||||||
|
|
||||||
@@ -113,6 +114,7 @@ interface UIStore {
|
|||||||
setDiffViewMode: (mode: 'single' | 'stacked') => void;
|
setDiffViewMode: (mode: 'single' | 'stacked') => void;
|
||||||
setMultiRunLauncherOpen: (open: boolean) => void;
|
setMultiRunLauncherOpen: (open: boolean) => void;
|
||||||
setTimelineDialogOpen: (open: boolean) => void;
|
setTimelineDialogOpen: (open: boolean) => void;
|
||||||
|
setImagePreviewOpen: (open: boolean) => void;
|
||||||
setNativeNotificationsEnabled: (value: boolean) => void;
|
setNativeNotificationsEnabled: (value: boolean) => void;
|
||||||
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
setNotificationMode: (mode: 'always' | 'hidden-only') => void;
|
||||||
openMultiRunLauncher: () => void;
|
openMultiRunLauncher: () => void;
|
||||||
@@ -165,6 +167,7 @@ export const useUIStore = create<UIStore>()(
|
|||||||
diffWrapLines: false,
|
diffWrapLines: false,
|
||||||
diffViewMode: 'stacked',
|
diffViewMode: 'stacked',
|
||||||
isTimelineDialogOpen: false,
|
isTimelineDialogOpen: false,
|
||||||
|
isImagePreviewOpen: false,
|
||||||
nativeNotificationsEnabled: false,
|
nativeNotificationsEnabled: false,
|
||||||
notificationMode: 'hidden-only',
|
notificationMode: 'hidden-only',
|
||||||
|
|
||||||
@@ -534,6 +537,10 @@ export const useUIStore = create<UIStore>()(
|
|||||||
set({ isTimelineDialogOpen: open });
|
set({ isTimelineDialogOpen: open });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setImagePreviewOpen: (open) => {
|
||||||
|
set({ isImagePreviewOpen: open });
|
||||||
|
},
|
||||||
|
|
||||||
setNativeNotificationsEnabled: (value) => {
|
setNativeNotificationsEnabled: (value) => {
|
||||||
set({ nativeNotificationsEnabled: value });
|
set({ nativeNotificationsEnabled: value });
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user