feat(macos): implement comprehensive native menu system

This commit is contained in:
Bohdan Triapitsyn
2025-12-19 17:57:50 +02:00
parent 08ac58aa8d
commit 560db1c5fa
10 changed files with 582 additions and 54 deletions
+7
View File
@@ -7,6 +7,7 @@ import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useEventStream } from '@/hooks/useEventStream';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
import { useMenuActions } from '@/hooks/useMenuActions';
import { useMessageSync } from '@/hooks/useMessageSync';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { GitPollingProvider } from '@/hooks/useGitPolling';
@@ -130,6 +131,12 @@ function App({ apis }: AppProps) {
useKeyboardShortcuts();
const handleToggleMemoryDebug = React.useCallback(() => {
setShowMemoryDebug(prev => !prev);
}, []);
useMenuActions(handleToggleMemoryDebug);
useMessageSync();
useSessionStatusBootstrap();
@@ -1,5 +1,6 @@
import React from 'react';
import { RiDownloadLine, RiSettings3Line } from '@remixicon/react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { useUIStore } from '@/stores/useUIStore';
@@ -84,6 +85,10 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
if (available || downloaded) {
setUpdateDialogOpen(true);
} else {
toast.success('No updates available', {
description: 'You are running the latest version.',
});
}
pendingMenuUpdateCheckRef.current = false;
}, [available, downloaded, checking]);
@@ -2,6 +2,7 @@ import React from 'react';
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react';
import { useDesktopServerInfo } from '@/hooks/useDesktopServerInfo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
@@ -153,40 +154,65 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
{}
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
if (currentSessionId) {
trimToViewportWindow(currentSessionId, 10);
}
}}
>
<RiDeleteBinLine className="h-3 w-3 mr-1" />
Force Trim (10)
</Button>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
evictLeastRecentlyUsed();
}}
>
Evict LRU
</Button>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
}}
>
Log State
</Button>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
if (currentSessionId) {
trimToViewportWindow(currentSessionId, 10);
}
}}
>
<RiDeleteBinLine className="h-3 w-3 mr-1" />
Force Trim (10)
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Trim current session to only 10 most recent messages
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
evictLeastRecentlyUsed();
}}
>
Evict LRU
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Remove least recently used sessions from memory cache
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="typography-meta"
onClick={() => {
console.log('[MemoryDebug] Session store state:', {
sessions: sessions.map(s => ({ id: s.id, title: s.title })),
currentSessionId,
cachedSessions: Array.from(messages.keys()),
memoryStates: Object.fromEntries(sessionMemoryState),
});
}}
>
Log State
</Button>
</TooltipTrigger>
<TooltipContent side="top">
Log current memory state to browser console
</TooltipContent>
</Tooltip>
</div>
</div>
</Card>
@@ -57,11 +57,12 @@ export const useKeyboardShortcuts = () => {
diagnostics
.downloadLogs()
.then(({ fileName, content }) => {
const finalFileName = fileName || 'openchamber.log';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = fileName || 'desktop.log';
anchor.download = finalFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
+193
View File
@@ -0,0 +1,193 @@
import React from 'react';
import { toast } from 'sonner';
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sessionEvents } from '@/lib/sessionEvents';
import { isDesktopRuntime } from '@/lib/desktop';
const MENU_ACTION_EVENT = 'openchamber:menu-action';
type MenuAction =
| 'settings'
| 'command-palette'
| 'new-session'
| 'worktree-creator'
| 'change-workspace'
| 'open-git-tab'
| 'open-diff-tab'
| 'open-terminal-tab'
| 'theme-light'
| 'theme-dark'
| 'theme-system'
| 'toggle-sidebar'
| 'toggle-memory-debug'
| 'help-dialog'
| 'download-logs';
export const useMenuActions = (
onToggleMemoryDebug?: () => void
) => {
const { createSession, initializeNewOpenChamberSession } = useSessionStore();
const {
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
} = useUIStore();
const { agents } = useConfigStore();
const { setDirectory } = useDirectoryStore();
const { setThemeMode } = useThemeSystem();
const isDownloadingLogsRef = React.useRef(false);
const handleChangeWorkspace = React.useCallback(() => {
if (isDesktopRuntime() && window.opencodeDesktop?.requestDirectoryAccess) {
window.opencodeDesktop
.requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
setDirectory(result.path, { showOverlay: true });
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
});
} else {
sessionEvents.requestDirectoryDialog();
}
}, [setDirectory]);
React.useEffect(() => {
const handleMenuAction = (event: Event) => {
const action = (event as CustomEvent<MenuAction>).detail;
switch (action) {
case 'settings':
setSettingsDialogOpen(true);
break;
case 'command-palette':
toggleCommandPalette();
break;
case 'new-session':
createSession().then(session => {
if (session) {
initializeNewOpenChamberSession(session.id, agents);
}
});
break;
case 'worktree-creator':
setSessionCreateDialogOpen(true);
break;
case 'change-workspace':
handleChangeWorkspace();
break;
case 'open-git-tab': {
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'git' ? 'chat' : 'git');
break;
}
case 'open-diff-tab': {
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'diff' ? 'chat' : 'diff');
break;
}
case 'open-terminal-tab': {
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
break;
}
case 'theme-light':
setThemeMode('light');
break;
case 'theme-dark':
setThemeMode('dark');
break;
case 'theme-system':
setThemeMode('system');
break;
case 'toggle-sidebar':
toggleSidebar();
break;
case 'toggle-memory-debug':
onToggleMemoryDebug?.();
break;
case 'help-dialog':
toggleHelpDialog();
break;
case 'download-logs': {
const runtimeAPIs = getRegisteredRuntimeAPIs();
const diagnostics = runtimeAPIs?.diagnostics;
if (!diagnostics || isDownloadingLogsRef.current) {
break;
}
isDownloadingLogsRef.current = true;
diagnostics
.downloadLogs()
.then(({ fileName, content }) => {
const finalFileName = fileName || 'openchamber.log';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = finalFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
toast.success('Logs saved', {
description: `Downloaded to ~/Downloads/${finalFileName}`,
});
})
.catch(() => {
toast.error('Failed to download logs');
})
.finally(() => {
isDownloadingLogsRef.current = false;
});
break;
}
}
};
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
}, [
createSession,
initializeNewOpenChamberSession,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionCreateDialogOpen,
setActiveMainTab,
setSettingsDialogOpen,
setThemeMode,
agents,
onToggleMemoryDebug,
handleChangeWorkspace,
]);
};