From c0be1b87242ef70d8e912d4575f80976ff6b75e1 Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 20:07:35 +0900 Subject: [PATCH 01/13] fix(tauri): fix message display by using ESM import for event listener (#2) - Switch from require() to ESM import for @tauri-apps/api/event listen() (require is not available in Vite ESM bundles, causing silent fallback to DOM events) - Enable withGlobalTauri in tauri.conf.json so window.__TAURI_INTERNALS__ is injected and the Tauri event bridge works correctly Co-authored-by: Daotian Zhang --- src-tauri/tauri.conf.json | 1 + src/components/ClaudeCodeSession.tsx | 38 ++++++++++++++++------------ 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f82be8a04..9ad4c05f5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -9,6 +9,7 @@ "frontendDist": "../dist" }, "app": { + "withGlobalTauri": true, "macOSPrivateApi": true, "windows": [ { diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index f0f164f21..5ed35d513 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -14,22 +14,17 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover } from "@/components/ui/popover"; import { api, type Session } from "@/lib/api"; +import { apiCall } from "@/lib/apiAdapter"; import { cn } from "@/lib/utils"; // Conditional imports for Tauri APIs -let tauriListen: any; +import { listen as tauriListenImport } from "@tauri-apps/api/event"; type UnlistenFn = () => void; -try { - if (typeof window !== 'undefined' && window.__TAURI__) { - tauriListen = require("@tauri-apps/api/event").listen; - } -} catch (e) { - console.log('[ClaudeCodeSession] Tauri APIs not available, using web mode'); -} +console.log('[ClaudeCodeSession] BUILD v7 - Tauri listen imported:', typeof tauriListenImport); // Web-compatible replacements -const listen = tauriListen || ((eventName: string, callback: (event: any) => void) => { +const listen = tauriListenImport || ((eventName: string, callback: (event: any) => void) => { console.log('[ClaudeCodeSession] Setting up DOM event listener for:', eventName); // In web mode, listen for DOM events @@ -495,14 +490,25 @@ export const ClaudeCodeSession: React.FC = ({ return; } - // If already loading, queue the prompt + // If already loading, inject mid-turn (matching Claude TUI behavior) if (isLoading) { - const newPrompt = { - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - prompt, - model - }; - setQueuedPrompts(prev => [...prev, newPrompt]); + console.log('[ClaudeCodeSession] BUILD v7 - mid-turn inject:', prompt); + try { + await apiCall('inject_claude_message', { message: prompt }); + // Add the user message to the UI immediately + setMessages(prev => [...prev, { + type: 'user', + content: prompt, + timestamp: new Date().toISOString(), + } as any]); + } catch (e) { + console.error('[ClaudeCodeSession] inject failed, queuing instead:', e); + setQueuedPrompts(prev => [...prev, { + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + prompt, + model + }]); + } return; } From 7b2020021549e6a27f8c00f882b82365753e3388 Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 20:12:46 +0900 Subject: [PATCH 02/13] feat: mid-turn message injection and fix loading spinner hang (#3) - Switch claude CLI execution to --print --input-format=stream-json mode so stdin stays open during execution - Add inject_claude_message Tauri command to write user messages into the running claude process stdin mid-turn (matching Claude TUI behavior) - Close stdin automatically on "result" message so the process exits cleanly and the loading spinner clears - Add ~/.local/bin to PATH so claude binary is always found Co-authored-by: Daotian Zhang --- bun.lock | 13 ++++- src-tauri/Cargo.toml | 1 + src-tauri/src/claude_binary.rs | 11 ++++ src-tauri/src/commands/claude.rs | 97 +++++++++++++++++++++++++++----- src-tauri/src/main.rs | 5 +- 5 files changed, 110 insertions(+), 17 deletions(-) diff --git a/bun.lock b/bun.lock index 0152c93a4..586a17b19 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "opcode", @@ -58,6 +59,10 @@ "typescript": "~5.6.2", "vite": "^6.0.3", }, + "optionalDependencies": { + "@esbuild/linux-x64": "^0.25.6", + "@rollup/rollup-linux-x64-gnu": "^4.45.1", + }, }, }, "trustedDependencies": [ @@ -139,7 +144,7 @@ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], @@ -357,7 +362,7 @@ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.61.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="], @@ -1061,6 +1066,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1087,6 +1094,8 @@ "rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], + "rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "@uiw/react-markdown-preview/rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 91288d4f6..56e43d43c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -5,6 +5,7 @@ description = "GUI app and Toolkit for Claude Code" authors = ["mufeedvh", "123vviekr"] license = "AGPL-3.0" edition = "2021" +default-run = "opcode" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src-tauri/src/claude_binary.rs b/src-tauri/src/claude_binary.rs index 0ce4ce48e..13cf0bb70 100644 --- a/src-tauri/src/claude_binary.rs +++ b/src-tauri/src/claude_binary.rs @@ -689,5 +689,16 @@ pub fn create_command_with_env(program: &str) -> Command { } } + // Ensure ~/.local/bin is in PATH (common install location for claude via npm/npx) + if let Ok(home) = std::env::var("HOME") { + let local_bin = format!("{}/.local/bin", home); + let current_path = std::env::var("PATH").unwrap_or_default(); + if !current_path.contains(&local_bin) { + let new_path = format!("{}:{}", local_bin, current_path); + debug!("Adding ~/.local/bin to PATH: {}", local_bin); + cmd.env("PATH", new_path); + } + } + cmd } diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 529eb1a2a..4760eca73 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -7,18 +7,21 @@ use std::process::Stdio; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use tauri::{AppHandle, Emitter, Manager}; -use tokio::process::{Child, Command}; +use tokio::io::AsyncWriteExt; +use tokio::process::{Child, ChildStdin, Command}; use tokio::sync::Mutex; -/// Global state to track current Claude process +/// Global state to track current Claude process and its stdin for mid-turn injection pub struct ClaudeProcessState { pub current_process: Arc>>, + pub current_stdin: Arc>>, } impl Default for ClaudeProcessState { fn default() -> Self { Self { current_process: Arc::new(Mutex::new(None)), + current_stdin: Arc::new(Mutex::new(None)), } } } @@ -299,6 +302,7 @@ fn create_system_command(claude_path: &str, args: Vec, project_path: &st } cmd.current_dir(project_path) + .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -933,12 +937,11 @@ pub async fn execute_claude_code( let claude_path = find_claude_binary(&app)?; let args = vec![ - "-p".to_string(), - prompt.clone(), - "--model".to_string(), - model.clone(), + "--print".to_string(), "--output-format".to_string(), "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), "--verbose".to_string(), "--dangerously-skip-permissions".to_string(), ]; @@ -965,12 +968,13 @@ pub async fn continue_claude_code( let args = vec![ "-c".to_string(), // Continue flag - "-p".to_string(), - prompt.clone(), + "--print".to_string(), "--model".to_string(), model.clone(), "--output-format".to_string(), "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), "--verbose".to_string(), "--dangerously-skip-permissions".to_string(), ]; @@ -1000,12 +1004,13 @@ pub async fn resume_claude_code( let args = vec![ "--resume".to_string(), session_id.clone(), - "-p".to_string(), - prompt.clone(), + "--print".to_string(), "--model".to_string(), model.clone(), "--output-format".to_string(), "stream-json".to_string(), + "--input-format".to_string(), + "stream-json".to_string(), "--verbose".to_string(), "--dangerously-skip-permissions".to_string(), ]; @@ -1014,6 +1019,43 @@ pub async fn resume_claude_code( spawn_claude_process(app, cmd, prompt, model, project_path).await } +/// Inject a message into the currently running Claude process stdin (mid-turn injection) +/// This allows steering Claude in real-time while it's working, matching Claude TUI behavior. +#[tauri::command] +pub async fn inject_claude_message( + app: AppHandle, + message: String, +) -> Result<(), String> { + log::info!("Injecting mid-turn message: {}", message); + + let claude_state = app.state::(); + let mut stdin_guard = claude_state.current_stdin.lock().await; + + if let Some(ref mut stdin) = *stdin_guard { + // Claude's stream-json input format expects a JSON object per line + let json_msg = serde_json::json!({ + "type": "user", + "message": { + "role": "user", + "content": message + } + }); + let line = format!("{}\n", json_msg.to_string()); + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| format!("Failed to write to Claude stdin: {}", e))?; + stdin + .flush() + .await + .map_err(|e| format!("Failed to flush Claude stdin: {}", e))?; + log::info!("Mid-turn message injected successfully"); + Ok(()) + } else { + Err("No active Claude process to inject message into".to_string()) + } +} + /// Cancel the currently running Claude Code execution #[tauri::command] pub async fn cancel_claude_execution( @@ -1186,9 +1228,10 @@ async fn spawn_claude_process( .spawn() .map_err(|e| format!("Failed to spawn Claude: {}", e))?; - // Get stdout and stderr + // Get stdout, stderr, and stdin let stdout = child.stdout.take().ok_or("Failed to get stdout")?; let stderr = child.stderr.take().ok_or("Failed to get stderr")?; + let stdin = child.stdin.take().ok_or("Failed to get stdin")?; // Get the child PID for logging let pid = child.id().unwrap_or(0); @@ -1202,7 +1245,7 @@ async fn spawn_claude_process( let session_id_holder: Arc>> = Arc::new(Mutex::new(None)); let run_id_holder: Arc>> = Arc::new(Mutex::new(None)); - // Store the child process in the global state (for backward compatibility) + // Store the child process and stdin in global state let claude_state = app.state::(); { let mut current_process = claude_state.current_process.lock().await; @@ -1213,6 +1256,20 @@ async fn spawn_claude_process( } *current_process = Some(child); } + { + let mut current_stdin = claude_state.current_stdin.lock().await; + // Write the initial prompt as the first stream-json message + let mut stdin_writer = stdin; + let initial_msg = format!("{{\"type\":\"user\",\"message\":{{\"role\":\"user\",\"content\":[{{\"type\":\"text\",\"text\":{}}}]}}}}\n", serde_json::to_string(&prompt).unwrap_or_default()); + use tokio::io::AsyncWriteExt; + if let Err(e) = stdin_writer.write_all(initial_msg.as_bytes()).await { + log::error!("Failed to write initial prompt to stdin: {}", e); + } + if let Err(e) = stdin_writer.flush().await { + log::error!("Failed to flush initial prompt to stdin: {}", e); + } + *current_stdin = Some(stdin_writer); + } // Spawn tasks to read stdout and stderr let app_handle = app.clone(); @@ -1223,6 +1280,7 @@ async fn spawn_claude_process( let project_path_clone = project_path.clone(); let prompt_clone = prompt.clone(); let model_clone = model.clone(); + let claude_stdin_close = claude_state.current_stdin.clone(); let stdout_task = tokio::spawn(async move { let mut lines = stdout_reader.lines(); while let Ok(Some(line)) = lines.next_line().await { @@ -1270,6 +1328,16 @@ async fn spawn_claude_process( } // Also emit to the generic event for backward compatibility let _ = app_handle.emit("claude-output", &line); + + // When Claude emits a "result" message, the turn is complete. + // Close stdin so Claude knows no more input is coming and exits cleanly. + if let Ok(msg) = serde_json::from_str::(&line) { + if msg["type"] == "result" { + log::info!("Claude turn complete (result message), closing stdin"); + let mut stdin_guard = claude_stdin_close.lock().await; + *stdin_guard = None; // dropping ChildStdin closes the pipe + } + } } }); @@ -1291,6 +1359,7 @@ async fn spawn_claude_process( // Wait for the process to complete let app_handle_wait = app.clone(); let claude_state_wait = claude_state.current_process.clone(); + let claude_stdin_wait = claude_state.current_stdin.clone(); let session_id_holder_clone3 = session_id_holder.clone(); let run_id_holder_clone2 = run_id_holder.clone(); let registry_clone2 = registry.0.clone(); @@ -1332,8 +1401,10 @@ async fn spawn_claude_process( let _ = registry_clone2.unregister_process(run_id); } - // Clear the process from state + // Clear the process and stdin from state *current_process = None; + let mut stdin_guard = claude_stdin_wait.lock().await; + *stdin_guard = None; }); Ok(()) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc93adbcf..aae801ee6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -25,8 +25,8 @@ use commands::claude::{ get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, - open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, - save_claude_md_file, save_claude_settings, save_system_prompt, search_files, + inject_claude_message, open_new_session, read_claude_md_file, restore_checkpoint, + resume_claude_code, save_claude_md_file, save_claude_settings, save_system_prompt, search_files, track_checkpoint_message, track_session_messages, update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState, }; @@ -203,6 +203,7 @@ fn main() { continue_claude_code, resume_claude_code, cancel_claude_execution, + inject_claude_message, list_running_claude_sessions, get_claude_session_output, list_directory_contents, From e63088f819fa0ceb580fc524447219e5f7263625 Mon Sep 17 00:00:00 2001 From: daotian-smartnews <86957655+daotian-smartnews@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:17:51 +0900 Subject: [PATCH 03/13] feat(ui): show send and stop buttons simultaneously (#4) - Display send (left) and stop (right) buttons at the same time instead of toggling between them - Stop button dims when Claude is not executing; send button remains always accessible for mid-turn message injection --- src/components/FloatingPromptInput.tsx | 80 ++++++++++++++++---------- 1 file changed, 49 insertions(+), 31 deletions(-) diff --git a/src/components/FloatingPromptInput.tsx b/src/components/FloatingPromptInput.tsx index 1f042b2c6..3d3baa897 100644 --- a/src/components/FloatingPromptInput.tsx +++ b/src/components/FloatingPromptInput.tsx @@ -1031,25 +1031,33 @@ const FloatingPromptInputInner = ( - - - - - + + + + + + + + + @@ -1263,26 +1271,36 @@ const FloatingPromptInputInner = ( - - + + + + + + + + From adf9b78416438a09a3bfb14fcfc7aed04dcb6182 Mon Sep 17 00:00:00 2001 From: daotian-smartnews <86957655+daotian-smartnews@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:20:35 +0900 Subject: [PATCH 04/13] feat(sidebar): add collapsible session sidebar with native context menu, resizable width, and session management (#5) - Add collapsible sidebar showing projects and sessions with expand/collapse per project - Left-click opens session in current tab, right-click shows native macOS context menu - Context menu: Open in New Tab, Rename Session, Delete Session, Refresh Sessions - Right-click on project: Add New Session - Inline rename or via context menu; delete confirmation dialog - Green dot indicator for running sessions - Compact timestamps (<1m, 3m, 13hr, 2d) right-aligned on session row - Custom session names stored in ~/.claude/opcode-metadata.json - Tab title format: ProjectName - SessionName - Resizable sidebar (drag right edge, min 180px / max 480px) - Sidebar-wide native context menu (Refresh Sessions) when not clicking a session - New Session button per project - Open URLs in system default browser instead of in-app webview - Fix user message newlines not rendering - Sidebar visible by default Co-authored-by: zdt90 --- TODO.md | 9 + src-tauri/capabilities/default.json | 6 +- src-tauri/src/commands/claude.rs | 144 ++++++++- src-tauri/src/main.rs | 20 +- src/App.tsx | 40 ++- src/components/RunningClaudeSessions.tsx | 1 + src/components/StreamMessage.tsx | 10 +- src/components/TabContent.tsx | 54 ++-- src/components/sidebar/Sidebar.tsx | 251 ++++++++++++++++ src/components/sidebar/SidebarProjectItem.tsx | 192 ++++++++++++ src/components/sidebar/SidebarSessionItem.tsx | 275 ++++++++++++++++++ src/lib/api.ts | 2 + src/lib/apiAdapter.ts | 6 + src/lib/date-utils.ts | 18 ++ src/lib/linkDetector.tsx | 10 +- src/services/sessionPersistence.ts | 3 +- 16 files changed, 1000 insertions(+), 41 deletions(-) create mode 100644 TODO.md create mode 100644 src/components/sidebar/Sidebar.tsx create mode 100644 src/components/sidebar/SidebarProjectItem.tsx create mode 100644 src/components/sidebar/SidebarSessionItem.tsx diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..78b106d49 --- /dev/null +++ b/TODO.md @@ -0,0 +1,9 @@ +# opcode TODO + +## Open Items + +1. **Debug green dot** — Running session indicator (green dot) not showing in sidebar. Root cause suspected: `list_running_claude_sessions` returns data but session IDs may not match. Debug logging already present in `Sidebar.tsx`. + +2. **Archive sessions** — Add archive support via `~/.claude/opcode-metadata.json` (add `archived_sessions: string[]`). Claude Code has no native archive; this would be a pure UI/metadata layer. Right-click menu placeholder "Delete/Archive" already noted. + +3. **Resizable sidebar** — Allow user to drag the sidebar edge to resize width. Currently fixed at 260px (`SIDEBAR_WIDTH` in `Sidebar.tsx`). diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 5e231dacf..89c0f973f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -55,6 +55,10 @@ "core:window:allow-unmaximize", "core:window:allow-close", "core:window:allow-is-maximized", - "core:window:allow-start-dragging" + "core:window:allow-start-dragging", + "core:menu:default", + "core:menu:allow-new", + "core:menu:allow-append", + "core:menu:allow-popup" ] } diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 4760eca73..f09873d4b 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -54,6 +54,8 @@ pub struct Session { pub todo_data: Option, /// Unix timestamp when the session file was created pub created_at: u64, + /// Unix timestamp of the last modification to the session file (best proxy for last activity) + pub last_updated_at: u64, /// First user message content (if available) pub first_message: Option, /// Timestamp of the first user message (if available) @@ -522,6 +524,13 @@ pub async fn get_project_sessions(project_id: String) -> Result, St .unwrap_or_default() .as_secs(); + let last_updated_at = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + // Extract first user message and timestamp let (first_message, message_timestamp) = extract_first_user_message(&path); @@ -541,6 +550,7 @@ pub async fn get_project_sessions(project_id: String) -> Result, St project_path: project_path.clone(), todo_data, created_at, + last_updated_at, first_message, message_timestamp, }); @@ -548,8 +558,8 @@ pub async fn get_project_sessions(project_id: String) -> Result, St } } - // Sort sessions by creation time (newest first) - sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + // Sort sessions by last activity time (newest first) + sessions.sort_by(|a, b| b.last_updated_at.cmp(&a.last_updated_at)); log::info!( "Found {} sessions for project {}", @@ -2259,6 +2269,136 @@ pub async fn validate_hook_command(command: String) -> Result Result { + let base = dirs::home_dir() + .context("Could not find home directory")?; + Ok(base.join(".claude").join("opcode-metadata.json")) +} + +/// Reads the opcode metadata map from `~/.claude/opcode-metadata.json` +fn read_opcode_metadata() -> serde_json::Map { + let path = match get_opcode_metadata_path() { + Ok(p) => p, + Err(_) => return serde_json::Map::new(), + }; + if !path.exists() { + return serde_json::Map::new(); + } + fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default() +} + +/// Writes the opcode metadata map back to disk +fn write_opcode_metadata(map: &serde_json::Map) -> Result<(), String> { + let path = get_opcode_metadata_path().map_err(|e| e.to_string())?; + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + } + let json = serde_json::to_string_pretty(&serde_json::Value::Object(map.clone())) + .map_err(|e| format!("Failed to serialize metadata: {}", e))?; + fs::write(&path, json).map_err(|e| format!("Failed to write opcode-metadata.json: {}", e)) +} + +/// Deletes a session: removes the `.jsonl` file and any same-name subdirectory +/// under `~/.claude/projects//`. +/// +/// Because we don't store per-session `project_id` here we scan all project +/// directories for the matching JSONL file. +#[tauri::command] +pub async fn delete_session(session_id: String) -> Result<(), String> { + log::info!("Deleting session: {}", session_id); + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + return Err("Projects directory does not exist".to_string()); + } + + let mut deleted = false; + + let entries = fs::read_dir(&projects_dir) + .map_err(|e| format!("Failed to read projects directory: {}", e))?; + + for entry in entries.flatten() { + let project_dir = entry.path(); + if !project_dir.is_dir() { + continue; + } + + let jsonl_path = project_dir.join(format!("{}.jsonl", session_id)); + if jsonl_path.exists() { + fs::remove_file(&jsonl_path) + .map_err(|e| format!("Failed to delete session file: {}", e))?; + deleted = true; + log::info!("Deleted session file: {:?}", jsonl_path); + } + + // Also remove a matching subdirectory if it exists (some Claude versions create one) + let session_subdir = project_dir.join(&session_id); + if session_subdir.exists() && session_subdir.is_dir() { + fs::remove_dir_all(&session_subdir) + .map_err(|e| format!("Failed to delete session directory: {}", e))?; + log::info!("Deleted session directory: {:?}", session_subdir); + } + } + + if !deleted { + return Err(format!("Session not found: {}", session_id)); + } + + // Also remove from metadata + let mut meta = read_opcode_metadata(); + if meta.remove(&session_id).is_some() { + let _ = write_opcode_metadata(&meta); + } + + Ok(()) +} + +/// Saves a custom display name for a session in `~/.claude/opcode-metadata.json` +#[tauri::command] +pub async fn rename_session(session_id: String, name: String) -> Result<(), String> { + log::info!("Renaming session {} to {}", session_id, name); + + let mut meta = read_opcode_metadata(); + meta.insert(session_id, serde_json::Value::String(name)); + write_opcode_metadata(&meta) +} + +/// Returns the custom display name for a session, or null if none is set +#[tauri::command] +pub async fn get_session_name(session_id: String) -> Result, String> { + let meta = read_opcode_metadata(); + Ok(meta + .get(&session_id) + .and_then(|v| v.as_str()) + .map(|s| s.to_string())) +} + +/// Returns a setting value from localStorage-backed app settings (thin wrapper) +/// Used by the StartupIntro component via the api module. +#[tauri::command] +pub async fn get_setting(key: String) -> Result, String> { + // Settings for desktop are stored in localStorage via the web layer; + // for now return None so callers fall back to defaults. + log::debug!("get_setting called for key: {}", key); + Ok(None) +} + +#[tauri::command] +pub async fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> { + #[cfg(debug_assertions)] + window.open_devtools(); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index aae801ee6..68ff22020 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,13 +20,14 @@ use commands::agents::{ use commands::claude::{ cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints, clear_checkpoint_manager, continue_claude_code, create_checkpoint, create_project, - execute_claude_code, find_claude_md_files, fork_from_checkpoint, get_checkpoint_diff, - get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, - get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, - get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, - list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, - inject_claude_message, open_new_session, read_claude_md_file, restore_checkpoint, - resume_claude_code, save_claude_md_file, save_claude_settings, save_system_prompt, search_files, + delete_session, execute_claude_code, find_claude_md_files, fork_from_checkpoint, open_devtools, + get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, + get_claude_session_output, get_claude_settings, get_home_directory, get_hooks_config, + get_project_sessions, get_recently_modified_files, get_session_name, get_session_timeline, + get_setting, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, + list_running_claude_sessions, load_session_history, inject_claude_message, open_new_session, + read_claude_md_file, rename_session, restore_checkpoint, resume_claude_code, + save_claude_md_file, save_claude_settings, save_system_prompt, search_files, track_checkpoint_message, track_session_messages, update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState, }; @@ -212,6 +213,11 @@ fn main() { get_hooks_config, update_hooks_config, validate_hook_command, + // Session metadata + delete_session, + rename_session, + get_session_name, + get_setting, // Checkpoint Management create_checkpoint, restore_checkpoint, diff --git a/src/App.tsx b/src/App.tsx index 1eb89e8b1..2f5eca8ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { TabContent } from "@/components/TabContent"; import { useTabState } from "@/hooks/useTabState"; import { useAppLifecycle, useTrackEvent } from "@/hooks"; import { StartupIntro } from "@/components/StartupIntro"; +import { Sidebar, SidebarToggleButton } from "@/components/sidebar/Sidebar"; type View = | "welcome" @@ -62,6 +63,7 @@ function AppContent() { const [toast, setToast] = useState<{ message: string; type: "success" | "error" | "info" } | null>(null); const [projectForSettings, setProjectForSettings] = useState(null); const [previousView] = useState("welcome"); + const [showSidebar, setShowSidebar] = useState(true); // Initialize analytics lifecycle tracking useAppLifecycle(); @@ -340,8 +342,42 @@ function AppContent() { return (
-
- +
+ {/* Session Sidebar */} + setShowSidebar(false)} + onSessionSelect={(session: Session, projectPath: string, displayName: string) => { + window.dispatchEvent( + new CustomEvent('claude-session-selected', { + detail: { session, projectPath, displayName }, + }) + ); + }} + onSessionOpenInNewTab={(session: Session, projectPath: string, displayName: string) => { + window.dispatchEvent( + new CustomEvent('claude-session-selected', { + detail: { session, projectPath, displayName, openInNewTab: true }, + }) + ); + }} + onNewSession={(projectPath: string) => { + window.dispatchEvent( + new CustomEvent('new-session-for-project', { + detail: { projectPath }, + }) + ); + }} + /> + {/* Collapse toggle strip (visible when sidebar is closed) */} + setShowSidebar(true)} + /> + {/* Main tab content area */} +
+ +
); diff --git a/src/components/RunningClaudeSessions.tsx b/src/components/RunningClaudeSessions.tsx index 496dec862..bd029caa2 100644 --- a/src/components/RunningClaudeSessions.tsx +++ b/src/components/RunningClaudeSessions.tsx @@ -61,6 +61,7 @@ export const RunningClaudeSessions: React.FC = ({ project_id: processInfo.project_path.replace(/[^a-zA-Z0-9]/g, '-'), project_path: processInfo.project_path, created_at: new Date(processInfo.started_at).getTime() / 1000, + last_updated_at: new Date(processInfo.started_at).getTime() / 1000, }; // Emit event to navigate to the session diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index ae43a5934..5e3b0c039 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -360,7 +360,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa // Otherwise render as plain text return ( -
+
{contentStr}
); @@ -612,13 +612,13 @@ const StreamMessageComponent: React.FC = ({ message, classNa // Text content if (content.type === "text") { // Handle both string and object formats - const textContent = typeof content.text === 'string' - ? content.text + const textContent = typeof content.text === 'string' + ? content.text : (content.text?.text || JSON.stringify(content.text)); - + renderedSomething = true; return ( -
+
{textContent}
); diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx index e306324ed..7cb51aeab 100644 --- a/src/components/TabContent.tsx +++ b/src/components/TabContent.tsx @@ -449,40 +449,50 @@ export const TabContent: React.FC = () => { closeTab(tabId); }; + const makeTabTitle = (session: { project_path: string; first_message?: string; id: string }, displayName?: string) => { + const projectName = session.project_path.split('/').pop() || 'Session'; + const sessionName = displayName + || (session.first_message ? session.first_message.trim().slice(0, 20) : session.id.slice(0, 8)); + return `${projectName} - ${sessionName}`; + }; + const handleClaudeSessionSelected = (event: CustomEvent) => { - const { session } = event.detail; - // Check if there's an existing tab for this session - const existingTab = findTabBySessionId(session.id); - if (existingTab) { - // If tab exists, just switch to it - updateTab(existingTab.id, { - sessionData: session, - title: session.project_path.split('/').pop() || 'Session', - }); - window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: existingTab.id } })); + const { session, openInNewTab, displayName } = event.detail; + const title = makeTabTitle(session, displayName); + + if (openInNewTab) { + const existingTab = findTabBySessionId(session.id); + if (existingTab) { + updateTab(existingTab.id, { sessionData: session, title }); + window.dispatchEvent(new CustomEvent('switch-to-tab', { detail: { tabId: existingTab.id } })); + } else { + const newTabId = createChatTab(session.id, title, session.project_path); + updateTab(newTabId, { sessionData: session, initialProjectPath: session.project_path }); + } } else { - // If we're in a projects tab, update it to show the session - // Otherwise create a new tab (for compatibility with other parts of the app) const currentTab = tabs.find(t => t.id === activeTabId); - if (currentTab && currentTab.type === 'projects') { + if (currentTab) { updateTab(currentTab.id, { type: 'chat', - title: session.project_path.split('/').pop() || 'Session', + title, sessionId: session.id, - sessionData: session, - initialProjectPath: session.project_path - }); - } else { - const projectName = session.project_path.split('/').pop() || 'Session'; - const newTabId = createChatTab(session.id, projectName, session.project_path); - updateTab(newTabId, { sessionData: session, initialProjectPath: session.project_path, }); + } else { + const newTabId = createChatTab(session.id, title, session.project_path); + updateTab(newTabId, { sessionData: session, initialProjectPath: session.project_path }); } } }; + const handleNewSessionForProject = (event: CustomEvent) => { + const { projectPath } = event.detail; + const projectName = projectPath.split('/').pop() || 'New Session'; + const newTabId = createChatTab(undefined, projectName, projectPath); + updateTab(newTabId, { initialProjectPath: projectPath }); + }; + window.addEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener); window.addEventListener('open-claude-file', handleOpenClaudeFile as EventListener); window.addEventListener('open-agent-execution', handleOpenAgentExecution as EventListener); @@ -490,6 +500,7 @@ export const TabContent: React.FC = () => { window.addEventListener('open-import-agent-tab', handleOpenImportAgentTab); window.addEventListener('close-tab', handleCloseTab as EventListener); window.addEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener); + window.addEventListener('new-session-for-project', handleNewSessionForProject as EventListener); return () => { window.removeEventListener('open-session-in-tab', handleOpenSessionInTab as EventListener); window.removeEventListener('open-claude-file', handleOpenClaudeFile as EventListener); @@ -498,6 +509,7 @@ export const TabContent: React.FC = () => { window.removeEventListener('open-import-agent-tab', handleOpenImportAgentTab); window.removeEventListener('close-tab', handleCloseTab as EventListener); window.removeEventListener('claude-session-selected', handleClaudeSessionSelected as EventListener); + window.removeEventListener('new-session-for-project', handleNewSessionForProject as EventListener); }; }, [createChatTab, findTabBySessionId, createClaudeFileTab, createAgentExecutionTab, createCreateAgentTab, createImportAgentTab, closeTab, updateTab]); diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx new file mode 100644 index 000000000..7f009ea55 --- /dev/null +++ b/src/components/sidebar/Sidebar.tsx @@ -0,0 +1,251 @@ +/** + * Sidebar component — collapsible left panel showing projects & sessions. + */ + +console.log('[Sidebar] BUILD v1 - loaded'); + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; +import { apiCall } from '@/lib/apiAdapter'; +import { SidebarProjectItem } from './SidebarProjectItem'; +import type { Project, Session } from '@/lib/api'; + +interface SidebarProps { + onSessionSelect: (session: Session, projectPath: string, displayName: string) => void; + onSessionOpenInNewTab: (session: Session, projectPath: string, displayName: string) => void; + onNewSession: (projectPath: string) => void; + activeSessionId?: string; + isOpen: boolean; + onToggle: () => void; + className?: string; +} + +const SIDEBAR_WIDTH_DEFAULT = 260; +const SIDEBAR_WIDTH_MIN = 180; +const SIDEBAR_WIDTH_MAX = 480; +const RUNNING_SESSIONS_POLL_INTERVAL = 5000; + +export const Sidebar: React.FC = ({ + onSessionSelect, + onSessionOpenInNewTab, + onNewSession, + activeSessionId, + isOpen, + onToggle, + className, +}) => { + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [runningSessionIds, setRunningSessionIds] = useState>(new Set()); + const [reloadSignal, setReloadSignal] = useState(0); + const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_WIDTH_DEFAULT); + const pollingIntervalRef = useRef | null>(null); + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + const dragStartWidthRef = useRef(0); + + const loadProjects = async () => { + setLoading(true); + setError(null); + try { + const result = await apiCall('list_projects'); + setProjects(result); + setReloadSignal((n) => n + 1); + } catch (err) { + console.error('[Sidebar] Failed to load projects:', err); + setError('Failed to load projects'); + } finally { + setLoading(false); + } + }; + + const pollRunningSessions = async () => { + try { + const claudeRuns = await apiCall>('list_running_claude_sessions'); + console.log('[Sidebar] running claude sessions:', JSON.stringify(claudeRuns)); + const ids = new Set( + claudeRuns + .map((r) => r.process_type?.ClaudeSession?.session_id) + .filter((id): id is string => Boolean(id)) + ); + setRunningSessionIds(ids); + } catch (_err) { + console.log('[Sidebar] pollRunningSessions error:', _err); + } + }; + + useEffect(() => { + if (isOpen) { + loadProjects(); + pollRunningSessions(); + pollingIntervalRef.current = setInterval(pollRunningSessions, RUNNING_SESSIONS_POLL_INTERVAL); + } else { + if (pollingIntervalRef.current !== null) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + } + return () => { + if (pollingIntervalRef.current !== null) { + clearInterval(pollingIntervalRef.current); + pollingIntervalRef.current = null; + } + }; + }, [isOpen]); + + // Sidebar-wide context menu: always show our custom menu anywhere in the sidebar + const handleSidebarContextMenu = useCallback(async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + // Only handle events that weren't already handled by a session item + // (session items call stopPropagation so this only fires for non-session areas) + try { + const { Menu, MenuItem } = await import('@tauri-apps/api/menu'); + const { LogicalPosition } = await import('@tauri-apps/api/dpi'); + + const refreshItem = await MenuItem.new({ + id: 'refresh-sessions', + text: 'Refresh Sessions', + action: () => loadProjects(), + }); + + const menuItems: any[] = [refreshItem]; + + if (import.meta.env.DEV) { + const { PredefinedMenuItem } = await import('@tauri-apps/api/menu'); + const sep = await PredefinedMenuItem.new({ item: 'Separator' }); + const inspectItem = await MenuItem.new({ + id: 'inspect-element', + text: 'Inspect Element', + action: () => apiCall('open_devtools', {}), + }); + menuItems.push(sep, inspectItem); + } + + const menu = await Menu.new({ items: menuItems }); + await menu.popup(new LogicalPosition(e.clientX, e.clientY)); + } catch (err) { + console.error('[Sidebar] Failed to show context menu:', err); + } + }, []); + + const handleResizeMouseDown = (e: React.MouseEvent) => { + e.preventDefault(); + isDraggingRef.current = true; + dragStartXRef.current = e.clientX; + dragStartWidthRef.current = sidebarWidth; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + + const onMouseMove = (ev: MouseEvent) => { + if (!isDraggingRef.current) return; + const delta = ev.clientX - dragStartXRef.current; + const next = Math.max(SIDEBAR_WIDTH_MIN, Math.min(SIDEBAR_WIDTH_MAX, dragStartWidthRef.current + delta)); + setSidebarWidth(next); + }; + + const onMouseUp = () => { + isDraggingRef.current = false; + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }; + + return ( +
+ {isOpen && ( + <> + {/* Sidebar header */} +
+ + Sessions + +
+ + +
+
+ + {/* Scrollable project list */} + +
+ {error && ( +

{error}

+ )} + {!error && projects.length === 0 && !loading && ( +

No projects found

+ )} + {projects.map((project) => ( + + ))} +
+
+ + )} + {/* Drag handle — sits on the right edge */} + {isOpen && ( +
+ )} +
+ ); +}; + +/** + * Small toggle button shown outside the sidebar when it is collapsed. + */ +export const SidebarToggleButton: React.FC<{ + onClick: () => void; + isOpen: boolean; +}> = ({ onClick, isOpen }) => { + if (isOpen) return null; + return ( + + ); +}; diff --git a/src/components/sidebar/SidebarProjectItem.tsx b/src/components/sidebar/SidebarProjectItem.tsx new file mode 100644 index 000000000..ed96d1bac --- /dev/null +++ b/src/components/sidebar/SidebarProjectItem.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect } from 'react'; +import { ChevronRight, ChevronDown, FolderOpen, Loader2, Plus } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiCall } from '@/lib/apiAdapter'; +import { SidebarSessionItem } from './SidebarSessionItem'; +import type { Session } from '@/lib/api'; + +interface ProjectItem { + id: string; + path: string; +} + +interface SidebarProjectItemProps { + project: ProjectItem; + activeSessionId?: string; + runningSessionIds?: Set; + onSessionSelect: (session: Session, projectPath: string, displayName: string) => void; + onSessionSelectNewTab: (session: Session, projectPath: string, displayName: string) => void; + onNewSession: (projectPath: string) => void; + /** Incrementing this triggers a force-reload of sessions without unmounting */ + reloadSignal?: number; +} + +function getProjectBaseName(path: string): string { + // Handle both Unix and Windows-style paths + const parts = path.replace(/\\/g, '/').split('/').filter(Boolean); + return parts[parts.length - 1] || path; +} + +export const SidebarProjectItem: React.FC = ({ + project, + activeSessionId, + runningSessionIds, + onSessionSelect, + onSessionSelectNewTab, + onNewSession, + reloadSignal, +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [loaded, setLoaded] = useState(false); + const [sessionNames, setSessionNames] = useState>({}); + + const projectName = getProjectBaseName(project.path); + + // When the parent bumps reloadSignal, reload sessions if already expanded + useEffect(() => { + if (reloadSignal && reloadSignal > 0 && isExpanded) { + loadSessions(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reloadSignal]); + + const loadSessions = async (force = false) => { + if (loaded && !force) return; + setLoading(true); + try { + const result = await apiCall('get_project_sessions', { projectId: project.id }); + setSessions(result); + setLoaded(true); + + // Load custom names for all sessions + const namesMap: Record = {}; + await Promise.all( + result.map(async (s) => { + try { + const name = await apiCall('get_session_name', { sessionId: s.id }); + if (name) namesMap[s.id] = name; + } catch (_) { + // no custom name + } + }) + ); + setSessionNames(namesMap); + } catch (err) { + console.error('[Sidebar] Failed to load sessions for project:', project.id, err); + } finally { + setLoading(false); + } + }; + + const handleToggle = () => { + const next = !isExpanded; + setIsExpanded(next); + if (next) loadSessions(); + }; + + const getDisplayName = (session: Session): string => { + if (sessionNames[session.id]) return sessionNames[session.id]; + if (session.first_message) { + const text = session.first_message.trim(); + return text.length > 40 ? text.slice(0, 40) + '...' : text; + } + return session.id.slice(0, 16) + '...'; + }; + + const handleDeleteSession = (sessionId: string) => { + setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + }; + + const handleRenameSession = (sessionId: string, newName: string) => { + setSessionNames((prev) => ({ ...prev, [sessionId]: newName })); + }; + + const handleProjectContextMenu = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + const { Menu, MenuItem } = await import('@tauri-apps/api/menu'); + const { LogicalPosition } = await import('@tauri-apps/api/dpi'); + + const newSessionItem = await MenuItem.new({ + id: 'new-session', + text: 'New Session', + action: () => onNewSession(project.path), + }); + + const menuItems: any[] = [newSessionItem]; + + if (import.meta.env.DEV) { + const { PredefinedMenuItem } = await import('@tauri-apps/api/menu'); + const sep = await PredefinedMenuItem.new({ item: 'Separator' }); + const inspectItem = await MenuItem.new({ + id: 'inspect-element', + text: 'Inspect Element', + action: () => apiCall('open_devtools', {}), + }); + menuItems.push(sep, inspectItem); + } + + const menu = await Menu.new({ items: menuItems }); + await menu.popup(new LogicalPosition(e.clientX, e.clientY)); + } catch (err) { + console.error('[SidebarProjectItem] Failed to show context menu:', err); + } + }; + + return ( +
+ {/* Project header row */} + + + {/* Sessions list */} + {isExpanded && ( +
+ {/* New Session button */} + + {sessions.length === 0 && !loading && ( +

No sessions

+ )} + {sessions.map((session) => ( + onSessionSelect(session, project.path, getDisplayName(session))} + onOpenInNewTab={() => onSessionSelectNewTab(session, project.path, getDisplayName(session))} + onRefreshSessions={() => loadSessions(true)} + onDelete={handleDeleteSession} + onRename={handleRenameSession} + /> + ))} +
+ )} +
+ ); +}; diff --git a/src/components/sidebar/SidebarSessionItem.tsx b/src/components/sidebar/SidebarSessionItem.tsx new file mode 100644 index 000000000..8f006fc3f --- /dev/null +++ b/src/components/sidebar/SidebarSessionItem.tsx @@ -0,0 +1,275 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Pencil, Trash2, Check, X, AlertTriangle } from 'lucide-react'; +import { Input } from '@/components/ui/input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { cn } from '@/lib/utils'; +import { formatTimeAgoBrief } from '@/lib/date-utils'; +import { apiCall } from '@/lib/apiAdapter'; +import type { Session } from '@/lib/api'; + +interface SidebarSessionItemProps { + session: Session; + isActive: boolean; + isRunning?: boolean; + displayName: string; + onClick: () => void; + onOpenInNewTab: () => void; + onRefreshSessions: () => void; + onDelete: (sessionId: string) => void; + onRename: (sessionId: string, newName: string) => void; +} + +function getSessionTimestampMs(session: Session): number { + if (session.last_updated_at) return session.last_updated_at * 1000; + return session.created_at * 1000; +} + +const RELATIVE_TIME_UPDATE_INTERVAL = 60_000; + +export const SidebarSessionItem: React.FC = ({ + session, + isActive, + isRunning = false, + displayName, + onClick, + onOpenInNewTab, + onRefreshSessions, + onDelete, + onRename, +}) => { + const [isHovered, setIsHovered] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(displayName); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const inputRef = useRef(null); + + const [relativeTime, setRelativeTime] = useState(() => + formatTimeAgoBrief(getSessionTimestampMs(session)) + ); + + useEffect(() => { + setRelativeTime(formatTimeAgoBrief(getSessionTimestampMs(session))); + const interval = setInterval(() => { + setRelativeTime(formatTimeAgoBrief(getSessionTimestampMs(session))); + }, RELATIVE_TIME_UPDATE_INTERVAL); + return () => clearInterval(interval); + }, [session.id, session.last_updated_at, session.created_at]); + + useEffect(() => { + if (isRenaming && inputRef.current) { + inputRef.current.focus(); + inputRef.current.select(); + } + }, [isRenaming]); + + const handleContextMenu = async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + try { + const { Menu, MenuItem, PredefinedMenuItem } = await import('@tauri-apps/api/menu'); + const { LogicalPosition } = await import('@tauri-apps/api/dpi'); + + const openInNewTabItem = await MenuItem.new({ + id: 'open-in-new-tab', + text: 'Open in New Tab', + action: () => onOpenInNewTab(), + }); + + const sep1 = await PredefinedMenuItem.new({ item: 'Separator' }); + + const renameItem = await MenuItem.new({ + id: 'rename-session', + text: 'Rename Session', + action: () => { setRenameValue(displayName); setIsRenaming(true); }, + }); + + const deleteItem = await MenuItem.new({ + id: 'delete-session', + text: 'Delete Session', + action: () => setConfirmDeleteOpen(true), + }); + + const sep2 = await PredefinedMenuItem.new({ item: 'Separator' }); + + const reloadItem = await MenuItem.new({ + id: 'refresh-sessions', + text: 'Refresh Sessions', + action: () => onRefreshSessions(), + }); + + const menuItems: any[] = [openInNewTabItem, sep1, renameItem, deleteItem, sep2, reloadItem]; + + // Add "Inspect Element" only in dev mode + if (import.meta.env.DEV) { + const inspectItem = await MenuItem.new({ + id: 'inspect-element', + text: 'Inspect Element', + action: () => apiCall('open_devtools', {}), + }); + menuItems.push(inspectItem); + } + + const menu = await Menu.new({ items: menuItems }); + await menu.popup(new LogicalPosition(e.clientX, e.clientY)); + } catch (err) { + console.error('[SidebarSessionItem] Failed to show context menu:', err); + } + }; + + const handleRenameStart = (e: React.MouseEvent) => { + e.stopPropagation(); + setRenameValue(displayName); + setIsRenaming(true); + }; + + const handleRenameCommit = async () => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== displayName) { + try { + await apiCall('rename_session', { sessionId: session.id, name: trimmed }); + onRename(session.id, trimmed); + } catch (err) { + console.error('[Sidebar] Failed to rename session:', err); + } + } + setIsRenaming(false); + }; + + const handleRenameCancel = () => { + setRenameValue(displayName); + setIsRenaming(false); + }; + + const handleRenameKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); handleRenameCommit(); } + else if (e.key === 'Escape') { handleRenameCancel(); } + }; + + const handleDeleteClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setConfirmDeleteOpen(true); + }; + + const handleDeleteConfirm = async () => { + setConfirmDeleteOpen(false); + try { + await apiCall('delete_session', { sessionId: session.id }); + onDelete(session.id); + } catch (err) { + console.error('[Sidebar] Failed to delete session:', err); + } + }; + + return ( + <> + + e.stopPropagation()}> + +
+
+ +
+ Delete Session +
+

