From d83697e5cfb648afb64bd123867f83b16cbe45c8 Mon Sep 17 00:00:00 2001 From: Naomi Carrigan Date: Tue, 20 Jan 2026 13:57:48 -0800 Subject: [PATCH] feat: add ability to run multiple agents via tabbed views (#47) ### Explanation _No response_ ### Issue Closes #30 Closes #41 ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/47 Co-authored-by: Naomi Carrigan Co-committed-by: Naomi Carrigan --- check-all.sh | 51 ++ src-tauri/capabilities/default.json | 6 +- src-tauri/src/bridge_manager.rs | 135 ++++++ src-tauri/src/commands.rs | 66 ++- src-tauri/src/lib.rs | 12 +- src-tauri/src/types.rs | 29 ++ src-tauri/src/wsl_bridge.rs | 109 +++-- src/lib/components/ConversationTabs.svelte | 279 +++++++++++ .../components/ConversationTabsDebug.svelte | 7 + src/lib/components/InputBar.svelte | 34 +- src/lib/components/PermissionModal.svelte | 14 +- src/lib/components/StatusBar.svelte | 12 +- src/lib/components/Terminal.svelte | 5 +- src/lib/stores/claude.ts | 209 +++----- src/lib/stores/conversations.ts | 459 ++++++++++++++++++ src/lib/stores/historyRestore.ts | 5 - src/lib/stores/messageMode.ts | 1 - src/lib/tauri.ts | 215 ++++++-- src/lib/types/messages.ts | 9 + src/routes/+page.svelte | 5 + 20 files changed, 1375 insertions(+), 287 deletions(-) create mode 100755 check-all.sh create mode 100644 src-tauri/src/bridge_manager.rs create mode 100644 src/lib/components/ConversationTabs.svelte create mode 100644 src/lib/components/ConversationTabsDebug.svelte create mode 100644 src/lib/stores/conversations.ts diff --git a/check-all.sh b/check-all.sh new file mode 100755 index 0000000..a0f7f46 --- /dev/null +++ b/check-all.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to run a command and check its status +run_check() { + local desc=$1 + local cmd=$2 + + echo -e "\n${YELLOW}Running: ${desc}${NC}" + echo -e "${YELLOW}Command: ${cmd}${NC}" + + if eval "$cmd"; then + echo -e "${GREEN}✓ ${desc} passed${NC}" + return 0 + else + echo -e "${RED}✗ ${desc} failed${NC}" + return 1 + fi +} + +# Track if any checks fail +failed=0 + +echo -e "${YELLOW}🔍 Running all checks for Hikari Desktop...${NC}" + +# Frontend checks +run_check "Frontend lint" "pnpm lint" || failed=1 +run_check "Frontend format check" "pnpm format:check" || failed=1 +run_check "Frontend type check" "pnpm check" || failed=1 +run_check "Frontend tests" "pnpm test" || failed=1 + +# Backend checks +run_check "Backend clippy (strict)" "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings" || failed=1 +run_check "Backend tests" "cargo test" || failed=1 + +# Summary +echo -e "\n${YELLOW}========================================${NC}" +if [ $failed -eq 0 ]; then + echo -e "${GREEN}✨ All checks passed! The code is looking great!${NC}" + echo -e "${GREEN} Naomi would be so proud of us! 💖${NC}" + exit 0 +else + echo -e "${RED}❌ Some checks failed. Let's fix them together!${NC}" + echo -e "${RED} Don't worry, we'll get through this! 💪${NC}" + exit 1 +fi diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index f47d967..5e2db1c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -9,6 +9,10 @@ "opener:default", "shell:allow-spawn", "shell:allow-stdin-write", - "shell:allow-kill" + "shell:allow-kill", + "notification:default", + "notification:allow-is-permission-granted", + "notification:allow-request-permission", + "notification:allow-notify" ] } diff --git a/src-tauri/src/bridge_manager.rs b/src-tauri/src/bridge_manager.rs new file mode 100644 index 0000000..086ab78 --- /dev/null +++ b/src-tauri/src/bridge_manager.rs @@ -0,0 +1,135 @@ +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::Arc; +use tauri::AppHandle; + +use crate::config::ClaudeStartOptions; +use crate::stats::UsageStats; +use crate::wsl_bridge::WslBridge; + +pub struct BridgeManager { + bridges: HashMap, + app_handle: Option, +} + +impl BridgeManager { + pub fn new() -> Self { + BridgeManager { + bridges: HashMap::new(), + app_handle: None, + } + } + + pub fn set_app_handle(&mut self, app: AppHandle) { + self.app_handle = Some(app); + } + + pub fn start_claude( + &mut self, + conversation_id: &str, + options: ClaudeStartOptions, + ) -> Result<(), String> { + // Check if a bridge already exists for this conversation + if self.bridges.get(conversation_id).map(|b| b.is_running()).unwrap_or(false) { + return Err("Claude is already running for this conversation".to_string()); + } + + let app = self.app_handle.as_ref() + .ok_or_else(|| "App handle not set".to_string())? + .clone(); + + // Create a new bridge for this conversation + let mut bridge = WslBridge::new_with_conversation_id(conversation_id.to_string()); + + // Start the Claude process + bridge.start(app, options)?; + + // Store the bridge + self.bridges.insert(conversation_id.to_string(), bridge); + + Ok(()) + } + + pub fn stop_claude(&mut self, conversation_id: &str) -> Result<(), String> { + if let Some(bridge) = self.bridges.get_mut(conversation_id) { + let app = self.app_handle.as_ref() + .ok_or_else(|| "App handle not set".to_string())?; + bridge.stop(app); + Ok(()) + } else { + Err("No Claude instance found for this conversation".to_string()) + } + } + + pub fn interrupt_claude(&mut self, conversation_id: &str) -> Result<(), String> { + if let Some(bridge) = self.bridges.get_mut(conversation_id) { + let app = self.app_handle.as_ref() + .ok_or_else(|| "App handle not set".to_string())?; + bridge.interrupt(app) + } else { + Err("No Claude instance found for this conversation".to_string()) + } + } + + pub fn send_prompt(&mut self, conversation_id: &str, message: String) -> Result<(), String> { + if let Some(bridge) = self.bridges.get_mut(conversation_id) { + bridge.send_message(&message) + } else { + Err("No Claude instance found for this conversation".to_string()) + } + } + + pub fn is_claude_running(&self, conversation_id: &str) -> bool { + self.bridges.get(conversation_id) + .map(|b| b.is_running()) + .unwrap_or(false) + } + + pub fn get_working_directory(&self, conversation_id: &str) -> Result { + self.bridges.get(conversation_id) + .map(|b| b.get_working_directory().to_string()) + .ok_or_else(|| "No Claude instance found for this conversation".to_string()) + } + + pub fn get_usage_stats(&self, conversation_id: &str) -> Result { + self.bridges.get(conversation_id) + .map(|b| b.get_stats()) + .ok_or_else(|| "No Claude instance found for this conversation".to_string()) + } + + #[allow(dead_code)] + pub fn cleanup_stopped_bridges(&mut self) { + // Remove bridges that are no longer running + self.bridges.retain(|_, bridge| bridge.is_running()); + } + + #[allow(dead_code)] + pub fn stop_all(&mut self) { + if let Some(app) = &self.app_handle { + for (_, bridge) in self.bridges.iter_mut() { + bridge.stop(app); + } + } + self.bridges.clear(); + } + + #[allow(dead_code)] + pub fn get_active_conversations(&self) -> Vec { + self.bridges.keys() + .filter(|id| self.bridges.get(*id).map(|b| b.is_running()).unwrap_or(false)) + .cloned() + .collect() + } +} + +impl Default for BridgeManager { + fn default() -> Self { + Self::new() + } +} + +pub type SharedBridgeManager = Arc>; + +pub fn create_shared_bridge_manager() -> SharedBridgeManager { + Arc::new(Mutex::new(BridgeManager::new())) +} \ No newline at end of file diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6862fa8..3868081 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,50 +3,65 @@ use tauri_plugin_store::StoreExt; use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::stats::UsageStats; -use crate::wsl_bridge::SharedBridge; +use crate::bridge_manager::SharedBridgeManager; use crate::achievements::{load_achievements, get_achievement_info, AchievementUnlockedEvent}; const CONFIG_STORE_KEY: &str = "config"; #[tauri::command] pub async fn start_claude( - app: AppHandle, - bridge: State<'_, SharedBridge>, + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, options: ClaudeStartOptions, ) -> Result<(), String> { - let mut bridge = bridge.lock(); - bridge.start(app, options) + let mut manager = bridge_manager.lock(); + manager.start_claude(&conversation_id, options) } #[tauri::command] -pub async fn stop_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> { - let mut bridge = bridge.lock(); - bridge.stop(&app); - Ok(()) +pub async fn stop_claude( + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, +) -> Result<(), String> { + let mut manager = bridge_manager.lock(); + manager.stop_claude(&conversation_id) } #[tauri::command] -pub async fn interrupt_claude(app: AppHandle, bridge: State<'_, SharedBridge>) -> Result<(), String> { - let mut bridge = bridge.lock(); - bridge.interrupt(&app) +pub async fn interrupt_claude( + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, +) -> Result<(), String> { + let mut manager = bridge_manager.lock(); + manager.interrupt_claude(&conversation_id) } #[tauri::command] -pub async fn send_prompt(bridge: State<'_, SharedBridge>, message: String) -> Result<(), String> { - let mut bridge = bridge.lock(); - bridge.send_message(&message) +pub async fn send_prompt( + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, + message: String, +) -> Result<(), String> { + let mut manager = bridge_manager.lock(); + manager.send_prompt(&conversation_id, message) } #[tauri::command] -pub async fn is_claude_running(bridge: State<'_, SharedBridge>) -> Result { - let bridge = bridge.lock(); - Ok(bridge.is_running()) +pub async fn is_claude_running( + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, +) -> Result { + let manager = bridge_manager.lock(); + Ok(manager.is_claude_running(&conversation_id)) } #[tauri::command] -pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result { - let bridge = bridge.lock(); - Ok(bridge.get_working_directory().to_string()) +pub async fn get_working_directory( + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, +) -> Result { + let manager = bridge_manager.lock(); + manager.get_working_directory(&conversation_id) } #[tauri::command] @@ -82,9 +97,12 @@ pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), Str } #[tauri::command] -pub async fn get_usage_stats(bridge: State<'_, SharedBridge>) -> Result { - let bridge = bridge.lock(); - Ok(bridge.get_stats()) +pub async fn get_usage_stats( + bridge_manager: State<'_, SharedBridgeManager>, + conversation_id: String, +) -> Result { + let manager = bridge_manager.lock(); + manager.get_usage_stats(&conversation_id) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c595bbf..0abb223 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod achievements; +mod bridge_manager; mod commands; mod config; mod notifications; @@ -11,7 +12,7 @@ mod windows_toast; use commands::*; use notifications::*; -use wsl_bridge::create_shared_bridge; +use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; use wsl_notifications::*; use vbs_notification::*; @@ -19,7 +20,7 @@ use windows_toast::*; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let bridge = create_shared_bridge(); + let bridge_manager = create_shared_bridge_manager(); tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) @@ -28,7 +29,12 @@ pub fn run() { .plugin(tauri_plugin_store::Builder::new().build()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_os::init()) - .manage(bridge) + .manage(bridge_manager.clone()) + .setup(move |app| { + // Initialize the app handle in the bridge manager + bridge_manager.lock().set_app_handle(app.handle().clone()); + Ok(()) + }) .invoke_handler(tauri::generate_handler![ start_claude, stop_claude, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index ddcf67c..873e9fc 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -172,6 +172,8 @@ pub struct DeltaContent { pub struct StateChangeEvent { pub state: CharacterState, pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -179,6 +181,8 @@ pub struct OutputEvent { pub line_type: String, pub content: String, pub tool_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -187,6 +191,29 @@ pub struct PermissionPromptEvent { pub tool_name: String, pub tool_input: serde_json::Value, pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConnectionEvent { + pub status: ConnectionStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionEvent { + pub session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkingDirectoryEvent { + pub directory: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, } #[cfg(test)] @@ -294,6 +321,7 @@ mod tests { let event = StateChangeEvent { state: CharacterState::Coding, tool_name: Some("Edit".to_string()), + conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); @@ -307,6 +335,7 @@ mod tests { line_type: "assistant".to_string(), content: "Test output".to_string(), tool_name: None, + conversation_id: None, }; let serialized = serde_json::to_string(&event).unwrap(); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 12e78ba..61d33c1 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -1,4 +1,3 @@ -use parking_lot::Mutex; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::Arc; @@ -12,7 +11,7 @@ use std::os::windows::process::CommandExt; use crate::config::ClaudeStartOptions; use crate::stats::{UsageStats, StatsUpdateEvent}; use parking_lot::RwLock; -use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; +use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent, ConnectionEvent, SessionEvent, WorkingDirectoryEvent}; use crate::achievements::{get_achievement_info, AchievementUnlockedEvent}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; @@ -76,6 +75,7 @@ pub struct WslBridge { session_id: Option, mcp_config_file: Option, stats: Arc>, + conversation_id: Option, } impl WslBridge { @@ -87,21 +87,23 @@ impl WslBridge { session_id: None, mcp_config_file: None, stats: Arc::new(RwLock::new(UsageStats::new())), + conversation_id: None, } } - #[allow(dead_code)] - pub async fn new_with_loaded_achievements(app: &tauri::AppHandle) -> Self { - let bridge = Self::new(); - - // Load saved achievements into the stats - let achievements = crate::achievements::load_achievements(app).await; - println!("Loaded achievements into bridge: {} unlocked", achievements.unlocked.len()); - bridge.stats.write().achievements = achievements; - - bridge + pub fn new_with_conversation_id(conversation_id: String) -> Self { + WslBridge { + process: None, + stdin: None, + working_directory: String::new(), + session_id: None, + mcp_config_file: None, + stats: Arc::new(RwLock::new(UsageStats::new())), + conversation_id: Some(conversation_id), + } } + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); @@ -120,7 +122,7 @@ impl WslBridge { let working_dir = &options.working_dir; self.working_directory = working_dir.clone(); - emit_connection_status(&app, ConnectionStatus::Connecting); + emit_connection_status(&app, ConnectionStatus::Connecting, self.conversation_id.clone()); // Create temp file for MCP config if provided let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { @@ -290,19 +292,21 @@ impl WslBridge { if let Some(stdout) = stdout { let app_clone = app.clone(); let stats_clone = self.stats.clone(); + let conv_id = self.conversation_id.clone(); thread::spawn(move || { - handle_stdout(stdout, app_clone, stats_clone); + handle_stdout(stdout, app_clone, stats_clone, conv_id); }); } if let Some(stderr) = stderr { let app_clone = app.clone(); + let conv_id = self.conversation_id.clone(); thread::spawn(move || { - handle_stderr(stderr, app_clone); + handle_stderr(stderr, app_clone, conv_id); }); } - emit_connection_status(&app, ConnectionStatus::Connected); + emit_connection_status(&app, ConnectionStatus::Connected, self.conversation_id.clone()); Ok(()) } @@ -348,7 +352,7 @@ impl WslBridge { // The user will see what session was interrupted // Emit disconnected status - emit_connection_status(app, ConnectionStatus::Disconnected); + emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); Ok(()) } else { @@ -364,7 +368,7 @@ impl WslBridge { self.stdin = None; self.session_id = None; self.mcp_config_file = None; // Temp file is automatically deleted when dropped - emit_connection_status(app, ConnectionStatus::Disconnected); + emit_connection_status(app, ConnectionStatus::Disconnected, self.conversation_id.clone()); } pub fn is_running(&self) -> bool { @@ -379,15 +383,6 @@ impl WslBridge { self.stats.read().clone() } - #[allow(dead_code)] - pub fn update_stats(&mut self, input_tokens: u64, output_tokens: u64, model: &str) { - self.stats.write().add_usage(input_tokens, output_tokens, model); - } - - #[allow(dead_code)] - pub fn reset_session_stats(&mut self) { - self.stats.write().reset_session(); - } } impl Default for WslBridge { @@ -396,13 +391,13 @@ impl Default for WslBridge { } } -fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>) { +fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc>, conversation_id: Option) { let reader = BufReader::new(stdout); for line in reader.lines() { match line { Ok(line) if !line.is_empty() => { - if let Err(e) = process_json_line(&line, &app, &stats) { + if let Err(e) = process_json_line(&line, &app, &stats, &conversation_id) { eprintln!("Error processing line: {}", e); } } @@ -414,10 +409,10 @@ fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc) { let reader = BufReader::new(stderr); for line in reader.lines() { @@ -427,6 +422,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { line_type: "error".to_string(), content: line, tool_name: None, + conversation_id: conversation_id.clone(), }); } Err(_) => break, @@ -435,7 +431,7 @@ fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) { } } -fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>) -> Result<(), String> { +fn process_json_line(line: &str, app: &AppHandle, stats: &Arc>, conversation_id: &Option) -> Result<(), String> { let message: ClaudeMessage = serde_json::from_str(line) .map_err(|e| format!("Failed to parse JSON: {} - Line: {}", e, line))?; @@ -443,12 +439,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc ClaudeMessage::System { subtype, session_id, cwd, .. } => { if subtype == "init" { if let Some(id) = session_id { - let _ = app.emit("claude:session", id.clone()); + let _ = app.emit("claude:session", SessionEvent { + session_id: id.clone(), + conversation_id: conversation_id.clone(), + }); } if let Some(dir) = cwd { - let _ = app.emit("claude:cwd", dir.clone()); + let _ = app.emit("claude:cwd", WorkingDirectoryEvent { + directory: dir.clone(), + conversation_id: conversation_id.clone(), + }); } - emit_state_change(app, CharacterState::Idle, None); + emit_state_change(app, CharacterState::Idle, None, conversation_id.clone()); } } @@ -502,6 +504,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc line_type: "tool".to_string(), content: desc, tool_name: Some(name.clone()), + conversation_id: conversation_id.clone(), }); } ContentBlock::Text { text } => { @@ -515,6 +518,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc line_type: "assistant".to_string(), content: text.clone(), tool_name: None, + conversation_id: conversation_id.clone(), }); } ContentBlock::Thinking { thinking } => { @@ -523,13 +527,14 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc line_type: "system".to_string(), content: format!("[Thinking] {}", thinking), tool_name: None, + conversation_id: conversation_id.clone(), }); } _ => {} } } - emit_state_change(app, state, tool_name); + emit_state_change(app, state, tool_name, conversation_id.clone()); } ClaudeMessage::StreamEvent { event } => { @@ -547,7 +552,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc } _ => CharacterState::Typing, }; - emit_state_change(app, state, block.name.clone()); + emit_state_change(app, state, block.name.clone(), conversation_id.clone()); } } else if event.event_type == "content_block_delta" { if let Some(delta) = &event.delta { @@ -614,6 +619,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc line_type: "error".to_string(), content: text.clone(), tool_name: None, + conversation_id: conversation_id.clone(), }); } } @@ -627,17 +633,18 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc tool_name: denial.tool_name.clone(), tool_input: denial.tool_input.clone(), description, + conversation_id: conversation_id.clone(), }); } // Show permission state if there were denials if !denials.is_empty() { - emit_state_change(app, CharacterState::Permission, None); + emit_state_change(app, CharacterState::Permission, None, conversation_id.clone()); return Ok(()); } } - emit_state_change(app, state, None); + emit_state_change(app, state, None, conversation_id.clone()); } ClaudeMessage::User { message } => { @@ -694,7 +701,7 @@ fn process_json_line(line: &str, app: &AppHandle, stats: &Arc }); } - emit_state_change(app, CharacterState::Thinking, None); + emit_state_change(app, CharacterState::Thinking, None, conversation_id.clone()); } } @@ -761,19 +768,14 @@ fn format_tool_description(name: &str, input: &serde_json::Value) -> String { } } -fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option) { - let _ = app.emit("claude:state", StateChangeEvent { state, tool_name }); +fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option, conversation_id: Option) { + let _ = app.emit("claude:state", StateChangeEvent { state, tool_name, conversation_id }); } -fn emit_connection_status(app: &AppHandle, status: ConnectionStatus) { - let _ = app.emit("claude:connection", status); +fn emit_connection_status(app: &AppHandle, status: ConnectionStatus, conversation_id: Option) { + let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id }); } -pub type SharedBridge = Arc>; - -pub fn create_shared_bridge() -> SharedBridge { - Arc::new(Mutex::new(WslBridge::new())) -} #[cfg(test)] mod tests { @@ -892,9 +894,10 @@ mod tests { } #[test] - fn test_create_shared_bridge() { - let shared = create_shared_bridge(); - let bridge = shared.lock(); - assert!(!bridge.is_running()); + fn test_create_shared_bridge_manager() { + use crate::bridge_manager::create_shared_bridge_manager; + let shared = create_shared_bridge_manager(); + let manager = shared.lock(); + assert!(manager.get_active_conversations().is_empty()); } } diff --git a/src/lib/components/ConversationTabs.svelte b/src/lib/components/ConversationTabs.svelte new file mode 100644 index 0000000..96864aa --- /dev/null +++ b/src/lib/components/ConversationTabs.svelte @@ -0,0 +1,279 @@ + + +
+ {#each Array.from(conversations.entries()) as [id, conversation] (id)} +
switchTab(id)} + onkeydown={(e) => handleTabKeydown(id, e)} + role="tab" + tabindex={0} + aria-selected={id === activeConversationId} + > + {#if editingTabId === id} + e.stopPropagation()} + class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32" + /> + {:else} +
+
+ startEditing(id, conversation.name, e)} + role="button" + tabindex={-1} + > + {conversation.name} + + {#if id !== activeConversationId && id === connectedConversationId} + + (connected) + + {/if} + {#if hasUnreadMessages(id, conversation)} +
+ {/if} +
+ {/if} + + {#if conversations.size > 1} + + {/if} +
+ {/each} + + +
+ + diff --git a/src/lib/components/ConversationTabsDebug.svelte b/src/lib/components/ConversationTabsDebug.svelte new file mode 100644 index 0000000..53c5b22 --- /dev/null +++ b/src/lib/components/ConversationTabsDebug.svelte @@ -0,0 +1,7 @@ + + +
+ Debug: Tabs Component Loaded +
diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 14af289..5d78367 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -1,5 +1,6 @@