mirror of
https://github.com/delibae/claude-prism.git
synced 2026-10-08 03:08:10 +00:00
feat: Implement project wizard for creating new LaTeX projects
- Added ProjectWizard component to guide users through project creation. - Integrated template selection with predefined LaTeX templates. - Implemented folder selection and file creation logic for new projects. - Updated ProjectPicker to launch the ProjectWizard instead of direct project creation. - Enhanced user experience with improved UI elements and tooltips. - Added keyboard shortcut for creating new windows. - Updated global styles for macOS titlebar compatibility. - Refactored existing components for better structure and maintainability.
This commit is contained in:
parent
a2af6513d0
commit
e178921dbc
17 changed files with 1311 additions and 259 deletions
|
|
@ -2,12 +2,19 @@
|
|||
"$schema": "https://raw.githubusercontent.com/nicegui-unofficial/nicegui-tauri-template/main/src-tauri/gen/schemas/capabilities-v2-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["*"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-create",
|
||||
"core:window:allow-set-title",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["main"],"permissions":["core:default","core:window:default","core:window:allow-set-title","core:window:allow-show","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-copy-file","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-file","fs:allow-write-file","fs:allow-stat","fs:allow-watch",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}}
|
||||
{"default":{"identifier":"default","description":"Default capabilities for the main window","local":true,"windows":["*"],"permissions":["core:default","core:window:default","core:window:allow-create","core:window:allow-set-title","core:window:allow-show","core:window:allow-start-dragging","core:window:allow-minimize","core:window:allow-maximize","core:window:allow-toggle-maximize","core:window:allow-close","core:webview:allow-create-webview-window","dialog:default","dialog:allow-open","dialog:allow-save","dialog:allow-message","dialog:allow-ask","fs:default","fs:allow-read","fs:allow-write","fs:allow-exists","fs:allow-mkdir","fs:allow-remove","fs:allow-rename","fs:allow-copy-file","fs:allow-read-dir","fs:allow-read-text-file","fs:allow-write-text-file","fs:allow-read-file","fs:allow-write-file","fs:allow-stat","fs:allow-watch",{"identifier":"fs:scope","allow":[{"path":"$HOME/**"},{"path":"$DOCUMENT/**"},{"path":"$DESKTOP/**"}]},"shell:default","shell:allow-open","shell:allow-spawn","shell:allow-stdin-write","shell:allow-kill","process:default","process:allow-exit"]}}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{Emitter, Manager, WebviewWindow};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeProcessState {
|
||||
pub current_process: Arc<Mutex<Option<Child>>>,
|
||||
pub processes: Arc<Mutex<HashMap<String, Child>>>,
|
||||
}
|
||||
|
||||
impl Default for ClaudeProcessState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
current_process: Arc::new(Mutex::new(None)),
|
||||
processes: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -148,10 +150,13 @@ fn create_command(program: &str, args: Vec<String>, cwd: &str) -> Command {
|
|||
}
|
||||
|
||||
/// Spawn the Claude CLI process and stream output via Tauri events.
|
||||
/// Events are emitted only to the originating window.
|
||||
async fn spawn_claude_process(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
mut cmd: Command,
|
||||
) -> Result<(), String> {
|
||||
let window_label = window.label().to_string();
|
||||
|
||||
// Spawn the process
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
|
|
@ -161,15 +166,15 @@ async fn spawn_claude_process(
|
|||
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
|
||||
|
||||
// Get a clone of the process state Arc before any moves
|
||||
let process_arc = app.state::<ClaudeProcessState>().inner().current_process.clone();
|
||||
let process_arc = window.state::<ClaudeProcessState>().inner().processes.clone();
|
||||
|
||||
// Store the child process in state (kill any existing process first)
|
||||
// Store the child process in state (kill any existing process for this window first)
|
||||
{
|
||||
let mut current_process = process_arc.lock().await;
|
||||
if let Some(mut existing) = current_process.take() {
|
||||
let mut processes = process_arc.lock().await;
|
||||
if let Some(mut existing) = processes.remove(&window_label) {
|
||||
let _ = existing.kill().await;
|
||||
}
|
||||
*current_process = Some(child);
|
||||
processes.insert(window_label.clone(), child);
|
||||
}
|
||||
|
||||
let stdout_reader = BufReader::new(stdout);
|
||||
|
|
@ -177,8 +182,8 @@ async fn spawn_claude_process(
|
|||
let session_id_holder: Arc<std::sync::Mutex<Option<String>>> =
|
||||
Arc::new(std::sync::Mutex::new(None));
|
||||
|
||||
// Spawn stdout streaming task
|
||||
let app_stdout = app.clone();
|
||||
// Spawn stdout streaming task — emit only to the originating window
|
||||
let win_stdout = window.clone();
|
||||
let session_id_stdout = session_id_holder.clone();
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut lines = stdout_reader.lines();
|
||||
|
|
@ -193,40 +198,41 @@ async fn spawn_claude_process(
|
|||
}
|
||||
}
|
||||
|
||||
// Emit session-scoped event
|
||||
// Emit session-scoped event to this window only
|
||||
if let Some(ref sid) = *session_id_stdout.lock().unwrap() {
|
||||
let _ = app_stdout.emit(&format!("claude-output:{}", sid), &line);
|
||||
let _ = win_stdout.emit(&format!("claude-output:{}", sid), &line);
|
||||
}
|
||||
// Always emit generic event (for initial listeners)
|
||||
let _ = app_stdout.emit("claude-output", &line);
|
||||
// Always emit generic event to this window
|
||||
let _ = win_stdout.emit("claude-output", &line);
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn stderr streaming task
|
||||
let app_stderr = app.clone();
|
||||
// Spawn stderr streaming task — emit only to the originating window
|
||||
let win_stderr = window.clone();
|
||||
let session_id_stderr = session_id_holder.clone();
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut lines = stderr_reader.lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if let Some(ref sid) = *session_id_stderr.lock().unwrap() {
|
||||
let _ = app_stderr.emit(&format!("claude-error:{}", sid), &line);
|
||||
let _ = win_stderr.emit(&format!("claude-error:{}", sid), &line);
|
||||
}
|
||||
let _ = app_stderr.emit("claude-error", &line);
|
||||
let _ = win_stderr.emit("claude-error", &line);
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn wait task — wait for process completion
|
||||
let process_arc_wait = process_arc.clone();
|
||||
let app_wait = app;
|
||||
let win_wait = window;
|
||||
let window_label_wait = window_label;
|
||||
let session_id_wait = session_id_holder.clone();
|
||||
tokio::spawn(async move {
|
||||
// Wait for stdout/stderr to finish
|
||||
let _ = stdout_task.await;
|
||||
let _ = stderr_task.await;
|
||||
|
||||
// Wait for process exit
|
||||
let mut current_process = process_arc_wait.lock().await;
|
||||
let success = if let Some(mut child) = current_process.take() {
|
||||
// Wait for process exit and remove from map
|
||||
let mut processes = process_arc_wait.lock().await;
|
||||
let success = if let Some(mut child) = processes.remove(&window_label_wait) {
|
||||
match child.wait().await {
|
||||
Ok(status) => status.success(),
|
||||
Err(_) => false,
|
||||
|
|
@ -234,15 +240,16 @@ async fn spawn_claude_process(
|
|||
} else {
|
||||
false
|
||||
};
|
||||
drop(processes);
|
||||
|
||||
// Small delay to ensure all events are flushed
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Emit completion event
|
||||
// Emit completion event to this window only
|
||||
if let Some(ref sid) = *session_id_wait.lock().unwrap() {
|
||||
let _ = app_wait.emit(&format!("claude-complete:{}", sid), success);
|
||||
let _ = win_wait.emit(&format!("claude-complete:{}", sid), success);
|
||||
}
|
||||
let _ = app_wait.emit("claude-complete", success);
|
||||
let _ = win_wait.emit("claude-complete", success);
|
||||
});
|
||||
|
||||
Ok(())
|
||||
|
|
@ -252,7 +259,7 @@ async fn spawn_claude_process(
|
|||
|
||||
#[tauri::command]
|
||||
pub async fn execute_claude_code(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
model: String,
|
||||
|
|
@ -271,12 +278,12 @@ pub async fn execute_claude_code(
|
|||
];
|
||||
|
||||
let cmd = create_command(&claude_path, args, &project_path);
|
||||
spawn_claude_process(app, cmd).await
|
||||
spawn_claude_process(window, cmd).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn continue_claude_code(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
model: String,
|
||||
|
|
@ -296,12 +303,12 @@ pub async fn continue_claude_code(
|
|||
];
|
||||
|
||||
let cmd = create_command(&claude_path, args, &project_path);
|
||||
spawn_claude_process(app, cmd).await
|
||||
spawn_claude_process(window, cmd).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn resume_claude_code(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
project_path: String,
|
||||
session_id: String,
|
||||
prompt: String,
|
||||
|
|
@ -323,23 +330,240 @@ pub async fn resume_claude_code(
|
|||
];
|
||||
|
||||
let cmd = create_command(&claude_path, args, &project_path);
|
||||
spawn_claude_process(app, cmd).await
|
||||
spawn_claude_process(window, cmd).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_claude_execution(
|
||||
app: AppHandle,
|
||||
window: WebviewWindow,
|
||||
) -> Result<(), String> {
|
||||
let claude_state = app.state::<ClaudeProcessState>();
|
||||
let mut current_process = claude_state.current_process.lock().await;
|
||||
if let Some(mut child) = current_process.take() {
|
||||
let window_label = window.label().to_string();
|
||||
let claude_state = window.state::<ClaudeProcessState>();
|
||||
let mut processes = claude_state.processes.lock().await;
|
||||
if let Some(mut child) = processes.remove(&window_label) {
|
||||
let _ = child.kill().await;
|
||||
// Emit completion with success=false
|
||||
let _ = app.emit("claude-complete", false);
|
||||
let _ = window.emit("claude-complete", false);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kill the Claude process associated with a specific window label.
|
||||
/// Called when a window is destroyed.
|
||||
pub async fn kill_process_for_window(state: &ClaudeProcessState, window_label: &str) {
|
||||
let mut processes = state.processes.lock().await;
|
||||
if let Some(mut child) = processes.remove(window_label) {
|
||||
let _ = child.kill().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Session Listing ───
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct ClaudeSessionInfo {
|
||||
pub session_id: String,
|
||||
pub title: String,
|
||||
pub last_modified: i64,
|
||||
}
|
||||
|
||||
/// Resolve the Claude Code sessions directory for a given project path.
|
||||
/// Claude Code encodes paths by replacing all non-alphanumeric characters with '-'.
|
||||
/// e.g. "/Users/dev/my_project" → "-Users-dev-my-project"
|
||||
fn get_sessions_dir(project_path: &str) -> Result<PathBuf, String> {
|
||||
let home = dirs::home_dir()
|
||||
.ok_or("Could not determine home directory")?;
|
||||
|
||||
let encoded: String = project_path
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
|
||||
eprintln!("[session] project_path={} encoded={}", project_path, encoded);
|
||||
|
||||
Ok(home.join(".claude").join("projects").join(&encoded))
|
||||
}
|
||||
|
||||
/// Clean raw user message text into a display title.
|
||||
fn clean_user_message_title(text: &str) -> Option<String> {
|
||||
// Skip IDE context tags
|
||||
if text.starts_with("<ide_") || text.starts_with("<system-reminder>") {
|
||||
return None;
|
||||
}
|
||||
// Skip command tags
|
||||
if text.starts_with("<command-name>") || text.starts_with("<local-command-stdout>") {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Strip context prefix like "[Currently open file: ...]\n\n"
|
||||
let clean = if let Some(idx) = text.rfind("]\n\n") {
|
||||
&text[idx + 3..]
|
||||
} else {
|
||||
text
|
||||
};
|
||||
|
||||
// Skip if still an IDE tag after stripping context
|
||||
if clean.starts_with("<ide_") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let clean = clean.trim();
|
||||
if clean.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let title = if clean.len() > 80 {
|
||||
format!("{}...", &clean[..77])
|
||||
} else {
|
||||
clean.to_string()
|
||||
};
|
||||
|
||||
Some(title)
|
||||
}
|
||||
|
||||
/// Extract the first valid user message from a JSONL session file.
|
||||
/// Handles both string content (stored JSONL) and array content (streaming format).
|
||||
fn extract_first_user_message(path: &PathBuf) -> (Option<String>, Option<String>) {
|
||||
let file = match std::fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return (None, None),
|
||||
};
|
||||
let reader = std::io::BufReader::new(file);
|
||||
use std::io::BufRead;
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let msg = match serde_json::from_str::<serde_json::Value>(&line) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if msg["type"] != "user" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content_val = &msg["message"]["content"];
|
||||
let timestamp = msg["timestamp"].as_str().map(|s| s.to_string());
|
||||
|
||||
// Case 1: content is a plain string (Claude Code stored JSONL format)
|
||||
if let Some(text) = content_val.as_str() {
|
||||
if let Some(title) = clean_user_message_title(text) {
|
||||
return (Some(title), timestamp);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Case 2: content is an array of blocks (streaming format)
|
||||
if let Some(blocks) = content_val.as_array() {
|
||||
for block in blocks {
|
||||
if block["type"] == "text" {
|
||||
if let Some(text) = block["text"].as_str() {
|
||||
if let Some(title) = clean_user_message_title(text) {
|
||||
return (Some(title), timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(None, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_claude_sessions(
|
||||
project_path: String,
|
||||
) -> Result<Vec<ClaudeSessionInfo>, String> {
|
||||
eprintln!("[session] list_claude_sessions called with project_path={}", project_path);
|
||||
let sessions_dir = get_sessions_dir(&project_path)?;
|
||||
eprintln!("[session] sessions_dir={:?} exists={}", sessions_dir, sessions_dir.exists());
|
||||
|
||||
if !sessions_dir.exists() {
|
||||
eprintln!("[session] sessions_dir does not exist, returning empty");
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut sessions = Vec::new();
|
||||
let entries = std::fs::read_dir(&sessions_dir)
|
||||
.map_err(|e| format!("Failed to read sessions directory: {}", e))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let session_id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
if session_id.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(&path).ok();
|
||||
let modified = metadata
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let (first_message, _timestamp) = extract_first_user_message(&path);
|
||||
let title = first_message.unwrap_or_else(|| "Untitled session".to_string());
|
||||
|
||||
sessions.push(ClaudeSessionInfo {
|
||||
session_id,
|
||||
title,
|
||||
last_modified: modified,
|
||||
});
|
||||
}
|
||||
|
||||
sessions.sort_by(|a, b| b.last_modified.cmp(&a.last_modified));
|
||||
|
||||
eprintln!("[session] found {} sessions", sessions.len());
|
||||
for s in &sessions {
|
||||
eprintln!("[session] id={} title={} modified={}", s.session_id, s.title, s.last_modified);
|
||||
}
|
||||
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// Load the full JSONL history for a specific session.
|
||||
#[tauri::command]
|
||||
pub async fn load_session_history(
|
||||
project_path: String,
|
||||
session_id: String,
|
||||
) -> Result<Vec<serde_json::Value>, String> {
|
||||
eprintln!("[session] load_session_history called: session_id={} project_path={}", session_id, project_path);
|
||||
let sessions_dir = get_sessions_dir(&project_path)?;
|
||||
let session_path = sessions_dir.join(format!("{}.jsonl", session_id));
|
||||
eprintln!("[session] session_path={:?} exists={}", session_path, session_path.exists());
|
||||
|
||||
if !session_path.exists() {
|
||||
return Err(format!("Session file not found: {}", session_id));
|
||||
}
|
||||
|
||||
let file = std::fs::File::open(&session_path)
|
||||
.map_err(|e| format!("Failed to open session file: {}", e))?;
|
||||
|
||||
let reader = std::io::BufReader::new(file);
|
||||
use std::io::BufRead;
|
||||
let mut messages = Vec::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
|
||||
messages.push(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("[session] loaded {} messages from session {}", messages.len(), session_id);
|
||||
|
||||
Ok(messages)
|
||||
}
|
||||
|
||||
// ─── Shell Command Execution ───
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ mod zotero;
|
|||
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Manager;
|
||||
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_macos_app_icon() {
|
||||
|
|
@ -55,6 +55,32 @@ fn get_sidecar_url() -> String {
|
|||
"http://localhost:3001".to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_new_window(app: tauri::AppHandle) -> Result<(), String> {
|
||||
let label = format!("window-{}", std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis());
|
||||
|
||||
let mut builder = WebviewWindowBuilder::new(&app, &label, WebviewUrl::default())
|
||||
.title("ClaudePrism")
|
||||
.inner_size(1400.0, 900.0)
|
||||
.min_inner_size(800.0, 600.0)
|
||||
.visible(false)
|
||||
.hidden_title(true);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
builder = builder
|
||||
.title_bar_style(tauri::TitleBarStyle::Overlay)
|
||||
.traffic_light_position(tauri::LogicalPosition::new(12.0, 12.0));
|
||||
}
|
||||
|
||||
builder.build().map_err(|e| format!("Failed to create window: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Load .env file (walks up from cwd to find it)
|
||||
|
|
@ -106,11 +132,14 @@ pub fn run() {
|
|||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_sidecar_url,
|
||||
create_new_window,
|
||||
claude::execute_claude_code,
|
||||
claude::continue_claude_code,
|
||||
claude::resume_claude_code,
|
||||
claude::cancel_claude_execution,
|
||||
claude::run_shell_command,
|
||||
claude::list_claude_sessions,
|
||||
claude::load_session_history,
|
||||
zotero::zotero_start_oauth,
|
||||
zotero::zotero_complete_oauth,
|
||||
zotero::zotero_cancel_oauth,
|
||||
|
|
@ -127,15 +156,32 @@ pub fn run() {
|
|||
.expect("error while building tauri application");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
if let tauri::RunEvent::ExitRequested { .. } = event {
|
||||
// Kill sidecar on exit
|
||||
let state = app_handle.state::<Mutex<SidecarState>>();
|
||||
if let Ok(mut guard) = state.lock() {
|
||||
if let Some(ref mut child) = guard.child {
|
||||
let _ = child.kill();
|
||||
println!("Sidecar process killed");
|
||||
match event {
|
||||
tauri::RunEvent::WindowEvent { label, event: tauri::WindowEvent::Destroyed, .. } => {
|
||||
// Kill Claude process associated with this window
|
||||
let claude_state = app_handle.state::<claude::ClaudeProcessState>();
|
||||
let label_clone = label.clone();
|
||||
let state_clone = claude_state.inner().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
claude::kill_process_for_window(&state_clone, &label_clone).await;
|
||||
});
|
||||
|
||||
// Quit the app when the last window is closed
|
||||
if app_handle.webview_windows().is_empty() {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
};
|
||||
}
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
// Kill sidecar on exit
|
||||
let state = app_handle.state::<Mutex<SidecarState>>();
|
||||
if let Ok(mut guard) = state.lock() {
|
||||
if let Some(ref mut child) = guard.child {
|
||||
let _ = child.kill();
|
||||
println!("Sidecar process killed");
|
||||
}
|
||||
};
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@
|
|||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"visible": false
|
||||
"visible": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 12, "y": 12 }
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { ThemeProvider } from "next-themes";
|
|||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { ProjectPicker } from "@/components/project-picker";
|
||||
import { WorkspaceLayout } from "@/components/workspace/workspace-layout";
|
||||
import { useEffect } from "react";
|
||||
|
|
@ -10,8 +11,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
|||
|
||||
function WorkspaceWithClaude() {
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
|
||||
useKeyboardShortcuts();
|
||||
const initialized = useDocumentStore((s) => s.initialized);
|
||||
|
||||
// Update window title
|
||||
useEffect(() => {
|
||||
|
|
@ -21,6 +21,19 @@ function WorkspaceWithClaude() {
|
|||
}
|
||||
}, [projectRoot]);
|
||||
|
||||
// Consume pending initial prompt from project wizard
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
// Delay to let ClaudeChatDrawer mount and register event listeners
|
||||
const timer = setTimeout(() => {
|
||||
const prompt = useClaudeChatStore.getState().consumePendingInitialPrompt();
|
||||
if (prompt) {
|
||||
useClaudeChatStore.getState().sendPrompt(prompt);
|
||||
}
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [initialized]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorkspaceLayout />
|
||||
|
|
@ -32,6 +45,9 @@ function WorkspaceWithClaude() {
|
|||
export function App({ onReady }: { onReady?: () => void }) {
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
|
||||
// Register global keyboard shortcuts (Cmd+S, Cmd+N) at the app level
|
||||
useKeyboardShortcuts();
|
||||
|
||||
useEffect(() => {
|
||||
onReady?.();
|
||||
}, [onReady]);
|
||||
|
|
@ -39,6 +55,11 @@ export function App({ onReady }: { onReady?: () => void }) {
|
|||
return (
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem>
|
||||
<TooltipProvider>
|
||||
{/* Global macOS titlebar drag region — sits above all content */}
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className="fixed inset-x-0 top-0 z-[9999] h-[var(--titlebar-height)]"
|
||||
/>
|
||||
{projectRoot ? <WorkspaceWithClaude /> : <ProjectPicker />}
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
|||
import { useClaudeEvents } from "@/hooks/use-claude-events";
|
||||
import { ChatMessages } from "./chat-messages";
|
||||
import { ChatComposer } from "./chat-composer";
|
||||
import { SessionSelector } from "./session-selector";
|
||||
|
||||
const MIN_HEIGHT = 150;
|
||||
const DEFAULT_HEIGHT = 360;
|
||||
|
|
@ -80,7 +81,7 @@ export function ClaudeChatDrawer() {
|
|||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-4 pb-6"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 flex justify-center p-4 pb-6"
|
||||
>
|
||||
{/* Floating toggle button */}
|
||||
<button
|
||||
|
|
@ -109,18 +110,23 @@ export function ClaudeChatDrawer() {
|
|||
)}
|
||||
style={{ height: isOpen ? height : 0 }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="group flex cursor-row-resize items-center justify-center gap-2 py-2 transition-colors hover:bg-muted/50"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={() => {
|
||||
if (!hasDraggedRef.current) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30 transition-all group-hover:w-8" />
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
{/* Header with drag handle and session selector */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="group flex cursor-row-resize items-center justify-center gap-2 py-2 transition-colors hover:bg-muted/50"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={() => {
|
||||
if (!hasDraggedRef.current) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30 transition-all group-hover:w-8" />
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
<div className="absolute top-1/2 right-2 -translate-y-1/2">
|
||||
<SessionSelector />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
|
|
|
|||
145
apps/desktop/src/components/claude-chat/session-selector.tsx
Normal file
145
apps/desktop/src/components/claude-chat/session-selector.tsx
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { HistoryIcon, PlusIcon, CheckIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
|
||||
interface ClaudeSessionInfo {
|
||||
session_id: string;
|
||||
title: string;
|
||||
last_modified: number;
|
||||
}
|
||||
|
||||
function formatRelativeTime(unixSeconds: number): string {
|
||||
const now = Date.now() / 1000;
|
||||
const delta = now - unixSeconds;
|
||||
|
||||
if (delta < 60) return "just now";
|
||||
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
|
||||
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
|
||||
if (delta < 604800) return `${Math.floor(delta / 86400)}d ago`;
|
||||
|
||||
const date = new Date(unixSeconds * 1000);
|
||||
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export function SessionSelector() {
|
||||
const [sessions, setSessions] = useState<ClaudeSessionInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const sessionId = useClaudeChatStore((s) => s.sessionId);
|
||||
const isStreaming = useClaudeChatStore((s) => s.isStreaming);
|
||||
const newSession = useClaudeChatStore((s) => s.newSession);
|
||||
const resumeSession = useClaudeChatStore((s) => s.resumeSession);
|
||||
const projectRoot = useDocumentStore((s) => s.projectRoot);
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
if (!projectRoot) return;
|
||||
setIsLoading(true);
|
||||
console.log("[session-selector] loading sessions for projectRoot:", projectRoot);
|
||||
try {
|
||||
const result = await invoke<ClaudeSessionInfo[]>(
|
||||
"list_claude_sessions",
|
||||
{ projectPath: projectRoot },
|
||||
);
|
||||
console.log("[session-selector] loaded sessions:", result);
|
||||
setSessions(result);
|
||||
} catch (err) {
|
||||
console.error("[session-selector] Failed to load sessions:", err);
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectRoot]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
loadSessions();
|
||||
}
|
||||
},
|
||||
[loadSessions],
|
||||
);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sid: string) => {
|
||||
if (isStreaming) return;
|
||||
if (sid === sessionId) return;
|
||||
console.log("[session-selector] selecting session:", sid);
|
||||
resumeSession(sid);
|
||||
},
|
||||
[isStreaming, sessionId, resumeSession],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
if (isStreaming) return;
|
||||
newSession();
|
||||
}, [isStreaming, newSession]);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Session history"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HistoryIcon className="size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
className="max-h-80 w-72 overflow-y-auto"
|
||||
>
|
||||
<DropdownMenuLabel>Sessions</DropdownMenuLabel>
|
||||
|
||||
<DropdownMenuItem onSelect={handleNewChat} disabled={isStreaming}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span>New Chat</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2Icon className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
No previous sessions
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<DropdownMenuItem
|
||||
key={session.session_id}
|
||||
onSelect={() => handleSelectSession(session.session_id)}
|
||||
disabled={isStreaming}
|
||||
className="flex items-start gap-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-sm">{session.title}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatRelativeTime(session.last_modified)}
|
||||
</span>
|
||||
</div>
|
||||
{session.session_id === sessionId && (
|
||||
<CheckIcon className="size-4 shrink-0 text-primary" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import {
|
||||
FolderOpenIcon,
|
||||
FolderPlusIcon,
|
||||
|
|
@ -9,135 +9,11 @@ import {
|
|||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { exists, join } from "@/lib/tauri/fs";
|
||||
|
||||
const DEFAULT_MAIN_TEX = `\\documentclass[12pt]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{amsmath,amssymb}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[margin=1in]{geometry}
|
||||
\\usepackage{hyperref}
|
||||
\\usepackage{enumitem}
|
||||
\\usepackage{booktabs}
|
||||
\\usepackage{float}
|
||||
|
||||
\\title{Sample Document}
|
||||
\\author{Your Name}
|
||||
\\date{\\today}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\maketitle
|
||||
|
||||
\\begin{abstract}
|
||||
This is a sample LaTeX document demonstrating common features including
|
||||
math equations, tables, lists, and cross-references.
|
||||
\\end{abstract}
|
||||
|
||||
\\tableofcontents
|
||||
|
||||
\\section{Introduction}
|
||||
|
||||
Welcome to your new LaTeX project. This template includes examples of
|
||||
commonly used features to help you get started.
|
||||
|
||||
You can reference other sections like Section~\\ref{sec:math} or
|
||||
Section~\\ref{sec:tables}.
|
||||
|
||||
\\section{Mathematics}
|
||||
\\label{sec:math}
|
||||
|
||||
\\subsection{Inline and Display Math}
|
||||
|
||||
Euler's identity is $e^{i\\pi} + 1 = 0$. The quadratic formula is:
|
||||
\\begin{equation}
|
||||
x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}
|
||||
\\label{eq:quadratic}
|
||||
\\end{equation}
|
||||
|
||||
We can refer to Equation~\\ref{eq:quadratic} anywhere in the document.
|
||||
|
||||
\\subsection{Aligned Equations}
|
||||
|
||||
\\begin{align}
|
||||
\\nabla \\cdot \\mathbf{E} &= \\frac{\\rho}{\\epsilon_0} \\\\
|
||||
\\nabla \\cdot \\mathbf{B} &= 0 \\\\
|
||||
\\nabla \\times \\mathbf{E} &= -\\frac{\\partial \\mathbf{B}}{\\partial t} \\\\
|
||||
\\nabla \\times \\mathbf{B} &= \\mu_0 \\mathbf{J} + \\mu_0 \\epsilon_0 \\frac{\\partial \\mathbf{E}}{\\partial t}
|
||||
\\end{align}
|
||||
|
||||
\\subsection{Matrices}
|
||||
|
||||
\\[
|
||||
A = \\begin{pmatrix}
|
||||
1 & 2 & 3 \\\\
|
||||
4 & 5 & 6 \\\\
|
||||
7 & 8 & 9
|
||||
\\end{pmatrix}
|
||||
\\]
|
||||
|
||||
\\section{Lists}
|
||||
|
||||
\\subsection{Itemized List}
|
||||
|
||||
\\begin{itemize}
|
||||
\\item First item
|
||||
\\item Second item with a nested list:
|
||||
\\begin{itemize}
|
||||
\\item Sub-item A
|
||||
\\item Sub-item B
|
||||
\\end{itemize}
|
||||
\\item Third item
|
||||
\\end{itemize}
|
||||
|
||||
\\subsection{Enumerated List}
|
||||
|
||||
\\begin{enumerate}
|
||||
\\item Prepare the data
|
||||
\\item Run the analysis
|
||||
\\item Interpret the results
|
||||
\\end{enumerate}
|
||||
|
||||
\\section{Tables}
|
||||
\\label{sec:tables}
|
||||
|
||||
\\begin{table}[H]
|
||||
\\centering
|
||||
\\caption{Sample results}
|
||||
\\label{tab:results}
|
||||
\\begin{tabular}{lrr}
|
||||
\\toprule
|
||||
Method & Accuracy (\\%) & Time (s) \\\\
|
||||
\\midrule
|
||||
Baseline & 85.2 & 1.3 \\\\
|
||||
Proposed & 92.7 & 2.1 \\\\
|
||||
Enhanced & 94.1 & 3.5 \\\\
|
||||
\\bottomrule
|
||||
\\end{tabular}
|
||||
\\end{table}
|
||||
|
||||
See Table~\\ref{tab:results} for a comparison of methods.
|
||||
|
||||
\\section{Figures}
|
||||
|
||||
% Uncomment the following when you have an image file:
|
||||
% \\begin{figure}[H]
|
||||
% \\centering
|
||||
% \\includegraphics[width=0.8\\textwidth]{example-image}
|
||||
% \\caption{An example figure.}
|
||||
% \\label{fig:example}
|
||||
% \\end{figure}
|
||||
|
||||
\\section{Conclusion}
|
||||
|
||||
This document demonstrated the basics of LaTeX. Edit freely and use
|
||||
the AI assistant (press the chat icon) to help you write.
|
||||
|
||||
\\end{document}
|
||||
`;
|
||||
import { ProjectWizard } from "./project-wizard";
|
||||
|
||||
export function ProjectPicker() {
|
||||
const [showWizard, setShowWizard] = useState(false);
|
||||
|
||||
const recentProjects = useProjectStore((s) => s.recentProjects);
|
||||
const addRecentProject = useProjectStore((s) => s.addRecentProject);
|
||||
const removeRecentProject = useProjectStore((s) => s.removeRecentProject);
|
||||
|
|
@ -147,7 +23,7 @@ export function ProjectPicker() {
|
|||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Open LaTeX Project Folder",
|
||||
title: "Open Project Folder",
|
||||
});
|
||||
if (selected) {
|
||||
addRecentProject(selected);
|
||||
|
|
@ -155,30 +31,15 @@ export function ProjectPicker() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleNewProject = async () => {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Choose Folder for New Project",
|
||||
});
|
||||
if (!selected) return;
|
||||
|
||||
const mainTexPath = await join(selected, "main.tex");
|
||||
const mainExists = await exists(mainTexPath);
|
||||
if (!mainExists) {
|
||||
await mkdir(selected, { recursive: true }).catch(() => {});
|
||||
await writeTextFile(mainTexPath, DEFAULT_MAIN_TEX);
|
||||
}
|
||||
|
||||
addRecentProject(selected);
|
||||
await openProject(selected);
|
||||
};
|
||||
|
||||
const handleOpenRecent = async (path: string) => {
|
||||
addRecentProject(path);
|
||||
await openProject(path);
|
||||
};
|
||||
|
||||
if (showWizard) {
|
||||
return <ProjectWizard onBack={() => setShowWizard(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-background">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-8 px-8">
|
||||
|
|
@ -186,13 +47,13 @@ export function ProjectPicker() {
|
|||
<img src="/icon-192.png" alt="ClaudePrism" className="size-16" />
|
||||
<h1 className="font-bold text-2xl">ClaudePrism</h1>
|
||||
<p className="text-center text-muted-foreground text-sm">
|
||||
AI-powered LaTeX writing workspace
|
||||
AI-powered academic writing workspace
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<Button
|
||||
onClick={handleNewProject}
|
||||
onClick={() => setShowWizard(true)}
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="flex-1 gap-2"
|
||||
|
|
|
|||
662
apps/desktop/src/components/project-wizard.tsx
Normal file
662
apps/desktop/src/components/project-wizard.tsx
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
import { useState, useCallback } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { mkdir, writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ArrowRightIcon,
|
||||
FileTextIcon,
|
||||
UserIcon,
|
||||
LayoutIcon,
|
||||
BookOpenIcon,
|
||||
MailIcon,
|
||||
MonitorIcon,
|
||||
BookIcon,
|
||||
FileIcon,
|
||||
FolderOpenIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
SparklesIcon,
|
||||
GraduationCapIcon,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useProjectStore } from "@/stores/project-store";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
import { useClaudeChatStore } from "@/stores/claude-chat-store";
|
||||
import { exists, join } from "@/lib/tauri/fs";
|
||||
|
||||
// ─── Template Definitions ───
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
documentClass: string;
|
||||
mainFileName: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
const TEMPLATES: Template[] = [
|
||||
{
|
||||
id: "paper",
|
||||
name: "Research Paper",
|
||||
description: "Academic paper with abstract, sections, references",
|
||||
icon: <FileTextIcon className="size-6" />,
|
||||
documentClass: "article",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[12pt]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{amsmath,amssymb}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[margin=1in]{geometry}
|
||||
\\usepackage{hyperref}
|
||||
\\usepackage{booktabs}
|
||||
\\usepackage{natbib}
|
||||
|
||||
\\title{Title}
|
||||
\\author{Author Name}
|
||||
\\date{\\today}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\maketitle
|
||||
|
||||
\\begin{abstract}
|
||||
Your abstract here.
|
||||
\\end{abstract}
|
||||
|
||||
\\section{Introduction}
|
||||
|
||||
\\section{Related Work}
|
||||
|
||||
\\section{Method}
|
||||
|
||||
\\section{Results}
|
||||
|
||||
\\section{Conclusion}
|
||||
|
||||
\\bibliographystyle{plainnat}
|
||||
\\bibliography{references}
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "cv",
|
||||
name: "CV / Resume",
|
||||
description: "Professional curriculum vitae",
|
||||
icon: <UserIcon className="size-6" />,
|
||||
documentClass: "article",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[11pt,a4paper]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage[margin=0.8in]{geometry}
|
||||
\\usepackage{hyperref}
|
||||
\\usepackage{enumitem}
|
||||
\\usepackage{titlesec}
|
||||
|
||||
\\titleformat{\\section}{\\large\\bfseries}{}{0em}{}[\\titlerule]
|
||||
\\titlespacing{\\section}{0pt}{12pt}{6pt}
|
||||
|
||||
\\pagestyle{empty}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\begin{center}
|
||||
{\\LARGE\\bfseries Your Name}\\\\[4pt]
|
||||
your.email@example.com \\quad | \\quad City, Country
|
||||
\\end{center}
|
||||
|
||||
\\section{Education}
|
||||
|
||||
\\section{Experience}
|
||||
|
||||
\\section{Skills}
|
||||
|
||||
\\section{Publications}
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "poster",
|
||||
name: "Poster",
|
||||
description: "Conference or research poster",
|
||||
icon: <LayoutIcon className="size-6" />,
|
||||
documentClass: "a0poster",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[a1paper,portrait]{a0poster}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{amsmath,amssymb}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage{multicol}
|
||||
\\usepackage[margin=2cm]{geometry}
|
||||
\\usepackage{xcolor}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\begin{center}
|
||||
{\\VERYHuge\\bfseries Poster Title}\\\\[1cm]
|
||||
{\\LARGE Author Name \\quad Institution}
|
||||
\\end{center}
|
||||
|
||||
\\vspace{1cm}
|
||||
|
||||
\\begin{multicols}{2}
|
||||
|
||||
\\section*{Introduction}
|
||||
|
||||
\\section*{Methods}
|
||||
|
||||
\\section*{Results}
|
||||
|
||||
\\section*{Conclusions}
|
||||
|
||||
\\section*{References}
|
||||
|
||||
\\end{multicols}
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "thesis",
|
||||
name: "Thesis",
|
||||
description: "Dissertation or thesis with chapters",
|
||||
icon: <GraduationCapIcon className="size-6" />,
|
||||
documentClass: "report",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[12pt,a4paper]{report}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{amsmath,amssymb}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[margin=1in]{geometry}
|
||||
\\usepackage{hyperref}
|
||||
\\usepackage{booktabs}
|
||||
\\usepackage{natbib}
|
||||
\\usepackage{setspace}
|
||||
|
||||
\\onehalfspacing
|
||||
|
||||
\\title{Thesis Title}
|
||||
\\author{Author Name}
|
||||
\\date{\\today}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\maketitle
|
||||
|
||||
\\begin{abstract}
|
||||
Your abstract here.
|
||||
\\end{abstract}
|
||||
|
||||
\\tableofcontents
|
||||
|
||||
\\chapter{Introduction}
|
||||
|
||||
\\chapter{Literature Review}
|
||||
|
||||
\\chapter{Methodology}
|
||||
|
||||
\\chapter{Results}
|
||||
|
||||
\\chapter{Discussion}
|
||||
|
||||
\\chapter{Conclusion}
|
||||
|
||||
\\bibliographystyle{plainnat}
|
||||
\\bibliography{references}
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "presentation",
|
||||
name: "Presentation",
|
||||
description: "Beamer slides for talks and lectures",
|
||||
icon: <MonitorIcon className="size-6" />,
|
||||
documentClass: "beamer",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass{beamer}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{amsmath,amssymb}
|
||||
\\usepackage{graphicx}
|
||||
|
||||
\\usetheme{Madrid}
|
||||
|
||||
\\title{Presentation Title}
|
||||
\\author{Author Name}
|
||||
\\institute{Institution}
|
||||
\\date{\\today}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\begin{frame}
|
||||
\\titlepage
|
||||
\\end{frame}
|
||||
|
||||
\\begin{frame}{Outline}
|
||||
\\tableofcontents
|
||||
\\end{frame}
|
||||
|
||||
\\section{Introduction}
|
||||
\\begin{frame}{Introduction}
|
||||
Content here.
|
||||
\\end{frame}
|
||||
|
||||
\\section{Main Content}
|
||||
\\begin{frame}{Main Content}
|
||||
Content here.
|
||||
\\end{frame}
|
||||
|
||||
\\section{Conclusion}
|
||||
\\begin{frame}{Conclusion}
|
||||
Content here.
|
||||
\\end{frame}
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "letter",
|
||||
name: "Letter",
|
||||
description: "Formal or cover letter",
|
||||
icon: <MailIcon className="size-6" />,
|
||||
documentClass: "letter",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[12pt]{letter}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage[margin=1in]{geometry}
|
||||
\\usepackage{hyperref}
|
||||
|
||||
\\signature{Your Name}
|
||||
\\address{Your Address \\\\ City, Country}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\begin{letter}{Recipient Name \\\\ Recipient Address \\\\ City, Country}
|
||||
|
||||
\\opening{Dear Recipient,}
|
||||
|
||||
Your letter content here.
|
||||
|
||||
\\closing{Sincerely,}
|
||||
|
||||
\\end{letter}
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "book",
|
||||
name: "Book",
|
||||
description: "Multi-chapter book or manuscript",
|
||||
icon: <BookIcon className="size-6" />,
|
||||
documentClass: "book",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[12pt,a4paper]{book}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
\\usepackage{amsmath,amssymb}
|
||||
\\usepackage{graphicx}
|
||||
\\usepackage[margin=1in]{geometry}
|
||||
\\usepackage{hyperref}
|
||||
|
||||
\\title{Book Title}
|
||||
\\author{Author Name}
|
||||
\\date{\\today}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
\\frontmatter
|
||||
\\maketitle
|
||||
\\tableofcontents
|
||||
|
||||
\\mainmatter
|
||||
|
||||
\\chapter{First Chapter}
|
||||
|
||||
\\chapter{Second Chapter}
|
||||
|
||||
\\backmatter
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: "blank",
|
||||
name: "Blank",
|
||||
description: "Minimal template to start from scratch",
|
||||
icon: <FileIcon className="size-6" />,
|
||||
documentClass: "article",
|
||||
mainFileName: "main.tex",
|
||||
content: `\\documentclass[12pt]{article}
|
||||
\\usepackage[utf8]{inputenc}
|
||||
\\usepackage[T1]{fontenc}
|
||||
|
||||
\\begin{document}
|
||||
|
||||
% Start writing here.
|
||||
|
||||
\\end{document}
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
const BIB_TEMPLATE = `% Add your references here
|
||||
% Example:
|
||||
% @article{key,
|
||||
% author = {Author Name},
|
||||
% title = {Article Title},
|
||||
% journal = {Journal Name},
|
||||
% year = {2024},
|
||||
% }
|
||||
`;
|
||||
|
||||
// ─── Wizard Component ───
|
||||
|
||||
interface ProjectWizardProps {
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function ProjectWizard({ onBack }: ProjectWizardProps) {
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||
const [purpose, setPurpose] = useState("");
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [projectFolder, setProjectFolder] = useState<string | null>(null);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const addRecentProject = useProjectStore((s) => s.addRecentProject);
|
||||
const openProject = useDocumentStore((s) => s.openProject);
|
||||
|
||||
const template = TEMPLATES.find((t) => t.id === selectedTemplate);
|
||||
|
||||
const handleSelectTemplate = (id: string) => {
|
||||
setSelectedTemplate(id);
|
||||
setStep(2);
|
||||
};
|
||||
|
||||
const handleChooseFolder = useCallback(async () => {
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: "Choose Location for New Project",
|
||||
});
|
||||
if (selected) {
|
||||
setProjectFolder(selected);
|
||||
if (!projectName) {
|
||||
setProjectName(selected.split("/").pop() || "my-project");
|
||||
}
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
const handleAddAttachments = useCallback(async () => {
|
||||
const selected = await open({
|
||||
multiple: true,
|
||||
title: "Add Reference Files",
|
||||
filters: [
|
||||
{
|
||||
name: "Documents & Images",
|
||||
extensions: [
|
||||
"pdf", "tex", "bib", "txt", "md",
|
||||
"png", "jpg", "jpeg", "gif", "svg",
|
||||
"csv", "tsv", "json",
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected) {
|
||||
const paths = Array.isArray(selected) ? selected : [selected];
|
||||
setAttachments((prev) => [...prev, ...paths.filter((p) => !prev.includes(p))]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRemoveAttachment = (path: string) => {
|
||||
setAttachments((prev) => prev.filter((p) => p !== path));
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!template || !projectFolder || !projectName.trim()) return;
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
// Create project directory
|
||||
const projectPath = await join(projectFolder, projectName.trim());
|
||||
await mkdir(projectPath, { recursive: true }).catch(() => {});
|
||||
|
||||
// Write main.tex
|
||||
const mainTexPath = await join(projectPath, template.mainFileName);
|
||||
const mainExists = await exists(mainTexPath);
|
||||
if (!mainExists) {
|
||||
await writeTextFile(mainTexPath, template.content);
|
||||
}
|
||||
|
||||
// Write references.bib for templates that use bibliography
|
||||
if (["paper", "thesis"].includes(template.id)) {
|
||||
const bibPath = await join(projectPath, "references.bib");
|
||||
const bibExists = await exists(bibPath);
|
||||
if (!bibExists) {
|
||||
await writeTextFile(bibPath, BIB_TEMPLATE);
|
||||
}
|
||||
}
|
||||
|
||||
// Import attachments into project
|
||||
if (attachments.length > 0) {
|
||||
const attachmentsDir = await join(projectPath, "attachments");
|
||||
await mkdir(attachmentsDir, { recursive: true }).catch(() => {});
|
||||
}
|
||||
|
||||
// Build the initial prompt for Claude
|
||||
if (purpose.trim()) {
|
||||
const attachmentNames = attachments.map((p) => p.split("/").pop()).filter(Boolean);
|
||||
let prompt = `I just created a new ${template.name} project using a ${template.documentClass} template.\n\n`;
|
||||
prompt += `Here is what I want to create:\n${purpose.trim()}\n\n`;
|
||||
if (attachmentNames.length > 0) {
|
||||
prompt += `I've included these reference files in the attachments/ folder: ${attachmentNames.join(", ")}\n`;
|
||||
prompt += `Please review them and incorporate relevant information.\n\n`;
|
||||
}
|
||||
prompt += `Please customize the template to match my requirements. Update the content, title, author, and structure as needed. Make it a complete, well-structured document ready for me to refine.`;
|
||||
|
||||
// Set as pending so it fires after workspace initializes
|
||||
useClaudeChatStore.getState().newSession();
|
||||
useClaudeChatStore.getState().setPendingInitialPrompt(prompt);
|
||||
}
|
||||
|
||||
// Open the project
|
||||
addRecentProject(projectPath);
|
||||
await openProject(projectPath);
|
||||
|
||||
// Import attachments after project is open
|
||||
if (attachments.length > 0) {
|
||||
await useDocumentStore.getState().importFiles(attachments, "attachments");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to create project:", err);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canCreate = template && projectFolder && projectName.trim();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background">
|
||||
{/* Header — padded top for macOS overlay titlebar */}
|
||||
<div className="flex shrink-0 items-center gap-3 border-b border-border px-4 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
||||
<Button variant="ghost" size="icon" className="size-7" onClick={step === 1 ? onBack : () => setStep(1)}>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<SparklesIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">
|
||||
{step === 1 ? "Choose a Template" : "Project Details"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<div className={`size-2 rounded-full ${step >= 1 ? "bg-foreground" : "bg-muted-foreground/30"}`} />
|
||||
<div className={`size-2 rounded-full ${step >= 2 ? "bg-foreground" : "bg-muted-foreground/30"}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{step === 1 ? (
|
||||
<TemplateGrid onSelect={handleSelectTemplate} selected={selectedTemplate} />
|
||||
) : (
|
||||
<div className="mx-auto max-w-lg space-y-6 p-6">
|
||||
{/* Selected template indicator */}
|
||||
{template && (
|
||||
<button
|
||||
onClick={() => setStep(1)}
|
||||
className="flex w-full items-center gap-3 rounded-lg border border-border bg-muted/30 p-3 text-left transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex size-10 items-center justify-center rounded-md bg-background text-muted-foreground">
|
||||
{template.icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{template.name}</div>
|
||||
<div className="text-muted-foreground text-xs">{template.description}</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground text-xs">Change</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Purpose */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm">What are you writing?</label>
|
||||
<Textarea
|
||||
placeholder="e.g., A research paper on transformer architectures for protein structure prediction, targeting NeurIPS 2025..."
|
||||
value={purpose}
|
||||
onChange={(e) => setPurpose(e.target.value)}
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Claude will use this to customize your template with relevant content and structure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm">Reference files (optional)</label>
|
||||
<div className="space-y-1.5">
|
||||
{attachments.map((path) => (
|
||||
<div
|
||||
key={path}
|
||||
className="flex items-center gap-2 rounded-md bg-muted/30 px-3 py-1.5 text-sm"
|
||||
>
|
||||
<PaperclipIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate text-xs">{path.split("/").pop()}</span>
|
||||
<button
|
||||
onClick={() => handleRemoveAttachment(path)}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<XIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" className="gap-1.5 text-xs" onClick={handleAddAttachments}>
|
||||
<PaperclipIcon className="size-3.5" />
|
||||
Add files
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
PDFs, images, .bib, .tex, or data files to include as references.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project location */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-sm">Project location</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Project name"
|
||||
value={projectName}
|
||||
onChange={(e) => setProjectName(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" className="shrink-0 gap-1.5" onClick={handleChooseFolder}>
|
||||
<FolderOpenIcon className="size-4" />
|
||||
{projectFolder ? "Change" : "Choose folder"}
|
||||
</Button>
|
||||
</div>
|
||||
{projectFolder && (
|
||||
<p className="truncate text-muted-foreground text-xs">
|
||||
{projectFolder}/{projectName.trim() || "..."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create button */}
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
size="lg"
|
||||
disabled={!canCreate || isCreating}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{isCreating ? (
|
||||
"Creating..."
|
||||
) : purpose.trim() ? (
|
||||
<>
|
||||
<SparklesIcon className="size-4" />
|
||||
Create & Generate with AI
|
||||
</>
|
||||
) : (
|
||||
"Create Project"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Template Grid ───
|
||||
|
||||
function TemplateGrid({
|
||||
onSelect,
|
||||
selected,
|
||||
}: {
|
||||
onSelect: (id: string) => void;
|
||||
selected: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<p className="mb-6 text-center text-muted-foreground text-sm">
|
||||
Pick a starting template. Claude will customize it based on your needs.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{TEMPLATES.map((tmpl) => (
|
||||
<button
|
||||
key={tmpl.id}
|
||||
onClick={() => onSelect(tmpl.id)}
|
||||
className={`flex flex-col items-center gap-2.5 rounded-xl border p-5 text-center transition-all hover:border-foreground/30 hover:bg-muted/50 ${
|
||||
selected === tmpl.id
|
||||
? "border-foreground bg-muted/50"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="text-muted-foreground">{tmpl.icon}</div>
|
||||
<div>
|
||||
<div className="font-medium text-sm">{tmpl.name}</div>
|
||||
<div className="mt-0.5 text-muted-foreground text-xs leading-tight">
|
||||
{tmpl.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -86,13 +86,14 @@ export function EditorToolbar({
|
|||
|
||||
if (fileType === "image") {
|
||||
return (
|
||||
<div className="flex h-9 items-center justify-between border-border border-b bg-muted/30 px-2">
|
||||
<div className="flex items-center justify-between border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
|
||||
<div className="flex items-center gap-1">
|
||||
<ImageIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
{fileName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -133,7 +134,7 @@ export function EditorToolbar({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-9 items-center gap-1 border-border border-b bg-muted/30 px-2">
|
||||
<div className="flex items-center gap-1 border-border border-b bg-muted/30 px-2 pt-[var(--titlebar-height)] h-[calc(36px+var(--titlebar-height))]">
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
<span className="mr-2 font-medium text-muted-foreground text-sm">
|
||||
{fileName}
|
||||
|
|
@ -196,6 +197,7 @@ export function EditorToolbar({
|
|||
>
|
||||
<BookMarkedIcon className="size-4" />
|
||||
</TooltipIconButton>
|
||||
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -447,7 +447,7 @@ export function PdfPreview() {
|
|||
|
||||
return (
|
||||
<div ref={previewContainerRef} className="relative flex h-full flex-col bg-muted/50">
|
||||
<div className="flex h-10 items-center justify-between border-border border-b bg-background px-2">
|
||||
<div className="flex items-center border-border border-b bg-background px-2 pt-[var(--titlebar-height)] h-[calc(40px+var(--titlebar-height))]">
|
||||
<div className="flex items-center gap-1">
|
||||
{isSaving && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1">
|
||||
|
|
@ -474,6 +474,7 @@ export function PdfPreview() {
|
|||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div data-tauri-drag-region className="flex-1 self-stretch" />
|
||||
<div className="flex items-center gap-1">
|
||||
{pdfData && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
|||
import {
|
||||
FileTextIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
FolderPlusIcon,
|
||||
ImageIcon,
|
||||
PlusIcon,
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
FileIcon,
|
||||
FileSpreadsheetIcon,
|
||||
GripVerticalIcon,
|
||||
AppWindowIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
|
|
@ -63,6 +64,7 @@ import {
|
|||
} from "@/components/ui/context-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
|
||||
// ─── Table of Contents ───
|
||||
|
|
@ -522,23 +524,25 @@ export function Sidebar() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-sidebar text-sidebar-foreground">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 items-center justify-between border-sidebar-border border-b px-3">
|
||||
<div className="flex flex-col">
|
||||
{/* Header — padded top for macOS overlay titlebar */}
|
||||
<div className="relative flex items-center justify-center border-sidebar-border border-b px-3 pt-[var(--titlebar-height)] h-[calc(48px+var(--titlebar-height))]">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="font-semibold text-sm">ClaudePrism</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{projectRoot?.split("/").pop() || "Desktop"}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={closeProject}
|
||||
title="Close Project"
|
||||
>
|
||||
<FolderOpenIcon className="size-3.5" />
|
||||
</Button>
|
||||
<div className="absolute right-3 flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6"
|
||||
onClick={closeProject}
|
||||
title="Close Project"
|
||||
>
|
||||
<HomeIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable sections */}
|
||||
|
|
@ -546,33 +550,35 @@ export function Sidebar() {
|
|||
{/* Files */}
|
||||
<Panel defaultSize={50} minSize={15}>
|
||||
<div ref={sidebarFilesRef} className="flex h-full flex-col" data-sidebar-files>
|
||||
<div className="flex h-8 shrink-0 items-center justify-between border-sidebar-border border-b px-3">
|
||||
<div className="relative flex h-8 shrink-0 items-center justify-center border-sidebar-border border-b px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-xs">Files</span>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-5" title="Add">
|
||||
<PlusIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openNewFileDialog()}>
|
||||
<FileTextIcon className="mr-2 size-4" />
|
||||
New LaTeX File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openNewFolderDialog()}>
|
||||
<FolderPlusIcon className="mr-2 size-4" />
|
||||
New Folder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleImport()}>
|
||||
<UploadIcon className="mr-2 size-4" />
|
||||
Import File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="absolute right-3">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-5" title="Add">
|
||||
<PlusIcon className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openNewFileDialog()}>
|
||||
<FileTextIcon className="mr-2 size-4" />
|
||||
New LaTeX File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openNewFolderDialog()}>
|
||||
<FolderPlusIcon className="mr-2 size-4" />
|
||||
New Folder
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => handleImport()}>
|
||||
<UploadIcon className="mr-2 size-4" />
|
||||
Import File
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragOver={(e) => console.log("[dnd] dragOver:", e.over?.id ?? "none")}>
|
||||
<ContextMenu>
|
||||
|
|
@ -634,7 +640,7 @@ export function Sidebar() {
|
|||
{/* Outline */}
|
||||
<Panel defaultSize={20} minSize={10}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-8 shrink-0 items-center gap-2 px-3">
|
||||
<div className="flex h-8 shrink-0 items-center justify-center gap-2 px-3">
|
||||
<ListIcon className="size-3.5 text-muted-foreground" />
|
||||
<span className="font-medium text-xs">Outline</span>
|
||||
</div>
|
||||
|
|
@ -676,9 +682,9 @@ export function Sidebar() {
|
|||
</PanelGroup>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-sidebar-border border-t px-3 py-2 text-muted-foreground text-xs">
|
||||
<div className="relative flex items-center justify-center border-sidebar-border border-t px-3 py-2 text-muted-foreground text-xs">
|
||||
<span>ClaudePrism v{APP_VERSION}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="absolute right-3 flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" className="size-6" asChild>
|
||||
<a
|
||||
href="https://github.com/delibae/claude-prism"
|
||||
|
|
|
|||
|
|
@ -153,7 +153,7 @@ export function ZoteroHeader() {
|
|||
const loadCollections = useZoteroStore((s) => s.loadCollections);
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between px-3">
|
||||
<div className="relative flex w-full items-center justify-center px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -164,7 +164,7 @@ export function ZoteroHeader() {
|
|||
<span className="font-medium text-xs">Zotero</span>
|
||||
</div>
|
||||
{isAuthenticated && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="absolute right-3 flex items-center gap-1">
|
||||
<button
|
||||
className="rounded p-1 text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground"
|
||||
onClick={loadCollections}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useDocumentStore } from "@/stores/document-store";
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
|
|
@ -12,6 +13,11 @@ export function useKeyboardShortcuts() {
|
|||
setTimeout(() => state.setIsSaving(false), 500);
|
||||
});
|
||||
}
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "n") {
|
||||
e.preventDefault();
|
||||
invoke("create_new_window").catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
|
|
|
|||
|
|
@ -57,11 +57,17 @@ interface ClaudeChatState {
|
|||
totalInputTokens: number;
|
||||
totalOutputTokens: number;
|
||||
|
||||
/** Deferred prompt to send once the workspace is ready (set by project wizard) */
|
||||
pendingInitialPrompt: string | null;
|
||||
setPendingInitialPrompt: (prompt: string | null) => void;
|
||||
consumePendingInitialPrompt: () => string | null;
|
||||
|
||||
// Actions
|
||||
sendPrompt: (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => Promise<void>;
|
||||
cancelExecution: () => Promise<void>;
|
||||
clearMessages: () => void;
|
||||
newSession: () => void;
|
||||
resumeSession: (sessionId: string) => Promise<void>;
|
||||
|
||||
// Internal actions (called by event hook)
|
||||
_appendMessage: (msg: ClaudeStreamMessage) => void;
|
||||
|
|
@ -80,6 +86,16 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
|
||||
pendingInitialPrompt: null,
|
||||
setPendingInitialPrompt: (prompt) => set({ pendingInitialPrompt: prompt }),
|
||||
consumePendingInitialPrompt: () => {
|
||||
const { pendingInitialPrompt } = get();
|
||||
if (pendingInitialPrompt) {
|
||||
set({ pendingInitialPrompt: null });
|
||||
}
|
||||
return pendingInitialPrompt;
|
||||
},
|
||||
|
||||
sendPrompt: async (userPrompt: string, contextOverride?: { label: string; filePath: string; selectedText: string }) => {
|
||||
const { sessionId, isStreaming } = get();
|
||||
if (isStreaming) return;
|
||||
|
|
@ -213,6 +229,49 @@ export const useClaudeChatStore = create<ClaudeChatState>()((set, get) => ({
|
|||
});
|
||||
},
|
||||
|
||||
resumeSession: async (sessionId: string) => {
|
||||
const projectPath = useDocumentStore.getState().projectRoot;
|
||||
console.log("[store] resumeSession called:", { sessionId, projectPath });
|
||||
|
||||
// Reset state with new session ID
|
||||
set({
|
||||
messages: [],
|
||||
sessionId,
|
||||
error: null,
|
||||
isStreaming: false,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
});
|
||||
|
||||
// Load session history from JSONL file
|
||||
if (projectPath) {
|
||||
try {
|
||||
console.log("[store] loading session history...");
|
||||
const history = await invoke<any[]>("load_session_history", {
|
||||
projectPath,
|
||||
sessionId,
|
||||
});
|
||||
console.log("[store] received history entries:", history.length);
|
||||
|
||||
// Filter to displayable message types and map to ClaudeStreamMessage
|
||||
const messages: ClaudeStreamMessage[] = [];
|
||||
for (const entry of history) {
|
||||
const type = entry.type;
|
||||
if (type === "user" || type === "assistant" || type === "result") {
|
||||
messages.push(entry as ClaudeStreamMessage);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[store] filtered to displayable messages:", messages.length);
|
||||
set({ messages });
|
||||
} catch (err) {
|
||||
console.error("[store] Failed to load session history:", err);
|
||||
}
|
||||
} else {
|
||||
console.warn("[store] no projectPath, skipping history load");
|
||||
}
|
||||
},
|
||||
|
||||
_appendMessage: (msg: ClaudeStreamMessage) => {
|
||||
set((state) => {
|
||||
let inputDelta = 0;
|
||||
|
|
|
|||
|
|
@ -121,6 +121,9 @@
|
|||
}
|
||||
html {
|
||||
@apply overscroll-y-none scroll-smooth;
|
||||
/* macOS overlay titlebar inset — traffic lights safe area */
|
||||
--titlebar-height: 18px;
|
||||
--traffic-light-width: 70px;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue