Improve Windows setup, API auth, sessions, and project recovery

This commit is contained in:
Gerard-Devlin 2026-06-08 00:50:44 +08:00
parent dc5e9a5d24
commit d993c85da1
15 changed files with 781 additions and 109 deletions

View file

@ -912,7 +912,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@ -1110,7 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -3424,7 +3424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
@ -4397,7 +4397,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -4463,7 +4463,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -5510,9 +5510,9 @@ dependencies = [
[[package]]
name = "tauri-runtime"
version = "2.11.2"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef"
checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2"
dependencies = [
"cookie",
"dpi",
@ -5920,7 +5920,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@ -6997,7 +6997,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]

View file

@ -7,6 +7,18 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
#[derive(Default, serde::Deserialize, serde::Serialize)]
#[serde(default)]
struct ClaudePrismAuthConfig {
anthropic_api_key: Option<String>,
anthropic_base_url: Option<String>,
}
struct StoredClaudeCredential {
api_key: String,
base_url: Option<String>,
}
/// Check if an environment variable should be explicitly passed to child processes.
///
/// NOTE: This is NOT a true whitelist — we do NOT call `env_clear()`, so the
@ -22,6 +34,8 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool {
"HOME" | "USER" | "SHELL" | "LANG"
| "HOMEBREW_PREFIX" | "HOMEBREW_CELLAR"
| "HTTP_PROXY" | "HTTPS_PROXY" | "NO_PROXY" | "ALL_PROXY"
| "ANTHROPIC_API_KEY" | "ANTHROPIC_AUTH_TOKEN"
| "ANTHROPIC_BASE_URL"
) || k.starts_with("LC_")
// Windows-specific
|| matches!(
@ -35,6 +49,148 @@ pub(crate) fn is_essential_env_var(key: &str) -> bool {
)
}
fn get_claude_prism_auth_path() -> Result<PathBuf, String> {
let config_dir = dirs::config_dir()
.or_else(dirs::home_dir)
.ok_or("Could not find config directory")?;
Ok(config_dir
.join("ClaudePrism")
.join("anthropic-auth.json"))
}
fn read_claude_prism_auth_config() -> Result<ClaudePrismAuthConfig, String> {
let path = get_claude_prism_auth_path()?;
if !path.exists() {
return Ok(ClaudePrismAuthConfig::default());
}
let content =
std::fs::read_to_string(&path).map_err(|e| format!("Failed to read auth settings: {}", e))?;
let content = content.trim_start_matches('\u{feff}');
serde_json::from_str(content).map_err(|e| format!("Failed to parse auth settings: {}", e))
}
fn write_claude_prism_auth_config(config: &ClaudePrismAuthConfig) -> Result<(), String> {
let path = get_claude_prism_auth_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create auth settings dir: {}", e))?;
}
let content = serde_json::to_string_pretty(config)
.map_err(|e| format!("Failed to serialize auth settings: {}", e))?;
std::fs::write(&path, content).map_err(|e| format!("Failed to write auth settings: {}", e))
}
fn normalize_api_key(value: &str) -> Result<String, String> {
let clean = strip_nul(value).trim().to_string();
if clean.is_empty() {
return Err("API key is empty".to_string());
}
if clean.chars().any(char::is_whitespace) {
return Err("API key cannot contain spaces or line breaks".to_string());
}
Ok(clean)
}
fn normalize_base_url(value: Option<&str>) -> Result<Option<String>, String> {
let Some(value) = value else {
return Ok(None);
};
let clean = strip_nul(value).trim().trim_end_matches('/').to_string();
if clean.is_empty() {
return Ok(None);
}
if clean.chars().any(char::is_whitespace) {
return Err("Base URL cannot contain spaces or line breaks".to_string());
}
if !(clean.starts_with("https://") || clean.starts_with("http://")) {
return Err("Base URL must start with http:// or https://".to_string());
}
Ok(Some(clean))
}
fn stored_claude_credential() -> Option<StoredClaudeCredential> {
let config = read_claude_prism_auth_config().ok()?;
let api_key = config
.anthropic_api_key
.and_then(|value| normalize_api_key(&value).ok())?;
let base_url = normalize_base_url(config.anthropic_base_url.as_deref()).ok()?;
if base_url.is_none() && !api_key.starts_with("sk-ant-") {
return None;
}
Some(StoredClaudeCredential { api_key, base_url })
}
fn claude_credential_label() -> Option<&'static str> {
if std::env::var("ANTHROPIC_API_KEY")
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
if std::env::var("ANTHROPIC_BASE_URL")
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return Some("External API key");
}
return Some("Anthropic API key");
}
if std::env::var("ANTHROPIC_AUTH_TOKEN")
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return Some("Anthropic auth token");
}
if let Some(credential) = stored_claude_credential() {
return Some(if credential.base_url.is_some() {
"External API key"
} else {
"Anthropic API key"
});
}
None
}
#[tauri::command]
pub async fn save_anthropic_api_key(
api_key: String,
base_url: Option<String>,
) -> Result<(), String> {
let api_key = normalize_api_key(&api_key)?;
let base_url = normalize_base_url(base_url.as_deref())?;
if base_url.is_none() && !api_key.starts_with("sk-ant-") {
return Err(
"This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-."
.to_string(),
);
}
let mut config = read_claude_prism_auth_config()?;
config.anthropic_api_key = Some(api_key);
config.anthropic_base_url = base_url;
write_claude_prism_auth_config(&config)
}
#[tauri::command]
pub async fn clear_anthropic_api_key() -> Result<(), String> {
let mut config = read_claude_prism_auth_config()?;
config.anthropic_api_key = None;
config.anthropic_base_url = None;
write_claude_prism_auth_config(&config)
}
/// Windows CREATE_NO_WINDOW flag to prevent console windows from flashing
/// when spawning child processes (e.g. Claude CLI, cmd.exe, node.exe).
#[cfg(target_os = "windows")]
@ -698,6 +854,24 @@ fn create_command(
// Set effort level (default: low for fast responses)
cmd.env("CLAUDE_CODE_EFFORT_LEVEL", effort_level.unwrap_or("low"));
if let Some(credential) = stored_claude_credential() {
if std::env::var("ANTHROPIC_API_KEY")
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
cmd.env("ANTHROPIC_API_KEY", credential.api_key);
}
if let Some(base_url) = credential.base_url {
if std::env::var("ANTHROPIC_BASE_URL")
.map(|value| value.trim().is_empty())
.unwrap_or(true)
{
cmd.env("ANTHROPIC_BASE_URL", base_url);
}
}
}
// On Windows, ensure CLAUDE_CODE_GIT_BASH_PATH is set.
// Claude Code requires git-bash to run on Windows.
// Uses find_git_bash() which also validates user-specified paths.
@ -883,12 +1057,15 @@ async fn spawn_claude_process(
let stderr_reader = BufReader::new(stderr);
let session_id_holder: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let result_success_holder: Arc<std::sync::Mutex<Option<bool>>> =
Arc::new(std::sync::Mutex::new(None));
let start_time = std::time::Instant::now();
// Spawn stdout streaming task — emit only to the originating window
let win_stdout = window.clone();
let session_id_stdout = session_id_holder.clone();
let result_success_stdout = result_success_holder.clone();
let tab_id_stdout = tab_id.clone();
let stdout_task = tokio::spawn(async move {
let mut lines = stdout_reader.lines();
@ -920,6 +1097,14 @@ async fn spawn_claude_process(
}
}
}
if msg.get("type").and_then(|v| v.as_str()) == Some("result") {
let is_success =
msg.get("subtype").and_then(|v| v.as_str()) == Some("success");
if let Ok(mut guard) = result_success_stdout.lock() {
*guard = Some(is_success);
}
}
}
// Emit output event to this window with tab_id
@ -966,6 +1151,7 @@ async fn spawn_claude_process(
let win_wait = window;
let process_key_wait = process_key;
let tab_id_wait = tab_id;
let result_success_wait = result_success_holder.clone();
tokio::spawn(async move {
// Wait for stdout/stderr to finish
let _ = stdout_task.await;
@ -976,13 +1162,18 @@ async fn spawn_claude_process(
let success = if let Some(mut child) = processes.remove(&process_key_wait) {
match child.wait().await {
Ok(status) => {
let exit_success = status.success();
let result_success = result_success_wait.lock().ok().and_then(|guard| *guard);
let success = exit_success || result_success == Some(true);
eprintln!(
"[claude-process] [{}] exited with status={} ({:.1}s)",
"[claude-process] [{}] exited with status={} result_success={:?} final_success={} ({:.1}s)",
tab_id_wait,
status,
result_success,
success,
start_time.elapsed().as_secs_f64()
);
status.success()
success
}
Err(e) => {
eprintln!(
@ -1137,7 +1328,10 @@ pub async fn check_claude_status() -> Result<ClaudeStatus, String> {
});
(true, email)
}
_ => (false, None),
_ => match claude_credential_label() {
Some(label) => (true, Some(label.to_string())),
None => (false, None),
},
};
Ok(ClaudeStatus {
@ -1848,6 +2042,44 @@ pub async fn load_session_history(
// ─── Shell Command Execution ───
#[tauri::command]
pub async fn delete_claude_session(project_path: String, session_id: String) -> Result<(), String> {
if session_id.is_empty()
|| !session_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
return Err("Invalid session id".to_string());
}
let sessions_dir = get_sessions_dir(&project_path)?;
let session_path = sessions_dir.join(format!("{}.jsonl", session_id));
if !session_path.exists() {
return Ok(());
}
let canonical_sessions_dir = sessions_dir
.canonicalize()
.map_err(|e| format!("Failed to resolve sessions directory: {}", e))?;
let canonical_session_path = session_path
.canonicalize()
.map_err(|e| format!("Failed to resolve session file: {}", e))?;
if !canonical_session_path.starts_with(&canonical_sessions_dir) {
return Err("Refusing to delete session outside project history".to_string());
}
if canonical_session_path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
return Err("Refusing to delete non-session file".to_string());
}
std::fs::remove_file(&canonical_session_path)
.map_err(|e| format!("Failed to delete session: {}", e))?;
Ok(())
}
#[derive(serde::Serialize)]
pub struct ShellCommandResult {
pub exit_code: i32,

View file

@ -214,6 +214,108 @@ fn allow_project_directory(app: tauri::AppHandle, root_path: String) -> Result<(
Ok(())
}
#[derive(serde::Serialize)]
struct ProjectCandidate {
path: String,
name: String,
last_modified: u64,
has_main_tex: bool,
}
fn modified_ms(path: &Path) -> u64 {
std::fs::metadata(path)
.and_then(|metadata| metadata.modified())
.ok()
.and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok())
.map(|duration| duration.as_millis() as u64)
.unwrap_or(0)
}
fn has_tex_file(dir: &Path) -> bool {
if dir.join("main.tex").is_file() || dir.join("document.tex").is_file() {
return true;
}
std::fs::read_dir(dir)
.ok()
.into_iter()
.flat_map(|entries| entries.flatten())
.any(|entry| {
let path = entry.path();
if !path.is_file() {
return false;
}
matches!(
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref(),
Some("tex" | "ltx")
)
})
}
fn project_modified_ms(dir: &Path) -> u64 {
let mut latest = modified_ms(dir);
for relative in [
"main.tex",
"document.tex",
".prism/build/main.pdf",
".claudeprism/history.git/.git/refs/heads/master",
] {
latest = latest.max(modified_ms(&dir.join(relative)));
}
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
latest = latest.max(modified_ms(&path));
}
}
}
latest
}
#[tauri::command]
fn list_default_projects() -> Result<Vec<ProjectCandidate>, String> {
let Some(home) = dirs::home_dir() else {
return Ok(Vec::new());
};
let base = home.join("Documents").join("ClaudePrism");
if !base.is_dir() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
let entries = std::fs::read_dir(&base)
.map_err(|e| format!("Failed to read default project directory: {}", e))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || !has_tex_file(&path) {
continue;
}
projects.push(ProjectCandidate {
path: path.to_string_lossy().to_string(),
name,
last_modified: project_modified_ms(&path),
has_main_tex: path.join("main.tex").is_file() || path.join("document.tex").is_file(),
});
}
projects.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
Ok(projects)
}
// --- Debug logging from JS (survives white-screen crashes) ---
#[tauri::command]
@ -366,6 +468,7 @@ pub fn run() {
.invoke_handler(tauri::generate_handler![
create_new_window,
allow_project_directory,
list_default_projects,
detect_editors,
open_in_editor,
js_log,
@ -376,6 +479,8 @@ pub fn run() {
claude::check_claude_status,
claude::install_claude_cli,
claude::login_claude,
claude::save_anthropic_api_key,
claude::clear_anthropic_api_key,
claude::execute_claude_code,
claude::continue_claude_code,
claude::resume_claude_code,
@ -385,6 +490,7 @@ pub fn run() {
claude::set_claude_fast_mode,
claude::list_claude_sessions,
claude::load_session_history,
claude::delete_claude_session,
zotero::zotero_start_oauth,
zotero::zotero_complete_oauth,
zotero::zotero_cancel_oauth,

View file

@ -3,8 +3,12 @@ use std::path::{Path, PathBuf};
use tauri::{Emitter, WebviewWindow};
const TARBALL_URL: &str =
"https://github.com/K-Dense-AI/claude-scientific-skills/archive/refs/heads/main.tar.gz";
const SKILLS_SUBFOLDER: &str = "scientific-skills";
"https://github.com/K-Dense-AI/scientific-agent-skills/archive/refs/heads/main.tar.gz";
const RAW_SKILL_URLS: &[&str] = &[
"https://raw.githubusercontent.com/K-Dense-AI/scientific-agent-skills/main/skills",
"https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills",
];
const SKILLS_SUBFOLDERS: &[&str] = &["skills", "scientific-skills"];
// ─── Data Types ───
@ -383,7 +387,7 @@ async fn download_tarball(tmp_dir: &Path) -> Result<(), String> {
.unpack(tmp_dir.join("repo-raw"))
.map_err(|e| format!("Failed to extract tarball: {}", e))?;
// The tarball extracts to claude-scientific-skills-main/
// The tarball extracts to scientific-agent-skills-main/
// We need to find it and rename to repo/
let raw_dir = tmp_dir.join("repo-raw");
if let Ok(mut entries) = std::fs::read_dir(&raw_dir) {
@ -399,16 +403,48 @@ async fn download_tarball(tmp_dir: &Path) -> Result<(), String> {
Ok(())
}
/// Copy the scientific-skills directory from the cloned repo to the target.
fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result<usize, String> {
let src = repo_dir.join(SKILLS_SUBFOLDER);
if !src.exists() {
return Err(format!(
"scientific-skills directory not found in cloned repo at {}",
src.display()
));
fn contains_skill_dirs(path: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(path) else {
return false;
};
entries
.flatten()
.any(|entry| entry.path().is_dir() && entry.path().join("SKILL.md").exists())
}
fn find_skills_source(repo_dir: &Path) -> Option<PathBuf> {
for subfolder in SKILLS_SUBFOLDERS {
let candidate = repo_dir.join(subfolder);
if contains_skill_dirs(&candidate) {
return Some(candidate);
}
}
if contains_skill_dirs(repo_dir) {
return Some(repo_dir.to_path_buf());
}
let entries = std::fs::read_dir(repo_dir).ok()?;
for entry in entries.flatten() {
let candidate = entry.path();
if candidate.is_dir() && contains_skill_dirs(&candidate) {
return Some(candidate);
}
}
None
}
/// Copy the skills directory from the downloaded repo to the target.
fn copy_skills(repo_dir: &Path, target_dir: &Path) -> Result<usize, String> {
let src = find_skills_source(repo_dir).ok_or_else(|| {
format!(
"skills directory not found in downloaded repo at {}",
repo_dir.display()
)
})?;
// Create target directory
std::fs::create_dir_all(target_dir)
.map_err(|e| format!("Failed to create target dir: {}", e))?;
@ -606,7 +642,7 @@ async fn install_skills_to(
// Create a temporary directory for the clone/download
let tmp_dir = std::env::temp_dir().join(format!(
"claude-scientific-skills-{}",
"scientific-agent-skills-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
@ -737,28 +773,30 @@ pub async fn get_skill_content(
}
}
// Fallback: fetch from GitHub
let url = format!(
"https://raw.githubusercontent.com/K-Dense-AI/claude-scientific-skills/main/scientific-skills/{}/SKILL.md",
skill_folder
);
// Fallback: fetch from GitHub. The upstream project moved from
// claude-scientific-skills/scientific-skills to scientific-agent-skills/skills.
let mut last_status = None;
for base_url in RAW_SKILL_URLS {
let url = format!("{}/{}/SKILL.md", base_url, skill_folder);
let response = reqwest::get(&url)
.await
.map_err(|e| format!("Failed to fetch from GitHub: {}", e))?;
let response = reqwest::get(&url)
.await
.map_err(|e| format!("Failed to fetch from GitHub: {}", e))?;
if response.status().is_success() {
return response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e));
}
if !response.status().is_success() {
return Err(format!(
"Skill '{}' not found (HTTP {})",
skill_folder,
response.status()
));
last_status = Some(response.status().to_string());
}
response
.text()
.await
.map_err(|e| format!("Failed to read response: {}", e))
Err(format!(
"Skill '{}' not found (HTTP {})",
skill_folder,
last_status.unwrap_or_else(|| "unknown".to_string())
))
}
// ─── Tests ───
@ -792,6 +830,34 @@ mod tests {
assert!(total >= 100);
}
#[test]
fn test_find_skills_source_new_repo_layout() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp.path().join("skills").join("exploratory-data-analysis");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Exploratory Data Analysis").unwrap();
let src = find_skills_source(tmp.path()).unwrap();
assert_eq!(src.file_name().and_then(|name| name.to_str()), Some("skills"));
}
#[test]
fn test_find_skills_source_legacy_repo_layout() {
let tmp = tempfile::tempdir().unwrap();
let skill_dir = tmp
.path()
.join("scientific-skills")
.join("exploratory-data-analysis");
std::fs::create_dir_all(&skill_dir).unwrap();
std::fs::write(skill_dir.join("SKILL.md"), "# Exploratory Data Analysis").unwrap();
let src = find_skills_source(tmp.path()).unwrap();
assert_eq!(
src.file_name().and_then(|name| name.to_str()),
Some("scientific-skills")
);
}
#[test]
fn test_parse_skill_md() {
let tmp = std::env::temp_dir().join("test-skill-parse");

View file

@ -52,8 +52,9 @@ describe("tauri fs helpers", () => {
describe("scanProjectFolder", () => {
it("does not recurse into generated cache directories", async () => {
vi.mocked(readDir).mockImplementation(async (dir: string) => {
if (dir === "/project") {
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
const dirPath = String(dir);
if (dirPath === "/project") {
return [
{ name: "__pycache__", isDirectory: true },
{ name: "node_modules", isDirectory: true },
@ -62,11 +63,11 @@ describe("tauri fs helpers", () => {
] as any;
}
if (dir === "/project/chapters") {
if (dirPath === "/project/chapters") {
return [{ name: "intro.tex", isDirectory: false }] as any;
}
throw new Error(`Unexpected readDir path: ${dir}`);
throw new Error(`Unexpected readDir path: ${dirPath}`);
});
const result = await scanProjectFolder("/project");

View file

@ -123,8 +123,9 @@ describe("useDocumentStore", () => {
it("skips Python cache directories and bytecode files during open", async () => {
vi.mocked(invoke).mockResolvedValue(undefined as never);
vi.mocked(readDir).mockImplementation(async (dir: string) => {
if (dir === "/project") {
vi.mocked(readDir).mockImplementation(async (dir: string | URL) => {
const dirPath = String(dir);
if (dirPath === "/project") {
return [
{ name: "__pycache__", isDirectory: true },
{ name: "main.tex", isDirectory: false },
@ -133,17 +134,18 @@ describe("useDocumentStore", () => {
] as any;
}
throw new Error(`Unexpected readDir path: ${dir}`);
throw new Error(`Unexpected readDir path: ${dirPath}`);
});
vi.mocked(stat).mockResolvedValue({ size: 32 } as any);
vi.mocked(readTextFile).mockImplementation(async (path: string) => {
if (path === "/project/main.tex") {
vi.mocked(readTextFile).mockImplementation(async (path: string | URL) => {
const filePath = String(path);
if (filePath === "/project/main.tex") {
return "\\documentclass{article}";
}
if (path === "/project/tool.py") {
if (filePath === "/project/tool.py") {
return "print('hello')";
}
throw new Error(`Unexpected readTextFile path: ${path}`);
throw new Error(`Unexpected readTextFile path: ${filePath}`);
});
await useDocumentStore.getState().openProject("/project");

View file

@ -1,6 +1,12 @@
import { useState, useCallback } from "react";
import { invoke } from "@tauri-apps/api/core";
import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react";
import {
HistoryIcon,
PlusIcon,
CheckIcon,
Loader2Icon,
Trash2Icon,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuTrigger,
@ -37,6 +43,7 @@ function formatRelativeTime(unixSeconds: number): string {
export function SessionSelector() {
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [deletingId, setDeletingId] = useState<string | null>(null);
const sessionId = useClaudeChatStore((s) => s.sessionId);
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
const newSession = useClaudeChatStore((s) => s.newSession);
@ -80,6 +87,36 @@ export function SessionSelector() {
[isStreaming, sessionId, resumeSession],
);
const handleDeleteSession = useCallback(
async (sid: string) => {
if (isStreaming || deletingId || !projectRoot) return;
const session = sessions.find((item) => item.session_id === sid);
const sessionTitle = session?.title || "this session";
if (!window.confirm(`Delete "${sessionTitle}"?`)) return;
setDeletingId(sid);
try {
await invoke("delete_claude_session", {
projectPath: projectRoot,
sessionId: sid,
});
setSessions((prev) => prev.filter((item) => item.session_id !== sid));
if (sid === sessionId) {
newSession();
}
} catch (err) {
log.error("Failed to delete session", {
sessionId: sid,
error: String(err),
});
} finally {
setDeletingId((current) => (current === sid ? null : current));
}
},
[deletingId, isStreaming, newSession, projectRoot, sessionId, sessions],
);
const handleNewChat = useCallback(() => {
if (isStreaming) return;
newSession();
@ -126,8 +163,8 @@ export function SessionSelector() {
<DropdownMenuItem
key={session.session_id}
onSelect={() => handleSelectSession(session.session_id)}
disabled={isStreaming}
className="flex items-start gap-2"
disabled={isStreaming || deletingId === session.session_id}
className="group flex items-start gap-2"
>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm">{session.title}</span>
@ -135,9 +172,30 @@ export function SessionSelector() {
{formatRelativeTime(session.last_modified)}
</span>
</div>
{session.session_id === sessionId && (
<CheckIcon className="size-4 shrink-0 text-primary" />
)}
<div className="flex shrink-0 items-center gap-1">
{session.session_id === sessionId && (
<CheckIcon className="size-4 text-primary" />
)}
<button
type="button"
className="flex size-6 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`Delete ${session.title}`}
title="Delete session"
disabled={isStreaming || deletingId === session.session_id}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleDeleteSession(session.session_id);
}}
>
{deletingId === session.session_id ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<Trash2Icon className="size-3.5" />
)}
</button>
</div>
</DropdownMenuItem>
))
)}

View file

@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import {
DownloadIcon,
@ -13,9 +13,12 @@ import {
ChevronRightIcon,
GitBranchIcon,
ExternalLinkIcon,
KeyRoundIcon,
} from "lucide-react";
import { open as shellOpen } from "@tauri-apps/plugin-shell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
useClaudeSetupStore,
type StepInfo,
@ -240,14 +243,18 @@ function InstallLogOutput() {
// ─── Main Component ───
export function ClaudeSetup() {
const [apiKey, setApiKey] = useState("");
const [baseUrl, setBaseUrl] = useState("");
const status = useClaudeSetupStore((s) => s.status);
const isInstalling = useClaudeSetupStore((s) => s.isInstalling);
const isLoggingIn = useClaudeSetupStore((s) => s.isLoggingIn);
const isSavingApiKey = useClaudeSetupStore((s) => s.isSavingApiKey);
const error = useClaudeSetupStore((s) => s.error);
const version = useClaudeSetupStore((s) => s.version);
const accountEmail = useClaudeSetupStore((s) => s.accountEmail);
const install = useClaudeSetupStore((s) => s.install);
const login = useClaudeSetupStore((s) => s.login);
const saveApiKey = useClaudeSetupStore((s) => s.saveApiKey);
const checkStatus = useClaudeSetupStore((s) => s.checkStatus);
const installSteps = useClaudeSetupStore((s) => s.installSteps);
const loginSteps = useClaudeSetupStore((s) => s.loginSteps);
@ -255,6 +262,14 @@ export function ClaudeSetup() {
useInstallEvents();
useLoginEvents();
const handleSaveApiKey = async () => {
const success = await saveApiKey(apiKey, baseUrl);
if (success) {
setApiKey("");
setBaseUrl("");
}
};
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">
@ -442,11 +457,11 @@ export function ClaudeSetup() {
return (
<div className="flex w-full flex-col gap-3 rounded-xl border border-border bg-muted/30 px-5 py-4">
<div className="flex items-center gap-2">
<LogInIcon className="size-5 shrink-0 text-muted-foreground" />
<KeyRoundIcon className="size-5 shrink-0 text-muted-foreground" />
<div>
<p className="font-medium text-sm">Sign in to Claude</p>
<p className="font-medium text-sm">Connect Claude</p>
<p className="text-muted-foreground text-xs">
Authenticate with your Anthropic account to continue.
Use an Anthropic key, an external API proxy, or browser sign-in.
</p>
</div>
</div>
@ -455,7 +470,80 @@ export function ClaudeSetup() {
Claude Code {version} installed
</p>
)}
<Button size="sm" className="w-full gap-2" onClick={login}>
<form
className="space-y-2"
onSubmit={(event) => {
event.preventDefault();
handleSaveApiKey();
}}
>
<div className="space-y-1.5">
<Label htmlFor="anthropic-api-key" className="text-xs">
Anthropic API Key
</Label>
<Input
id="anthropic-api-key"
type="password"
placeholder="sk-ant-..."
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
disabled={isSavingApiKey}
autoComplete="off"
/>
<p className="text-muted-foreground text-[11px]">
Anthropic Console keys start with sk-ant-. External provider keys
need a Base URL.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="anthropic-base-url" className="text-xs">
Base URL
</Label>
<Input
id="anthropic-base-url"
type="url"
placeholder="https://proxy.example.com/claude"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
disabled={isSavingApiKey}
autoComplete="off"
/>
<p className="text-muted-foreground text-[11px]">
Leave blank for Anthropic direct API.
</p>
</div>
{error && (
<p className="break-words text-destructive text-xs">{error}</p>
)}
<Button
type="submit"
size="sm"
className="w-full gap-2"
disabled={!apiKey.trim() || isSavingApiKey}
>
{isSavingApiKey ? (
<LoaderIcon className="size-3.5 animate-spin" />
) : (
<KeyRoundIcon className="size-3.5" />
)}
{isSavingApiKey ? "Saving..." : "Use API Key"}
</Button>
</form>
<div className="flex items-center gap-2">
<div className="h-px flex-1 bg-border" />
<span className="text-muted-foreground text-[11px]">or</span>
<div className="h-px flex-1 bg-border" />
</div>
<Button
size="sm"
variant="outline"
className="w-full gap-2"
onClick={login}
disabled={isSavingApiKey}
>
<LogInIcon className="size-3.5" />
Sign in with Browser
</Button>

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { listen } from "@tauri-apps/api/event";
@ -34,10 +34,19 @@ import { ProjectWizard, type CreationMode } from "./project-wizard";
import { ClaudeSetup } from "./claude-setup";
import { cn } from "@/lib/utils";
interface DefaultProject {
path: string;
name: string;
last_modified: number;
has_main_tex: boolean;
}
export function ProjectPicker() {
const [showModeDialog, setShowModeDialog] = useState(false);
const [wizardMode, setWizardMode] = useState<CreationMode | null>(null);
const [appVersion, setAppVersion] = useState("");
const [isRestoringProject, setIsRestoringProject] = useState(false);
const recoveryAttemptedRef = useRef(false);
const { status: updateStatus, checkForUpdate, installUpdate } = useUpdater();
const recentProjects = useProjectStore((s) => s.recentProjects);
@ -54,6 +63,41 @@ export function ProjectPicker() {
getVersion().then(setAppVersion);
}, [checkClaudeStatus]);
useEffect(() => {
if (recoveryAttemptedRef.current || recentProjects.length > 0) return;
recoveryAttemptedRef.current = true;
let cancelled = false;
async function recoverDefaultProjects() {
try {
const projects = await invoke<DefaultProject[]>(
"list_default_projects",
);
if (cancelled || projects.length === 0) return;
for (const project of [...projects].reverse()) {
addRecentProject(project.path);
}
setIsRestoringProject(true);
await openProject(projects[0].path);
} catch (err) {
console.warn("Failed to recover default projects:", err);
} finally {
if (!cancelled) {
setIsRestoringProject(false);
}
}
}
recoverDefaultProjects();
return () => {
cancelled = true;
};
}, [addRecentProject, openProject, recentProjects.length]);
const handleOpenFolder = async () => {
const selected = await open({
directory: true,
@ -101,15 +145,20 @@ export function ProjectPicker() {
{!isClaudeReady ? <ClaudeSetup /> : <EnvironmentStatus />}
<div
className={`flex w-full gap-3 ${!isClaudeReady ? "pointer-events-none opacity-50" : ""}`}
>
{isRestoringProject && (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
<span>Restoring last project...</span>
</div>
)}
<div className="flex w-full gap-3">
<Button
onClick={() => setShowModeDialog(true)}
size="lg"
variant="outline"
className="flex-1 gap-2"
disabled={!isClaudeReady}
disabled={isRestoringProject}
>
<FolderPlusIcon className="size-5" />
New Project
@ -118,7 +167,7 @@ export function ProjectPicker() {
onClick={handleOpenFolder}
size="lg"
className="flex-1 gap-2"
disabled={!isClaudeReady}
disabled={isRestoringProject}
>
<FolderOpenIcon className="size-5" />
Open Folder

View file

@ -217,7 +217,7 @@ export function ScientificSkillsOnboarding({
{totalSkills} AI skills across {categories.length} domains —
powered by{" "}
<a
href="https://github.com/K-Dense-AI/claude-scientific-skills"
href="https://github.com/K-Dense-AI/scientific-agent-skills"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline decoration-border underline-offset-2 hover:text-foreground"

View file

@ -71,22 +71,22 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] overflow-hidden sm:max-w-none">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<TerminalIcon className="size-5" />
Python Environment (uv)
<DialogTitle className="flex min-w-0 items-center gap-2 pr-8">
<TerminalIcon className="size-5 shrink-0" />
<span className="min-w-0 truncate">Python Environment (uv)</span>
</DialogTitle>
<DialogDescription>
Manage the Python virtual environment for this project.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="min-w-0 space-y-4 py-2">
{/* uv status */}
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
<StatusIcon status={status} isInstalling={isInstalling} />
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="font-medium text-sm">
{status === "checking"
? "Checking uv..."
@ -102,7 +102,9 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
</div>
)}
{error && (
<div className="mt-1 text-destructive text-xs">{error}</div>
<div className="mt-1 break-words text-destructive text-xs">
{error}
</div>
)}
</div>
{status === "not-installed" && !isInstalling && (
@ -121,10 +123,10 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{/* venv status — only show when uv is ready */}
{status === "ready" && (
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className="flex min-w-0 items-center gap-3 overflow-hidden rounded-lg border p-3">
<div
className={cn(
"flex size-8 items-center justify-center rounded-full",
"flex size-8 shrink-0 items-center justify-center rounded-full",
venvReady
? "bg-accent text-accent-foreground"
: "bg-muted text-muted-foreground",
@ -132,7 +134,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
>
<FolderIcon className="size-4" />
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="font-medium text-sm">
{venvReady
? "Virtual Environment Active"
@ -140,7 +142,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
</div>
{venvPath && (
<div
className="truncate text-muted-foreground text-xs"
className="max-w-full break-all text-muted-foreground text-xs leading-snug"
title={venvPath}
>
{venvPath}
@ -165,7 +167,7 @@ export function UvSetupDialog({ open, onClose }: UvSetupDialogProps) {
{/* Info text */}
{status === "ready" && venvReady && (
<p className="text-muted-foreground text-xs leading-relaxed">
<p className="max-w-full break-words text-muted-foreground text-xs leading-relaxed">
Claude Code will automatically use this environment when running
Python code. Use{" "}
<code className="text-foreground">uv pip install</code> to add
@ -187,28 +189,28 @@ function StatusIcon({
}) {
if (isInstalling || status === "checking") {
return (
<div className="flex size-8 items-center justify-center rounded-full bg-muted">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
</div>
);
}
if (status === "ready") {
return (
<div className="flex size-8 items-center justify-center rounded-full bg-accent text-accent-foreground">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-accent text-accent-foreground">
<CheckCircle2Icon className="size-4" />
</div>
);
}
if (status === "error") {
return (
<div className="flex size-8 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive">
<AlertCircleIcon className="size-4" />
</div>
);
}
// not-installed
return (
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-muted-foreground">
<div className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<TerminalIcon className="size-4" />
</div>
);

View file

@ -1,6 +1,5 @@
import { useEffect } from "react";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { PanelRightCloseIcon, PanelRightOpenIcon } from "lucide-react";
import { Sidebar } from "./sidebar";
import { LatexEditor } from "./editor/latex-editor";
import { PdfPreview } from "./preview/pdf-preview";
@ -41,25 +40,7 @@ export function WorkspaceLayout() {
<PanelResizeHandle className="w-px bg-border transition-colors hover:bg-ring" />
<Panel defaultSize={previewVisible ? 42.5 : 85} minSize={25}>
<div className="relative h-full">
<LatexEditor />
<button
type="button"
onClick={togglePreview}
title={
previewVisible
? "Hide PDF preview (Cmd+\\)"
: "Show PDF preview (Cmd+\\)"
}
className="absolute right-3 bottom-3 z-40 rounded-md border bg-background/85 p-1.5 text-muted-foreground shadow-sm backdrop-blur transition-colors hover:bg-muted hover:text-foreground"
>
{previewVisible ? (
<PanelRightCloseIcon className="size-4" />
) : (
<PanelRightOpenIcon className="size-4" />
)}
</button>
</div>
<LatexEditor />
</Panel>
{previewVisible && (

View file

@ -32,6 +32,7 @@ interface ClaudeSetupState {
status: SetupStatus;
isInstalling: boolean;
isLoggingIn: boolean;
isSavingApiKey: boolean;
error: string | null;
version: string | null;
accountEmail: string | null;
@ -48,6 +49,7 @@ interface ClaudeSetupState {
checkStatus: () => Promise<void>;
install: () => Promise<void>;
login: () => Promise<void>;
saveApiKey: (apiKey: string, baseUrl?: string) => Promise<boolean>;
toggleInstallLogs: () => void;
// Internal helpers
@ -107,6 +109,7 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
status: "checking",
isInstalling: false,
isLoggingIn: false,
isSavingApiKey: false,
error: null,
version: null,
accountEmail: null,
@ -204,6 +207,50 @@ export const useClaudeSetupStore = create<ClaudeSetupState>((set, get) => ({
}
},
saveApiKey: async (apiKey: string, baseUrl?: string) => {
const key = apiKey.trim();
const url = baseUrl?.trim() ?? "";
if (!key) {
set({ error: "API key is empty" });
return false;
}
if (/\s/.test(key)) {
set({ error: "API key cannot contain spaces or line breaks" });
return false;
}
if (url && !/^https?:\/\//.test(url)) {
set({ error: "Base URL must start with http:// or https://" });
return false;
}
if (!url && !key.startsWith("sk-ant-")) {
set({
error:
"This looks like an external provider key. Set the provider Base URL, or use an Anthropic key that starts with sk-ant-.",
});
return false;
}
set({ isSavingApiKey: true, error: null });
try {
await invoke("save_anthropic_api_key", {
apiKey: key,
baseUrl: url || null,
});
set({ isSavingApiKey: false });
await get().checkStatus();
return true;
} catch (err: any) {
set({
isSavingApiKey: false,
error: err?.message || String(err),
});
return false;
}
},
toggleInstallLogs: () => {
set((state) => ({ installLogsVisible: !state.installLogsVisible }));
},

View file

@ -13,7 +13,7 @@
],
"scripts": {
"dev:web": "pnpm turbo dev --filter=@claude-prism/web --filter=@claude-prism/latex-api",
"dev:desktop": "pnpm --filter=@claude-prism/desktop-sidecar dev & pnpm --filter=@claude-prism/desktop tauri dev",
"dev:desktop": "node scripts/dev-desktop.mjs",
"build:desktop": "pnpm --filter=@claude-prism/desktop tauri build",
"lint": "pnpm exec biome check",
"lint:fix": "pnpm exec biome check --fix",

40
scripts/dev-desktop.mjs Normal file
View file

@ -0,0 +1,40 @@
import { spawn } from "node:child_process";
import { join } from "node:path";
const env = { ...process.env };
if (process.platform === "win32") {
env.VCPKG_ROOT ||= join(env.USERPROFILE ?? "", "vcpkg");
env.TECTONIC_DEP_BACKEND ||= "vcpkg";
env.VCPKGRS_TRIPLET ||= "x64-windows-static-md-release";
env.CXXFLAGS = [env.CXXFLAGS, "/std:c++17"].filter(Boolean).join(" ");
}
const child =
process.platform === "win32"
? spawn(
process.env.ComSpec ?? "cmd.exe",
[
"/d",
"/s",
"/c",
"corepack pnpm --filter=@claude-prism/desktop tauri dev",
],
{
env,
stdio: "inherit",
},
)
: spawn("pnpm", ["--filter=@claude-prism/desktop", "tauri", "dev"], {
env,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});