mirror of
https://github.com/delibae/claude-prism.git
synced 2026-10-10 03:27:52 +00:00
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:
parent
37eb80b20a
commit
4176a915f5
5 changed files with 777 additions and 231 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue