Add a skippable first-use Setup Center that guides users through three prerequisites: Claude Code CLI detection, API provider configuration, and default project directory selection. Replace passive fail-then-hint patterns with proactive guidance and actionable error recovery. Setup Center (13 new files): - SetupCenter overlay with 4 cards (Welcome, ClaudeCode, Provider, ProjectDir) - Auto-close only when user completes last card in current session - Re-openable from Settings > General > "首次设置引导" - Claude Code conflict detection with per-install-type uninstall commands - Provider detection covers DB providers, env vars, and legacy app-settings token - ANTHROPIC_BASE_URL excluded from credential check (not a real credential) - skipped != completed: /chat still prompts for provider after skip Project directory: - Validation via /api/files/browse before trusting saved paths - Fallback chain: localStorage → setup_default_project → empty state - Cross-component sync via project-directory-changed CustomEvent Toast system: - Global showToast() API with success/error/warning/info types - FIFO eviction (max 3), action button support Git panel: - Move Commit + Push buttons from UnifiedTopBar into GitStatusSection - Push enabled for new branches (backend auto-handles -u origin) Other: - Windows top-bar safe zone (138px spacer for caption buttons) - Cross-platform path splitting with /[\\/]/ regex - Platform-aware file manager name (Finder/Explorer/Files) - ~80 new i18n keys (en + zh) - Handover doc: docs/handover/onboarding-setup-center.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useEffect } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from "@/components/ui/alert-dialog";
|
|
import { ArrowClockwise, SpinnerGap } from "@/components/ui/icon";
|
|
import { useUpdate } from "@/hooks/useUpdate";
|
|
import { useTranslation } from "@/hooks/useTranslation";
|
|
import { useAccountInfo } from "@/hooks/useAccountInfo";
|
|
import { SUPPORTED_LOCALES, type Locale } from "@/i18n";
|
|
import type { TranslationKey } from "@/i18n";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { SettingsCard } from "@/components/patterns/SettingsCard";
|
|
import { FieldRow } from "@/components/patterns/FieldRow";
|
|
import { StatusBanner } from "@/components/patterns/StatusBanner";
|
|
import { AppearanceSection } from "./AppearanceSection";
|
|
|
|
function UpdateCard() {
|
|
const { updateInfo, checking, checkForUpdates, downloadUpdate, quitAndInstall, setShowDialog } = useUpdate();
|
|
const { t } = useTranslation();
|
|
const currentVersion = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
|
|
|
const isDownloading = updateInfo?.isNativeUpdate && !updateInfo.readyToInstall
|
|
&& updateInfo.downloadProgress != null;
|
|
|
|
return (
|
|
<SettingsCard>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h2 className="text-sm font-medium">{t('settings.codepilot')}</h2>
|
|
<p className="text-xs text-muted-foreground">{t('settings.version', { version: currentVersion })}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Show install/restart button when update available */}
|
|
{updateInfo?.updateAvailable && !checking && (
|
|
updateInfo.readyToInstall ? (
|
|
<Button size="sm" onClick={quitAndInstall}>
|
|
{t('update.restartToUpdate')}
|
|
</Button>
|
|
) : updateInfo.isNativeUpdate && !isDownloading ? (
|
|
<Button size="sm" onClick={downloadUpdate}>
|
|
{t('update.installUpdate')}
|
|
</Button>
|
|
) : !updateInfo.isNativeUpdate ? (
|
|
<Button size="sm" variant="outline" onClick={() => window.open(updateInfo.releaseUrl, "_blank")}>
|
|
{t('settings.viewRelease')}
|
|
</Button>
|
|
) : null
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={checkForUpdates}
|
|
disabled={checking}
|
|
className="gap-2"
|
|
>
|
|
{checking ? (
|
|
<SpinnerGap size={14} className="animate-spin" />
|
|
) : (
|
|
<ArrowClockwise size={14} />
|
|
)}
|
|
{checking ? t('settings.checking') : t('settings.checkForUpdates')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{updateInfo && !checking && (
|
|
<div className="mt-3">
|
|
{updateInfo.updateAvailable ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`h-2 w-2 rounded-full ${updateInfo.readyToInstall ? 'bg-status-success' : isDownloading ? 'bg-status-warning animate-pulse' : 'bg-primary'}`} />
|
|
<span className="text-sm">
|
|
{updateInfo.readyToInstall
|
|
? t('update.readyToInstall', { version: updateInfo.latestVersion })
|
|
: isDownloading
|
|
? `${t('update.downloading')} ${Math.round(updateInfo.downloadProgress!)}%`
|
|
: t('settings.updateAvailable', { version: updateInfo.latestVersion })}
|
|
</span>
|
|
{updateInfo.releaseNotes && (
|
|
<Button
|
|
variant="link"
|
|
size="sm"
|
|
className="h-auto p-0 text-xs text-muted-foreground"
|
|
onClick={() => setShowDialog(true)}
|
|
>
|
|
{t('gallery.viewDetails')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{/* Download progress bar */}
|
|
{isDownloading && (
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all"
|
|
style={{ width: `${Math.min(updateInfo.downloadProgress!, 100)}%` }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{updateInfo.lastError && (
|
|
<p className="text-xs text-status-error-foreground">
|
|
{updateInfo.lastError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">{t('settings.latestVersion')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</SettingsCard>
|
|
);
|
|
}
|
|
|
|
export function GeneralSection() {
|
|
const [skipPermissions, setSkipPermissions] = useState(false);
|
|
const [showSkipPermWarning, setShowSkipPermWarning] = useState(false);
|
|
const [skipPermSaving, setSkipPermSaving] = useState(false);
|
|
const { accountInfo } = useAccountInfo();
|
|
const { t, locale, setLocale } = useTranslation();
|
|
|
|
const fetchAppSettings = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/settings/app");
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const appSettings = data.settings || {};
|
|
setSkipPermissions(appSettings.dangerously_skip_permissions === "true");
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchAppSettings();
|
|
}, [fetchAppSettings]);
|
|
|
|
const handleSkipPermToggle = (checked: boolean) => {
|
|
if (checked) {
|
|
setShowSkipPermWarning(true);
|
|
} else {
|
|
saveSkipPermissions(false);
|
|
}
|
|
};
|
|
|
|
const saveSkipPermissions = async (enabled: boolean) => {
|
|
setSkipPermSaving(true);
|
|
try {
|
|
const res = await fetch("/api/settings/app", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
settings: { dangerously_skip_permissions: enabled ? "true" : "" },
|
|
}),
|
|
});
|
|
if (res.ok) {
|
|
setSkipPermissions(enabled);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setSkipPermSaving(false);
|
|
setShowSkipPermWarning(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-3xl space-y-6">
|
|
<UpdateCard />
|
|
|
|
{/* General settings card */}
|
|
<SettingsCard className={skipPermissions ? "border-status-warning-border bg-status-warning-muted" : undefined}>
|
|
{/* Auto-approve toggle */}
|
|
<FieldRow
|
|
label={t('settings.autoApproveTitle')}
|
|
description={t('settings.autoApproveDesc')}
|
|
>
|
|
<Switch
|
|
checked={skipPermissions}
|
|
onCheckedChange={handleSkipPermToggle}
|
|
disabled={skipPermSaving}
|
|
/>
|
|
</FieldRow>
|
|
{skipPermissions && (
|
|
<StatusBanner variant="warning">
|
|
<span className="h-2 w-2 shrink-0 rounded-full bg-status-warning inline-block mr-1" />
|
|
{t('settings.autoApproveWarning')}
|
|
</StatusBanner>
|
|
)}
|
|
|
|
{/* Language picker */}
|
|
<FieldRow
|
|
label={t('settings.language')}
|
|
description={t('settings.languageDesc')}
|
|
separator
|
|
>
|
|
<Select value={locale} onValueChange={(v) => setLocale(v as Locale)}>
|
|
<SelectTrigger className="w-[140px]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{SUPPORTED_LOCALES.map((l) => (
|
|
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</FieldRow>
|
|
|
|
{/* Setup Center */}
|
|
<FieldRow
|
|
label={t('setup.openSetupCenter')}
|
|
description={t('setup.openSetupCenterDesc')}
|
|
separator
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-xs"
|
|
onClick={() => window.dispatchEvent(new CustomEvent('open-setup-center'))}
|
|
>
|
|
{t('setup.open')}
|
|
</Button>
|
|
</FieldRow>
|
|
|
|
</SettingsCard>
|
|
|
|
{/* Appearance */}
|
|
<AppearanceSection />
|
|
|
|
{/* Account info */}
|
|
{accountInfo && (
|
|
<SettingsCard title={t('settings.accountInfo' as TranslationKey)}>
|
|
<div className="space-y-1">
|
|
{accountInfo.email && (
|
|
<p className="text-xs text-muted-foreground">
|
|
<span className="font-medium text-foreground">{t('settings.email' as TranslationKey)}:</span> {accountInfo.email}
|
|
</p>
|
|
)}
|
|
{accountInfo.organization && (
|
|
<p className="text-xs text-muted-foreground">
|
|
<span className="font-medium text-foreground">{t('settings.organization' as TranslationKey)}:</span> {accountInfo.organization}
|
|
</p>
|
|
)}
|
|
{accountInfo.subscriptionType && (
|
|
<p className="text-xs text-muted-foreground">
|
|
<span className="font-medium text-foreground">{t('settings.subscription' as TranslationKey)}:</span> {accountInfo.subscriptionType}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</SettingsCard>
|
|
)}
|
|
|
|
{/* Skip-permissions warning dialog */}
|
|
<AlertDialog open={showSkipPermWarning} onOpenChange={setShowSkipPermWarning}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{t('settings.autoApproveDialogTitle')}</AlertDialogTitle>
|
|
<AlertDialogDescription asChild>
|
|
<div className="space-y-2">
|
|
<p>
|
|
{t('settings.autoApproveDialogDesc')}
|
|
</p>
|
|
<ul className="list-disc pl-5 space-y-1">
|
|
<li>{t('settings.autoApproveShellCommands')}</li>
|
|
<li>{t('settings.autoApproveFileOps')}</li>
|
|
<li>{t('settings.autoApproveNetwork')}</li>
|
|
</ul>
|
|
<p className="font-medium text-status-warning-foreground">
|
|
{t('settings.autoApproveTrustWarning')}
|
|
</p>
|
|
</div>
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{t('settings.cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => saveSkipPermissions(true)}
|
|
className="bg-status-warning hover:bg-status-warning/80 text-white"
|
|
>
|
|
{t('settings.enableAutoApprove')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|