Files
openchamber/packages/ui/src/lib/desktopBoot.ts
T
jwcrystalandBohdan Triapitsyn 9b169aaacf 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>
2026-04-14 20:32:59 +03:00

302 lines
9.9 KiB
TypeScript

/**
* 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<string, unknown>;
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';
}