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:
delibae 2026-02-22 05:25:27 +09:00
parent a2af6513d0
commit e178921dbc
17 changed files with 1311 additions and 259 deletions

View file

@ -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",

View file

@ -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"]}}

View file

@ -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)]

View file

@ -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");
}
};
}
_ => {}
}
});
}

View file

@ -18,7 +18,10 @@
"height": 900,
"minWidth": 800,
"minHeight": 600,
"visible": false
"visible": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12, "y": 12 }
}
],
"security": {

View file

@ -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>

View file

@ -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 */}

View 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>
);
}

View file

@ -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"

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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 && (
<>

View file

@ -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"

View file

@ -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}

View file

@ -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);

View file

@ -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;

View file

@ -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;