## What / Why This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome). This unblocks: - consistent behavior across web/desktop/vscode (single backend) - simpler desktop maintenance (no duplicated Rust backend) - host switching between Local + remote instances in desktop - reliable cold-start behavior on slow machines (VSCode + desktop) ## Key changes - Desktop sidecar runtime - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`) - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`) - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins) - disable native right-click context menu in production builds (dev keeps it) - Desktop instance switcher (Tauri-only) - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch - auth gate includes host switcher so you can recover when a remote host is broken/auth-required - host list stored desktop-locally (not tied to the currently selected remote server) - Notifications - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active) - restore macOS notification sound - Updates - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart) - Settings persistence & UX polish - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent) - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles) - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned) - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines - misc lint/type fixes + bun.lock sync - Desktop bootstrap / resiliency - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install ## Testing notes - Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local - Web: favorites/recents + per-project collapsed state persist across reload/restart - VSCode: slow startup no longer results in missing providers/agents/models
247 lines
9.5 KiB
TypeScript
247 lines
9.5 KiB
TypeScript
import React from 'react';
|
|
import { RiDownloadLine, RiInformationLine, RiSettings3Line } from '@remixicon/react';
|
|
import { toast } from '@/components/ui';
|
|
import { cn } from '@/lib/utils';
|
|
import { ErrorBoundary } from '../ui/ErrorBoundary';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useUpdateStore } from '@/stores/useUpdateStore';
|
|
import { UpdateDialog } from '../ui/UpdateDialog';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
|
|
|
export const SIDEBAR_CONTENT_WIDTH = 264;
|
|
const SIDEBAR_MIN_WIDTH = 200;
|
|
const SIDEBAR_MAX_WIDTH = 500;
|
|
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
|
|
|
|
interface SidebarProps {
|
|
isOpen: boolean;
|
|
isMobile: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children }) => {
|
|
const { sidebarWidth, setSidebarWidth, setSettingsDialogOpen, setAboutDialogOpen } = useUIStore();
|
|
const [isResizing, setIsResizing] = React.useState(false);
|
|
const startXRef = React.useRef(0);
|
|
const startWidthRef = React.useRef(sidebarWidth || SIDEBAR_CONTENT_WIDTH);
|
|
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
|
|
|
|
const updateStore = useUpdateStore();
|
|
const pendingMenuUpdateCheckRef = React.useRef(false);
|
|
|
|
const checkForUpdates = updateStore.checkForUpdates;
|
|
const { available, downloaded, checking } = updateStore;
|
|
|
|
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
|
|
});
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
setIsDesktopApp(Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__));
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const handleMenuUpdateCheck = () => {
|
|
if (!(window as unknown as { __TAURI__?: unknown }).__TAURI__) {
|
|
return;
|
|
}
|
|
pendingMenuUpdateCheckRef.current = true;
|
|
void checkForUpdates();
|
|
};
|
|
|
|
window.addEventListener(CHECK_FOR_UPDATES_EVENT, handleMenuUpdateCheck as EventListener);
|
|
return () => {
|
|
window.removeEventListener(CHECK_FOR_UPDATES_EVENT, handleMenuUpdateCheck as EventListener);
|
|
};
|
|
}, [checkForUpdates]);
|
|
|
|
React.useEffect(() => {
|
|
if (!pendingMenuUpdateCheckRef.current) {
|
|
return;
|
|
}
|
|
if (checking) {
|
|
return;
|
|
}
|
|
|
|
if (available || downloaded) {
|
|
setUpdateDialogOpen(true);
|
|
} else {
|
|
toast.success('No updates available', {
|
|
description: 'You are running the latest version.',
|
|
});
|
|
}
|
|
pendingMenuUpdateCheckRef.current = false;
|
|
}, [available, downloaded, checking]);
|
|
|
|
React.useEffect(() => {
|
|
if (isMobile || !isResizing) {
|
|
return;
|
|
}
|
|
|
|
const handlePointerMove = (event: PointerEvent) => {
|
|
const delta = event.clientX - startXRef.current;
|
|
const nextWidth = Math.min(
|
|
SIDEBAR_MAX_WIDTH,
|
|
Math.max(SIDEBAR_MIN_WIDTH, startWidthRef.current + delta)
|
|
);
|
|
setSidebarWidth(nextWidth);
|
|
};
|
|
|
|
const handlePointerUp = () => {
|
|
setIsResizing(false);
|
|
};
|
|
|
|
window.addEventListener('pointermove', handlePointerMove);
|
|
window.addEventListener('pointerup', handlePointerUp, { once: true });
|
|
|
|
return () => {
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
window.removeEventListener('pointerup', handlePointerUp);
|
|
};
|
|
}, [isMobile, isResizing, setSidebarWidth]);
|
|
|
|
React.useEffect(() => {
|
|
if (isMobile && isResizing) {
|
|
setIsResizing(false);
|
|
}
|
|
}, [isMobile, isResizing]);
|
|
|
|
if (isMobile) {
|
|
|
|
return null;
|
|
}
|
|
|
|
const appliedWidth = isOpen ? Math.min(
|
|
SIDEBAR_MAX_WIDTH,
|
|
Math.max(SIDEBAR_MIN_WIDTH, sidebarWidth || SIDEBAR_CONTENT_WIDTH)
|
|
) : 0;
|
|
|
|
const handlePointerDown = (event: React.PointerEvent) => {
|
|
if (!isOpen) {
|
|
return;
|
|
}
|
|
setIsResizing(true);
|
|
startXRef.current = event.clientX;
|
|
startWidthRef.current = appliedWidth;
|
|
event.preventDefault();
|
|
};
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
'relative flex h-full overflow-hidden border-r border-border',
|
|
isDesktopApp
|
|
? 'bg-[color:var(--sidebar-overlay-strong)] backdrop-blur supports-[backdrop-filter]:bg-[color:var(--sidebar-overlay-soft)]'
|
|
: 'bg-sidebar',
|
|
isResizing ? 'transition-none' : 'transition-[width] duration-300 ease-in-out',
|
|
!isOpen && 'border-r-0'
|
|
)}
|
|
style={{
|
|
width: `${appliedWidth}px`,
|
|
minWidth: `${appliedWidth}px`,
|
|
maxWidth: `${appliedWidth}px`,
|
|
overflowX: 'clip',
|
|
}}
|
|
aria-hidden={!isOpen || appliedWidth === 0}
|
|
>
|
|
{isOpen && (
|
|
<div
|
|
className={cn(
|
|
'absolute right-0 top-0 z-20 h-full w-[4px] cursor-col-resize hover:bg-primary/50 transition-colors',
|
|
isResizing && 'bg-primary'
|
|
)}
|
|
onPointerDown={handlePointerDown}
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="Resize left panel"
|
|
/>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
'relative z-10 flex h-full flex-col transition-opacity duration-300 ease-in-out',
|
|
!isOpen && 'pointer-events-none select-none opacity-0'
|
|
)}
|
|
style={{ width: `${appliedWidth}px`, overflowX: 'hidden' }}
|
|
aria-hidden={!isOpen}
|
|
>
|
|
<div className="flex-1 overflow-hidden">
|
|
<ErrorBoundary>{children}</ErrorBoundary>
|
|
</div>
|
|
<div className="flex-shrink-0 border-t border-border h-12 px-2 bg-sidebar">
|
|
<div className="flex h-full items-center justify-between gap-2">
|
|
<button
|
|
onClick={() => setSettingsDialogOpen(true)}
|
|
className={cn(
|
|
'flex h-8 items-center gap-2 rounded-md px-2',
|
|
'text-sm font-semibold text-sidebar-foreground/90',
|
|
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
|
'transition-all duration-200'
|
|
)}
|
|
>
|
|
<RiSettings3Line className="h-4 w-4" />
|
|
<span>Settings</span>
|
|
</button>
|
|
{(available || downloaded) ? (
|
|
<button
|
|
onClick={() => setUpdateDialogOpen(true)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 rounded-md px-2 py-1',
|
|
'text-xs font-semibold',
|
|
'bg-primary/10 text-primary',
|
|
'hover:bg-primary/20',
|
|
'transition-colors'
|
|
)}
|
|
>
|
|
<RiDownloadLine className="h-3.5 w-3.5" />
|
|
<span>Update</span>
|
|
</button>
|
|
|
|
) : !isDesktopApp && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={() => setAboutDialogOpen(true)}
|
|
className={cn(
|
|
'flex h-8 w-8 items-center justify-center rounded-md',
|
|
'text-sidebar-foreground/70',
|
|
'hover:text-sidebar-foreground hover:bg-interactive-hover',
|
|
'transition-all duration-200'
|
|
)}
|
|
>
|
|
<RiInformationLine className="h-4 w-4" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">About OpenChamber</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<UpdateDialog
|
|
open={updateDialogOpen}
|
|
onOpenChange={setUpdateDialogOpen}
|
|
info={updateStore.info}
|
|
downloading={updateStore.downloading}
|
|
downloaded={updateStore.downloaded}
|
|
progress={updateStore.progress}
|
|
error={updateStore.error}
|
|
onDownload={updateStore.downloadUpdate}
|
|
onRestart={updateStore.restartToUpdate}
|
|
runtimeType={updateStore.runtimeType}
|
|
/>
|
|
</div>
|
|
</aside>
|
|
);
|
|
};
|