+ Are you sure you want to permanently delete{' '} + {displayName}? + This cannot be undone. +

+
+
+ + +
+
+
+ +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={isRenaming ? undefined : onClick} + onContextMenu={handleContextMenu} + > + {isRenaming ? ( +
e.stopPropagation()}> + setRenameValue(e.target.value)} + onKeyDown={handleRenameKeyDown} + onBlur={handleRenameCommit} + className="h-5 px-1 py-0 text-xs flex-1 min-w-0" + /> + + +
+ ) : ( + <> + {isRunning && ( + + )} +
+ {displayName} + {relativeTime} +
+ + {isHovered && !isActive && ( +
+ + +
+ )} + + )} +
+ + ); +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index eb76da821..309c8703c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -47,6 +47,8 @@ export interface Session { todo_data?: any; /** Unix timestamp when the session file was created */ created_at: number; + /** Unix timestamp of the last modification to the session file (best proxy for last activity) */ + last_updated_at: number; /** First user message content (if available) */ first_message?: string; /** Timestamp of the first user message (if available) */ diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index ece52c491..75b42ecf1 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -251,6 +251,12 @@ function mapCommandToEndpoint(command: string, _params?: any): string { 'get_hooks_config': '/api/hooks/config', 'update_hooks_config': '/api/hooks/config', 'validate_hook_command': '/api/hooks/validate', + + // Session metadata (opcode-specific) + 'delete_session': '/api/sessions/{sessionId}/delete', + 'rename_session': '/api/sessions/{sessionId}/rename', + 'get_session_name': '/api/sessions/{sessionId}/name', + 'get_setting': '/api/settings/app/{key}', // Slash commands 'slash_commands_list': '/api/slash-commands', diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts index 70e6b8633..e7a96cd56 100644 --- a/src/lib/date-utils.ts +++ b/src/lib/date-utils.ts @@ -114,6 +114,24 @@ function getDayName(date: Date): string { * formatTimeAgo(Date.now() - 3600000) // "1 hour ago" * formatTimeAgo(Date.now() - 86400000) // "1 day ago" */ +/** + * Compact relative time: <1m, 3m, 13hr, 2d, 3w, 2mo, 1y + */ +export function formatTimeAgoBrief(timestamp: number): string { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (minutes < 1) return '<1m'; + if (hours < 1) return `${minutes}m`; + if (days < 1) return `${hours}hr`; + if (days < 7) return `${days}d`; + if (days < 30) return `${Math.floor(days / 7)}w`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}y`; +} + export function formatTimeAgo(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; diff --git a/src/lib/linkDetector.tsx b/src/lib/linkDetector.tsx index 5938ba017..ed15a15b5 100644 --- a/src/lib/linkDetector.tsx +++ b/src/lib/linkDetector.tsx @@ -121,9 +121,15 @@ export function makeLinksClickable( { + onClick={async (e) => { e.preventDefault(); - onLinkClick(link.fullUrl); + try { + const { open } = await import('@tauri-apps/plugin-shell'); + await open(link.fullUrl); + } catch { + // Fallback for web/non-Tauri env + onLinkClick(link.fullUrl); + } }} className="text-primary underline hover:text-primary/80 cursor-pointer" title={link.fullUrl} diff --git a/src/services/sessionPersistence.ts b/src/services/sessionPersistence.ts index 2c3f513e9..de91352f3 100644 --- a/src/services/sessionPersistence.ts +++ b/src/services/sessionPersistence.ts @@ -167,7 +167,8 @@ export class SessionPersistenceService { id: data.sessionId, project_id: data.projectId, project_path: data.projectPath, - created_at: data.timestamp / 1000, // Convert to seconds + created_at: data.timestamp / 1000, + last_updated_at: data.timestamp / 1000, first_message: "Restored session" }; } From 14adf2aeab6ab4298492509b21a42d9d9efd48a3 Mon Sep 17 00:00:00 2001 From: daotian-smartnews <86957655+daotian-smartnews@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:52:13 +0900 Subject: [PATCH 05/13] fix: block external URL navigation at webview level (#7) Add a Tauri nav-guard plugin that intercepts all navigation events and opens external http(s):// URLs in the system default browser instead of loading them inside the app webview. Also refactor duplicate markdownComponents inline definitions in StreamMessage.tsx into a shared function. Co-authored-by: zdt90 --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/main.rs | 21 ++++++++++++ src/components/StreamMessage.tsx | 57 +++++++++++--------------------- 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5569e2304..1e07b3528 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3566,6 +3566,7 @@ dependencies = [ "libc", "log", "objc", + "open", "regex", "reqwest", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 56e43d43c..77f69060c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -57,6 +57,7 @@ sha2 = "0.10" zstd = "0.13" uuid = { version = "1.6", features = ["v4", "serde"] } walkdir = "2" +open = "5" serde_yaml = "0.9" axum = { version = "0.8", features = ["ws"] } tower = "0.5" diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 68ff22020..2305af348 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -56,7 +56,28 @@ fn main() { // Initialize logger env_logger::init(); + // Intercept external navigation and open in system browser instead + let nav_plugin = tauri::plugin::Builder::::new("nav-guard") + .on_navigation(|_webview, url| { + let url_str = url.as_str(); + if url_str.starts_with("tauri://") + || url_str.starts_with("http://localhost") + || url_str.starts_with("https://localhost") + || url_str.starts_with("http://127.0.0.1") + || url_str.starts_with("asset://") + { + return true; + } + if url_str.starts_with("http://") || url_str.starts_with("https://") { + let _ = open::that(url_str); + return false; + } + true + }) + .build(); + tauri::Builder::default() + .plugin(nav_plugin) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_shell::init()) .setup(|app| { diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index 5e3b0c039..48bed5610 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -41,6 +41,23 @@ import { WebFetchWidget } from "./ToolWidgets"; +function markdownComponents(syntaxTheme: any) { + return { + code({ node, inline, className, children, ...props }: any) { + const match = /language-(\w+)/.exec(className || ''); + return !inline && match ? ( + + {String(children).replace(/\n$/, '')} + + ) : ( + + {children} + + ); + }, + }; +} + interface StreamMessageProps { message: ClaudeStreamMessage; className?: string; @@ -131,25 +148,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa
- {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - } - }} + components={markdownComponents(syntaxTheme)} > {textContent} @@ -660,25 +659,7 @@ const StreamMessageComponent: React.FC = ({ message, classNa
- {String(children).replace(/\n$/, '')} - - ) : ( - - {children} - - ); - } - }} + components={markdownComponents(syntaxTheme)} > {message.result} From 9bbe17964095330e643b6aa6869df182576178f3 Mon Sep 17 00:00:00 2001 From: daotian-smartnews <86957655+daotian-smartnews@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:08:24 +0900 Subject: [PATCH 06/13] fix(ui): remove abnormally large gaps between chat messages (#8) * fix(ui): remove abnormally large gaps between chat messages The virtualized message list rendered very large vertical gaps between messages due to two separate issues: 1. measureElement was attached to a framer-motion element wrapped in AnimatePresence, which made dynamic height measurement unreliable and frequently fell back to the 150px estimate. Move measurement and absolute positioning to a stable native div, and keep only the enter animation on an inner motion.div. 2. displayableMessages did not filter opcode-internal metadata messages (last-prompt, ai-title, mode, queue-operation, attachment). These render as null in StreamMessage but still left empty placeholder rows (~16px each) that piled up into large blank areas. Restrict the list to message types StreamMessage actually renders. Co-authored-by: Cursor * chore(dev): enable Vite dev server with HMR for tauri dev Set beforeDevCommand and devUrl so `tauri dev` runs the Vite dev server with hot module reload instead of loading the prebuilt dist/. Frontend changes now reflect immediately during development without a manual rebuild and app restart. Co-authored-by: Cursor --------- Co-authored-by: zdt90 Co-authored-by: Cursor --- src-tauri/tauri.conf.json | 3 ++- src/components/ClaudeCodeSession.tsx | 40 +++++++++++++++++----------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 9ad4c05f5..550ed3f4d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -4,7 +4,8 @@ "version": "0.2.1", "identifier": "opcode.asterisk.so", "build": { - "beforeDevCommand": "", + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", "beforeBuildCommand": "bun run build", "frontendDist": "../dist" }, diff --git a/src/components/ClaudeCodeSession.tsx b/src/components/ClaudeCodeSession.tsx index 5ed35d513..6c2cf73fc 100644 --- a/src/components/ClaudeCodeSession.tsx +++ b/src/components/ClaudeCodeSession.tsx @@ -194,7 +194,17 @@ export const ClaudeCodeSession: React.FC = ({ // Filter out messages that shouldn't be displayed const displayableMessages = useMemo(() => { + // Only these message types produce visible output in StreamMessage. + // Everything else (e.g. opcode-internal "last-prompt", "ai-title", "mode", + // "queue-operation", "attachment" records) renders as null and would + // otherwise leave empty placeholder rows that pile up as large gaps in the + // virtualized list. + const renderableTypes = ["assistant", "user", "result", "system", "summary"]; return messages.filter((message, index) => { + if (!renderableTypes.includes(String(message.type))) { + return false; + } + // Skip meta messages that don't have meaningful content if (message.isMeta && !message.leafUuid && !message.summary) { return false; @@ -1239,22 +1249,22 @@ export const ClaudeCodeSession: React.FC = ({ minHeight: '100px', }} > - - {rowVirtualizer.getVirtualItems().map((virtualItem) => { - const message = displayableMessages[virtualItem.index]; - return ( + {rowVirtualizer.getVirtualItems().map((virtualItem) => { + const message = displayableMessages[virtualItem.index]; + return ( +
el && rowVirtualizer.measureElement(el)} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} - exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.3 }} - className="absolute inset-x-4 pb-4" - style={{ - top: virtualItem.start, - }} > = ({ onLinkDetected={handleLinkDetected} /> - ); - })} - +
+ ); + })}
{/* Loading indicator under the latest message */} From fb4439097a084bcb652651a8c3e2f9e01fb73b27 Mon Sep 17 00:00:00 2001 From: daotian-smartnews <86957655+daotian-smartnews@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:14:47 +0900 Subject: [PATCH 07/13] feat(ui): add copy button to markdown code blocks (#9) Fenced code blocks in chat messages now render with a hover-revealed copy-to-clipboard button. Clicking copies the full snippet and briefly shows a confirmation check. Inline code is intentionally left unchanged. Co-authored-by: zdt90 Co-authored-by: Cursor --- src/components/StreamMessage.tsx | 57 +++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/src/components/StreamMessage.tsx b/src/components/StreamMessage.tsx index 48bed5610..2870448e6 100644 --- a/src/components/StreamMessage.tsx +++ b/src/components/StreamMessage.tsx @@ -4,7 +4,9 @@ import { User, Bot, AlertCircle, - CheckCircle2 + CheckCircle2, + Copy, + Check } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { cn } from "@/lib/utils"; @@ -41,14 +43,61 @@ import { WebFetchWidget } from "./ToolWidgets"; +/** + * A fenced code block rendered with syntax highlighting and a copy-to-clipboard + * button shown on hover. Only used for block-level code, not inline code. + */ +const CodeBlock: React.FC<{ + language: string; + value: string; + syntaxTheme: any; +}> = ({ language, value, syntaxTheme }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error("Failed to copy code block:", err); + } + }; + + return ( +
+ + + {value} + +
+ ); +}; + function markdownComponents(syntaxTheme: any) { return { code({ node, inline, className, children, ...props }: any) { const match = /language-(\w+)/.exec(className || ''); + // Block-level code (fenced, with a language) gets syntax highlighting and + // a copy button. Inline code is left untouched. return !inline && match ? ( - - {String(children).replace(/\n$/, '')} - + ) : ( {children} From 24be39a5d8302842a4026d0b5538b5da5877e13e Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 22:44:32 +0900 Subject: [PATCH 08/13] fix: restore --model flag for new Claude sessions The switch to --print/stream-json mode in execute_claude_code accidentally dropped the --model argument, so newly started sessions silently ignored the user-selected model and fell back to Claude's default. continue/resume already kept --model; this restores it for new sessions to match. Co-authored-by: Cursor --- src-tauri/src/commands/claude.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index f09873d4b..6d6ddb263 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -948,6 +948,8 @@ pub async fn execute_claude_code( let args = vec![ "--print".to_string(), + "--model".to_string(), + model.clone(), "--output-format".to_string(), "stream-json".to_string(), "--input-format".to_string(), From dcfb51c94d75d097ae88972e6003e2b17226794b Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 22:45:08 +0900 Subject: [PATCH 09/13] chore(sidebar): remove leftover debug logging Drop the module-level "BUILD v1 - loaded" log and the per-poll running-sessions JSON dump (printed every 5s), and silence the non-fatal polling-error log. These were development-time aids that only added console noise in production. Co-authored-by: Cursor --- src/components/sidebar/Sidebar.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 7f009ea55..8c7516cc1 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -2,8 +2,6 @@ * Sidebar component — collapsible left panel showing projects & sessions. */ -console.log('[Sidebar] BUILD v1 - loaded'); - import React, { useState, useEffect, useRef, useCallback } from 'react'; import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -65,15 +63,14 @@ export const Sidebar: React.FC = ({ const pollRunningSessions = async () => { try { const claudeRuns = await apiCall>('list_running_claude_sessions'); - console.log('[Sidebar] running claude sessions:', JSON.stringify(claudeRuns)); const ids = new Set( claudeRuns .map((r) => r.process_type?.ClaudeSession?.session_id) .filter((id): id is string => Boolean(id)) ); setRunningSessionIds(ids); - } catch (_err) { - console.log('[Sidebar] pollRunningSessions error:', _err); + } catch { + // Polling failures are non-fatal; ignore and retry on the next interval } }; From 22418129ee33215226218e1e03fd9b8051f6666d Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 22:45:20 +0900 Subject: [PATCH 10/13] chore: remove stale TODO.md This was a development scratch note that leaked into the tree. Its contents are already out of date (e.g. it lists the resizable sidebar as "currently fixed at 260px" even though resizing was implemented in the same change set), so it is misleading rather than useful. Co-authored-by: Cursor --- TODO.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 78b106d49..000000000 --- a/TODO.md +++ /dev/null @@ -1,9 +0,0 @@ -# opcode TODO - -## Open Items - -1. **Debug green dot** — Running session indicator (green dot) not showing in sidebar. Root cause suspected: `list_running_claude_sessions` returns data but session IDs may not match. Debug logging already present in `Sidebar.tsx`. - -2. **Archive sessions** — Add archive support via `~/.claude/opcode-metadata.json` (add `archived_sessions: string[]`). Claude Code has no native archive; this would be a pure UI/metadata layer. Right-click menu placeholder "Delete/Archive" already noted. - -3. **Resizable sidebar** — Allow user to drag the sidebar edge to resize width. Currently fixed at 260px (`SIDEBAR_WIDTH` in `Sidebar.tsx`). From 63edd6ca048a36059fba933949a8e1e8076cf2a3 Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 22:47:05 +0900 Subject: [PATCH 11/13] refactor: remove unused get_setting backend command The get_setting Tauri command always returned None and was never invoked: the frontend api.getSetting reads from localStorage and the app_settings table directly, never through this command. Remove the command, its registration, and the apiAdapter endpoint mapping (the comment claiming StartupIntro used it was inaccurate). Co-authored-by: Cursor --- src-tauri/src/commands/claude.rs | 10 ---------- src-tauri/src/main.rs | 3 +-- src/lib/apiAdapter.ts | 1 - 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 6d6ddb263..c883a4c21 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -2384,16 +2384,6 @@ pub async fn get_session_name(session_id: String) -> Result, Stri .map(|s| s.to_string())) } -/// Returns a setting value from localStorage-backed app settings (thin wrapper) -/// Used by the StartupIntro component via the api module. -#[tauri::command] -pub async fn get_setting(key: String) -> Result, String> { - // Settings for desktop are stored in localStorage via the web layer; - // for now return None so callers fall back to defaults. - log::debug!("get_setting called for key: {}", key); - Ok(None) -} - #[tauri::command] pub async fn open_devtools(window: tauri::WebviewWindow) -> Result<(), String> { #[cfg(debug_assertions)] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2305af348..2cdcaa735 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -24,7 +24,7 @@ use commands::claude::{ get_checkpoint_diff, get_checkpoint_settings, get_checkpoint_state_stats, get_claude_session_output, get_claude_settings, get_home_directory, get_hooks_config, get_project_sessions, get_recently_modified_files, get_session_name, get_session_timeline, - get_setting, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, + get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, inject_claude_message, open_new_session, read_claude_md_file, rename_session, restore_checkpoint, resume_claude_code, save_claude_md_file, save_claude_settings, save_system_prompt, search_files, @@ -238,7 +238,6 @@ fn main() { delete_session, rename_session, get_session_name, - get_setting, // Checkpoint Management create_checkpoint, restore_checkpoint, diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index 75b42ecf1..1c12f54e1 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -256,7 +256,6 @@ function mapCommandToEndpoint(command: string, _params?: any): string { 'delete_session': '/api/sessions/{sessionId}/delete', 'rename_session': '/api/sessions/{sessionId}/rename', 'get_session_name': '/api/sessions/{sessionId}/name', - 'get_setting': '/api/settings/app/{key}', // Slash commands 'slash_commands_list': '/api/slash-commands', From bb61ab553a0299455687632ff77f5788093cbc42 Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 22:47:32 +0900 Subject: [PATCH 12/13] fix: align mid-turn injection message format with initial prompt inject_claude_message sent the user content as a bare string while the initial prompt sent it as an array of typed content blocks. Use the same array shape in both paths so mid-turn injected messages match the structure Claude already receives for the first prompt. Co-authored-by: Cursor --- src-tauri/src/commands/claude.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index c883a4c21..74caf1124 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -1044,12 +1044,17 @@ pub async fn inject_claude_message( let mut stdin_guard = claude_state.current_stdin.lock().await; if let Some(ref mut stdin) = *stdin_guard { - // Claude's stream-json input format expects a JSON object per line + // Claude's stream-json input format expects a JSON object per line. + // Use the same content shape as the initial prompt (an array of typed + // content blocks) so both code paths produce identical message structure. let json_msg = serde_json::json!({ "type": "user", "message": { "role": "user", - "content": message + "content": [{ + "type": "text", + "text": message + }] } }); let line = format!("{}\n", json_msg.to_string()); From 5c38764ccc99a77e0e8817f751da85880f2ff8d3 Mon Sep 17 00:00:00 2001 From: zdt90 Date: Thu, 11 Jun 2026 23:15:24 +0900 Subject: [PATCH 13/13] chore: ignore local TODO.md scratch notes TODO.md is a local development scratch pad and should not be tracked (an earlier stale copy had leaked into the repo). Ignore it so future notes stay local. Co-authored-by: Cursor --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e140c1fdf..dfbaa64c3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ temp_lib/ AGENTS.md CLAUDE.md *_TASK.md +TODO.md # Claude project-specific files .claude/