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
+75 -16
View File
@@ -1,4 +1,3 @@
import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop';
import type { DesktopSettings } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
@@ -49,6 +48,18 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('pinnedDirectories');
}
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
const collapsed = settings.projects
.filter((project) => (project as unknown as { sidebarCollapsed?: boolean }).sidebarCollapsed === true)
.map((project) => project.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (collapsed.length > 0) {
localStorage.setItem('oc.sessions.projectCollapse', JSON.stringify(collapsed));
} else {
localStorage.removeItem('oc.sessions.projectCollapse');
}
}
if (typeof settings.gitmojiEnabled === 'boolean') {
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
} else {
@@ -143,6 +154,9 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
}
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
@@ -164,6 +178,30 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
return result.length > 0 ? result : undefined;
};
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: Array<{ providerID: string; modelID: string }> = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const providerID = typeof candidate.providerID === 'string' ? candidate.providerID.trim() : '';
const modelID = typeof candidate.modelID === 'string' ? candidate.modelID.trim() : '';
if (!providerID || !modelID) continue;
const key = `${providerID}/${modelID}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ providerID, modelID });
if (result.length >= limit) break;
}
return result;
};
const getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
if (candidate && typeof candidate === 'object') {
@@ -240,6 +278,28 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) {
store.setInputBarOffset(settings.inputBarOffset);
}
if (Array.isArray(settings.favoriteModels)) {
const current = store.favoriteModels;
const next = settings.favoriteModels;
const same =
current.length === next.length &&
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
if (!same) {
useUIStore.setState({ favoriteModels: next });
}
}
if (Array.isArray(settings.recentModels)) {
const current = store.recentModels;
const next = settings.recentModels;
const same =
current.length === next.length &&
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
if (!same) {
useUIStore.setState({ recentModels: next });
}
}
if (typeof settings.diffLayoutPreference === 'string'
&& (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
@@ -391,6 +451,16 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
result.inputBarOffset = candidate.inputBarOffset;
}
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
if (favoriteModels) {
result.favoriteModels = favoriteModels;
}
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
if (recentModels) {
result.recentModels = recentModels;
}
if (
typeof candidate.diffLayoutPreference === 'string'
&& (candidate.diffLayoutPreference === 'dynamic'
@@ -487,9 +557,9 @@ export const syncDesktopSettings = async (): Promise<void> => {
};
try {
const settings = isDesktopRuntime() ? await getDesktopSettings() : await fetchWebSettings();
if (settings) {
applySettings(settings);
const webSettings = await fetchWebSettings();
if (webSettings) {
applySettings(webSettings);
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
@@ -501,18 +571,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
return;
}
if (isDesktopRuntime()) {
try {
const updated = await updateDesktopSettingsApi(changes);
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
}
} catch (error) {
console.warn('Failed to update desktop settings:', error);
}
return;
}
// Desktop shell uses the same HTTP settings API as web.
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {