Files
openchamber/packages/ui/src/components/onboarding/LocalSetupScreen.tsx
T

364 lines
12 KiB
TypeScript
Raw Normal View History

import React from 'react';
import { isDesktopShell, requestFileAccess, startDesktopWindowDrag } from '@/lib/desktop';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Icon } from "@/components/icon/Icon";
import { updateDesktopSettings } from '@/lib/persistence';
import { copyTextToClipboard } from '@/lib/clipboard';
import { restartDesktopApp } from '@/lib/desktop';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const DOCS_URL = 'https://opencode.ai/docs';
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, copyTitle }: { onCopy: () => void; copyTitle: string }) {
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={copyTitle}
>
<Icon name="file-copy" className="h-4 w-4" />
</button>
</div>
);
}
const HINT_DELAY_MS = 30000;
export function LocalSetupScreen({
onBack,
onCliAvailable,
isFromRecovery = false,
onSwitchToRemote,
}: LocalSetupScreenProps) {
const { t } = useI18n();
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');
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 runtimeFetch('/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<boolean> => {
try {
const response = await runtimeFetch('/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) {
return;
}
try {
const selected = await requestFileAccess();
if (selected.success && selected.path && selected.path.trim().length > 0) {
setOpencodeBinary(selected.path.trim());
}
} catch {
// ignore
}
}, [isDesktopApp]);
const handleApplyPath = React.useCallback(async () => {
setIsRetrying(true);
try {
await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() });
// In desktop boot flow, restart the app so the native host can
// re-evaluate the boot outcome with the updated binary path.
if (isDesktopApp) {
await restartDesktopApp();
return;
}
await runtimeFetch('/api/config/reload', { method: 'POST' });
} finally {
setTimeout(() => setIsRetrying(false), 1000);
}
}, [isDesktopApp, 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(t('onboarding.localSetup.errors.cliNotReady'));
}
} catch (err) {
setCheckError(err instanceof Error ? err.message : t('onboarding.localSetup.errors.detectionFailed'));
} finally {
setIsChecking(false);
}
}, [checkCliAvailability, onCliAvailable, t]);
const docsUrl = 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 max-w-lg space-y-4 text-center">
<div className="flex items-center">
<Button
variant="ghost"
onClick={onBack}
className="p-0 text-muted-foreground hover:text-foreground"
>
{t('onboarding.common.actions.back')}
</Button>
</div>
<div className="space-y-4">
<h1 className="text-3xl font-semibold tracking-tight text-foreground">
{t('onboarding.localSetup.title')}
</h1>
<p className="text-muted-foreground">
{t('onboarding.localSetup.description')}
</p>
</div>
{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">{t('onboarding.localSetup.windows.title')}</div>
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
<li>{t('onboarding.localSetup.windows.stepRunInstallInWsl')}</li>
<li>{t('onboarding.localSetup.windows.stepSetBinaryPath')}</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)' }}>
<Icon name="check" className="h-4 w-4" />
{t('onboarding.common.status.copiedToClipboard')}
</div>
) : (
<BashCommand onCopy={handleCopy} copyTitle={t('onboarding.common.copyToClipboard')} />
)}
</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' ? t('onboarding.localSetup.docs.windows') : t('onboarding.localSetup.docs.default')}
<Icon name="external-link" 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 ? t('onboarding.localSetup.actions.checking') : t('onboarding.localSetup.actions.checkAndContinue')}
</Button>
<p className="text-xs text-muted-foreground">
{t('onboarding.localSetup.helper.checkAndContinue')}
</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">{t('onboarding.localSetup.field.alreadyInstalled')}</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}
>
{t('onboarding.localSetup.actions.browse')}
</Button>
<Button
type="button"
onClick={handleApplyPath}
disabled={isRetrying}
>
{t('onboarding.localSetup.actions.apply')}
</Button>
</div>
<div className="text-xs text-muted-foreground/70">{t('onboarding.localSetup.helper.saveAndReload')}</div>
</div>
</div>
{isFromRecovery && onSwitchToRemote && (
<div className="text-center pt-4">
<p className="text-sm text-muted-foreground mb-2">
{t('onboarding.localSetup.remotePreference')}
</p>
<Button
variant="link"
onClick={onSwitchToRemote}
>
{t('onboarding.localSetup.actions.connectRemoteServer')}
</Button>
</div>
)}
</div>
{showHint && (
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
{platform === 'windows' ? (
<>
<p className="text-sm text-muted-foreground/70">
{t('onboarding.localSetup.windows.hintDetectionFailed')}
</p>
</>
) : (
<>
<p className="text-sm text-muted-foreground/70">
{t('onboarding.localSetup.hint.ensurePath')}
</p>
<p className="text-sm text-muted-foreground/70">
{t('onboarding.localSetup.hint.setEnv')}
</p>
<p className="text-sm text-muted-foreground/70">
{t('onboarding.localSetup.hint.missingRuntime')}
</p>
</>
)}
</div>
)}
</div>
);
}