feat(macos): implement comprehensive native menu system
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
Reference in New Issue
Block a user