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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1951,12 +1951,18 @@ export const dict = {
|
||||
'onboarding.localSetup.windows.stepInstallWslSuffix': 'in PowerShell.',
|
||||
'onboarding.localSetup.windows.stepRunInstallInWsl': 'Run the install command below inside your WSL terminal.',
|
||||
'onboarding.localSetup.windows.stepSetBinaryPath': 'If OpenChamber does not detect OpenCode automatically, set the binary path below.',
|
||||
'onboarding.localSetup.docs.windows': 'View Windows + WSL documentation',
|
||||
'onboarding.localSetup.docs.default': 'View documentation',
|
||||
'onboarding.localSetup.actions.checking': 'Checking...',
|
||||
'onboarding.localSetup.intro': 'OpenCode is the heart of OpenChamber — install it to get started.',
|
||||
'onboarding.localSetup.docs.windows': 'OpenCode Windows + WSL docs',
|
||||
'onboarding.localSetup.docs.default': 'OpenCode docs',
|
||||
'onboarding.localSetup.actions.checking': 'Checking…',
|
||||
'onboarding.localSetup.actions.checkAndContinue': "I've completed installation, check and continue",
|
||||
'onboarding.localSetup.actions.checkNow': 'Check now',
|
||||
'onboarding.localSetup.helper.checkAndContinue': 'Click to check if OpenCode CLI is available. If successful, you\'ll automatically enter the main screen.',
|
||||
'onboarding.localSetup.status.watching': 'Waiting for OpenCode',
|
||||
'onboarding.localSetup.status.autoContinue': "We'll continue automatically once it's detected.",
|
||||
'onboarding.localSetup.field.alreadyInstalled': 'Already installed? Set the OpenCode CLI path:',
|
||||
'onboarding.localSetup.advanced.title': 'Set a custom binary path',
|
||||
'onboarding.localSetup.troubleshoot.title': 'Having trouble?',
|
||||
'onboarding.localSetup.actions.browse': 'Browse',
|
||||
'onboarding.localSetup.actions.apply': 'Apply',
|
||||
'onboarding.localSetup.helper.saveAndReload': 'Saves to OpenChamber settings and reloads OpenCode configuration.',
|
||||
|
||||
@@ -1917,12 +1917,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
"onboarding.localSetup.windows.stepInstallWslSuffix": "en PowerShell.",
|
||||
"onboarding.localSetup.windows.stepRunInstallInWsl": "Ejecuta el comando de instalación a continuación dentro de tu terminal de WSL.",
|
||||
"onboarding.localSetup.windows.stepSetBinaryPath": "Si OpenChamber no detecta OpenCode automáticamente, establece la ruta del ejecutable a continuación.",
|
||||
"onboarding.localSetup.docs.windows": "Ver documentación de Windows + WSL",
|
||||
"onboarding.localSetup.docs.default": "Ver documentación",
|
||||
"onboarding.localSetup.intro": "OpenCode es el corazón de OpenChamber. Instálalo para empezar.",
|
||||
"onboarding.localSetup.docs.windows": "Documentación de OpenCode para Windows + WSL",
|
||||
"onboarding.localSetup.docs.default": "Documentación de OpenCode",
|
||||
"onboarding.localSetup.actions.checking": "Verificando...",
|
||||
"onboarding.localSetup.actions.checkAndContinue": "He completado la instalación, verificar y continuar",
|
||||
"onboarding.localSetup.actions.checkNow": "Verificar ahora",
|
||||
"onboarding.localSetup.helper.checkAndContinue": "Haz clic para verificar si la CLI de OpenCode está disponible. Si la verificación funciona, entrarás automáticamente en la pantalla principal.",
|
||||
"onboarding.localSetup.status.watching": "Esperando OpenCode",
|
||||
"onboarding.localSetup.status.autoContinue": "Continuaremos automáticamente al detectarlo.",
|
||||
"onboarding.localSetup.field.alreadyInstalled": "¿Ya está instalado? Establece la ruta de la CLI de OpenCode:",
|
||||
"onboarding.localSetup.advanced.title": "Establecer una ruta personalizada del binario",
|
||||
"onboarding.localSetup.troubleshoot.title": "¿Tienes problemas?",
|
||||
"onboarding.localSetup.actions.browse": "Explorar",
|
||||
"onboarding.localSetup.actions.apply": "Aplicar",
|
||||
"onboarding.localSetup.helper.saveAndReload": "Guarda en la configuración de OpenChamber y recarga la configuración de OpenCode.",
|
||||
|
||||
@@ -1951,12 +1951,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
'onboarding.localSetup.windows.stepInstallWslSuffix': 'PowerShell에서 실행하세요.',
|
||||
'onboarding.localSetup.windows.stepRunInstallInWsl': '아래 설치 명령을 WSL 터미널에서 실행하세요.',
|
||||
'onboarding.localSetup.windows.stepSetBinaryPath': 'OpenChamber가 OpenCode를 자동으로 찾지 못하면 아래에 바이너리 경로를 설정하세요.',
|
||||
'onboarding.localSetup.docs.windows': 'Windows + WSL 문서 보기',
|
||||
'onboarding.localSetup.docs.default': '문서 보기',
|
||||
'onboarding.localSetup.intro': 'OpenCode는 OpenChamber의 심장입니다. 먼저 설치하고 시작하세요.',
|
||||
'onboarding.localSetup.docs.windows': 'OpenCode Windows + WSL 문서',
|
||||
'onboarding.localSetup.docs.default': 'OpenCode 문서',
|
||||
'onboarding.localSetup.actions.checking': '확인 중…',
|
||||
'onboarding.localSetup.actions.checkAndContinue': '설치를 마쳤습니다. 확인 후 계속',
|
||||
'onboarding.localSetup.actions.checkNow': '지금 확인',
|
||||
'onboarding.localSetup.helper.checkAndContinue': 'OpenCode CLI를 사용할 수 있으면 확인을 누르세요. 성공하면 자동으로 메인 화면으로 이동합니다.',
|
||||
'onboarding.localSetup.status.watching': 'OpenCode 대기 중',
|
||||
'onboarding.localSetup.status.autoContinue': '감지되면 자동으로 계속됩니다.',
|
||||
'onboarding.localSetup.field.alreadyInstalled': '이미 설치되어 있나요? OpenCode CLI 경로 설정:',
|
||||
'onboarding.localSetup.advanced.title': '사용자 지정 바이너리 경로 설정',
|
||||
'onboarding.localSetup.troubleshoot.title': '문제가 있나요?',
|
||||
'onboarding.localSetup.actions.browse': '찾아보기',
|
||||
'onboarding.localSetup.actions.apply': '적용',
|
||||
'onboarding.localSetup.helper.saveAndReload': 'OpenChamber 설정에 저장하고 OpenCode 구성을 다시 로드합니다.',
|
||||
|
||||
@@ -1917,12 +1917,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
"onboarding.localSetup.windows.stepInstallWslSuffix": "no PowerShell.",
|
||||
"onboarding.localSetup.windows.stepRunInstallInWsl": "Execute o comando de instalação a seguir dentro de seu terminal de WSL.",
|
||||
"onboarding.localSetup.windows.stepSetBinaryPath": "Se o OpenChamber não detectar o OpenCode automaticamente, defina o caminho do executável a seguir.",
|
||||
"onboarding.localSetup.docs.windows": "Ver documentação de Windows + WSL",
|
||||
"onboarding.localSetup.docs.default": "Ver documentação",
|
||||
"onboarding.localSetup.intro": "OpenCode é o coração do OpenChamber. Instale-o para começar.",
|
||||
"onboarding.localSetup.docs.windows": "Documentação do OpenCode para Windows + WSL",
|
||||
"onboarding.localSetup.docs.default": "Documentação do OpenCode",
|
||||
"onboarding.localSetup.actions.checking": "Verificando...",
|
||||
"onboarding.localSetup.actions.checkAndContinue": "Concluí a instalação, verificar e continuar",
|
||||
"onboarding.localSetup.actions.checkNow": "Verificar agora",
|
||||
"onboarding.localSetup.helper.checkAndContinue": "Clique para verificar se a CLI do OpenCode está disponível. Se a verificação funcionar, você entrará automaticamente na tela principal.",
|
||||
"onboarding.localSetup.status.watching": "Aguardando OpenCode",
|
||||
"onboarding.localSetup.status.autoContinue": "Continuaremos automaticamente assim que detectado.",
|
||||
"onboarding.localSetup.field.alreadyInstalled": "Já está instalado? Defina o caminho da CLI do OpenCode:",
|
||||
"onboarding.localSetup.advanced.title": "Definir um caminho personalizado do binário",
|
||||
"onboarding.localSetup.troubleshoot.title": "Está com problemas?",
|
||||
"onboarding.localSetup.actions.browse": "Explorar",
|
||||
"onboarding.localSetup.actions.apply": "Aplicar",
|
||||
"onboarding.localSetup.helper.saveAndReload": "Salve nas configurações do OpenChamber e recarregue configurações do OpenCode.",
|
||||
|
||||
@@ -1917,12 +1917,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
"onboarding.localSetup.windows.stepInstallWslSuffix": "в PowerShell.",
|
||||
"onboarding.localSetup.windows.stepRunInstallInWsl": "Виконайте наведену нижче команду встановлення всередині терміналу WSL.",
|
||||
"onboarding.localSetup.windows.stepSetBinaryPath": "Якщо OpenChamber не визначає OpenCode автоматично, укажіть шлях до виконуваного файла нижче.",
|
||||
"onboarding.localSetup.docs.windows": "Перегляньте документацію Windows + WSL",
|
||||
"onboarding.localSetup.docs.default": "Переглянути документацію",
|
||||
"onboarding.localSetup.intro": "OpenCode — серце OpenChamber. Встановіть його, щоб почати.",
|
||||
"onboarding.localSetup.docs.windows": "Документація OpenCode для Windows + WSL",
|
||||
"onboarding.localSetup.docs.default": "Документація OpenCode",
|
||||
"onboarding.localSetup.actions.checking": "Перевірка...",
|
||||
"onboarding.localSetup.actions.checkAndContinue": "Я завершив встановлення, перевірте та продовжуйте",
|
||||
"onboarding.localSetup.actions.checkNow": "Перевірити зараз",
|
||||
"onboarding.localSetup.helper.checkAndContinue": "Натисніть, щоб перевірити, чи доступний OpenCode CLI. У разі успіху ви автоматично перейдете на головний екран.",
|
||||
"onboarding.localSetup.status.watching": "Очікування OpenCode",
|
||||
"onboarding.localSetup.status.autoContinue": "Ми автоматично продовжимо після виявлення.",
|
||||
"onboarding.localSetup.field.alreadyInstalled": "Вже встановлено? Встановіть шлях OpenCode CLI:",
|
||||
"onboarding.localSetup.advanced.title": "Вказати власний шлях до бінарного файлу",
|
||||
"onboarding.localSetup.troubleshoot.title": "Виникли проблеми?",
|
||||
"onboarding.localSetup.actions.browse": "Огляд",
|
||||
"onboarding.localSetup.actions.apply": "Застосувати",
|
||||
"onboarding.localSetup.helper.saveAndReload": "Зберігає налаштування в OpenChamber і перезавантажує конфігурацію OpenCode.",
|
||||
|
||||
@@ -1917,12 +1917,18 @@ export const dict: Record<I18nKey, string> = {
|
||||
'onboarding.localSetup.windows.stepInstallWslSuffix': '安装 WSL。',
|
||||
'onboarding.localSetup.windows.stepRunInstallInWsl': '在 WSL 终端中运行下方安装命令。',
|
||||
'onboarding.localSetup.windows.stepSetBinaryPath': '如果 OpenChamber 未自动检测到 OpenCode,请在下方设置可执行文件路径。',
|
||||
'onboarding.localSetup.docs.windows': '查看 Windows + WSL 文档',
|
||||
'onboarding.localSetup.docs.default': '查看文档',
|
||||
'onboarding.localSetup.intro': 'OpenCode 是 OpenChamber 的核心,先安装它即可开始。',
|
||||
'onboarding.localSetup.docs.windows': 'OpenCode Windows + WSL 文档',
|
||||
'onboarding.localSetup.docs.default': 'OpenCode 文档',
|
||||
'onboarding.localSetup.actions.checking': '检测中...',
|
||||
'onboarding.localSetup.actions.checkAndContinue': '我已完成安装,检测并继续',
|
||||
'onboarding.localSetup.actions.checkNow': '立即检测',
|
||||
'onboarding.localSetup.helper.checkAndContinue': '点击检测 OpenCode CLI 是否可用。成功后将自动进入主界面。',
|
||||
'onboarding.localSetup.status.watching': '正在等待 OpenCode',
|
||||
'onboarding.localSetup.status.autoContinue': '检测到后将自动继续。',
|
||||
'onboarding.localSetup.field.alreadyInstalled': '已安装?设置 OpenCode CLI 路径:',
|
||||
'onboarding.localSetup.advanced.title': '设置自定义二进制路径',
|
||||
'onboarding.localSetup.troubleshoot.title': '遇到问题?',
|
||||
'onboarding.localSetup.actions.browse': '浏览',
|
||||
'onboarding.localSetup.actions.apply': '应用',
|
||||
'onboarding.localSetup.helper.saveAndReload': '将保存到 OpenChamber 设置并重新加载 OpenCode 配置。',
|
||||
|
||||
Reference in New Issue
Block a user