refactor(desktop): make Tauri thin shell running web sidecar (#273)

## 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
This commit is contained in:
Bohdan Triapitsyn
2026-02-05 01:59:49 +02:00
committed by GitHub
parent b733f26aed
commit 83ffb1af34
130 changed files with 4230 additions and 23488 deletions
@@ -0,0 +1,704 @@
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiCheckLine,
RiCloudOffLine,
RiEarthLine,
RiLoader4Line,
RiMore2Line,
RiPencilLine,
RiRefreshLine,
RiServerLine,
RiShieldKeyholeLine,
RiStarFill,
RiStarLine,
RiDeleteBinLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import {
desktopHostProbe,
desktopHostsGet,
desktopHostsSet,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
const LOCAL_HOST_ID = 'local';
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
};
const normalizeHostUrl = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
return url.origin;
} catch {
// Tauri/WebKit edge: accept origin without trailing slash.
try {
const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
return url.origin;
} catch {
return null;
}
}
};
const toNavigationUrl = (origin: string): string => {
const trimmed = origin.trim();
if (!trimmed) return trimmed;
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
};
const getLocalOrigin = (): string => {
if (typeof window === 'undefined') return '';
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
};
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'bg-status-success';
if (status === 'auth') return 'bg-status-warning';
if (status === 'unreachable') return 'bg-status-error';
return 'bg-muted-foreground/40';
};
const statusLabel = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'Connected';
if (status === 'auth') return 'Auth required';
if (status === 'unreachable') return 'Unreachable';
return 'Unknown';
};
const statusIcon = (status: HostProbeResult['status'] | null) => {
if (status === 'ok') return <RiCheckLine className="h-4 w-4" />;
if (status === 'auth') return <RiShieldKeyholeLine className="h-4 w-4" />;
if (status === 'unreachable') return <RiCloudOffLine className="h-4 w-4" />;
return <RiEarthLine className="h-4 w-4" />;
};
const buildLocalHost = (): DesktopHost => ({
id: LOCAL_HOST_ID,
label: 'Local',
url: getLocalOrigin(),
});
const resolveCurrentHost = (hosts: DesktopHost[]) => {
const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin;
const localOrigin = getLocalOrigin();
const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin;
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
const match = hosts.find((h) => {
const normalized = normalizeHostUrl(h.url);
return normalized && normalized === normalizedCurrent;
});
if (match) {
return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
}
return {
id: 'custom',
label: normalizedCurrent || 'Instance',
url: normalizedCurrent,
};
};
type DesktopHostSwitcherDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) {
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [error, setError] = React.useState<string>('');
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editLabel, setEditLabel] = React.useState('');
const [editUrl, setEditUrl] = React.useState('');
const [newLabel, setNewLabel] = React.useState('');
const [newUrl, setNewUrl] = React.useState('');
const allHosts = React.useMemo(() => {
const local = buildLocalHost();
const normalizedRemote = configHosts.map((h) => ({
...h,
url: normalizeHostUrl(h.url) || h.url,
}));
return [local, ...normalizedRemote];
}, [configHosts]);
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
const currentDefaultLabel = React.useMemo(() => {
const id = defaultHostId || LOCAL_HOST_ID;
return allHosts.find((h) => h.id === id)?.label || 'Local';
}, [allHosts, defaultHostId]);
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
if (!isTauriShell()) return;
setIsSaving(true);
setError('');
try {
// Persist only remote hosts; Local is derived.
const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID);
await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId });
setConfigHosts(remote);
setDefaultHostId(nextDefaultHostId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setIsSaving(false);
}
}, []);
const refresh = React.useCallback(async () => {
if (!isTauriShell()) return;
setIsLoading(true);
setError('');
try {
const cfg = await desktopHostsGet();
setConfigHosts(cfg.hosts || []);
setDefaultHostId(cfg.defaultHostId ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setConfigHosts([]);
setDefaultHostId(null);
} finally {
setIsLoading(false);
}
}, []);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isTauriShell()) return;
setIsProbing(true);
try {
const results = await Promise.all(
hosts.map(async (h) => {
const url = normalizeHostUrl(h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
const next: Record<string, HostStatus> = {};
for (const [id, val] of results) {
next[id] = val;
}
setStatusById(next);
} finally {
setIsProbing(false);
}
}, []);
React.useEffect(() => {
if (!open) {
setEditingId(null);
setEditLabel('');
setEditUrl('');
setNewLabel('');
setNewUrl('');
setError('');
return;
}
void refresh();
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
void probeAll(allHosts);
}, [open, allHosts, probeAll]);
const handleSwitch = React.useCallback((host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
if (!origin) return;
const target = toNavigationUrl(origin);
try {
window.location.assign(target);
} catch {
window.location.href = target;
}
}, []);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
setEditLabel(host.label);
setEditUrl(host.url);
setError('');
}, []);
const cancelEdit = React.useCallback(() => {
setEditingId(null);
setEditLabel('');
setEditUrl('');
}, []);
const commitEdit = React.useCallback(async () => {
if (!editingId) return;
if (editingId === LOCAL_HOST_ID) {
cancelEdit();
return;
}
const url = normalizeHostUrl(editUrl);
if (!url) {
setError('Invalid URL (must be http/https)');
return;
}
const label = (editLabel || url).trim();
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
const addHost = React.useCallback(async () => {
const url = normalizeHostUrl(newUrl);
if (!url) {
setError('Invalid URL (must be http/https)');
return;
}
const label = (newLabel || url).trim();
const id = makeId();
const nextHosts = [{ id, label, url }, ...configHosts];
await persist(nextHosts, defaultHostId);
setNewLabel('');
setNewUrl('');
}, [configHosts, defaultHostId, newLabel, newUrl, persist]);
const deleteHost = React.useCallback(async (id: string) => {
if (id === LOCAL_HOST_ID) return;
const nextHosts = configHosts.filter((h) => h.id !== id);
const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
await persist(nextHosts, nextDefault);
}, [configHosts, defaultHostId, persist]);
const setDefault = React.useCallback(async (id: string) => {
const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
await persist(configHosts, next);
}, [configHosts, persist]);
if (!isDesktopShell()) {
return null;
}
const tauriAvailable = isTauriShell();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiServerLine className="h-5 w-5" />
Instance
</DialogTitle>
<DialogDescription>
Switch between Local and remote OpenChamber servers
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-meta text-muted-foreground">Current:</span>
<span className="typography-ui-label text-foreground truncate">{current.label}</span>
<span className="typography-meta text-muted-foreground">Current default:</span>
<span className="typography-ui-label text-foreground truncate">{currentDefaultLabel}</span>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void probeAll(allHosts)}
disabled={!tauriAvailable || isLoading || isProbing}
>
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
Refresh
</Button>
</div>
</div>
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="typography-meta text-muted-foreground">
Instance switcher is limited on this page. Use Local to recover.
</div>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
{isLoading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">Loading</div>
) : (
allHosts.map((host) => {
const isLocal = host.id === LOCAL_HOST_ID;
const isActive = host.id === current.id;
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
const status = statusById[host.id] || null;
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
return (
<div
key={host.id}
className={cn(
'group flex items-center gap-2 px-2.5 py-2 rounded-md overflow-hidden',
isEditing ? 'bg-interactive-hover/20' : 'hover:bg-interactive-hover/30'
)}
>
<button
type="button"
className={cn(
'flex items-center gap-2 flex-1 min-w-0 text-left',
isEditing && 'pointer-events-none opacity-70'
)}
onClick={() => handleSwitch(host)}
aria-label={`Switch to ${host.label}`}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
{host.label}
</span>
{isActive && (
<span className="typography-micro text-muted-foreground">Current</span>
)}
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
{statusIcon(status?.status ?? null)}
<span>
{statusLabel(status?.status ?? null)}
{status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
</span>
</span>
</div>
<div className="typography-micro text-muted-foreground truncate font-mono">
{effectiveUrl}
</div>
</div>
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
isDefault
? 'text-primary hover:text-primary/80'
: 'text-muted-foreground/60 hover:text-primary/80',
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? 'Default instance' : 'Set as default'}
disabled={isSaving}
>
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{isDefault ? 'Default' : 'Set as default'}
</TooltipContent>
</Tooltip>
{!isLocal && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
aria-label="Instance actions"
disabled={isSaving}
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
beginEdit(host);
}}
disabled={isSaving}
>
<RiPencilLine className="h-4 w-4 mr-1" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void deleteHost(host.id);
}}
className="text-destructive focus:text-destructive"
disabled={isSaving}
>
<RiDeleteBinLine className="h-4 w-4 mr-1" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isLocal && (
<div
className="h-8 w-8 opacity-0 pointer-events-none"
aria-hidden="true"
/>
)}
</div>
</div>
);
})
)}
</div>
</div>
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Edit instance</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelEdit} disabled={isSaving}>
Cancel
</Button>
<Button type="button" size="sm" onClick={() => void commitEdit()} disabled={isSaving}>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Save
</Button>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label"
disabled={isSaving}
/>
<Input
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
placeholder="https://host:port"
disabled={isSaving}
/>
</div>
</div>
)}
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
<Button
type="button"
size="sm"
onClick={() => void addHost()}
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Add
</Button>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (optional)"
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://host:port"
disabled={!tauriAvailable || isSaving}
/>
</div>
</div>
{error && (
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
)}
</DialogContent>
</Dialog>
);
}
type DesktopHostSwitcherButtonProps = {
headerIconButtonClass: string;
};
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
React.useEffect(() => {
if (!isTauriShell()) return;
let cancelled = false;
const run = async () => {
try {
const cfg = await desktopHostsGet();
const local = buildLocalHost();
const all = [local, ...(cfg.hosts || [])];
const current = resolveCurrentHost(all);
if (cancelled) return;
setLabel(current.label || 'Instance');
const normalized = normalizeHostUrl(current.url);
if (!normalized) {
setStatus(null);
return;
}
const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
if (cancelled) return;
setStatus(res.status);
} catch {
if (!cancelled) {
setLabel('Instance');
setStatus(null);
}
}
};
void run();
const interval = window.setInterval(() => {
void run();
}, 10_000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, []);
if (!isDesktopShell()) {
return null;
}
const isCurrentlyLocal = (() => {
try {
const current = normalizeHostUrl(window.location.origin);
const local = normalizeHostUrl(getLocalOrigin());
return Boolean(current && local && current === local);
} catch {
return false;
}
})();
// Fallback label when Tauri IPC is temporarily unavailable.
const fallbackLabel = (() => {
try {
const host = typeof window !== 'undefined' ? window.location.hostname : '';
return host ? host : 'Instance';
} catch {
return 'Instance';
}
})();
const effectiveLabel = isCurrentlyLocal
? 'Local'
: label === 'Local'
? fallbackLabel
: label;
return (
<>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Switch instance"
data-oc-host-switcher
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
>
<RiServerLine className="h-5 w-5" />
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
{effectiveLabel}
</span>
<span
className={cn(
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
statusDotClass(status)
)}
aria-label="Instance status"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Instance</p>
</TooltipContent>
</Tooltip>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
</>
);
}
export function DesktopHostSwitcherInline() {
const [open, setOpen] = React.useState(false);
if (!isDesktopShell()) {
return null;
}
return (
<>
<Button
type="button"
variant="ghost"
size="sm"
data-oc-host-switcher
className="w-full justify-center"
onClick={() => setOpen(true)}
>
<RiServerLine className="h-4 w-4" />
Switch instance
</Button>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
</>
);
}