+
diff --git a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
index 226bba8a..a9d560c0 100644
--- a/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
+++ b/packages/ui/src/components/desktop/DesktopHostSwitcher.tsx
@@ -97,6 +97,7 @@ const makeId = (): string => {
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'bg-status-success';
if (status === 'auth') return 'bg-status-warning';
+ if (status === 'wrong-service') return 'bg-status-error';
if (status === 'unreachable') return 'bg-status-error';
return 'bg-muted-foreground/40';
};
@@ -104,6 +105,7 @@ const statusDotClass = (status: HostProbeResult['status'] | null): string => {
const statusLabel = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'Connected';
if (status === 'auth') return 'Auth required';
+ if (status === 'wrong-service') return 'Wrong service';
if (status === 'unreachable') return 'Unreachable';
return 'Unknown';
};
@@ -111,6 +113,7 @@ const statusLabel = (status: HostProbeResult['status'] | null): string => {
const statusIcon = (status: HostProbeResult['status'] | null) => {
if (status === 'ok') return ;
if (status === 'auth') return ;
+ if (status === 'wrong-service') return ;
if (status === 'unreachable') return ;
return ;
};
@@ -516,7 +519,7 @@ export function DesktopHostSwitcherDialog({
[host.id]: { status: probe.status, latencyMs: probe.latencyMs },
}));
- if (probe.status === 'unreachable') {
+ if (probe.status === 'unreachable' || probe.status === 'wrong-service') {
toast.error(`Instance "${redactSensitiveUrl(host.label)}" is unreachable`);
setSwitchingHostId(null);
return;
@@ -909,7 +912,7 @@ export function DesktopHostSwitcherDialog({
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? 'Default instance' : 'Set as default'}
- disabled={isSaving}
+ disabled={isSaving || (!isDefault && (statusKind === 'unreachable' || statusKind === 'wrong-service'))}
>
{isDefault ? : }
@@ -925,7 +928,7 @@ export function DesktopHostSwitcherDialog({
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
- statusKind === 'unreachable'
+ statusKind === 'unreachable' || statusKind === 'wrong-service'
? 'text-muted-foreground/30 cursor-not-allowed'
: 'text-muted-foreground/60 hover:text-foreground',
)}
@@ -933,14 +936,14 @@ export function DesktopHostSwitcherDialog({
e.stopPropagation();
openInNewWindow(host);
}}
- disabled={statusKind === 'unreachable'}
+ disabled={statusKind === 'unreachable' || statusKind === 'wrong-service'}
aria-label="Open in new window"
>
- {statusKind === 'unreachable' ? 'Instance unreachable' : 'Open in new window'}
+ {(statusKind === 'unreachable' || statusKind === 'wrong-service') ? 'Instance unreachable' : 'Open in new window'}
diff --git a/packages/ui/src/components/onboarding/ChooserScreen.tsx b/packages/ui/src/components/onboarding/ChooserScreen.tsx
new file mode 100644
index 00000000..23576e1c
--- /dev/null
+++ b/packages/ui/src/components/onboarding/ChooserScreen.tsx
@@ -0,0 +1,420 @@
+import React from 'react';
+import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
+import { isDesktopShell, isTauriShell } from '@/lib/desktop';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { updateDesktopSettings } from '@/lib/persistence';
+import { copyTextToClipboard } from '@/lib/clipboard';
+import { restartDesktopApp } from '@/lib/desktop';
+import { cn } from '@/lib/utils';
+import { RemoteConnectionForm } from './RemoteConnectionForm';
+import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
+
+const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
+const DOCS_URL = 'https://opencode.ai/docs';
+const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
+
+type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
+
+type ChooserScreenProps = {
+ /** Callback when CLI becomes available */
+ onCliAvailable?: () => void;
+};
+
+function BashCommand({ onCopy }: { onCopy: () => void }) {
+ return (
+
+
+ curl
+ -fsSL
+ https://opencode.ai/install
+ |
+ bash
+
+
+
+ );
+}
+
+const HINT_DELAY_MS = 30000;
+
+export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
+ const [copied, setCopied] = React.useState(false);
+ const [showHint, setShowHint] = React.useState(false);
+ const [isDesktopApp, setIsDesktopApp] = React.useState(false);
+ const [isRetrying, setIsRetrying] = React.useState(false);
+ const [isChecking, setIsChecking] = React.useState(false);
+ const [checkError, setCheckError] = React.useState
(null);
+ const [opencodeBinary, setOpencodeBinary] = React.useState('');
+ const [platform, setPlatform] = React.useState('unknown');
+ const [activeTab, setActiveTab] = React.useState<'local' | 'remote'>('local');
+
+ React.useEffect(() => {
+ const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, []);
+
+ React.useEffect(() => {
+ setIsDesktopApp(isDesktopShell());
+ }, []);
+
+ React.useEffect(() => {
+ if (typeof navigator === 'undefined') {
+ setPlatform('unknown');
+ return;
+ }
+
+ const ua = navigator.userAgent || '';
+ if (/Windows/i.test(ua)) {
+ setPlatform('windows');
+ return;
+ }
+ if (/Macintosh|Mac OS X/i.test(ua)) {
+ setPlatform('macos');
+ return;
+ }
+ if (/Linux/i.test(ua)) {
+ setPlatform('linux');
+ return;
+ }
+ setPlatform('unknown');
+ }, []);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
+ if (!response.ok) return;
+ const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
+ if (!data || cancelled) return;
+ const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
+ if (value) {
+ setOpencodeBinary(value);
+ }
+ } catch {
+ // ignore
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
+ return;
+ }
+ if (e.button !== 0) return;
+ if (isDesktopApp && isTauriShell()) {
+ try {
+ const { getCurrentWindow } = await import('@tauri-apps/api/window');
+ const window = getCurrentWindow();
+ await window.startDragging();
+ } catch (error) {
+ console.error('Failed to start window dragging:', error);
+ }
+ }
+ }, [isDesktopApp]);
+
+ const checkCliAvailability = React.useCallback(async (): Promise => {
+ try {
+ const response = await fetch('/health');
+ if (!response.ok) return false;
+ const data = await response.json();
+ return data.openCodeRunning === true || data.isOpenCodeReady === true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ const handleBrowse = React.useCallback(async () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (!isDesktopApp || !isTauriShell()) {
+ return;
+ }
+
+ const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__;
+ if (!tauri?.dialog?.open) {
+ return;
+ }
+
+ try {
+ const selected = await tauri.dialog.open({
+ title: 'Select opencode binary',
+ multiple: false,
+ directory: false,
+ });
+ if (typeof selected === 'string' && selected.trim().length > 0) {
+ setOpencodeBinary(selected.trim());
+ }
+ } catch {
+ // ignore
+ }
+ }, [isDesktopApp]);
+
+ // Persist the user's first choice (local or remote)
+ const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
+ if (!isTauriShell()) return;
+
+ const config = await desktopHostsGet();
+ await desktopHostsSet({
+ ...config,
+ // Only change defaultHostId when switching to local; remote keeps
+ // whatever was there (or null) until a successful connect.
+ ...(choice === 'local' ? { defaultHostId: 'local' } : {}),
+ initialHostChoiceCompleted: true,
+ });
+ }, []);
+
+ const handleApplyPath = React.useCallback(async () => {
+ setIsRetrying(true);
+ try {
+ await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
+
+ // In first-launch mode, persist the local choice when user manually
+ // sets the binary path, so the choice is remembered after restart.
+ if (isTauriShell()) {
+ await persistFirstChoice('local');
+ }
+
+ // In desktop boot flow, always restart the entire Tauri app so Rust
+ // can re-evaluate the boot outcome with the updated binary path.
+ if (isTauriShell()) {
+ await restartDesktopApp();
+ return;
+ }
+
+ await fetch('/api/config/reload', { method: 'POST' });
+ } finally {
+ setTimeout(() => setIsRetrying(false), 1000);
+ }
+ }, [opencodeBinary, persistFirstChoice]);
+
+ const handleCopy = React.useCallback(async () => {
+ const result = await copyTextToClipboard(INSTALL_COMMAND);
+ if (result.ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } else {
+ console.error('Failed to copy:', result.error);
+ }
+ }, []);
+
+ const handleChooseRemote = React.useCallback(() => {
+ setActiveTab('remote');
+ }, []);
+
+ const handleCheckAndContinue = React.useCallback(async () => {
+ setIsChecking(true);
+ setCheckError(null);
+ try {
+ const available = await checkCliAvailability();
+ if (available) {
+ // In first-launch mode, persist the local choice when CLI becomes
+ // available, so the choice is remembered after restart.
+ if (isTauriShell()) {
+ await persistFirstChoice('local');
+ }
+ onCliAvailable?.();
+ } else {
+ setCheckError('OpenCode CLI is not ready yet. Please confirm installation is complete and try again.');
+ }
+ } catch (err) {
+ setCheckError(err instanceof Error ? err.message : 'Detection failed');
+ } finally {
+ setIsChecking(false);
+ }
+ }, [checkCliAvailability, onCliAvailable, persistFirstChoice]);
+
+ const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
+ const binaryPlaceholder =
+ platform === 'windows'
+ ? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
+ : platform === 'linux'
+ ? '/home/you/.bun/bin/opencode'
+ : '/Users/you/.bun/bin/opencode';
+
+ return (
+
+
+
+
+ Welcome to OpenChamber
+
+
+ Choose how you want to connect to get started.
+
+
+
+ {isDesktopApp && isTauriShell() && (
+
+
+
+
+ )}
+
+ {isDesktopApp && isTauriShell() && activeTab === 'remote' ? (
+
setActiveTab('local')}
+ showBackButton={false}
+ onSwitchToLocal={() => setActiveTab('local')}
+ />
+ ) : (
+ <>
+ {(!isDesktopApp || !isTauriShell() || activeTab === 'local') && (
+ <>
+ {platform === 'windows' && (
+
+
Windows setup (WSL recommended)
+
+ - Install WSL (if needed) with
wsl --install in PowerShell.
+ - Run the install command below inside your WSL terminal.
+ - If OpenChamber does not detect OpenCode automatically, set the binary path below.
+
+
+ )}
+
+
+
+ {copied ? (
+
+
+ Copied to clipboard
+
+ ) : (
+
+ )}
+
+
+
+
+ {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
+
+
+
+ {checkError && (
+
+ {checkError}
+
+ )}
+
+
+
+
+
+ Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
+
+
+
+
+
+
Already installed? Set the OpenCode CLI path:
+
+ setOpencodeBinary(e.target.value)}
+ placeholder={binaryPlaceholder}
+ disabled={isRetrying}
+ className="flex-1 font-mono text-xs"
+ />
+
+
+
+
Saves to OpenChamber settings and reloads OpenCode configuration.
+
+
+ >
+ )}
+ >
+ )}
+
+
+ {showHint && activeTab === 'local' && (
+
+ {platform === 'windows' ? (
+ <>
+
+ On Windows, install and run OpenCode in WSL for best compatibility.
+
+
+ If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode.
+
+ >
+ ) : (
+ <>
+
+ Already installed? Make sure opencode is in your PATH
+
+
+ or set OPENCODE_BINARY environment variable.
+
+
+ If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH.
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx
new file mode 100644
index 00000000..5a72d06e
--- /dev/null
+++ b/packages/ui/src/components/onboarding/DesktopConnectionRecovery.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import { RiRefreshLine, RiServerLine, RiMacbookLine } from '@remixicon/react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { redactSensitiveUrl } from '@/lib/desktopHosts';
+import {
+ getDesktopRecoveryConfig,
+ type RecoveryVariant,
+} from './desktopRecoveryConfig';
+
+export type { RecoveryVariant } from './desktopRecoveryConfig';
+
+export type DesktopConnectionRecoveryProps = {
+ variant: RecoveryVariant;
+ hostLabel?: string;
+ hostUrl?: string;
+ onRetry?: () => void;
+ onUseLocal?: () => void;
+ onUseRemote?: () => void;
+ isRetrying?: boolean;
+};
+
+/** Maps iconKey from config to actual icon component */
+function getRecoveryIcon(iconKey: 'local' | 'remote'): React.ReactNode {
+ switch (iconKey) {
+ case 'local':
+ return ;
+ case 'remote':
+ return ;
+ }
+}
+
+export function DesktopConnectionRecovery({
+ variant,
+ hostLabel,
+ hostUrl,
+ onRetry,
+ onUseLocal,
+ onUseRemote,
+ isRetrying = false,
+}: DesktopConnectionRecoveryProps) {
+ const config = getDesktopRecoveryConfig(variant, hostLabel, hostUrl);
+
+ return (
+
+
+ {/* Icon and title */}
+
+
+
+ {getRecoveryIcon(config.iconKey)}
+
+
+
+ {config.title}
+
+
+ {config.description}
+
+
+
+ {/* Host info if available */}
+ {hostUrl && (variant === 'remote-unreachable' || variant === 'remote-wrong-service') && (
+
+
Server Address
+
{redactSensitiveUrl(hostUrl)}
+
+ )}
+
+ {/* Action buttons */}
+
+ {config.showRetry && onRetry && (
+
+ )}
+
+
+ {config.showUseLocal && onUseLocal && (
+
+ )}
+
+ {config.showUseRemote && onUseRemote && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/ui/src/components/onboarding/LocalSetupScreen.tsx b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
new file mode 100644
index 00000000..ae011489
--- /dev/null
+++ b/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
@@ -0,0 +1,380 @@
+import React from 'react';
+import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
+import { isDesktopShell, isTauriShell } from '@/lib/desktop';
+import { Input } from '@/components/ui/input';
+import { Button } from '@/components/ui/button';
+import { updateDesktopSettings } from '@/lib/persistence';
+import { copyTextToClipboard } from '@/lib/clipboard';
+import { restartDesktopApp } from '@/lib/desktop';
+
+const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
+const DOCS_URL = 'https://opencode.ai/docs';
+const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
+
+type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
+
+type LocalSetupScreenProps = {
+ /** Callback when user goes back */
+ onBack: () => void;
+ /** Callback when CLI becomes available */
+ onCliAvailable?: () => void;
+ /** Whether this screen was entered from recovery flow (shows "Connect to Remote" link) */
+ isFromRecovery?: boolean;
+ /** Callback when user wants to switch to remote */
+ onSwitchToRemote?: () => void;
+};
+
+function BashCommand({ onCopy }: { onCopy: () => void }) {
+ return (
+
+
+ curl
+ -fsSL
+ https://opencode.ai/install
+ |
+ bash
+
+
+
+ );
+}
+
+const HINT_DELAY_MS = 30000;
+
+export function LocalSetupScreen({
+ onBack,
+ onCliAvailable,
+ isFromRecovery = false,
+ onSwitchToRemote,
+}: LocalSetupScreenProps) {
+ const [copied, setCopied] = React.useState(false);
+ const [showHint, setShowHint] = React.useState(false);
+ const [isDesktopApp, setIsDesktopApp] = React.useState(false);
+ const [isRetrying, setIsRetrying] = React.useState(false);
+ const [isChecking, setIsChecking] = React.useState(false);
+ const [checkError, setCheckError] = React.useState(null);
+ const [opencodeBinary, setOpencodeBinary] = React.useState('');
+ const [platform, setPlatform] = React.useState('unknown');
+
+ React.useEffect(() => {
+ const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, []);
+
+ React.useEffect(() => {
+ setIsDesktopApp(isDesktopShell());
+ }, []);
+
+ React.useEffect(() => {
+ if (typeof navigator === 'undefined') {
+ setPlatform('unknown');
+ return;
+ }
+
+ const ua = navigator.userAgent || '';
+ if (/Windows/i.test(ua)) {
+ setPlatform('windows');
+ return;
+ }
+ if (/Macintosh|Mac OS X/i.test(ua)) {
+ setPlatform('macos');
+ return;
+ }
+ if (/Linux/i.test(ua)) {
+ setPlatform('linux');
+ return;
+ }
+ setPlatform('unknown');
+ }, []);
+
+ React.useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
+ if (!response.ok) return;
+ const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
+ if (!data || cancelled) return;
+ const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
+ if (value) {
+ setOpencodeBinary(value);
+ }
+ } catch {
+ // ignore
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
+ if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
+ return;
+ }
+ if (e.button !== 0) return;
+ if (isDesktopApp && isTauriShell()) {
+ try {
+ const { getCurrentWindow } = await import('@tauri-apps/api/window');
+ const window = getCurrentWindow();
+ await window.startDragging();
+ } catch (error) {
+ console.error('Failed to start window dragging:', error);
+ }
+ }
+ }, [isDesktopApp]);
+
+ const checkCliAvailability = React.useCallback(async (): Promise => {
+ try {
+ const response = await fetch('/health');
+ if (!response.ok) return false;
+ const data = await response.json();
+ return data.openCodeRunning === true || data.isOpenCodeReady === true;
+ } catch {
+ return false;
+ }
+ }, []);
+
+ const handleBrowse = React.useCallback(async () => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ if (!isDesktopApp || !isTauriShell()) {
+ return;
+ }
+
+ const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__;
+ if (!tauri?.dialog?.open) {
+ return;
+ }
+
+ try {
+ const selected = await tauri.dialog.open({
+ title: 'Select opencode binary',
+ multiple: false,
+ directory: false,
+ });
+ if (typeof selected === 'string' && selected.trim().length > 0) {
+ setOpencodeBinary(selected.trim());
+ }
+ } catch {
+ // ignore
+ }
+ }, [isDesktopApp]);
+
+ const handleApplyPath = React.useCallback(async () => {
+ setIsRetrying(true);
+ try {
+ await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
+
+ // In desktop boot flow, always restart the entire Tauri app so Rust
+ // can re-evaluate the boot outcome with the updated binary path.
+ if (isTauriShell()) {
+ await restartDesktopApp();
+ return;
+ }
+
+ await fetch('/api/config/reload', { method: 'POST' });
+ } finally {
+ setTimeout(() => setIsRetrying(false), 1000);
+ }
+ }, [opencodeBinary]);
+
+ const handleCopy = React.useCallback(async () => {
+ const result = await copyTextToClipboard(INSTALL_COMMAND);
+ if (result.ok) {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } else {
+ console.error('Failed to copy:', result.error);
+ }
+ }, []);
+
+ const handleCheckAndContinue = React.useCallback(async () => {
+ setIsChecking(true);
+ setCheckError(null);
+ try {
+ const available = await checkCliAvailability();
+ if (available) {
+ // CLI is available, proceed to main screen
+ onCliAvailable?.();
+ } else {
+ setCheckError('OpenCode CLI is not ready yet. Please confirm installation is complete and try again.');
+ }
+ } catch (err) {
+ setCheckError(err instanceof Error ? err.message : 'Detection failed');
+ } finally {
+ setIsChecking(false);
+ }
+ }, [checkCliAvailability, onCliAvailable]);
+
+ const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
+ const binaryPlaceholder =
+ platform === 'windows'
+ ? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
+ : platform === 'linux'
+ ? '/home/you/.bun/bin/opencode'
+ : '/Users/you/.bun/bin/opencode';
+
+ return (
+
+
+
+
+
+
+
+
+ Setting Up OpenCode
+
+
+ Install OpenCode CLI to continue.
+
+
+
+ {platform === 'windows' && (
+
+
Windows setup (WSL recommended)
+
+ - Install WSL (if needed) with
wsl --install in PowerShell.
+ - Run the install command below inside your WSL terminal.
+ - If OpenChamber does not detect OpenCode automatically, set the binary path below.
+
+
+ )}
+
+
+
+ {copied ? (
+
+
+ Copied to clipboard
+
+ ) : (
+
+ )}
+
+
+
+
+ {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
+
+
+
+ {checkError && (
+
+ {checkError}
+
+ )}
+
+
+
+
+
+ Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
+
+
+
+
+
+
Already installed? Set the OpenCode CLI path:
+
+ setOpencodeBinary(e.target.value)}
+ placeholder={binaryPlaceholder}
+ disabled={isRetrying}
+ className="flex-1 font-mono text-xs"
+ />
+
+
+
+
Saves to OpenChamber settings and reloads OpenCode configuration.
+
+
+
+ {isFromRecovery && onSwitchToRemote && (
+
+
+ Prefer to use a remote server?
+
+
+
+ )}
+
+
+ {showHint && (
+
+ {platform === 'windows' ? (
+ <>
+
+ On Windows, install and run OpenCode in WSL for best compatibility.
+
+
+ If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode.
+
+ >
+ ) : (
+ <>
+
+ Already installed? Make sure opencode is in your PATH
+
+
+ or set OPENCODE_BINARY environment variable.
+
+
+ If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH.
+
+ >
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/ui/src/components/onboarding/OnboardingScreen.tsx b/packages/ui/src/components/onboarding/OnboardingScreen.tsx
index d07c3166..95db4a08 100644
--- a/packages/ui/src/components/onboarding/OnboardingScreen.tsx
+++ b/packages/ui/src/components/onboarding/OnboardingScreen.tsx
@@ -1,335 +1,96 @@
import React from 'react';
-import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
-import { isDesktopShell, isTauriShell, startDesktopWindowDrag } from '@/lib/desktop';
-import { Input } from '@/components/ui/input';
-import { Button } from '@/components/ui/button';
-import { updateDesktopSettings } from '@/lib/persistence';
-import { copyTextToClipboard } from '@/lib/clipboard';
+import { ChooserScreen } from './ChooserScreen';
+import { LocalSetupScreen } from './LocalSetupScreen';
+import { RecoveryScreen } from './RecoveryScreen';
+import type { RecoveryVariant } from './DesktopConnectionRecovery';
-const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
-const POLL_INTERVAL_MS = 3000;
-const DOCS_URL = 'https://opencode.ai/docs';
-const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
-
-type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
+export type OnboardingScreenMode = 'first-launch' | 'local-setup' | 'recovery';
type OnboardingScreenProps = {
+ /** Callback when user goes back from local-setup */
+ onBack?: () => void;
+ /** Callback when CLI becomes available */
onCliAvailable?: () => void;
+ /** Screen mode to render */
+ mode?: OnboardingScreenMode;
+ /** Recovery variant (only used when mode is 'recovery') */
+ recoveryVariant?: RecoveryVariant;
+ /** Host URL for recovery context */
+ recoveryHostUrl?: string;
+ /** Host label for recovery context */
+ recoveryHostLabel?: string;
+ /** Callback when user enters local setup from recovery */
+ onEnterLocalSetup?: () => void;
+ /** Callback when user wants to switch to remote (first-launch only) */
+ onChooseRemote?: () => void;
};
-function BashCommand({ onCopy }: { onCopy: () => void }) {
+export function OnboardingScreen({
+ onBack,
+ onCliAvailable,
+ mode = 'first-launch',
+ recoveryVariant = 'missing-default-host',
+ recoveryHostUrl,
+ recoveryHostLabel,
+ onEnterLocalSetup,
+}: OnboardingScreenProps) {
+ const [showRecoveryRemoteForm, setShowRecoveryRemoteForm] = React.useState(false);
+ const [recoveryEnteredLocalSetup, setRecoveryEnteredLocalSetup] = React.useState(false);
+
+ // Reset transient recovery subflow state when the flow identity changes, so
+ // stale local-setup or remote-form views don't bleed across prop updates.
+ React.useEffect(() => {
+ setRecoveryEnteredLocalSetup(false);
+ setShowRecoveryRemoteForm(false);
+ }, [mode, recoveryVariant, recoveryHostUrl, recoveryHostLabel]);
+
+ // Derive the effective mode: recovery → local-setup can fall through to the
+ // existing local-setup branch instead of getting stuck behind the early return.
+ const effectiveMode = recoveryEnteredLocalSetup ? 'local-setup' : mode;
+
+ // Recovery mode
+ if (effectiveMode === 'recovery') {
+ return (
+ setShowRecoveryRemoteForm(false)}
+ onSwitchToLocalFromRemote={() => {
+ setShowRecoveryRemoteForm(false);
+ setRecoveryEnteredLocalSetup(true);
+ }}
+ onEnterLocalSetup={() => {
+ setRecoveryEnteredLocalSetup(true);
+ onEnterLocalSetup?.();
+ }}
+ />
+ );
+ }
+
+ // Local-setup mode
+ if (effectiveMode === 'local-setup') {
+ return (
+ {
+ if (recoveryEnteredLocalSetup) {
+ setRecoveryEnteredLocalSetup(false);
+ } else {
+ onBack?.();
+ }
+ }}
+ onCliAvailable={onCliAvailable}
+ isFromRecovery={recoveryEnteredLocalSetup}
+ onSwitchToRemote={() => setShowRecoveryRemoteForm(true)}
+ />
+ );
+ }
+
+ // First-launch mode (default)
return (
-
-
- curl
- -fsSL
- https://opencode.ai/install
- |
- bash
-
-
-
- );
-}
-
-const HINT_DELAY_MS = 30000;
-
-export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
- const [copied, setCopied] = React.useState(false);
- const [showHint, setShowHint] = React.useState(false);
- const [isDesktopApp, setIsDesktopApp] = React.useState(false);
- const [isRetrying, setIsRetrying] = React.useState(false);
- const [opencodeBinary, setOpencodeBinary] = React.useState('');
- const [platform, setPlatform] = React.useState('unknown');
-
- React.useEffect(() => {
- const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
- return () => clearTimeout(timer);
- }, []);
-
- React.useEffect(() => {
- setIsDesktopApp(isDesktopShell());
- }, []);
-
- React.useEffect(() => {
- if (typeof navigator === 'undefined') {
- setPlatform('unknown');
- return;
- }
-
- const ua = navigator.userAgent || '';
- if (/Windows/i.test(ua)) {
- setPlatform('windows');
- return;
- }
- if (/Macintosh|Mac OS X/i.test(ua)) {
- setPlatform('macos');
- return;
- }
- if (/Linux/i.test(ua)) {
- setPlatform('linux');
- return;
- }
- setPlatform('unknown');
- }, []);
-
- React.useEffect(() => {
- let cancelled = false;
- void (async () => {
- try {
- const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } });
- if (!response.ok) return;
- const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown };
- if (!data || cancelled) return;
- const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : '';
- if (value) {
- setOpencodeBinary(value);
- }
- } catch {
- // ignore
- }
- })();
- return () => {
- cancelled = true;
- };
- }, []);
-
- const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
- if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
- return;
- }
- if (e.button !== 0) return;
- if (isDesktopApp) {
- await startDesktopWindowDrag();
- }
- }, [isDesktopApp]);
-
- const checkCliAvailability = React.useCallback(async (): Promise => {
- try {
- const response = await fetch('/health');
- if (!response.ok) return false;
- const data = await response.json();
- return data.openCodeRunning === true || data.isOpenCodeReady === true;
- } catch {
- return false;
- }
- }, []);
-
- const handleRetry = React.useCallback(async () => {
- setIsRetrying(true);
- try {
- await fetch('/api/config/reload', { method: 'POST' });
- } finally {
- setTimeout(() => setIsRetrying(false), 1000);
- }
- }, []);
-
- const handleBrowse = React.useCallback(async () => {
- if (typeof window === 'undefined') {
- return;
- }
- if (!isDesktopApp || !isTauriShell()) {
- return;
- }
-
- const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__;
- if (!tauri?.dialog?.open) {
- return;
- }
-
- try {
- const selected = await tauri.dialog.open({
- title: 'Select opencode binary',
- multiple: false,
- directory: false,
- });
- if (typeof selected === 'string' && selected.trim().length > 0) {
- setOpencodeBinary(selected.trim());
- }
- } catch {
- // ignore
- }
- }, [isDesktopApp]);
-
- const handleApplyPath = React.useCallback(async () => {
- setIsRetrying(true);
- try {
- await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
- await fetch('/api/config/reload', { method: 'POST' });
- } finally {
- setTimeout(() => setIsRetrying(false), 1000);
- }
- }, [opencodeBinary]);
-
- const handleCopy = React.useCallback(async () => {
- const result = await copyTextToClipboard(INSTALL_COMMAND);
- if (result.ok) {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } else {
- console.error('Failed to copy:', result.error);
- }
- }, []);
-
- React.useEffect(() => {
- const poll = async () => {
- const available = await checkCliAvailability();
- if (available) {
- onCliAvailable?.();
- }
- };
-
- const interval = setInterval(poll, POLL_INTERVAL_MS);
- poll();
-
- return () => clearInterval(interval);
- }, [checkCliAvailability, onCliAvailable]);
-
- const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
- const binaryPlaceholder =
- platform === 'windows'
- ? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
- : platform === 'linux'
- ? '/home/you/.bun/bin/opencode'
- : '/Users/you/.bun/bin/opencode';
-
- return (
-
-
-
-
- {platform === 'windows' && (
-
-
Windows setup (WSL recommended)
-
- - Install WSL (if needed) with
wsl --install in PowerShell.
- - Run the install command below inside your WSL terminal.
- - If OpenChamber does not detect OpenCode automatically, set the binary path below.
-
-
- )}
-
-
-
- {copied ? (
-
-
- Copied to clipboard
-
- ) : (
-
- )}
-
-
-
-
- {platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
-
-
-
-
- Waiting for OpenCode installation...
-
-
-
-
-
-
-
-
-
Already installed? Set the OpenCode CLI path:
-
- setOpencodeBinary(e.target.value)}
- placeholder={binaryPlaceholder}
- disabled={isRetrying}
- className="flex-1 font-mono text-xs"
- />
-
-
-
-
Saves to OpenChamber settings and reloads OpenCode configuration.
-
-
-
-
- {showHint && (
-
- {platform === 'windows' ? (
- <>
-
- On Windows, install and run OpenCode in WSL for best compatibility.
-
-
- If detection fails, set a native path (opencode.cmd/opencode.exe), wsl.exe, or wsl:/usr/local/bin/opencode.
-
- >
- ) : (
- <>
-
- Already installed? Make sure opencode is in your PATH
-
-
- or set OPENCODE_BINARY environment variable.
-
-
- If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH.
-
- >
- )}
-
- )}
-
+
);
}
diff --git a/packages/ui/src/components/onboarding/RecoveryScreen.tsx b/packages/ui/src/components/onboarding/RecoveryScreen.tsx
new file mode 100644
index 00000000..fc9b0c96
--- /dev/null
+++ b/packages/ui/src/components/onboarding/RecoveryScreen.tsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { isTauriShell, restartDesktopApp } from '@/lib/desktop';
+import { DesktopConnectionRecovery, type RecoveryVariant } from './DesktopConnectionRecovery';
+import { RemoteConnectionForm } from './RemoteConnectionForm';
+import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
+import { desktopHostsGet, desktopHostsSet } from '@/lib/desktopHosts';
+
+type RecoveryScreenProps = {
+ /** Recovery variant */
+ variant: RecoveryVariant;
+ /** Host URL for recovery context */
+ hostUrl?: string;
+ /** Host label for recovery context */
+ hostLabel?: string;
+ /** Callback when user wants to retry */
+ onRetry?: () => void;
+ /** Callback when user chooses remote */
+ onChooseRemote?: () => void;
+ /** Whether to show the remote connection form */
+ showRemoteForm?: boolean;
+ /** Callback when closing remote form */
+ onCloseRemoteForm?: () => void;
+ /** Callback when switching to local from remote form */
+ onSwitchToLocalFromRemote?: () => void;
+ /** Callback when entering local setup */
+ onEnterLocalSetup?: () => void;
+ /** Whether retry action is in progress */
+ isRetrying?: boolean;
+};
+
+export function RecoveryScreen({
+ variant,
+ hostUrl,
+ hostLabel,
+ onRetry,
+ onChooseRemote,
+ showRemoteForm = false,
+ onCloseRemoteForm,
+ onSwitchToLocalFromRemote,
+ onEnterLocalSetup,
+ isRetrying = false,
+}: RecoveryScreenProps) {
+ // Persist the user's first choice (local or remote)
+ const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
+ if (!isTauriShell()) return;
+
+ const config = await desktopHostsGet();
+ await desktopHostsSet({
+ ...config,
+ // Only change defaultHostId when switching to local; remote keeps
+ // whatever was there (or null) until a successful connect.
+ ...(choice === 'local' ? { defaultHostId: 'local' } : {}),
+ initialHostChoiceCompleted: true,
+ });
+ }, []);
+
+ const handleRecoveryRetry = React.useCallback(async () => {
+ // In desktop boot flow, always restart the entire Tauri app so Rust
+ // can re-evaluate the boot outcome.
+ if (isTauriShell()) {
+ await restartDesktopApp();
+ return;
+ }
+
+ await fetch('/api/config/reload', { method: 'POST' });
+ onRetry?.();
+ }, [onRetry]);
+
+ const handleRecoveryUseLocal = React.useCallback(async () => {
+ const step = resolveRecoveryNextStep(variant, 'use-local');
+ if (step.kind === 'local-setup') {
+ // local-unavailable + local → enter local-setup subflow without reload
+ onEnterLocalSetup?.();
+ return;
+ }
+ // switch-default-to-local → persist local choice and restart
+ await persistFirstChoice('local');
+
+ if (isTauriShell()) {
+ await restartDesktopApp();
+ return;
+ }
+
+ window.location.reload();
+ }, [variant, persistFirstChoice, onEnterLocalSetup]);
+
+ const handleRecoveryUseRemote = React.useCallback(() => {
+ const step = resolveRecoveryNextStep(variant, 'use-remote');
+ if (step.kind === 'remote-form') {
+ onChooseRemote?.();
+ }
+ }, [variant, onChooseRemote]);
+
+ // Recovery mode — show recovery component first; only switch to remote form on explicit user action
+ if (showRemoteForm) {
+ // For remote-wrong-service, do NOT auto-populate the known bad URL
+ const prefillUrl = variant === 'remote-wrong-service' ? '' : (hostUrl || '');
+ const prefillLabel = variant === 'remote-wrong-service' ? '' : (hostLabel || '');
+ return (
+ onChooseRemote?.())}
+ initialUrl={prefillUrl}
+ initialLabel={prefillLabel}
+ isRecoveryMode={true}
+ onSwitchToLocal={onSwitchToLocalFromRemote || (() => {
+ persistFirstChoice('local').then(() => {
+ if (isTauriShell()) {
+ restartDesktopApp();
+ } else {
+ onEnterLocalSetup?.();
+ }
+ });
+ })}
+ />
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
new file mode 100644
index 00000000..ae6677d2
--- /dev/null
+++ b/packages/ui/src/components/onboarding/RemoteConnectionForm.tsx
@@ -0,0 +1,318 @@
+import { useState, useCallback } from 'react';
+import {
+ desktopHostsGet,
+ desktopHostsSet,
+ desktopHostProbe,
+ normalizeHostUrl,
+ type HostProbeResult,
+} from '@/lib/desktopHosts';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { isTauriShell } from '@/lib/desktop';
+
+type ConnectionState = 'idle' | 'testing' | 'success' | 'error';
+
+export interface RemoteConnectionFormProps {
+ onBack: () => void;
+ /** Optional: show the back button (default: true) */
+ showBackButton?: boolean;
+ /** Optional: initial URL to pre-populate */
+ initialUrl?: string;
+ /** Optional: initial label to pre-populate */
+ initialLabel?: string;
+ /** Optional: show recovery mode styling/behavior */
+ isRecoveryMode?: boolean;
+ /** Optional: callback when successfully connected */
+ onConnect?: () => void;
+ /** Optional: callback when user wants to switch to local setup */
+ onSwitchToLocal?: () => void;
+}
+
+type ProbeStatus = HostProbeResult['status'] | null;
+
+function getProbeStatusMessage(status: ProbeStatus): string | null {
+ switch (status) {
+ case 'ok':
+ return null; // Success is shown separately
+ case 'auth':
+ return 'Server requires authentication. You can still connect, but may need to provide credentials.';
+ case 'wrong-service':
+ return 'Server responded but is not running OpenChamber. Verify the address points to an OpenChamber server.';
+ case 'unreachable':
+ return 'Server is unreachable. Check your network connection and verify the server address.';
+ default:
+ return null;
+ }
+}
+
+function isBlockingStatus(status: ProbeStatus): boolean {
+ return status === 'wrong-service' || status === 'unreachable';
+}
+
+export function RemoteConnectionForm({
+ onBack,
+ showBackButton = true,
+ initialUrl = '',
+ initialLabel = '',
+ isRecoveryMode = false,
+ onConnect,
+ onSwitchToLocal,
+}: RemoteConnectionFormProps) {
+ const [url, setUrl] = useState(initialUrl);
+ const [label, setLabel] = useState(initialLabel);
+ const [state, setState] = useState('idle');
+ const [probeResult, setProbeResult] = useState(null);
+ const [error, setError] = useState('');
+
+ const normalizedUrl = normalizeHostUrl(url);
+
+ const handleUrlChange = useCallback((e: React.ChangeEvent) => {
+ setUrl(e.target.value);
+ setState('idle');
+ setProbeResult(null);
+ setError('');
+ }, []);
+
+ const handleLabelChange = useCallback((e: React.ChangeEvent) => {
+ setLabel(e.target.value);
+ }, []);
+
+ const handleTest = useCallback(async () => {
+ if (!normalizedUrl) return;
+
+ setState('testing');
+ setProbeResult(null);
+ setError('');
+
+ try {
+ const result = await desktopHostProbe(normalizedUrl);
+ setProbeResult(result);
+ setState(result.status === 'ok' ? 'success' : 'error');
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Connection test failed');
+ setState('error');
+ }
+ }, [normalizedUrl]);
+
+ const handleConnect = useCallback(async () => {
+ if (!normalizedUrl) return;
+
+ setState('testing');
+ setProbeResult(null);
+ setError('');
+
+ try {
+ const probe = await desktopHostProbe(normalizedUrl);
+ setProbeResult(probe);
+
+ // Block connection on wrong-service or unreachable
+ if (isBlockingStatus(probe.status)) {
+ setState('error');
+ return;
+ }
+
+ const config = await desktopHostsGet();
+ const hostLabel = label.trim() || normalizedUrl;
+
+ const existingHost = config.hosts.find(
+ (h) => h.url === normalizedUrl
+ );
+
+ const hostId = existingHost ? existingHost.id : `host-${Date.now().toString(16)}`;
+
+ const newHost = {
+ id: hostId,
+ label: hostLabel,
+ url: normalizedUrl,
+ };
+
+ const updatedHosts = existingHost
+ ? config.hosts.map((h) => (h.id === hostId ? newHost : h))
+ : [...config.hosts, newHost];
+
+ // Set as default and mark initial choice completed
+ await desktopHostsSet({
+ hosts: updatedHosts,
+ defaultHostId: hostId,
+ initialHostChoiceCompleted: true,
+ });
+
+ onConnect?.();
+
+ if (isTauriShell()) {
+ const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record) => Promise } } }).__TAURI__;
+ await tauri?.core?.invoke?.('desktop_restart');
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to save connection');
+ setState('error');
+ }
+ }, [normalizedUrl, label, onConnect]);
+
+ const isTesting = state === 'testing';
+ const canTest = normalizedUrl !== null && !isTesting;
+ const canConnect = normalizedUrl !== null && !isTesting && !isBlockingStatus(probeResult?.status ?? null);
+
+ const probeMessage = getProbeStatusMessage(probeResult?.status ?? null);
+ const isSuccess = probeResult?.status === 'ok';
+ const isAuth = probeResult?.status === 'auth';
+ const isBlocking = isBlockingStatus(probeResult?.status ?? null);
+
+ return (
+
+
+ {showBackButton && (
+
+
+
+ )}
+
+
+
+ {isRecoveryMode ? 'Connect to a Different Server' : 'Connect to Remote Server'}
+
+
+ {isRecoveryMode
+ ? 'Enter the address of an OpenChamber server to connect to.'
+ : 'Enter the address of an OpenChamber server to connect to.'}
+
+
+
+
+
+ {/* Success message */}
+ {probeResult && isSuccess && (
+
+ Connected successfully ({probeResult.latencyMs}ms)
+
+ )}
+
+ {/* Auth warning (non-blocking) */}
+ {probeResult && isAuth && (
+
+ Server requires authentication. You can still connect.
+
+ )}
+
+ {/* Blocking errors */}
+ {probeResult && isBlocking && (
+
+
+
Connection Failed
+
{probeMessage}
+
+
+ {probeResult.status === 'unreachable'
+ ? 'Suggestions: Check the server address, verify the server is running, or check your network connection.'
+ : 'Suggestions: Verify the URL points to an OpenChamber server, or contact the server administrator.'}
+
+
+ )}
+
+ {/* Generic error */}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ {/* Suggested actions when connection is blocked */}
+ {isBlocking && (
+
+
What would you like to do?
+
+
+ {!isRecoveryMode && onSwitchToLocal && (
+
+ )}
+
+
+ )}
+
+
+ );
+}
diff --git a/packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts b/packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts
new file mode 100644
index 00000000..f963446f
--- /dev/null
+++ b/packages/ui/src/components/onboarding/desktopRecoveryConfig.test.ts
@@ -0,0 +1,248 @@
+import { describe, expect, test } from 'bun:test';
+import { getDesktopRecoveryConfig } from './desktopRecoveryConfig';
+
+describe('getDesktopRecoveryConfig', () => {
+ // ---------------------------------------------------------------------------
+ // 1. local-unavailable: both actions visible + retry labeled "Retry Local"
+ // ---------------------------------------------------------------------------
+ test('local-unavailable exposes both actions and Retry Local', () => {
+ const config = getDesktopRecoveryConfig('local-unavailable');
+
+ expect(config.title).toBe('Local OpenCode Unavailable');
+ expect(config.iconKey).toBe('local');
+ expect(config.showRetry).toBe(true);
+ expect(config.retryLabel).toBe('Retry Local');
+ expect(config.showUseLocal).toBe(true);
+ expect(config.showUseRemote).toBe(true);
+ // local-unavailable uses setup-oriented label since local needs installing
+ expect(config.useLocalLabel).toBe('Set Up Local');
+ expect(config.useRemoteLabel).toBe('Use Remote');
+ });
+
+ // ---------------------------------------------------------------------------
+ // 2. remote-unreachable: both actions + retry
+ // ---------------------------------------------------------------------------
+ test('remote-unreachable exposes both actions + retry', () => {
+ const config = getDesktopRecoveryConfig(
+ 'remote-unreachable',
+ 'My Server',
+ 'https://example.com:4096',
+ );
+
+ expect(config.title).toBe('Remote Server Unreachable');
+ expect(config.iconKey).toBe('remote');
+ expect(config.showRetry).toBe(true);
+ expect(config.retryLabel).toBe('Retry Connection');
+ expect(config.showUseLocal).toBe(true);
+ expect(config.showUseRemote).toBe(true);
+ // remote variants keep standard "Use Local"
+ expect(config.useLocalLabel).toBe('Use Local');
+ expect(config.useRemoteLabel).toBe('Use Remote');
+ });
+
+ // ---------------------------------------------------------------------------
+ // 3. remote-wrong-service: both actions, NO retry
+ // ---------------------------------------------------------------------------
+ test('remote-wrong-service exposes both actions and no retry', () => {
+ const config = getDesktopRecoveryConfig(
+ 'remote-wrong-service',
+ 'Bad Host',
+ 'https://wrong.example.com',
+ );
+
+ expect(config.title).toBe('Incompatible Server');
+ expect(config.iconKey).toBe('remote');
+ expect(config.showRetry).toBe(false);
+ expect(config.retryLabel).toBe(undefined);
+ expect(config.showUseLocal).toBe(true);
+ expect(config.showUseRemote).toBe(true);
+ expect(config.useLocalLabel).toBe('Use Local');
+ expect(config.useRemoteLabel).toBe('Use Remote');
+ });
+
+ // ---------------------------------------------------------------------------
+ // 4. missing-default-host: chooser-with-context (both actions, no retry)
+ // ---------------------------------------------------------------------------
+ test('missing-default-host behaves like chooser-with-context', () => {
+ const config = getDesktopRecoveryConfig('missing-default-host');
+
+ expect(config.title).toBe('No Default Connection');
+ expect(config.iconKey).toBe('local');
+ expect(config.showRetry).toBe(false);
+ expect(config.retryLabel).toBe(undefined);
+ expect(config.showUseLocal).toBe(true);
+ expect(config.showUseRemote).toBe(true);
+ expect(config.useLocalLabel).toBe('Use Local');
+ expect(config.useRemoteLabel).toBe('Use Remote');
+ });
+
+ // ---------------------------------------------------------------------------
+ // 5. descriptions redact sensitive query params for remote variants
+ // ---------------------------------------------------------------------------
+ test('remote-unreachable description redacts sensitive query params in URL', () => {
+ const sensitiveUrl =
+ 'https://example.com:4096?token=super-secret&auth=abc123';
+ const config = getDesktopRecoveryConfig(
+ 'remote-unreachable',
+ undefined,
+ sensitiveUrl,
+ );
+
+ // Secrets must never appear in the description
+ expect(config.description).not.toContain('super-secret');
+ expect(config.description).not.toContain('abc123');
+ // Redaction marker is present
+ expect(config.description).toContain('REDACTED');
+ expect(config.description).toContain('example.com');
+ });
+
+ test('remote-wrong-service description redacts sensitive query params in URL', () => {
+ const sensitiveUrl =
+ 'https://wrong.example.com?api_key=sk-12345&secret=mysecret';
+ const config = getDesktopRecoveryConfig(
+ 'remote-wrong-service',
+ undefined,
+ sensitiveUrl,
+ );
+
+ // Secrets must never appear in the description
+ expect(config.description).not.toContain('sk-12345');
+ expect(config.description).not.toContain('mysecret');
+ // Redaction marker is present
+ expect(config.description).toContain('REDACTED');
+ expect(config.description).toContain('wrong.example.com');
+ });
+
+ test('local-unavailable description does not reference host URL', () => {
+ const config = getDesktopRecoveryConfig(
+ 'local-unavailable',
+ 'Some Host',
+ 'https://example.com?token=secret',
+ );
+
+ // local-unavailable ignores hostUrl in its description
+ expect(config.description).not.toContain('example.com');
+ expect(config.description).not.toContain('secret');
+ });
+
+ test('missing-default-host description does not reference host URL', () => {
+ const config = getDesktopRecoveryConfig(
+ 'missing-default-host',
+ 'Some Host',
+ 'https://example.com?token=secret',
+ );
+
+ expect(config.description).not.toContain('example.com');
+ expect(config.description).not.toContain('secret');
+ });
+
+ // ---------------------------------------------------------------------------
+ // 6. URL-like hostLabel is also redacted (sensitive data leak prevention)
+ // ---------------------------------------------------------------------------
+ test('remote-unreachable redacts URL-like hostLabel containing sensitive query params', () => {
+ const urlAsLabel =
+ 'https://example.com:4096?token=super-secret&auth=abc123';
+ const config = getDesktopRecoveryConfig(
+ 'remote-unreachable',
+ urlAsLabel,
+ 'https://fallback.example.com',
+ );
+
+ // Secrets in hostLabel must never appear in the description
+ expect(config.description).not.toContain('super-secret');
+ expect(config.description).not.toContain('abc123');
+ // Redaction marker is present
+ expect(config.description).toContain('REDACTED');
+ expect(config.description).toContain('example.com');
+ });
+
+ test('remote-wrong-service redacts URL-like hostLabel containing sensitive query params', () => {
+ const urlAsLabel =
+ 'https://wrong.example.com?api_key=sk-12345&secret=mysecret';
+ const config = getDesktopRecoveryConfig(
+ 'remote-wrong-service',
+ urlAsLabel,
+ 'https://fallback.example.com',
+ );
+
+ // Secrets in hostLabel must never appear in the description
+ expect(config.description).not.toContain('sk-12345');
+ expect(config.description).not.toContain('mysecret');
+ // Redaction marker is present
+ expect(config.description).toContain('REDACTED');
+ expect(config.description).toContain('wrong.example.com');
+ });
+
+ test('remote-unreachable redacts embedded credentials in URL-like hostLabel', () => {
+ const urlWithCreds = 'https://admin:s3cret@example.com:4096';
+ const config = getDesktopRecoveryConfig(
+ 'remote-unreachable',
+ urlWithCreds,
+ 'https://fallback.example.com',
+ );
+
+ // Username/password must never appear in the description
+ expect(config.description).not.toContain('admin');
+ expect(config.description).not.toContain('s3cret');
+ // Hostname should still be visible
+ expect(config.description).toContain('example.com');
+ });
+
+ test('remote-wrong-service redacts embedded credentials in URL-like hostLabel', () => {
+ const urlWithCreds = 'https://user:pass123@wrong.example.com';
+ const config = getDesktopRecoveryConfig(
+ 'remote-wrong-service',
+ urlWithCreds,
+ 'https://fallback.example.com',
+ );
+
+ expect(config.description).not.toContain('user');
+ expect(config.description).not.toContain('pass123');
+ expect(config.description).toContain('wrong.example.com');
+ });
+
+ test('non-URL hostLabel is used as-is without redaction', () => {
+ const config = getDesktopRecoveryConfig(
+ 'remote-unreachable',
+ 'My Server',
+ 'https://example.com?token=secret',
+ );
+
+ // Plain label should appear verbatim
+ expect(config.description).toContain('My Server');
+ // hostUrl secrets should not leak (already tested above, but sanity check)
+ expect(config.description).not.toContain('secret');
+ });
+
+ // ---------------------------------------------------------------------------
+ // Fallback descriptions when no host info is provided
+ // ---------------------------------------------------------------------------
+ test('remote-unreachable falls back to generic text when no host info', () => {
+ const config = getDesktopRecoveryConfig('remote-unreachable');
+
+ expect(config.description).toContain('the remote server');
+ expect(config.description).not.toContain('undefined');
+ });
+
+ test('remote-wrong-service falls back to generic text when no host info', () => {
+ const config = getDesktopRecoveryConfig('remote-wrong-service');
+
+ expect(config.description).toContain('unknown');
+ });
+
+ // ---------------------------------------------------------------------------
+ // Whitespace-only hostLabel is treated as absent
+ // ---------------------------------------------------------------------------
+ test('whitespace-only hostLabel falls back to hostUrl', () => {
+ const config = getDesktopRecoveryConfig(
+ 'remote-unreachable',
+ ' ',
+ 'https://fallback.example.com?token=secret',
+ );
+
+ // hostLabel is whitespace-only → should use redacted hostUrl instead
+ expect(config.description).not.toContain('secret');
+ expect(config.description).toContain('fallback.example.com');
+ expect(config.description).toContain('REDACTED');
+ });
+});
diff --git a/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts
new file mode 100644
index 00000000..61f53325
--- /dev/null
+++ b/packages/ui/src/components/onboarding/desktopRecoveryConfig.ts
@@ -0,0 +1,109 @@
+import { redactSensitiveUrl } from '@/lib/desktopHosts';
+
+export type RecoveryVariant =
+ | 'local-unavailable'
+ | 'remote-unreachable'
+ | 'remote-wrong-service'
+ | 'remote-missing'
+ | 'missing-default-host';
+
+export type DesktopRecoveryConfig = {
+ title: string;
+ description: string;
+ iconKey: 'local' | 'remote';
+ showRetry: boolean;
+ retryLabel?: string;
+ showUseLocal: boolean;
+ showUseRemote: boolean;
+ /** Label for the "use local" primary action button */
+ useLocalLabel: string;
+ /** Label for the "use remote" primary action button */
+ useRemoteLabel: string;
+};
+
+function formatHostDisplay(hostLabel?: string, hostUrl?: string): string | undefined {
+ if (hostLabel?.trim()) return redactSensitiveUrl(hostLabel.trim());
+ if (hostUrl) return redactSensitiveUrl(hostUrl);
+ return undefined;
+}
+
+export function getDesktopRecoveryConfig(
+ variant: RecoveryVariant,
+ hostLabel?: string,
+ hostUrl?: string,
+): DesktopRecoveryConfig {
+ switch (variant) {
+ case 'local-unavailable':
+ return {
+ title: 'Local OpenCode Unavailable',
+ description:
+ 'OpenCode CLI could not be started or is not installed. Install OpenCode or connect to a remote server instead.',
+ iconKey: 'local',
+ showRetry: true,
+ retryLabel: 'Retry Local',
+ showUseLocal: true,
+ showUseRemote: true,
+ useLocalLabel: 'Set Up Local',
+ useRemoteLabel: 'Use Remote',
+ };
+
+ case 'remote-missing':
+ return {
+ title: 'No Default Connection',
+ description: 'Your saved default connection could not be found. Choose how you want to connect.',
+ iconKey: 'local',
+ showRetry: false,
+ showUseLocal: true,
+ showUseRemote: true,
+ useLocalLabel: 'Use Local',
+ useRemoteLabel: 'Use Remote',
+ };
+
+ case 'remote-unreachable': {
+ const host = formatHostDisplay(hostLabel, hostUrl);
+ return {
+ title: 'Remote Server Unreachable',
+ description: `Could not connect to "${host || 'the remote server'}". Check your network connection and verify the server address.`,
+ iconKey: 'remote',
+ showRetry: true,
+ retryLabel: 'Retry Connection',
+ showUseLocal: true,
+ showUseRemote: true,
+ useLocalLabel: 'Use Local',
+ useRemoteLabel: 'Use Remote',
+ };
+ }
+
+ case 'remote-wrong-service': {
+ const host = formatHostDisplay(hostLabel, hostUrl);
+ return {
+ title: 'Incompatible Server',
+ description: `The server at "${host || 'unknown'}" is not running OpenChamber. Verify the address points to an OpenChamber server.`,
+ iconKey: 'remote',
+ showRetry: false,
+ showUseLocal: true,
+ showUseRemote: true,
+ useLocalLabel: 'Use Local',
+ useRemoteLabel: 'Use Remote',
+ };
+ }
+
+ case 'missing-default-host':
+ return {
+ title: 'No Default Connection',
+ description: 'Your saved default connection could not be found. Choose how you want to connect.',
+ iconKey: 'local',
+ showRetry: false,
+ showUseLocal: true,
+ showUseRemote: true,
+ useLocalLabel: 'Use Local',
+ useRemoteLabel: 'Use Remote',
+ };
+
+ default: {
+ // TypeScript exhaustive check - this should never be reached
+ const exhaustive: never = variant;
+ throw new Error(`Unknown recovery variant: ${exhaustive}`);
+ }
+ }
+}
diff --git a/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts
new file mode 100644
index 00000000..b270f56c
--- /dev/null
+++ b/packages/ui/src/components/onboarding/desktopRecoveryRouting.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, test } from 'bun:test';
+import { resolveRecoveryNextStep } from './desktopRecoveryRouting';
+import type { RecoveryPrimaryAction, RecoveryNextStep } from './desktopRecoveryRouting';
+import type { RecoveryVariant } from './desktopRecoveryConfig';
+
+// ---------------------------------------------------------------------------
+// Compile-time completeness: this Record must list every RecoveryVariant key
+// and every RecoveryPrimaryAction key. Adding a new variant/action to the
+// union without updating this table will cause a type error.
+// ---------------------------------------------------------------------------
+const EXPECTED_ROUTING: Record> = {
+ 'local-unavailable': {
+ 'use-local': 'local-setup',
+ 'use-remote': 'remote-form',
+ },
+ 'remote-unreachable': {
+ 'use-local': 'switch-default-to-local',
+ 'use-remote': 'remote-form',
+ },
+ 'remote-wrong-service': {
+ 'use-local': 'switch-default-to-local',
+ 'use-remote': 'remote-form',
+ },
+ 'remote-missing': {
+ 'use-local': 'switch-default-to-local',
+ 'use-remote': 'remote-form',
+ },
+ 'missing-default-host': {
+ 'use-local': 'switch-default-to-local',
+ 'use-remote': 'remote-form',
+ },
+};
+
+describe('resolveRecoveryNextStep', () => {
+ for (const [variant, actions] of Object.entries(EXPECTED_ROUTING) as [
+ RecoveryVariant,
+ Record,
+ ][]) {
+ for (const [action, expectedKind] of Object.entries(actions) as [
+ RecoveryPrimaryAction,
+ RecoveryNextStep['kind'],
+ ][]) {
+ test(`${variant} + ${action} -> ${expectedKind}`, () => {
+ const result = resolveRecoveryNextStep(variant, action);
+ expect(result).toEqual({ kind: expectedKind });
+ });
+ }
+ }
+});
diff --git a/packages/ui/src/components/onboarding/desktopRecoveryRouting.ts b/packages/ui/src/components/onboarding/desktopRecoveryRouting.ts
new file mode 100644
index 00000000..5788150a
--- /dev/null
+++ b/packages/ui/src/components/onboarding/desktopRecoveryRouting.ts
@@ -0,0 +1,32 @@
+import type { RecoveryVariant } from './desktopRecoveryConfig';
+
+export type RecoveryPrimaryAction = 'use-local' | 'use-remote';
+
+export type RecoveryNextStep =
+ | { kind: 'local-setup' }
+ | { kind: 'switch-default-to-local' }
+ | { kind: 'remote-form' };
+
+export function resolveRecoveryNextStep(
+ variant: RecoveryVariant,
+ action: RecoveryPrimaryAction,
+): RecoveryNextStep {
+ if (action === 'use-remote') {
+ return { kind: 'remote-form' };
+ }
+
+ // action === 'use-local'
+ switch (variant) {
+ case 'local-unavailable':
+ return { kind: 'local-setup' };
+ case 'remote-unreachable':
+ case 'remote-wrong-service':
+ case 'remote-missing':
+ case 'missing-default-host':
+ return { kind: 'switch-default-to-local' };
+ default: {
+ const exhaustive: never = variant;
+ throw new Error(`Unhandled RecoveryVariant: ${exhaustive}`);
+ }
+ }
+}
diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts
index 8c26bf85..5701e842 100644
--- a/packages/ui/src/lib/desktop.ts
+++ b/packages/ui/src/lib/desktop.ts
@@ -458,12 +458,20 @@ export const restartToApplyUpdate = async (): Promise => {
return false;
}
+ return restartDesktopApp();
+};
+
+export const restartDesktopApp = async (): Promise => {
+ if (!isTauriShell()) {
+ return false;
+ }
+
try {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
await tauri?.core?.invoke?.('desktop_restart');
return true;
} catch (error) {
- console.warn('Failed to restart for update (tauri)', error);
+ console.warn('Failed to restart desktop app (tauri)', error);
return false;
}
};
diff --git a/packages/ui/src/lib/desktopBoot.test.ts b/packages/ui/src/lib/desktopBoot.test.ts
new file mode 100644
index 00000000..5621da74
--- /dev/null
+++ b/packages/ui/src/lib/desktopBoot.test.ts
@@ -0,0 +1,372 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ resolveDesktopBootView,
+ canDismissInitialLoading,
+ getInjectedBootOutcome,
+ getBootInjectionStatus,
+ shouldRestartDesktopBootFlow,
+} from './desktopBoot';
+
+describe('resolveDesktopBootView', () => {
+ test('returns chooser for first launch (not-configured)', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: { target: null, status: 'not-configured' },
+ }),
+ ).toEqual({ screen: 'chooser' });
+ });
+
+ test('returns recovery view for broken saved remote', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: {
+ target: 'remote',
+ status: 'unreachable',
+ hostId: 'remote-a',
+ url: 'https://x.test',
+ },
+ }),
+ ).toEqual({ screen: 'recovery', variant: 'remote-unreachable', hostId: 'remote-a', url: 'https://x.test' });
+ });
+
+ test('returns main for local ok', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: { target: 'local', status: 'ok' },
+ }),
+ ).toEqual({ screen: 'main' });
+ });
+
+ test('returns main with hostId for remote ok', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: { target: 'remote', status: 'ok', hostId: 'remote-1', url: 'https://example.com' },
+ }),
+ ).toEqual({ screen: 'main', hostId: 'remote-1', url: 'https://example.com' });
+ });
+
+ test('returns recovery-remote for remote wrong-service', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: {
+ target: 'remote',
+ status: 'wrong-service',
+ hostId: 'bad-host',
+ url: 'https://bad.test',
+ },
+ }),
+ ).toEqual({ screen: 'recovery', variant: 'remote-wrong-service', hostId: 'bad-host', url: 'https://bad.test' });
+ });
+
+ test('returns recovery view for local unreachable', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: { target: 'local', status: 'unreachable' },
+ }),
+ ).toEqual({ screen: 'recovery', variant: 'local-unreachable' });
+ });
+
+ test('returns recovery view for remote missing', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: { target: 'remote', status: 'missing', hostId: 'gone-1' },
+ }),
+ ).toEqual({ screen: 'recovery', variant: 'remote-missing', hostId: 'gone-1' });
+ });
+
+ test('returns null for non-desktop shell', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: false,
+ bootOutcome: { target: 'local', status: 'ok' },
+ }),
+ ).toBeNull();
+ });
+
+ test('returns null when no boot outcome and desktop shell', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ bootOutcome: null,
+ }),
+ ).toBeNull();
+ });
+});
+
+describe('canDismissInitialLoading', () => {
+ test('does not dismiss desktop loading before boot outcome is known', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: true,
+ bootOutcomeKnown: false,
+ }),
+ ).toBe(false);
+ });
+
+ test('dismisses desktop when main outcome is known and initialized', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: true,
+ bootOutcomeKnown: true,
+ bootViewIsMain: true,
+ }),
+ ).toBe(true);
+ });
+
+ test('does not dismiss desktop when main outcome is known but not initialized', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: false,
+ bootOutcomeKnown: true,
+ bootViewIsMain: true,
+ }),
+ ).toBe(false);
+ });
+
+ test('dismisses desktop for non-main outcome without waiting for init', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: false,
+ bootOutcomeKnown: true,
+ bootViewIsMain: false,
+ }),
+ ).toBe(true);
+ });
+
+ test('does not dismiss desktop for non-main outcome when outcome is not known', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: true,
+ bootOutcomeKnown: false,
+ bootViewIsMain: false,
+ }),
+ ).toBe(false);
+ });
+
+ test('dismisses non-desktop when initialized', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: false,
+ isInitialized: true,
+ bootOutcomeKnown: false,
+ }),
+ ).toBe(true);
+ });
+
+ test('does not dismiss non-desktop when not initialized', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: false,
+ isInitialized: false,
+ bootOutcomeKnown: false,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('shouldRestartDesktopBootFlow', () => {
+ test('restarts the desktop app when boot UI is running in the startup window', () => {
+ expect(
+ shouldRestartDesktopBootFlow({
+ isTauriShell: true,
+ isDesktopLocalOriginActive: false,
+ }),
+ ).toBe(true);
+ });
+
+ test('does not restart when the local desktop origin is already active', () => {
+ expect(
+ shouldRestartDesktopBootFlow({
+ isTauriShell: true,
+ isDesktopLocalOriginActive: true,
+ }),
+ ).toBe(false);
+ });
+
+ test('does not restart outside the tauri shell', () => {
+ expect(
+ shouldRestartDesktopBootFlow({
+ isTauriShell: false,
+ isDesktopLocalOriginActive: false,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('getInjectedBootOutcome', () => {
+ // Bun test runner does not provide `window`. Mock it for these tests.
+ const mockWindow = () => {
+ const w: Record = {};
+ (globalThis as Record).window = w;
+ return w;
+ };
+ const restoreWindow = () => {
+ delete (globalThis as Record).window;
+ };
+
+ test('returns null when window global is undefined', () => {
+ delete (globalThis as Record).window;
+ try {
+ expect(getInjectedBootOutcome()).toBeNull();
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns null for malformed payload with unknown kind', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'unknown-kind' };
+ try {
+ expect(getInjectedBootOutcome()).toBeNull();
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns null for payload missing required hostId', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-remote', url: 'https://x.test' };
+ try {
+ expect(getInjectedBootOutcome()).toBeNull();
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns null for non-object payload', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = 'not-an-object';
+ try {
+ expect(getInjectedBootOutcome()).toBeNull();
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns valid outcome for well-formed main-local', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' };
+ try {
+ expect(getInjectedBootOutcome()).toEqual({ kind: 'main-local' });
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns null for payload with numeric kind', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 42 };
+ try {
+ expect(getInjectedBootOutcome()).toBeNull();
+ } finally {
+ restoreWindow();
+ }
+ });
+});
+
+describe('resolveDesktopBootView validation', () => {
+ test('returns null for unknown kind via default branch', () => {
+ expect(
+ resolveDesktopBootView({
+ isDesktopShell: true,
+ // @ts-expect-error — testing unknown kind
+ bootOutcome: { kind: 'totally-unknown' },
+ }),
+ ).toBeNull();
+ });
+});
+
+describe('getBootInjectionStatus', () => {
+ const mockWindow = () => {
+ const w: Record = {};
+ (globalThis as Record).window = w;
+ return w;
+ };
+ const restoreWindow = () => {
+ delete (globalThis as Record).window;
+ };
+
+ test('returns "not-injected" when window is undefined', () => {
+ delete (globalThis as Record).window;
+ try {
+ expect(getBootInjectionStatus()).toBe('not-injected');
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns "not-injected" when global is absent', () => {
+ mockWindow();
+ // Do not set the global — it should be absent.
+ try {
+ expect(getBootInjectionStatus()).toBe('not-injected');
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns "not-injected" when global is explicitly null', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = null;
+ try {
+ expect(getBootInjectionStatus()).toBe('not-injected');
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns "malformed" when global is present but invalid', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'bad' };
+ try {
+ expect(getBootInjectionStatus()).toBe('malformed');
+ } finally {
+ restoreWindow();
+ }
+ });
+
+ test('returns "valid" when global is present and well-formed', () => {
+ const w = mockWindow();
+ w.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ = { kind: 'main-local' };
+ try {
+ expect(getBootInjectionStatus()).toBe('valid');
+ } finally {
+ restoreWindow();
+ }
+ });
+});
+
+describe('canDismissInitialLoading with malformed injection', () => {
+ test('does NOT dismiss desktop splash when injection is malformed', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: true,
+ bootOutcomeKnown: false,
+ }),
+ ).toBe(false);
+ });
+
+ test('dismisses desktop main outcome when valid and initialized', () => {
+ expect(
+ canDismissInitialLoading({
+ isDesktopShell: true,
+ isInitialized: true,
+ bootOutcomeKnown: true,
+ bootViewIsMain: true,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/ui/src/lib/desktopBoot.ts b/packages/ui/src/lib/desktopBoot.ts
new file mode 100644
index 00000000..adb074f1
--- /dev/null
+++ b/packages/ui/src/lib/desktopBoot.ts
@@ -0,0 +1,301 @@
+/**
+ * Authoritative desktop boot outcome types and UI-facing resolver.
+ *
+ * The Rust backend computes a `DesktopBootOutcome` at startup and injects
+ * it as `window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__`. This module provides
+ * pure functions to read that outcome and derive the minimal UI state
+ * needed for the loading/chooser/recovery/main decision.
+ */
+
+// ── Boot outcome (must match Rust injection) ──
+
+/**
+ * Structured boot outcome type.
+ *
+ * Instead of 8 magic string kinds, we use a structured type that clearly
+ * separates the target (local/remote/null) from the status (ok/not-configured/error).
+ *
+ * This makes it easier to add new states without updating multiple files and
+ * allows UI to reason about outcomes with simple status checks.
+ */
+export type DesktopBootOutcome =
+ // Main screens - CLI or remote connection is working
+ | { target: 'local'; status: 'ok' }
+ | { target: 'remote'; status: 'ok'; hostId: string; url: string }
+
+ // First launch - user hasn't made a choice yet
+ | { target: null; status: 'not-configured' }
+
+ // Recovery screens - something is wrong
+ | { target: 'local'; status: 'unreachable' }
+ | { target: 'remote'; status: 'unreachable'; hostId: string; url: string }
+ | { target: 'remote'; status: 'wrong-service'; hostId: string; url: string }
+ | { target: 'remote'; status: 'missing'; hostId: string };
+
+// ── UI-facing view ──
+
+export type DesktopBootView =
+ | { screen: 'main' }
+ | { screen: 'main'; hostId: string; url: string }
+ | { screen: 'chooser' }
+ | { screen: 'recovery'; variant: 'local-unavailable' }
+ | { screen: 'recovery'; variant: 'remote-unreachable'; hostId: string; url: string }
+ | { screen: 'recovery'; variant: 'remote-wrong-service'; hostId: string; url: string }
+ | { screen: 'recovery'; variant: 'remote-missing'; hostId: string };
+
+// ── Resolver inputs ──
+
+export type DesktopBootViewInput = {
+ isDesktopShell: boolean;
+ bootOutcome: DesktopBootOutcome | null;
+};
+
+// ── Public API ──
+
+/** Valid target values */
+const VALID_TARGETS = ['local', 'remote', null] as const;
+
+/** Valid status values */
+const VALID_STATUSES = ['ok', 'not-configured', 'unreachable', 'wrong-service', 'missing'] as const;
+
+/** Return type for `validateBootOutcome`. */
+type ValidationResult =
+ | { valid: true; outcome: DesktopBootOutcome }
+ | { valid: false };
+
+/**
+ * Runtime-validate a raw injected payload.
+ * Returns a tagged result so callers can distinguish "not set yet" (null raw)
+ * from "set but malformed" (valid: false).
+ */
+function validateBootOutcome(raw: unknown): ValidationResult {
+ if (!raw || typeof raw !== 'object') {
+ return { valid: false };
+ }
+
+ const record = raw as Record;
+ const target = record.target;
+ const status = record.status;
+
+ // Validate target
+ if (target !== null && (typeof target !== 'string' || !VALID_TARGETS.includes(target as never))) {
+ return { valid: false };
+ }
+
+ // Validate status
+ if (typeof status !== 'string' || !VALID_STATUSES.includes(status as never)) {
+ return { valid: false };
+ }
+
+ // Validate required fields per combination
+ if (target === 'remote' || target === 'local') {
+ if (status === 'ok' && target === 'local') {
+ // { target: 'local'; status: 'ok' } is valid
+ return { valid: true, outcome: { target: 'local', status: 'ok' } };
+ }
+
+ if (status === 'ok' && target === 'remote') {
+ // { target: 'remote'; status: 'ok' } requires hostId and url
+ if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
+ return { valid: false };
+ }
+ return { valid: true, outcome: { target: 'remote', status: 'ok', hostId: record.hostId, url: record.url } };
+ }
+
+ if (status === 'unreachable') {
+ if (target === 'local') {
+ // { target: 'local'; status: 'unreachable' } is valid
+ return { valid: true, outcome: { target: 'local', status: 'unreachable' } };
+ } else {
+ // { target: 'remote'; status: 'unreachable' } requires hostId and url
+ if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
+ return { valid: false };
+ }
+ return { valid: true, outcome: { target: 'remote', status: 'unreachable', hostId: record.hostId, url: record.url } };
+ }
+ }
+
+ if (status === 'wrong-service') {
+ if (target !== 'remote') return { valid: false };
+ if (typeof record.hostId !== 'string' || typeof record.url !== 'string') {
+ return { valid: false };
+ }
+ return { valid: true, outcome: { target: 'remote', status: 'wrong-service', hostId: record.hostId, url: record.url } };
+ }
+
+ if (status === 'missing') {
+ if (target !== 'remote') return { valid: false };
+ if (typeof record.hostId !== 'string') {
+ return { valid: false };
+ }
+ return { valid: true, outcome: { target: 'remote', status: 'missing', hostId: record.hostId } };
+ }
+ }
+
+ if (target === null) {
+ if (status === 'not-configured') {
+ // { target: null; status: 'not-configured' } is valid (first launch)
+ return { valid: true, outcome: { target: null, status: 'not-configured' } };
+ }
+
+ if (status === 'missing') {
+ // { target: null; status: 'missing' } would be redundant with not-configured
+ return { valid: false };
+ }
+ }
+
+ return { valid: false };
+}
+
+/**
+ * Derive the minimal UI view from the injected boot outcome.
+ *
+ * Returns `null` when not in desktop shell, when the outcome is not yet
+ * known, or when the injected payload is malformed.
+ */
+export function resolveDesktopBootView(
+ input: DesktopBootViewInput,
+): DesktopBootView | null {
+ if (!input.isDesktopShell) {
+ return null;
+ }
+
+ const outcome = input.bootOutcome;
+ if (!outcome) {
+ return null;
+ }
+
+ // Main screens - CLI or remote connection is working
+ if (outcome.status === 'ok') {
+ if (outcome.target === 'local') {
+ return { screen: 'main' };
+ } else if (outcome.target === 'remote') {
+ return { screen: 'main', hostId: outcome.hostId, url: outcome.url };
+ }
+ }
+
+ // First launch - user hasn't made a choice yet
+ if (outcome.target === null && outcome.status === 'not-configured') {
+ return { screen: 'chooser' };
+ }
+
+ // Recovery screens - something is wrong
+ if (outcome.target === 'local' && outcome.status === 'unreachable') {
+ return { screen: 'recovery', variant: 'local-unavailable' };
+ }
+
+ if (outcome.target === 'remote') {
+ if (outcome.status === 'unreachable') {
+ return { screen: 'recovery', variant: 'remote-unreachable', hostId: outcome.hostId, url: outcome.url };
+ } else if (outcome.status === 'wrong-service') {
+ return { screen: 'recovery', variant: 'remote-wrong-service', hostId: outcome.hostId, url: outcome.url };
+ } else if (outcome.status === 'missing') {
+ return { screen: 'recovery', variant: 'remote-missing', hostId: outcome.hostId };
+ }
+ }
+
+ // Unknown outcome — defensive null.
+ return null;
+}
+
+// ── Loading gate ──
+
+export type BootInjectionStatus =
+ | 'not-injected'
+ | 'malformed'
+ | 'valid';
+
+export type InitialLoadingState = {
+ isDesktopShell: boolean;
+ isInitialized: boolean;
+ bootOutcomeKnown: boolean;
+ /**
+ * Whether the resolved boot view is 'main'.
+ * When false (chooser/recovery), splash dismisses on bootOutcomeKnown alone.
+ * When true or absent, splash also requires isInitialized.
+ */
+ bootViewIsMain?: boolean;
+};
+
+export type DesktopBootFlowRestartInput = {
+ isTauriShell: boolean;
+ isDesktopLocalOriginActive: boolean;
+};
+
+/**
+ * Whether the initial loading screen can be dismissed.
+ *
+ * Desktop shells must wait until a valid boot outcome is injected by Rust.
+ * For non-main views (chooser, recovery), the splash can dismiss as soon as
+ * the outcome is known — `isInitialized` is not required because OpenCode
+ * may not be available in those flows.
+ * For main views, both `isInitialized` and `bootOutcomeKnown` are required.
+ * Non-desktop shells only need the app to be initialized.
+ */
+export function canDismissInitialLoading(state: InitialLoadingState): boolean {
+ if (!state.isDesktopShell) {
+ return state.isInitialized;
+ }
+
+ if (!state.bootOutcomeKnown) {
+ return false;
+ }
+
+ // Non-main boot views (chooser, recovery) can dismiss without waiting for init.
+ if (state.bootViewIsMain === false) {
+ return true;
+ }
+
+ return state.isInitialized;
+}
+
+/**
+ * Boot/recovery UI can render in the Tauri startup window before the local
+ * desktop HTTP origin is active. In that state, same-origin reloads and
+ * `/api/*` requests cannot recover the app, so callers must restart Tauri.
+ */
+export function shouldRestartDesktopBootFlow(input: DesktopBootFlowRestartInput): boolean {
+ return input.isTauriShell && !input.isDesktopLocalOriginActive;
+}
+
+/**
+ * Read the boot outcome injected by the Rust backend.
+ * Returns `null` when not in desktop, when the outcome has not been set yet,
+ * or when the injected payload is malformed.
+ */
+export function getInjectedBootOutcome(): DesktopBootOutcome | null {
+ const status = getBootInjectionStatus();
+ if (status !== 'valid') {
+ return null;
+ }
+
+ const raw = (window as { __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: unknown })
+ .__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__;
+
+ const result = validateBootOutcome(raw);
+ return result.valid ? result.outcome : null;
+}
+
+/**
+ * Check the injection status of the desktop boot outcome.
+ *
+ * Distinguishes three states:
+ * - `'not-injected'`: the global is absent or null (keep waiting)
+ * - `'malformed'`: the global is present but failed validation (deterministic failure)
+ * - `'valid'`: the global is present and passes validation
+ */
+export function getBootInjectionStatus(): BootInjectionStatus {
+ if (typeof window === 'undefined') {
+ return 'not-injected';
+ }
+
+ const raw = (window as { __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: unknown })
+ .__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__;
+
+ if (raw === undefined || raw === null) {
+ return 'not-injected';
+ }
+
+ const result = validateBootOutcome(raw);
+ return result.valid ? 'valid' : 'malformed';
+}
diff --git a/packages/ui/src/lib/desktopHosts.ts b/packages/ui/src/lib/desktopHosts.ts
index f74ef703..280ba08f 100644
--- a/packages/ui/src/lib/desktopHosts.ts
+++ b/packages/ui/src/lib/desktopHosts.ts
@@ -17,10 +17,18 @@ export type DesktopHost = {
export type DesktopHostsConfig = {
hosts: DesktopHost[];
defaultHostId: string | null;
+ initialHostChoiceCompleted: boolean;
+};
+
+/** Backward-compatible input type — callers may omit `initialHostChoiceCompleted`. */
+export type DesktopHostsConfigInput = {
+ hosts: DesktopHost[];
+ defaultHostId: string | null;
+ initialHostChoiceCompleted?: boolean;
};
export type HostProbeResult = {
- status: 'ok' | 'auth' | 'unreachable';
+ status: 'ok' | 'auth' | 'wrong-service' | 'unreachable';
latencyMs: number;
};
@@ -48,6 +56,12 @@ export const redactSensitiveUrl = (raw: string): string => {
try {
const url = new URL(normalized);
+ // Redact embedded credentials (userinfo) to prevent leaking user:pass
+ if (url.username || url.password) {
+ url.username = '';
+ url.password = '';
+ }
+
const keys = Array.from(new Set(Array.from(url.searchParams.keys())));
for (const key of keys) {
if (SENSITIVE_QUERY_KEY.test(key)) {
@@ -121,12 +135,12 @@ const getInvoke = (): TauriInvoke | null => {
export const desktopHostsGet = async (): Promise => {
const invoke = getInvoke();
if (!invoke) {
- return { hosts: [], defaultHostId: 'local' };
+ return { hosts: [], defaultHostId: 'local', initialHostChoiceCompleted: false };
}
const raw = await invoke('desktop_hosts_get');
if (!isRecord(raw)) {
- return { hosts: [], defaultHostId: null };
+ return { hosts: [], defaultHostId: null, initialHostChoiceCompleted: false };
}
const hostsRaw = raw.hosts;
@@ -139,16 +153,20 @@ export const desktopHostsGet = async (): Promise => {
readString(raw, 'default_host_id') ||
readString(raw, 'defaultHostID');
- return { hosts, defaultHostId };
+ const initialHostChoiceCompleted =
+ raw.initialHostChoiceCompleted === true || raw.initial_host_choice_completed === true;
+
+ return { hosts, defaultHostId, initialHostChoiceCompleted };
};
-export const desktopHostsSet = async (config: DesktopHostsConfig): Promise => {
+export const desktopHostsSet = async (config: DesktopHostsConfigInput): Promise => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_hosts_set', {
- config: {
+ input: {
hosts: config.hosts,
defaultHostId: config.defaultHostId,
+ initialHostChoiceCompleted: config.initialHostChoiceCompleted,
},
});
};
@@ -166,7 +184,7 @@ export const desktopHostProbe = async (url: string): Promise =>
const rawStatus = raw.status;
const status: HostProbeResult['status'] =
- rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'unreachable'
+ rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'wrong-service' || rawStatus === 'unreachable'
? rawStatus
: 'unreachable';
diff --git a/packages/ui/src/types/bun-test.d.ts b/packages/ui/src/types/bun-test.d.ts
new file mode 100644
index 00000000..429350a7
--- /dev/null
+++ b/packages/ui/src/types/bun-test.d.ts
@@ -0,0 +1,24 @@
+// Minimal type declarations for bun:test to satisfy tsc.
+// Only the subset used by our test files is declared.
+
+declare module "bun:test" {
+ export function describe(name: string, fn: () => void): void;
+ export function test(name: string, fn: () => void | Promise): void;
+ export function expect(value: unknown): {
+ toEqual(expected: unknown): void;
+ toBe(expected: unknown): void;
+ toBeTruthy(): void;
+ toBeFalsy(): void;
+ toBeNull(): void;
+ toThrow(expected?: string | RegExp): void;
+ toContain(expected: unknown): void;
+ toBeGreaterThan(expected: number): void;
+ toBeLessThan(expected: number): void;
+ toHaveLength(expected: number): void;
+ not: {
+ toEqual(expected: unknown): void;
+ toBe(expected: unknown): void;
+ toContain(expected: unknown): void;
+ };
+ };
+}
diff --git a/packages/ui/src/types/desktop.d.ts b/packages/ui/src/types/desktop.d.ts
index 361cd17a..10410225 100644
--- a/packages/ui/src/types/desktop.d.ts
+++ b/packages/ui/src/types/desktop.d.ts
@@ -1,8 +1,11 @@
+import type { DesktopBootOutcome } from '@/lib/desktopBoot';
+
declare global {
interface Window {
__OPENCHAMBER_HOME__?: string;
__OPENCHAMBER_MACOS_MAJOR__?: number;
__OPENCHAMBER_LOCAL_ORIGIN__?: string;
+ __OPENCHAMBER_DESKTOP_BOOT_OUTCOME__?: DesktopBootOutcome;
}
}
diff --git a/packages/web/index.html b/packages/web/index.html
index e0b7f4ea..79d23e01 100644
--- a/packages/web/index.html
+++ b/packages/web/index.html
@@ -501,9 +501,14 @@