feat: auto-detect OpenCode CLI on onboarding screen
Polls every 2.5s and transitions automatically once the CLI is reachable Advanced settings and troubleshooting moved into collapsible accordions New i18n keys added for status indicator and section titles across 6 locales
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
|
||||
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
|
||||
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine, RiArrowDownSLine } 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';
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
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';
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
|
||||
type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
|
||||
|
||||
@@ -24,8 +25,8 @@ type ChooserScreenProps = {
|
||||
|
||||
function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<code>
|
||||
<div className="flex items-center justify-between gap-3 w-full">
|
||||
<code className="flex-1 text-left overflow-x-auto whitespace-nowrap">
|
||||
<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>
|
||||
@@ -34,8 +35,9 @@ function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: str
|
||||
</code>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
title={copyTitle}
|
||||
aria-label={copyTitle}
|
||||
>
|
||||
<RiFileCopyLine className="h-4 w-4" />
|
||||
</button>
|
||||
@@ -43,24 +45,17 @@ function BashCommand({ onCopy, copyTitle }: { onCopy: () => void; copyTitle: str
|
||||
);
|
||||
}
|
||||
|
||||
const HINT_DELAY_MS = 30000;
|
||||
|
||||
export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
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 [isApplyingPath, setIsApplyingPath] = React.useState(false);
|
||||
const [isManualChecking, setIsManualChecking] = React.useState(false);
|
||||
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);
|
||||
}, []);
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
const [troubleOpen, setTroubleOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsDesktopApp(isDesktopShell());
|
||||
@@ -73,19 +68,10 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
}
|
||||
|
||||
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');
|
||||
if (/Windows/i.test(ua)) setPlatform('windows');
|
||||
else if (/Macintosh|Mac OS X/i.test(ua)) setPlatform('macos');
|
||||
else if (/Linux/i.test(ua)) setPlatform('linux');
|
||||
else setPlatform('unknown');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -97,9 +83,7 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
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);
|
||||
}
|
||||
if (value) setOpencodeBinary(value);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -110,18 +94,12 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
}, []);
|
||||
|
||||
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) {
|
||||
return;
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.app-region-no-drag')) return;
|
||||
if (target.closest('button, a, input, select, textarea, code, summary, details')) 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);
|
||||
}
|
||||
if (isDesktopApp) {
|
||||
await startDesktopWindowDrag();
|
||||
}
|
||||
}, [isDesktopApp]);
|
||||
|
||||
@@ -136,18 +114,74 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistFirstChoice = React.useCallback(async (choice: 'local' | 'remote') => {
|
||||
if (!isTauriShell()) return;
|
||||
|
||||
const config = await desktopHostsGet();
|
||||
await desktopHostsSet({
|
||||
...config,
|
||||
...(choice === 'local' ? { defaultHostId: 'local' } : {}),
|
||||
initialHostChoiceCompleted: true,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const announceAvailable = React.useCallback(async () => {
|
||||
if (isTauriShell()) {
|
||||
await persistFirstChoice('local');
|
||||
}
|
||||
onCliAvailable?.();
|
||||
}, [onCliAvailable, persistFirstChoice]);
|
||||
|
||||
// Background polling: while the local tab is visible, periodically check
|
||||
// whether the OpenCode CLI is reachable. As soon as it is, transition
|
||||
// automatically — the user doesn't have to click anything.
|
||||
React.useEffect(() => {
|
||||
if (activeTab !== 'local') return;
|
||||
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const available = await checkCliAvailability();
|
||||
if (cancelled) return;
|
||||
if (available) {
|
||||
await announceAvailable();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (!cancelled) {
|
||||
timer = setTimeout(tick, POLL_INTERVAL_MS);
|
||||
}
|
||||
};
|
||||
|
||||
timer = setTimeout(tick, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [activeTab, checkCliAvailability, announceAvailable]);
|
||||
|
||||
const handleManualCheck = React.useCallback(async () => {
|
||||
setIsManualChecking(true);
|
||||
try {
|
||||
const available = await checkCliAvailability();
|
||||
if (available) await announceAvailable();
|
||||
} finally {
|
||||
setIsManualChecking(false);
|
||||
}
|
||||
}, [checkCliAvailability, announceAvailable]);
|
||||
|
||||
const handleBrowse = React.useCallback(async () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (!isDesktopApp || !isTauriShell()) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (!tauri?.dialog?.open) return;
|
||||
|
||||
try {
|
||||
const selected = await tauri.dialog.open({
|
||||
@@ -163,41 +197,18 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
}
|
||||
}, [isDesktopApp, t]);
|
||||
|
||||
// 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);
|
||||
setIsApplyingPath(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);
|
||||
setTimeout(() => setIsApplyingPath(false), 1000);
|
||||
}
|
||||
}, [opencodeBinary, persistFirstChoice]);
|
||||
|
||||
@@ -211,32 +222,6 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
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(t('onboarding.localSetup.errors.cliNotReady'));
|
||||
}
|
||||
} catch (err) {
|
||||
setCheckError(err instanceof Error ? err.message : t('onboarding.localSetup.errors.detectionFailed'));
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}, [checkCliAvailability, onCliAvailable, persistFirstChoice, t]);
|
||||
|
||||
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
|
||||
const binaryPlaceholder =
|
||||
platform === 'windows'
|
||||
@@ -245,27 +230,29 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
? '/home/you/.bun/bin/opencode'
|
||||
: '/Users/you/.bun/bin/opencode';
|
||||
|
||||
const showLocal = !isDesktopApp || !isTauriShell() || activeTab === 'local';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
|
||||
className="app-region-drag h-full flex items-center justify-center bg-transparent p-8 cursor-default select-none overflow-y-auto"
|
||||
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">
|
||||
<div className="w-full max-w-md space-y-7">
|
||||
<header className="text-center space-y-1.5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{t('onboarding.chooser.title')}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('onboarding.chooser.description')}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isDesktopApp && isTauriShell() && (
|
||||
<div className="flex gap-2 justify-center">
|
||||
<div className="app-region-no-drag flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 max-w-[200px] px-4 py-2.5 rounded-lg border transition-all text-sm',
|
||||
'flex-1 px-4 py-2 rounded-lg border transition-colors 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'
|
||||
@@ -277,12 +264,12 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex-1 max-w-[200px] px-4 py-2.5 rounded-lg border transition-all text-sm',
|
||||
'flex-1 px-4 py-2 rounded-lg border transition-colors 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}
|
||||
onClick={() => setActiveTab('remote')}
|
||||
>
|
||||
{t('onboarding.chooser.tabs.connectRemote')}
|
||||
</button>
|
||||
@@ -290,133 +277,168 @@ export function ChooserScreen({ onCliAvailable }: ChooserScreenProps) {
|
||||
)}
|
||||
|
||||
{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">{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.stepInstallWsl')} <code className="text-foreground/80">wsl --install</code> {t('onboarding.localSetup.windows.stepInstallWslSuffix')}</li>
|
||||
<li>{t('onboarding.localSetup.windows.stepRunInstallInWsl')}</li>
|
||||
<li>{t('onboarding.localSetup.windows.stepSetBinaryPath')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
<div className="app-region-no-drag">
|
||||
<RemoteConnectionForm
|
||||
onBack={() => setActiveTab('local')}
|
||||
showBackButton={false}
|
||||
onSwitchToLocal={() => setActiveTab('local')}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<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" />
|
||||
{t('onboarding.common.status.copiedToClipboard')}
|
||||
</div>
|
||||
) : (
|
||||
<BashCommand onCopy={handleCopy} copyTitle={t('onboarding.common.copyToClipboard')} />
|
||||
)}
|
||||
</div>
|
||||
{showLocal && (
|
||||
<div className="space-y-4">
|
||||
{platform === 'windows' && (
|
||||
<div className="rounded-lg border border-border bg-background/50 p-4">
|
||||
<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.stepInstallWsl')} <code className="text-foreground/80">wsl --install</code> {t('onboarding.localSetup.windows.stepInstallWslSuffix')}</li>
|
||||
<li>{t('onboarding.localSetup.windows.stepRunInstallInWsl')}</li>
|
||||
<li>{t('onboarding.localSetup.windows.stepSetBinaryPath')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center leading-relaxed">
|
||||
{t('onboarding.localSetup.intro')}
|
||||
</p>
|
||||
|
||||
<div className="app-region-no-drag rounded-lg border border-border bg-background/60 backdrop-blur-sm px-4 py-3 font-mono text-sm">
|
||||
{copied ? (
|
||||
<div className="flex items-center gap-2" style={{ color: 'var(--status-success)' }}>
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
{t('onboarding.common.status.copiedToClipboard')}
|
||||
</div>
|
||||
) : (
|
||||
<BashCommand onCopy={handleCopy} copyTitle={t('onboarding.common.copyToClipboard')} />
|
||||
)}
|
||||
</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')}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="app-region-no-drag flex items-center justify-between">
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
{platform === 'windows' ? t('onboarding.localSetup.docs.windows') : t('onboarding.localSetup.docs.default')}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleManualCheck}
|
||||
disabled={isManualChecking}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isManualChecking ? t('onboarding.localSetup.actions.checking') : t('onboarding.localSetup.actions.checkNow')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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="rounded-lg border px-4 py-3 flex items-center gap-3"
|
||||
style={{
|
||||
borderColor: 'color-mix(in srgb, var(--primary-base) 20%, transparent)',
|
||||
backgroundColor: 'color-mix(in srgb, var(--primary-base) 6%, transparent)',
|
||||
}}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className="relative inline-flex h-2.5 w-2.5 shrink-0" aria-hidden>
|
||||
<span
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
backgroundColor: 'var(--primary-base)',
|
||||
animation: 'pulse-opacity 1.6s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-[-4px] rounded-full"
|
||||
style={{
|
||||
backgroundColor: 'var(--primary-base)',
|
||||
animation: 'pulse-opacity-dim 1.6s ease-in-out infinite',
|
||||
opacity: 0,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-foreground leading-tight">
|
||||
{t('onboarding.localSetup.status.watching')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground leading-tight mt-0.5">
|
||||
{t('onboarding.localSetup.status.autoContinue')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<details
|
||||
className="app-region-no-drag group rounded-lg border border-border/60 px-4 open:bg-background/40 transition-colors"
|
||||
open={advancedOpen}
|
||||
onToggle={(e) => setAdvancedOpen((e.currentTarget as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary className="flex items-center justify-between cursor-pointer py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors list-none [&::-webkit-details-marker]:hidden">
|
||||
<span>{t('onboarding.localSetup.advanced.title')}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<div className="pb-4 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={opencodeBinary}
|
||||
onChange={(e) => setOpencodeBinary(e.target.value)}
|
||||
placeholder={binaryPlaceholder}
|
||||
disabled={isApplyingPath}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCheckAndContinue}
|
||||
disabled={isChecking}
|
||||
className="w-full max-w-xs"
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleBrowse}
|
||||
disabled={isApplyingPath || !isDesktopApp || !isTauriShell()}
|
||||
>
|
||||
{isChecking ? t('onboarding.localSetup.actions.checking') : t('onboarding.localSetup.actions.checkAndContinue')}
|
||||
{t('onboarding.localSetup.actions.browse')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleApplyPath}
|
||||
disabled={isApplyingPath || !opencodeBinary.trim()}
|
||||
>
|
||||
{t('onboarding.localSetup.actions.apply')}
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('onboarding.localSetup.helper.checkAndContinue')}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground/70">
|
||||
{t('onboarding.localSetup.helper.saveAndReload')}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<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 || !isTauriShell()}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
<details
|
||||
className="app-region-no-drag group rounded-lg border border-border/60 px-4 open:bg-background/40 transition-colors"
|
||||
open={troubleOpen}
|
||||
onToggle={(e) => setTroubleOpen((e.currentTarget as HTMLDetailsElement).open)}
|
||||
>
|
||||
<summary className="flex items-center justify-between cursor-pointer py-2.5 text-sm text-muted-foreground hover:text-foreground transition-colors list-none [&::-webkit-details-marker]:hidden">
|
||||
<span>{t('onboarding.localSetup.troubleshoot.title')}</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 transition-transform group-open:rotate-180" />
|
||||
</summary>
|
||||
<ul className="pb-4 space-y-1.5 text-xs text-muted-foreground list-disc pl-4">
|
||||
{platform === 'windows' ? (
|
||||
<>
|
||||
<li>{t('onboarding.localSetup.windows.hintInstallInWsl')}</li>
|
||||
<li>{t('onboarding.localSetup.windows.hintDetectionFailed')}</li>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li>{t('onboarding.localSetup.hint.ensurePath')}</li>
|
||||
<li>{t('onboarding.localSetup.hint.setEnv')}</li>
|
||||
<li>{t('onboarding.localSetup.hint.missingRuntime')}</li>
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
</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">
|
||||
{t('onboarding.localSetup.windows.hintInstallInWsl')}
|
||||
</p>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user