From e178921dbcd43a6f1c176c0d355beb00a0d2d48d Mon Sep 17 00:00:00 2001 From: delibae Date: Sun, 22 Feb 2026 05:25:27 +0900 Subject: [PATCH] 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. --- .../src-tauri/capabilities/default.json | 9 +- .../src-tauri/gen/schemas/capabilities.json | 2 +- apps/desktop/src-tauri/src/claude.rs | 300 +++++++- apps/desktop/src-tauri/src/lib.rs | 64 +- apps/desktop/src-tauri/tauri.conf.json | 5 +- apps/desktop/src/App.tsx | 25 +- .../claude-chat/claude-chat-drawer.tsx | 32 +- .../claude-chat/session-selector.tsx | 145 ++++ .../desktop/src/components/project-picker.tsx | 161 +---- .../desktop/src/components/project-wizard.tsx | 662 ++++++++++++++++++ .../workspace/editor/editor-toolbar.tsx | 6 +- .../workspace/preview/pdf-preview.tsx | 3 +- .../src/components/workspace/sidebar.tsx | 84 +-- .../src/components/workspace/zotero-panel.tsx | 4 +- .../src/hooks/use-keyboard-shortcuts.ts | 6 + apps/desktop/src/stores/claude-chat-store.ts | 59 ++ apps/desktop/src/styles/globals.css | 3 + 17 files changed, 1311 insertions(+), 259 deletions(-) create mode 100644 apps/desktop/src/components/claude-chat/session-selector.tsx create mode 100644 apps/desktop/src/components/project-wizard.tsx diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index c819871..7e7d455 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -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", diff --git a/apps/desktop/src-tauri/gen/schemas/capabilities.json b/apps/desktop/src-tauri/gen/schemas/capabilities.json index ecd69e6..7f50862 100644 --- a/apps/desktop/src-tauri/gen/schemas/capabilities.json +++ b/apps/desktop/src-tauri/gen/schemas/capabilities.json @@ -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"]}} \ No newline at end of file +{"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"]}} \ No newline at end of file diff --git a/apps/desktop/src-tauri/src/claude.rs b/apps/desktop/src-tauri/src/claude.rs index 7a9fd92..83f0734 100644 --- a/apps/desktop/src-tauri/src/claude.rs +++ b/apps/desktop/src-tauri/src/claude.rs @@ -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>>, + pub processes: Arc>>, } 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, 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::().inner().current_process.clone(); + let process_arc = window.state::().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>> = 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::(); - 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::(); + 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 { + 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 { + // Skip IDE context tags + if text.starts_with("") { + return None; + } + // Skip command tags + if text.starts_with("") || text.starts_with("") { + 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(" 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, Option) { + 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::(&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, 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, 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::(&line) { + messages.push(json); + } + } + } + + eprintln!("[session] loaded {} messages from session {}", messages.len(), session_id); + + Ok(messages) +} + // ─── Shell Command Execution ─── #[derive(serde::Serialize)] diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 813730b..dc7507a 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -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::>(); - 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::(); + 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::>(); + if let Ok(mut guard) = state.lock() { + if let Some(ref mut child) = guard.child { + let _ = child.kill(); + println!("Sidecar process killed"); + } + }; + } + _ => {} } }); } diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index b285e8e..35e2afe 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -18,7 +18,10 @@ "height": 900, "minWidth": 800, "minHeight": 600, - "visible": false + "visible": false, + "titleBarStyle": "Overlay", + "hiddenTitle": true, + "trafficLightPosition": { "x": 12, "y": 12 } } ], "security": { diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 07901e5..39a1dbd 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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 ( <> @@ -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 ( + {/* Global macOS titlebar drag region — sits above all content */} +
{projectRoot ? : } diff --git a/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx b/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx index 598df81..012143b 100644 --- a/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx +++ b/apps/desktop/src/components/claude-chat/claude-chat-drawer.tsx @@ -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 (
{/* Floating toggle button */} + + + + Sessions + + + + New Chat + + + + + {isLoading ? ( +
+ +
+ ) : sessions.length === 0 ? ( +
+ No previous sessions +
+ ) : ( + sessions.map((session) => ( + handleSelectSession(session.session_id)} + disabled={isStreaming} + className="flex items-start gap-2" + > +
+ {session.title} + + {formatRelativeTime(session.last_modified)} + +
+ {session.session_id === sessionId && ( + + )} +
+ )) + )} +
+ + ); +} diff --git a/apps/desktop/src/components/project-picker.tsx b/apps/desktop/src/components/project-picker.tsx index 9bfab95..f92d205 100644 --- a/apps/desktop/src/components/project-picker.tsx +++ b/apps/desktop/src/components/project-picker.tsx @@ -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 setShowWizard(false)} />; + } + return (
@@ -186,13 +47,13 @@ export function ProjectPicker() { ClaudePrism

ClaudePrism

- AI-powered LaTeX writing workspace + AI-powered academic writing workspace

+
+ + + {step === 1 ? "Choose a Template" : "Project Details"} + +
+
+
= 1 ? "bg-foreground" : "bg-muted-foreground/30"}`} /> +
= 2 ? "bg-foreground" : "bg-muted-foreground/30"}`} /> +
+
+ + {/* Content */} +
+ {step === 1 ? ( + + ) : ( +
+ {/* Selected template indicator */} + {template && ( + + )} + + {/* Purpose */} +
+ +