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
+372
View File
@@ -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<string, unknown> = {};
(globalThis as Record<string, unknown>).window = w;
return w;
};
const restoreWindow = () => {
delete (globalThis as Record<string, unknown>).window;
};
test('returns null when window global is undefined', () => {
delete (globalThis as Record<string, unknown>).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<string, unknown> = {};
(globalThis as Record<string, unknown>).window = w;
return w;
};
const restoreWindow = () => {
delete (globalThis as Record<string, unknown>).window;
};
test('returns "not-injected" when window is undefined', () => {
delete (globalThis as Record<string, unknown>).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);
});
});