feat: enhance Claude setup with embedded variant and error handling

- Updated `useClaudeSetupStore` to require Claude Code installation before saving provider credentials.
- Modified `ClaudeSetup` component to support an embedded variant with improved UI for installation and configuration.
- Added detailed status messages for missing Git and installation prompts.
- Refactored project picker to include theme toggle and version display.
- Improved layout and styling for settings and environment status sections.
This commit is contained in:
Gerard-Devlin 2026-06-14 02:34:43 +08:00
parent 37eb80b20a
commit 4176a915f5
5 changed files with 777 additions and 231 deletions

View file

@ -108,6 +108,312 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool {
)
}
fn normalize_proxy_url_with_default(raw: &str, default_scheme: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.contains("://") {
Some(trimmed.to_string())
} else {
Some(format!("{}://{}", default_scheme, trimmed))
}
}
fn normalize_proxy_url(raw: &str) -> Option<String> {
normalize_proxy_url_with_default(raw, "http")
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProxyEnvVar {
key: &'static str,
value: String,
source: String,
}
fn first_env_value(names: &[&str]) -> Option<(String, String)> {
for name in names {
let Ok(value) = std::env::var(name) else {
continue;
};
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(((*name).to_string(), trimmed.to_string()));
}
}
None
}
fn explicit_proxy_env_vars() -> Vec<ProxyEnvVar> {
let mut vars = Vec::new();
let mut has_https_proxy = false;
let mut has_all_proxy = false;
let mut http_proxy = None;
if let Some((source, raw)) = first_env_value(&["HTTPS_PROXY", "https_proxy"]) {
if let Some(value) = normalize_proxy_url(&raw) {
vars.push(ProxyEnvVar {
key: "HTTPS_PROXY",
value,
source,
});
has_https_proxy = true;
}
}
if let Some((source, raw)) = first_env_value(&["HTTP_PROXY", "http_proxy"]) {
if let Some(value) = normalize_proxy_url(&raw) {
http_proxy = Some((source.clone(), value.clone()));
vars.push(ProxyEnvVar {
key: "HTTP_PROXY",
value,
source,
});
}
}
if let Some((source, raw)) = first_env_value(&["ALL_PROXY", "all_proxy"]) {
if let Some(value) = normalize_proxy_url(&raw) {
vars.push(ProxyEnvVar {
key: "ALL_PROXY",
value,
source,
});
has_all_proxy = true;
}
}
if !has_https_proxy && !has_all_proxy {
if let Some((source, value)) = http_proxy {
vars.insert(
0,
ProxyEnvVar {
key: "HTTPS_PROXY",
value,
source: format!("{} (HTTPS fallback)", source),
},
);
}
}
vars
}
#[cfg(target_os = "windows")]
fn windows_proxy_override_to_no_proxy_env(raw: &str) -> Option<String> {
let entries = raw
.split([';', ','])
.filter_map(|part| {
let trimmed = part.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("<local>") {
return Some("localhost,127.0.0.1,::1".to_string());
}
if trimmed == "*" {
return Some(trimmed.to_string());
}
if trimmed.contains('*') {
return trimmed
.strip_prefix("*.")
.map(|domain| format!(".{}", domain.trim_start_matches('.')));
}
Some(trimmed.to_string())
})
.collect::<Vec<_>>();
if entries.is_empty() {
None
} else {
Some(entries.join(","))
}
}
#[cfg(target_os = "windows")]
fn windows_system_no_proxy_env() -> Option<String> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let settings = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings")
.ok()?;
let raw = settings.get_value::<String, _>("ProxyOverride").ok()?;
windows_proxy_override_to_no_proxy_env(&raw)
}
#[cfg(target_os = "windows")]
fn parse_windows_proxy_server_for_env(raw: &str) -> Vec<ProxyEnvVar> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Vec::new();
}
if !trimmed.contains('=') {
let Some(value) = normalize_proxy_url(trimmed) else {
return Vec::new();
};
return vec![
ProxyEnvVar {
key: "HTTPS_PROXY",
value: value.clone(),
source: "Windows system proxy".to_string(),
},
ProxyEnvVar {
key: "HTTP_PROXY",
value: value.clone(),
source: "Windows system proxy".to_string(),
},
ProxyEnvVar {
key: "ALL_PROXY",
value,
source: "Windows system proxy".to_string(),
},
];
}
let mut vars = Vec::new();
let mut has_https_proxy = false;
let mut has_all_proxy = false;
let mut http_proxy = None;
for entry in trimmed.split(';') {
let Some((scheme, value)) = entry.split_once('=') else {
continue;
};
let scheme = scheme.trim();
let (key, default_proxy_scheme) = match scheme.to_ascii_lowercase().as_str() {
"http" => ("HTTP_PROXY", "http"),
"https" => ("HTTPS_PROXY", "http"),
"socks" | "socks5" => ("ALL_PROXY", "socks5"),
"socks4" => ("ALL_PROXY", "socks4"),
_ => continue,
};
let Some(value) = normalize_proxy_url_with_default(value, default_proxy_scheme) else {
continue;
};
let source = format!("Windows system proxy ({})", scheme);
if key == "HTTP_PROXY" {
http_proxy = Some((source.clone(), value.clone()));
} else if key == "HTTPS_PROXY" {
has_https_proxy = true;
} else if key == "ALL_PROXY" {
has_all_proxy = true;
}
vars.push(ProxyEnvVar { key, value, source });
}
if !has_https_proxy && !has_all_proxy {
if let Some((source, value)) = http_proxy {
vars.insert(
0,
ProxyEnvVar {
key: "HTTPS_PROXY",
value,
source: format!("{} (HTTPS fallback)", source),
},
);
}
}
vars
}
#[cfg(target_os = "windows")]
fn windows_system_proxy_env_vars() -> Vec<ProxyEnvVar> {
use winreg::enums::HKEY_CURRENT_USER;
use winreg::RegKey;
let Ok(settings) = RegKey::predef(HKEY_CURRENT_USER)
.open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Internet Settings")
else {
return Vec::new();
};
let proxy_enabled = settings.get_value::<u32, _>("ProxyEnable").unwrap_or(0) != 0;
if !proxy_enabled {
return Vec::new();
}
settings
.get_value::<String, _>("ProxyServer")
.map(|raw| parse_windows_proxy_server_for_env(&raw))
.unwrap_or_default()
}
#[cfg(not(target_os = "windows"))]
fn windows_system_proxy_env_vars() -> Vec<ProxyEnvVar> {
Vec::new()
}
#[cfg(not(target_os = "windows"))]
fn windows_system_no_proxy_env() -> Option<String> {
None
}
fn redacted_proxy_url(url: &str) -> String {
let Ok(mut parsed) = reqwest::Url::parse(url) else {
return "<invalid proxy URL>".to_string();
};
if !parsed.username().is_empty() {
let _ = parsed.set_username("***");
if parsed.password().is_some() {
let _ = parsed.set_password(Some("***"));
}
}
parsed.to_string()
}
fn apply_proxy_env_to_command(cmd: &mut Command, window: Option<&WebviewWindow>) {
let mut vars = explicit_proxy_env_vars();
let mut no_proxy = first_env_value(&["NO_PROXY", "no_proxy"]).map(|(_, value)| value);
if vars.is_empty() {
vars = windows_system_proxy_env_vars();
if no_proxy.is_none() {
no_proxy = windows_system_no_proxy_env();
}
}
if vars.is_empty() {
if let Some(window) = window {
let _ = window.emit(
"install-output",
"Using system proxy settings when available",
);
}
return;
}
for var in vars {
if let Some(window) = window {
let _ = window.emit(
"install-output",
format!(
"Using proxy from {}: {}",
var.source,
redacted_proxy_url(&var.value)
),
);
}
cmd.env(var.key, var.value);
}
if let Some(no_proxy) = no_proxy {
cmd.env("NO_PROXY", no_proxy);
}
}
fn get_claude_prism_auth_path() -> Result<PathBuf, String> {
let config_dir = dirs::config_dir()
.or_else(dirs::home_dir)
@ -2037,6 +2343,7 @@ pub async fn install_claude_cli(window: WebviewWindow) -> Result<bool, String> {
cmd.env(&key, &value);
}
}
apply_proxy_env_to_command(&mut cmd, Some(&window));
let mut child = cmd
.spawn()
@ -3931,6 +4238,84 @@ mod tests {
}
}
#[test]
fn test_normalize_proxy_url_defaults_to_http() {
assert_eq!(
normalize_proxy_url("127.0.0.1:7890"),
Some("http://127.0.0.1:7890".to_string())
);
assert_eq!(
normalize_proxy_url("socks5://127.0.0.1:7891"),
Some("socks5://127.0.0.1:7891".to_string())
);
assert_eq!(normalize_proxy_url(" "), None);
}
#[cfg(target_os = "windows")]
#[test]
fn test_parse_windows_proxy_server_single_proxy_for_installer_env() {
let vars = parse_windows_proxy_server_for_env("127.0.0.1:7890");
assert_eq!(
vars,
vec![
ProxyEnvVar {
key: "HTTPS_PROXY",
value: "http://127.0.0.1:7890".to_string(),
source: "Windows system proxy".to_string(),
},
ProxyEnvVar {
key: "HTTP_PROXY",
value: "http://127.0.0.1:7890".to_string(),
source: "Windows system proxy".to_string(),
},
ProxyEnvVar {
key: "ALL_PROXY",
value: "http://127.0.0.1:7890".to_string(),
source: "Windows system proxy".to_string(),
},
]
);
}
#[cfg(target_os = "windows")]
#[test]
fn test_parse_windows_proxy_server_per_scheme_for_installer_env() {
let vars = parse_windows_proxy_server_for_env(
"http=127.0.0.1:7890;https=127.0.0.1:7891;socks=127.0.0.1:7892",
);
assert_eq!(
vars,
vec![
ProxyEnvVar {
key: "HTTP_PROXY",
value: "http://127.0.0.1:7890".to_string(),
source: "Windows system proxy (http)".to_string(),
},
ProxyEnvVar {
key: "HTTPS_PROXY",
value: "http://127.0.0.1:7891".to_string(),
source: "Windows system proxy (https)".to_string(),
},
ProxyEnvVar {
key: "ALL_PROXY",
value: "socks5://127.0.0.1:7892".to_string(),
source: "Windows system proxy (socks)".to_string(),
},
]
);
}
#[cfg(target_os = "windows")]
#[test]
fn test_windows_proxy_override_to_no_proxy_env() {
assert_eq!(
windows_proxy_override_to_no_proxy_env("<local>;*.example.com;api.test"),
Some("localhost,127.0.0.1,::1,.example.com,api.test".to_string())
);
}
#[test]
fn test_provider_model_override_ignores_claude_model_selectors() {
assert_eq!(

View file

@ -102,14 +102,14 @@ describe("useClaudeSetupStore.saveApiKey", () => {
beforeEach(() => {
vi.clearAllMocks();
useClaudeSetupStore.setState({
status: "not-installed",
status: "ready",
isInstalling: false,
isLoggingIn: false,
isSavingApiKey: false,
isClearingApiKey: false,
error: null,
version: null,
providerKind: null,
version: "1.0.0",
providerKind: "claude-code",
accountEmail: null,
providerModel: null,
providerBaseUrl: null,
@ -122,6 +122,25 @@ describe("useClaudeSetupStore.saveApiKey", () => {
});
});
it("requires Claude Code before saving provider credentials", async () => {
useClaudeSetupStore.setState({ status: "not-installed" });
const success = await useClaudeSetupStore
.getState()
.saveApiKey(
"sk-test",
"https://api.deepseek.com/anthropic",
"openai-compatible",
"deepseek-v4-pro",
);
expect(success).toBe(false);
expect(invoke).not.toHaveBeenCalled();
expect(useClaudeSetupStore.getState().error).toBe(
"Install Claude Code before configuring an AI provider.",
);
});
it("verifies OpenAI-compatible credentials before saving them", async () => {
vi.mocked(invoke).mockImplementation(async (command) => {
if (command === "check_claude_status") {

View file

@ -490,7 +490,7 @@ function InstallLogOutput() {
// ─── Main Component ───
interface ClaudeSetupProps {
variant?: "default" | "provider-dialog";
variant?: "default" | "provider-dialog" | "embedded";
onSaved?: () => void;
onCancel?: () => void;
}
@ -535,6 +535,21 @@ export function ClaudeSetup({
useInstallEvents();
useLoginEvents();
const isEmbedded = variant === "embedded";
const setupSurfaceClass = (
tone: "default" | "error" | "warning" = "default",
) =>
cn(
"flex w-full flex-col gap-3",
isEmbedded
? "px-4 py-3"
: tone === "error"
? "rounded-xl border border-destructive/30 bg-destructive/5 px-5 py-4"
: tone === "warning"
? "rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4"
: "rounded-xl border border-border bg-muted/30 px-5 py-4",
);
const handleSaveApiKey = async (
selectedProvider: "claude-code" | "openai-compatible" = provider,
credentialLabel?: string,
@ -967,7 +982,14 @@ export function ClaudeSetup({
if (status === "checking") {
return (
<div className="flex w-full items-center justify-center gap-2 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div
className={cn(
"flex w-full items-center justify-center gap-2",
isEmbedded
? "px-4 py-3"
: "rounded-xl border border-border bg-muted/30 px-5 py-4",
)}
>
<LoaderIcon className="size-4 animate-spin text-muted-foreground" />
<span className="text-muted-foreground text-sm">
Checking Claude Code...
@ -976,6 +998,63 @@ export function ClaudeSetup({
);
}
if (variant === "provider-dialog" && status === "missing-git") {
return (
<div className={setupSurfaceClass("warning")}>
<div className="flex items-center gap-2">
<GitBranchIcon className="size-5 shrink-0 text-amber-600" />
<div>
<p className="font-medium text-sm">Install Git first</p>
<p className="text-muted-foreground text-xs">
Claude Code needs Git for Windows before providers can be added.
</p>
</div>
</div>
<Button
size="sm"
variant="outline"
className="w-full gap-2"
onClick={() => {
shellOpen("https://git-scm.com/downloads/win");
}}
>
<ExternalLinkIcon className="size-3.5" />
Download Git for Windows
</Button>
<Button
size="sm"
variant="ghost"
className="w-full gap-2 text-muted-foreground"
onClick={checkStatus}
>
<RefreshCwIcon className="size-3.5" />
I've installed Git
</Button>
</div>
);
}
if (variant === "provider-dialog" && status === "not-installed") {
return (
<div className={setupSurfaceClass()}>
<div className="flex items-center gap-2">
<DownloadIcon className="size-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-sm">Install Claude Code first</p>
<p className="text-muted-foreground text-xs">
AI providers can be configured after the Claude Code CLI is
installed.
</p>
</div>
</div>
<Button size="sm" className="w-full gap-2" onClick={install}>
<DownloadIcon className="size-3.5" />
Install Claude Code
</Button>
</div>
);
}
if (variant === "provider-dialog") {
return (
<div className="min-w-0 max-w-full space-y-3 overflow-hidden">
@ -999,13 +1078,24 @@ export function ClaudeSetup({
if (status === "ready") {
const isDirectProvider = providerKind === "openai-compatible";
const configuredProviderCount = isDirectProvider
? Math.max(
openAiCredentials.length,
providerModel || providerBaseUrl ? 1 : 0,
)
: 1;
const readyDetail = isDirectProvider
? [version, providerModel, providerBaseUrl].filter(Boolean).join(" / ")
? [
`${configuredProviderCount} provider${configuredProviderCount === 1 ? "" : "s"} configured`,
version ? `Claude Code ${version}` : null,
]
.filter(Boolean)
.join(" / ")
: [version, accountEmail].filter(Boolean).join(" / ");
if (isEditingProvider) {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className={setupSurfaceClass()}>
<div className="flex items-center gap-3">
<CheckCircle2Icon className="size-5 shrink-0 text-green-600" />
<div className="min-w-0 flex-1">
@ -1053,20 +1143,36 @@ export function ClaudeSetup({
}
return (
<div className="flex w-full flex-col gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3">
<div className="flex min-w-0 items-center gap-2.5">
<CheckCircle2Icon className="size-3.5 shrink-0 text-foreground" />
<span className="shrink-0 text-sm">
{isDirectProvider ? "AI Providers" : "Claude Code"}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{readyDetail}
</span>
<div
className={cn(
"w-full",
isEmbedded ? "" : "rounded-xl border border-border bg-muted/30",
)}
>
<div className="flex min-w-0 items-center gap-3 px-4 py-3.5">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-green-500/20 bg-green-500/10 text-green-600">
<CheckCircle2Icon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-semibold text-sm">
{isDirectProvider ? "AI Providers" : "Claude Code"}
</span>
{isDirectProvider && (
<span className="shrink-0 rounded-md bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{configuredProviderCount}
</span>
)}
</div>
<p className="mt-0.5 truncate text-muted-foreground text-xs">
{readyDetail}
</p>
</div>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 shrink-0 gap-1 px-2 text-xs"
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-xs"
onClick={() => beginProviderEdit(isDirectProvider)}
>
<RefreshCwIcon className="size-3" />
@ -1076,7 +1182,7 @@ export function ClaudeSetup({
type="button"
size="sm"
variant="ghost"
className="h-6 shrink-0 gap-1 px-2 text-destructive text-xs hover:text-destructive"
className="h-8 shrink-0 gap-1.5 rounded-md px-2.5 text-destructive text-xs hover:text-destructive"
onClick={handleClearApiKey}
disabled={isClearingApiKey}
>
@ -1090,7 +1196,7 @@ export function ClaudeSetup({
</div>
{isDirectProvider && openAiCredentials.length > 0 && (
<div className="space-y-1 border-border border-t pt-2">
<div className="space-y-1.5 border-border/60 border-t px-4 py-3">
{openAiCredentials.map((credential) => {
const displayName = getProviderDisplayName({
label: credential.label,
@ -1106,26 +1212,34 @@ export function ClaudeSetup({
return (
<div
key={credential.id}
className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1 text-xs"
className="flex min-h-9 min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-xs transition-colors hover:bg-muted/50"
>
{iconSrc ? (
<img
src={iconSrc}
alt=""
className="size-3.5 shrink-0 object-contain"
className="size-4 shrink-0 object-contain"
/>
) : (
<CircleIcon className="size-2.5 shrink-0 text-muted-foreground/50" />
<CircleIcon className="size-3 shrink-0 text-muted-foreground/50" />
)}
<span className="shrink-0 font-medium">{displayName}</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">
{credential.model}
</span>
<ModelCapabilityBadges
label={credential.label}
baseUrl={credential.base_url}
model={credential.model}
/>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 font-medium">
{displayName}
</span>
<span className="min-w-0 truncate text-muted-foreground">
{credential.model}
</span>
</div>
</div>
<div className="shrink-0">
<ModelCapabilityBadges
label={credential.label}
baseUrl={credential.base_url}
model={credential.model}
/>
</div>
</div>
);
})}
@ -1138,7 +1252,7 @@ export function ClaudeSetup({
// Installation in progress
if (isInstalling) {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className={setupSurfaceClass()}>
<div className="flex items-center gap-2">
<TerminalIcon className="size-5 shrink-0 text-muted-foreground" />
<p className="font-medium text-sm">Installing Claude Code</p>
@ -1158,7 +1272,7 @@ export function ClaudeSetup({
// Login in progress
if (isLoggingIn) {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className={setupSurfaceClass()}>
<div className="flex items-center gap-2">
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
<p className="font-medium text-sm">Signing in to Claude</p>
@ -1181,7 +1295,7 @@ export function ClaudeSetup({
const hasInstallSteps = installSteps.length > 0;
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-destructive/30 bg-destructive/5 px-5 py-4">
<div className={setupSurfaceClass("error")}>
<div className="flex items-center gap-2">
<AlertCircleIcon className="size-5 shrink-0 text-destructive" />
<p className="font-medium text-sm">
@ -1235,23 +1349,17 @@ export function ClaudeSetup({
if (status === "missing-git") {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-amber-500/30 bg-amber-500/5 px-5 py-4">
<div className={setupSurfaceClass("warning")}>
<div className="flex items-center gap-2">
<GitBranchIcon className="size-5 shrink-0 text-amber-600" />
<div>
<p className="font-medium text-sm">Connect AI Provider</p>
<p className="font-medium text-sm">Install Git first</p>
<p className="text-muted-foreground text-xs">
OpenAI-compatible providers work without Git. Git for Windows is
only needed if you want Claude Code/browser sign-in.
Git for Windows is required before Claude Code can be installed
and providers can be configured.
</p>
</div>
</div>
{renderApiKeyForm({ forceOpenAiCompatible: true })}
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">Claude Code</span>
<div className="h-px flex-1 bg-border" />
</div>
<Button
size="sm"
variant="outline"
@ -1278,26 +1386,19 @@ export function ClaudeSetup({
if (status === "not-installed") {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className={setupSurfaceClass()}>
<div className="flex items-center gap-2">
<KeyRoundIcon className="size-5 shrink-0 text-muted-foreground" />
<DownloadIcon className="size-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-sm">Connect AI Provider</p>
<p className="font-medium text-sm">Install Claude Code first</p>
<p className="text-muted-foreground text-xs">
Use Qwen, DeepSeek, GLM, Gemini, or another OpenAI-compatible
endpoint without installing Claude Code.
Provider keys can be added after the Claude Code CLI is installed.
</p>
</div>
</div>
{renderApiKeyForm({ forceOpenAiCompatible: true })}
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-[11px] text-muted-foreground">Claude Code</span>
<div className="h-px flex-1 bg-border" />
</div>
<Button
size="sm"
variant="outline"
variant="default"
className="w-full gap-2"
onClick={install}
>
@ -1313,7 +1414,7 @@ export function ClaudeSetup({
if (status === "not-authenticated") {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className={setupSurfaceClass()}>
<div className="flex items-center gap-2">
<KeyRoundIcon className="size-5 shrink-0 text-muted-foreground" />
<div>

View file

@ -22,21 +22,23 @@ import {
CircleIcon,
DownloadIcon,
Loader2Icon,
RefreshCwIcon,
ArrowUpCircleIcon,
KeyRoundIcon,
SearchIcon,
PanelLeftIcon,
PlusIcon,
SettingsIcon,
GithubIcon,
MonitorIcon,
MoonIcon,
SunIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useTheme } from "next-themes";
import { useProjectStore } from "@/stores/project-store";
import { useDocumentStore } from "@/stores/document-store";
import { useClaudeSetupStore } from "@/stores/claude-setup-store";
import { useUvSetupStore } from "@/stores/uv-setup-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useUpdater } from "@/hooks/use-updater";
import { compileLatex } from "@/lib/latex-compiler";
import { getMupdfClient } from "@/lib/mupdf/mupdf-client";
import { exists, join } from "@/lib/tauri/fs";
@ -61,6 +63,7 @@ interface DefaultProject {
}
type ProjectPickerSection = "projects" | "settings";
type SettingsDetailSection = "provider" | "environment";
type RecentProject = {
path: string;
@ -92,12 +95,14 @@ export function ProjectPicker() {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [activeSection, setActiveSection] =
useState<ProjectPickerSection>("projects");
const [settingsDetailSection, setSettingsDetailSection] =
useState<SettingsDetailSection>("provider");
const [searchQuery, setSearchQuery] = useState("");
const [removeProjectTarget, setRemoveProjectTarget] =
useState<RecentProject | null>(null);
const defaultProjectsDiscoveredRef = useRef(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const { status: updateStatus, checkForUpdate, installUpdate } = useUpdater();
const { theme = "system", setTheme } = useTheme();
const searchShortcutLabel = "⌘ K";
const recentProjects = useProjectStore((s) => s.recentProjects);
@ -283,6 +288,58 @@ export function ProjectPicker() {
Settings
</ProjectNavButton>
</nav>
<div
className={cn(
"mt-auto flex h-9 items-center border-sidebar-border border-t text-muted-foreground text-xs",
isSidebarCollapsed ? "justify-center px-0" : "justify-between px-3",
)}
>
{isSidebarCollapsed ? (
<img src="/icon-192.png" alt="ClaudePrism" className="size-4" />
) : (
<>
<span className="truncate">ClaudePrism v{appVersion}</span>
<div className="flex shrink-0 items-center gap-1">
<Button variant="ghost" size="icon" className="size-6" asChild>
<a
href="https://github.com/delibae/claude-prism"
target="_blank"
rel="noopener noreferrer"
title="GitHub"
>
<GithubIcon className="size-3.5" />
</a>
</Button>
<Button
variant="ghost"
size="icon"
className="size-6"
onClick={() => {
if (theme === "system") setTheme("light");
else if (theme === "light") setTheme("dark");
else setTheme("system");
}}
title={
theme === "system"
? "System theme"
: theme === "light"
? "Light mode"
: "Dark mode"
}
>
{theme === "system" ? (
<MonitorIcon className="size-3.5" />
) : theme === "light" ? (
<SunIcon className="size-3.5" />
) : (
<MoonIcon className="size-3.5" />
)}
</Button>
</div>
</>
)}
</div>
</aside>
<main className="flex min-w-0 flex-1 flex-col">
@ -330,22 +387,43 @@ export function ProjectPicker() {
<div className="min-h-0 flex-1 overflow-auto">
{activeSection === "settings" ? (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-6 py-5">
<section className="rounded-lg border border-border/60 bg-background p-4">
<h2 className="font-semibold text-sm">Application</h2>
<div className="mt-3">
<VersionBadge
version={appVersion}
updateStatus={updateStatus}
onCheck={checkForUpdate}
onInstall={installUpdate}
/>
</div>
</section>
<section className="space-y-3">
<h2 className="font-semibold text-sm">Environment</h2>
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
</section>
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 gap-6 px-8 py-7 lg:grid-cols-[13rem_minmax(0,1fr)]">
<aside className="space-y-1 lg:border-border/60 lg:border-r lg:pr-4">
<SettingsDetailButton
active={settingsDetailSection === "provider"}
icon={KeyRoundIcon}
label="Provider"
meta={isClaudeReady ? "Ready" : "Setup"}
onClick={() => setSettingsDetailSection("provider")}
/>
<SettingsDetailButton
active={settingsDetailSection === "environment"}
icon={CheckCircle2Icon}
label="Environment"
meta="Python / Skills"
onClick={() => setSettingsDetailSection("environment")}
/>
</aside>
<div className="min-w-0">
{settingsDetailSection === "provider" ? (
<SettingsPanel
title="Provider"
icon={KeyRoundIcon}
contentClassName="p-0"
>
<ClaudeSetup variant="embedded" />
</SettingsPanel>
) : (
<SettingsPanel
title="Environment"
icon={CheckCircle2Icon}
contentClassName="p-0"
>
<EnvironmentStatus appVersion={appVersion} />
</SettingsPanel>
)}
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4 px-5 py-5">
@ -832,20 +910,75 @@ function ProjectNavButton({
);
}
function EnvironmentStatus() {
const [showAiSetup, setShowAiSetup] = useState(false);
const claudeVersion = useClaudeSetupStore((s) => s.version);
const providerKind = useClaudeSetupStore((s) => s.providerKind);
const claudeEmail = useClaudeSetupStore((s) => s.accountEmail);
const providerModel = useClaudeSetupStore((s) => s.providerModel);
const providerBaseUrl = useClaudeSetupStore((s) => s.providerBaseUrl);
const isDirectProvider = providerKind === "openai-compatible";
const aiDetail = isDirectProvider
? [claudeVersion, providerModel, providerBaseUrl]
.filter(Boolean)
.join(" · ")
: [claudeVersion, claudeEmail].filter(Boolean).join(" · ");
function SettingsDetailButton({
active,
icon: Icon,
label,
meta,
onClick,
}: {
active: boolean;
icon: LucideIcon;
label: string;
meta: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
>
<div
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md border",
active
? "border-border/70 bg-background/70"
: "border-border/60 bg-muted/20",
)}
>
<Icon className="size-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm">{label}</div>
<div className="truncate text-muted-foreground text-xs">{meta}</div>
</div>
</button>
);
}
function SettingsPanel({
title,
icon: Icon,
contentClassName,
children,
}: {
title: string;
icon: LucideIcon;
contentClassName?: string;
children: ReactNode;
}) {
return (
<section className="overflow-hidden rounded-xl border border-border/60 bg-muted/10">
<div className="flex items-center gap-3 border-border/60 border-b px-5 py-4">
<div className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-muted/30 text-muted-foreground">
<Icon className="size-4" />
</div>
<div className="min-w-0">
<h2 className="truncate font-semibold text-sm">{title}</h2>
</div>
</div>
<div className={cn("p-4", contentClassName)}>{children}</div>
</section>
);
}
function EnvironmentStatus({ appVersion }: { appVersion: string }) {
const uvStatus = useUvSetupStore((s) => s.status);
const uvVersion = useUvSetupStore((s) => s.version);
const uvInstalling = useUvSetupStore((s) => s.isInstalling);
@ -900,19 +1033,7 @@ function EnvironmentStatus() {
return (
<>
<div className="flex w-full flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 px-3 py-2.5">
{/* AI provider — always ready here */}
<StatusRow
ok={true}
label={isDirectProvider ? "AI Provider" : "Claude Code"}
detail={aiDetail}
action={{
label: showAiSetup ? "Hide" : "Manage",
onClick: () => setShowAiSetup((value) => !value),
icon: "key",
}}
/>
<div className="divide-y divide-border/60">
{/* Python (uv) */}
<StatusRow
ok={uvStatus === "ready"}
@ -947,17 +1068,22 @@ function EnvironmentStatus() {
: "Not installed"
}
action={
!skillsStatus?.installed && !skillsInstalling
? {
label: "Install",
skillsInstalling
? { label: "Installing...", loading: true }
: {
label: skillsStatus?.installed ? "Manage" : "Install",
onClick: () => setShowSkillsOnboarding(true),
icon: skillsStatus?.installed ? "settings" : "download",
}
: undefined
}
/>
</div>
{showAiSetup && <ClaudeSetup />}
<StatusRow
ok={true}
label="ClaudePrism"
detail={appVersion ? `v${appVersion}` : "Checking..."}
/>
</div>
{showSkillsOnboarding && OnboardingComponent && (
<OnboardingComponent
@ -984,32 +1110,43 @@ function StatusRow({
label: string;
onClick?: () => void;
loading?: boolean;
icon?: "download" | "key";
icon?: "download" | "key" | "settings";
};
}) {
return (
<div className="flex min-w-0 items-center gap-2.5">
{ok ? (
<CheckCircle2Icon className="size-3.5 shrink-0 text-foreground" />
) : (
<CircleIcon className="size-3.5 shrink-0 text-muted-foreground/40" />
)}
<span
<div className="flex min-h-12 min-w-0 items-center gap-3 px-4 py-3">
<div
className={cn(
"shrink-0 text-sm",
ok ? "text-foreground" : "text-muted-foreground",
"flex size-7 shrink-0 items-center justify-center rounded-md border",
ok
? "border-green-500/20 bg-green-500/10 text-green-600"
: "border-border/70 bg-muted/30 text-muted-foreground",
)}
>
{label}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{detail}
</span>
{ok ? (
<CheckCircle2Icon className="size-3.5" />
) : (
<CircleIcon className="size-3.5" />
)}
</div>
<div className="flex min-w-0 flex-1 items-baseline gap-3">
<span
className={cn(
"w-32 shrink-0 truncate font-medium text-sm",
ok ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground text-xs">
{detail}
</span>
</div>
{action && (
<Button
variant="ghost"
size="sm"
className="h-6 shrink-0 px-2 text-xs"
className="h-7 shrink-0 rounded-md px-2.5 text-xs"
onClick={action.onClick}
disabled={action.loading}
>
@ -1017,6 +1154,8 @@ function StatusRow({
<Loader2Icon className="mr-1 size-3 animate-spin" />
) : action.icon === "key" ? (
<KeyRoundIcon className="mr-1 size-3" />
) : action.icon === "settings" ? (
<SettingsIcon className="mr-1 size-3" />
) : (
<DownloadIcon className="mr-1 size-3" />
)}
@ -1026,109 +1165,3 @@ function StatusRow({
</div>
);
}
// ─── Version Badge with Update Status ───
function VersionBadge({
version,
updateStatus,
onCheck,
onInstall,
}: {
version: string;
updateStatus: import("@/hooks/use-updater").UpdateStatus;
onCheck: () => void;
onInstall: () => void;
}) {
if (!version) return null;
switch (updateStatus.state) {
case "available":
return (
<button
onClick={onInstall}
className="flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-primary text-xs transition-colors hover:bg-primary/20"
>
<ArrowUpCircleIcon className="size-3.5" />v{updateStatus.version}{" "}
available — click to update
</button>
);
case "downloading":
return (
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-muted-foreground text-xs">
<Loader2Icon className="size-3.5 animate-spin" />
Downloading... {updateStatus.percent}%
</div>
);
case "installing":
return (
<div className="flex items-center gap-1.5 rounded-full bg-muted px-3 py-1 text-muted-foreground text-xs">
<Loader2Icon className="size-3.5 animate-spin" />
Installing...
</div>
);
case "ready":
return (
<div className="flex items-center gap-1.5 rounded-full bg-green-500/10 px-3 py-1 text-green-600 text-xs">
<CheckCircle2Icon className="size-3.5" />
Update complete — restarting...
</div>
);
case "checking":
return (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<Loader2Icon className="size-3 animate-spin" />v{version} — checking
for updates...
</div>
);
case "error":
return (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span>v{version}</span>
<span className="mx-0.5">·</span>
<button
onClick={onCheck}
className="flex items-center gap-1 transition-colors hover:text-foreground"
>
<RefreshCwIcon className="size-3" />
Retry
</button>
</div>
);
case "up-to-date":
return (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span>v{version}</span>
<span className="mx-0.5">·</span>
<button
onClick={onCheck}
className="flex items-center gap-1 transition-colors hover:text-foreground"
>
<CheckCircle2Icon className="size-3 text-green-500" />
Up to date
</button>
</div>
);
default:
return (
<div className="flex items-center gap-1.5 text-muted-foreground text-xs">
<span>v{version}</span>
<span className="mx-0.5">·</span>
<button
onClick={onCheck}
className="flex items-center gap-1 transition-colors hover:text-foreground"
>
<RefreshCwIcon className="size-3" />
Check for updates
</button>
</div>
);
}
}

View file

@ -355,6 +355,14 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
model?: string,
credentialLabel?: string,
) => {
const status = get().status;
if (status === "missing-git" || status === "not-installed") {
set({
error: "Install Claude Code before configuring an AI provider.",
});
return false;
}
const key = apiKey.trim();
const rawUrl = baseUrl?.trim() ?? "";
const url =