feat: deliver polished desktop first-launch experience with smart recovery (#850)

* feat: implement desktop boot outcome architecture

- Add structured DesktopBootOutcome with target/status fields
- Implement boot outcome computation and validation
- Add desktop hosts configuration management (Tauri + TypeScript)
- Add desktop hosts probing with timeout and retry logic
- Support local/remote host classification and health checks

This provides the foundational infrastructure for desktop onboarding
flow to determine whether to show local setup, remote connection,
or recovery screens based on OpenCode availability and remote host
reachability.

* feat: add desktop onboarding UI components

Add comprehensive onboarding flow for desktop app:

- ChooserScreen: First-launch local/remote selection
- LocalSetupScreen: CLI installation guidance and manual detection
- RecoveryScreen: Recovery mode with routing to local/remote
- RemoteConnectionForm: Remote host connection with validation
- DesktopConnectionRecovery: Recovery variants and routing logic
- ConnectionSettingsPage: Manage remote connections

Components handle:
- Local vs remote choice persistence
- Recovery scenarios (unreachable, wrong-service, missing)
- Manual CLI detection (replaced auto-polling)
- Back navigation and state preservation

* feat: integrate desktop onboarding with app shell

- Update App.tsx to handle onboarding routing and recovery
- Add onboarding mode switching (first-launch/local-setup/recovery)
- Integrate desktop hosts in SettingsView
- Update DesktopHostSwitcher with recovery routing
- Add desktop shell utilities for onboarding detection
- Update web manifest for desktop app metadata

Completes the desktop onboarding feature integration,
allowing users to choose local or remote OpenCode on
first launch and recover from connection failures.

* fix: hide back button in remote connection form for first-launch chooser

In first-launch chooser mode, the back button is redundant since users
can simply click the "Local Install" tab. The back button is still shown
in recovery mode where there's no tab interface.

Changes:
- Add showBackButton prop to RemoteConnectionForm (default: true)
- Set showBackButton={false} in ChooserScreen remote tab
- Keep showBackButton={true} in RecoveryScreen for navigation

* refactor: remove Connection Settings page and simplify recovery UI

Remove the Connection Settings page as it was redundant:
- Local server is single-instance (no need to "choose")
- Remote servers are one-time setup (first-launch chooser)
- SSH Instances remain for multi-instance management

Changes:
- Remove ConnectionSettingsPage component and directory
- Remove 'connection' from Settings metadata
- Remove "Open Settings" button from recovery screens
- Remove desktopBootBypassToSettings state and logic
- Update recovery config to use 'local' icon instead of 'settings'
- Update tests to reflect removed showOpenSettings field

This simplifies the UX by focusing on:
- First-launch chooser for initial local/remote decision
- Remote Instances (SSH) for managing multiple remote machines
- No persistent "server management" needed for typical desktop usage

* fix: remove unused enableCliPolling prop and clean up TypeScript errors

Remove the obsolete enableCliPolling prop that was used for auto-
polling CLI detection. We replaced this with manual "Check and Continue"
button in a previous commit, so this prop is no longer needed.

Changes:
- Remove enableCliPolling from OnboardingScreen props and usage
- Remove enableCliPolling from App.tsx calls
- Remove unused 'connection' case from getSettingsNavIcon()
- Remove unused RiGlobalLine import

This resolves all TypeScript compilation errors reported by Copilot.

* fix: remove unused onChooseLocal prop and CLI_MISSING_ERROR_REGEX

These were left over from the refactoring:
- onChooseLocal in RecoveryScreen was defined but never used
- CLI_MISSING_ERROR_REGEX in App.tsx was leftover from removed enableCliPolling code

* fix: remove unused variables and fix React Hook dependency warnings

Remove unused memoized components and variables that were causing
lint errors in packages/ui:

- MainLayout.tsx: Remove unused MemoHeader, MemoChatView, MemoPlanView,
  MemoGitView, MemoDiffView, MemoTerminalView, MemoFilesView,
  MemoRightSidebarTabs, DesktopLeftSidebar, and DesktopRightPanel
- useGitHubPrStatusStore.ts: Remove unused prVisualPriority function
- useChatScrollManager.ts: Add missing markProgrammaticScroll dependency
  to React.useEffect hook

These fixes resolve the CI lint failures in PR 850.

* chore: remove local claude settings from repo

* refactor(desktop): drop vibrancy code from onboarding PR

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
jwcrystal
2026-04-14 20:32:59 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent bb1d522838
commit 9b169aaacf
22 changed files with 3851 additions and 568 deletions
@@ -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 (
<div className="flex items-center justify-center gap-3">
<code>
<span style={{ color: 'var(--syntax-keyword)' }}>curl</span>
<span className="text-muted-foreground"> -fsSL </span>
<span style={{ color: 'var(--syntax-string)' }}>https://opencode.ai/install</span>
<span className="text-muted-foreground"> | </span>
<span style={{ color: 'var(--syntax-keyword)' }}>bash</span>
</code>
<button
onClick={onCopy}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title="Copy to clipboard"
>
<RiFileCopyLine className="h-4 w-4" />
</button>
</div>
);
}
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<string | null>(null);
const [opencodeBinary, setOpencodeBinary] = React.useState('');
const [platform, setPlatform] = React.useState<OnboardingPlatform>('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<boolean> => {
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<string, unknown>) => Promise<unknown> } } }).__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 (
<div
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
onMouseDown={handleDragStart}
>
<div className="w-full space-y-4 text-center">
<div className="space-y-4">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
Welcome to OpenChamber
</h1>
<p className="text-muted-foreground">
Choose how you want to connect to get started.
</p>
</div>
{isDesktopApp && isTauriShell() && (
<div className="flex gap-2 justify-center">
<button
type="button"
className={cn(
'flex-1 max-w-[200px] px-4 py-2.5 rounded-lg border transition-all text-sm',
activeTab === 'local'
? 'border-[var(--interactive-selection)] text-foreground bg-[var(--interactive-selection)]/10'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
)}
onClick={() => setActiveTab('local')}
>
Local Install
</button>
<button
type="button"
className={cn(
'flex-1 max-w-[200px] px-4 py-2.5 rounded-lg border transition-all text-sm',
activeTab === 'remote'
? 'border-[var(--interactive-selection)] text-foreground bg-[var(--interactive-selection)]/10'
: 'border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground'
)}
onClick={handleChooseRemote}
>
Connect Remote
</button>
</div>
)}
{isDesktopApp && isTauriShell() && activeTab === 'remote' ? (
<RemoteConnectionForm
onBack={() => setActiveTab('local')}
showBackButton={false}
onSwitchToLocal={() => setActiveTab('local')}
/>
) : (
<>
{(!isDesktopApp || !isTauriShell() || activeTab === 'local') && (
<>
{platform === 'windows' && (
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
<li>Run the install command below inside your WSL terminal.</li>
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
</ol>
</div>
)}
<div className="flex justify-center">
<div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
{copied ? (
<div className="flex items-center justify-center gap-2" style={{ color: 'var(--status-success)' }}>
<RiCheckLine className="h-4 w-4" />
Copied to clipboard
</div>
) : (
<BashCommand onCopy={handleCopy} />
)}
</div>
</div>
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
>
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
<RiExternalLinkLine className="h-3 w-3" />
</a>
{checkError && (
<div className="mx-auto max-w-md rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{checkError}
</div>
)}
<div className="space-y-3">
<Button
type="button"
onClick={handleCheckAndContinue}
disabled={isChecking}
className="w-full max-w-xs"
size="lg"
>
{isChecking ? 'Checking...' : "I've completed installation, check and continue"}
</Button>
<p className="text-xs text-muted-foreground">
Click to check if OpenCode CLI is available. If successful, you'll automatically enter the main screen.
</p>
</div>
<div className="mx-auto w-full max-w-xl pt-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">Already installed? Set the OpenCode CLI path:</div>
<div className="flex gap-2">
<Input
value={opencodeBinary}
onChange={(e) => setOpencodeBinary(e.target.value)}
placeholder={binaryPlaceholder}
disabled={isRetrying}
className="flex-1 font-mono text-xs"
/>
<Button
type="button"
variant="secondary"
onClick={handleBrowse}
disabled={isRetrying || !isDesktopApp || !isTauriShell()}
>
Browse
</Button>
<Button
type="button"
onClick={handleApplyPath}
disabled={isRetrying}
>
Apply
</Button>
</div>
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
</div>
</div>
</>
)}
</>
)}
</div>
{showHint && activeTab === 'local' && (
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
{platform === 'windows' ? (
<>
<p className="text-sm text-muted-foreground/70">
On Windows, install and run OpenCode in WSL for best compatibility.
</p>
<p className="text-sm text-muted-foreground/70">
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground/70">
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
</p>
<p className="text-sm text-muted-foreground/70">
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
</p>
<p className="text-sm text-muted-foreground/70">
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
</p>
</>
)}
</div>
)}
</div>
);
}