feat/tabs #47

Merged
naomi merged 10 commits from feat/tabs into main 2026-01-20 13:57:49 -08:00
20 changed files with 1375 additions and 287 deletions
Executable
+51
View File
@@ -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
+5 -1
View File
@@ -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"
]
}
+135
View File
@@ -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<String, WslBridge>,
app_handle: Option<AppHandle>,
}
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<String, String> {
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<UsageStats, String> {
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<String> {
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<Mutex<BridgeManager>>;
pub fn create_shared_bridge_manager() -> SharedBridgeManager {
Arc::new(Mutex::new(BridgeManager::new()))
}
+42 -24
View File
@@ -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<bool, String> {
let bridge = bridge.lock();
Ok(bridge.is_running())
pub async fn is_claude_running(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<bool, String> {
let manager = bridge_manager.lock();
Ok(manager.is_claude_running(&conversation_id))
}
#[tauri::command]
pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result<String, String> {
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<String, String> {
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<UsageStats, String> {
let bridge = bridge.lock();
Ok(bridge.get_stats())
pub async fn get_usage_stats(
bridge_manager: State<'_, SharedBridgeManager>,
conversation_id: String,
) -> Result<UsageStats, String> {
let manager = bridge_manager.lock();
manager.get_usage_stats(&conversation_id)
}
#[tauri::command]
+9 -3
View File
@@ -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,
+29
View File
@@ -172,6 +172,8 @@ pub struct DeltaContent {
pub struct StateChangeEvent {
pub state: CharacterState,
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -179,6 +181,8 @@ pub struct OutputEvent {
pub line_type: String,
pub content: String,
pub tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionEvent {
pub status: ConnectionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEvent {
pub session_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingDirectoryEvent {
pub directory: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
}
#[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();
+56 -53
View File
@@ -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<String>,
mcp_config_file: Option<NamedTempFile>,
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
}
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<RwLock<UsageStats>>) {
fn handle_stdout(stdout: std::process::ChildStdout, app: AppHandle, stats: Arc<RwLock<UsageStats>>, conversation_id: Option<String>) {
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<R
}
}
emit_connection_status(&app, ConnectionStatus::Disconnected);
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle) {
fn handle_stderr(stderr: std::process::ChildStderr, app: AppHandle, conversation_id: Option<String>) {
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<RwLock<UsageStats>>) -> Result<(), String> {
fn process_json_line(line: &str, app: &AppHandle, stats: &Arc<RwLock<UsageStats>>, conversation_id: &Option<String>) -> 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<RwLock<UsageStats>
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<RwLock<UsageStats>
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<RwLock<UsageStats>
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<RwLock<UsageStats>
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<RwLock<UsageStats>
}
_ => 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<RwLock<UsageStats>
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<RwLock<UsageStats>
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<RwLock<UsageStats>
});
}
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<String>) {
let _ = app.emit("claude:state", StateChangeEvent { state, tool_name });
fn emit_state_change(app: &AppHandle, state: CharacterState, tool_name: Option<String>, conversation_id: Option<String>) {
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<String>) {
let _ = app.emit("claude:connection", ConnectionEvent { status, conversation_id });
}
pub type SharedBridge = Arc<Mutex<WslBridge>>;
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());
}
}
+279
View File
@@ -0,0 +1,279 @@
<script lang="ts">
import { claudeStore } from "$lib/stores/claude";
import { onMount } from "svelte";
import type { Conversation } from "$lib/stores/conversations";
import { SvelteMap } from "svelte/reactivity";
let conversations: Map<string, Conversation> = new Map();
let activeConversationId: string | null = null;
let editingTabId: string | null = null;
let editingName = "";
// Track which conversation actually has the Claude connection
let connectedConversationId: string | null = null;
// Track last seen message count for each conversation
let lastSeenMessageCount = new SvelteMap<string, number>();
claudeStore.conversations.subscribe((convs) => {
conversations = convs;
// Update the last seen count for the active conversation
if (activeConversationId) {
const activeConv = convs.get(activeConversationId);
if (activeConv) {
lastSeenMessageCount.set(activeConversationId, activeConv.terminalLines.length);
}
}
});
claudeStore.activeConversationId.subscribe((id) => {
activeConversationId = id;
});
// Find the connected conversation
$: {
let foundConnected = false;
for (const [id, conv] of conversations) {
if (conv.connectionStatus === "connected" || conv.connectionStatus === "connecting") {
connectedConversationId = id;
foundConnected = true;
break;
}
}
if (!foundConnected) {
connectedConversationId = null;
}
}
function createNewTab() {
claudeStore.createConversation();
}
async function switchTab(id: string) {
if (editingTabId) {
saveTabName();
}
await claudeStore.switchConversation(id);
// Mark messages as seen when switching to this tab
const conv = conversations.get(id);
if (conv) {
lastSeenMessageCount.set(id, conv.terminalLines.length);
// Trigger reactivity
lastSeenMessageCount = lastSeenMessageCount;
}
}
function deleteTab(id: string, event: MouseEvent) {
event.stopPropagation();
if (conversations.size > 1) {
claudeStore.deleteConversation(id);
}
}
function startEditing(id: string, name: string, event: MouseEvent) {
event.stopPropagation();
editingTabId = id;
editingName = name;
// Focus input after DOM update
setTimeout(() => {
const input = document.querySelector('.tab-item input[type="text"]') as HTMLInputElement;
if (input) input.focus();
}, 0);
}
function saveTabName() {
if (editingTabId && editingName.trim()) {
claudeStore.renameConversation(editingTabId, editingName.trim());
}
editingTabId = null;
editingName = "";
}
function getConnectionStatusColor(status: Conversation["connectionStatus"]): string {
switch (status) {
case "connected":
return "bg-green-500";
case "connecting":
return "bg-yellow-500";
case "disconnected":
return "bg-red-500";
default:
return "bg-gray-500";
}
}
function hasUnreadMessages(id: string, conversation: Conversation): boolean {
if (id === activeConversationId) return false; // Active tab never has unread
const lastSeen = lastSeenMessageCount.get(id) || 0;
return conversation.terminalLines.length > lastSeen;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
saveTabName();
} else if (event.key === "Escape") {
editingTabId = null;
editingName = "";
}
}
function handleTabKeydown(id: string, event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
switchTab(id);
}
}
// Keyboard shortcuts
onMount(() => {
function handleGlobalKeydown(event: KeyboardEvent) {
// Ctrl/Cmd + T: New tab
if ((event.ctrlKey || event.metaKey) && event.key === "t") {
event.preventDefault();
createNewTab();
}
// Ctrl/Cmd + W: Close current tab
else if ((event.ctrlKey || event.metaKey) && event.key === "w") {
event.preventDefault();
if (activeConversationId && conversations.size > 1) {
claudeStore.deleteConversation(activeConversationId);
}
}
// Ctrl/Cmd + Tab: Next tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && !event.shiftKey) {
event.preventDefault();
const tabs = Array.from(conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
if (currentIndex !== -1) {
const nextIndex = (currentIndex + 1) % tabs.length;
claudeStore.switchConversation(tabs[nextIndex]);
}
}
// Ctrl/Cmd + Shift + Tab: Previous tab
else if ((event.ctrlKey || event.metaKey) && event.key === "Tab" && event.shiftKey) {
event.preventDefault();
const tabs = Array.from(conversations.keys());
const currentIndex = tabs.findIndex((id) => id === activeConversationId);
if (currentIndex !== -1) {
const prevIndex = (currentIndex - 1 + tabs.length) % tabs.length;
claudeStore.switchConversation(tabs[prevIndex]);
}
}
}
window.addEventListener("keydown", handleGlobalKeydown);
return () => window.removeEventListener("keydown", handleGlobalKeydown);
});
</script>
<div
class="terminal-tabs flex items-center gap-1 px-2 py-1 bg-[var(--bg-secondary)] border-b border-[var(--border-color)]"
>
{#each Array.from(conversations.entries()) as [id, conversation] (id)}
<div
class="tab-item group relative flex items-center px-3 py-1.5 rounded-t cursor-pointer transition-all
{id === activeConversationId
? 'bg-[var(--bg-terminal)] text-[var(--text-primary)] border-t border-l border-r border-[var(--border-color)]'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-terminal)]/50'}"
onclick={() => switchTab(id)}
onkeydown={(e) => handleTabKeydown(id, e)}
role="tab"
tabindex={0}
aria-selected={id === activeConversationId}
>
{#if editingTabId === id}
<input
type="text"
bind:value={editingName}
onblur={saveTabName}
onkeydown={handleKeydown}
onclick={(e) => e.stopPropagation()}
class="bg-transparent border-b border-[var(--border-color)] outline-none px-0 py-0 text-sm w-32"
/>
{:else}
<div class="flex items-center gap-2">
<div
class="w-2 h-2 rounded-full {getConnectionStatusColor(conversation.connectionStatus)}"
title="Connection: {conversation.connectionStatus}{id !== connectedConversationId &&
connectedConversationId
? ' (Another tab is connected)'
: ''}"
></div>
<span
class="text-sm pr-6 max-w-[150px] truncate"
ondblclick={(e) => startEditing(id, conversation.name, e)}
role="button"
tabindex={-1}
>
{conversation.name}
</span>
{#if id !== activeConversationId && id === connectedConversationId}
<span
class="text-xs text-[var(--text-tertiary)]"
title="This tab has the Claude connection"
>
(connected)
</span>
{/if}
{#if hasUnreadMessages(id, conversation)}
<div
class="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-blue-500 animate-pulse"
title="New messages"
></div>
{/if}
</div>
{/if}
{#if conversations.size > 1}
<button
onclick={(e) => deleteTab(id, e)}
class="absolute right-1 top-1/2 -translate-y-1/2 w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)] opacity-0 group-hover:opacity-100 transition-opacity"
title="Close tab"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{/each}
<button
onclick={createNewTab}
class="new-tab-btn flex items-center justify-center w-7 h-7 rounded hover:bg-[var(--bg-tertiary)] text-[var(--text-secondary)] transition-colors"
title="New conversation (Ctrl+T)"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</button>
</div>
<style>
.terminal-tabs {
min-height: 36px;
}
.tab-item {
min-width: 100px;
}
</style>
@@ -0,0 +1,7 @@
<script lang="ts">
console.log("ConversationTabs component loading...");
</script>
<div class="terminal-tabs" style="background: red; height: 36px; color: white;">
Debug: Tabs Component Loaded
</div>
+21 -13
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, isClaudeProcessing } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { handleNewUserMessage } from "$lib/notifications/rules";
@@ -45,8 +46,6 @@
let messageToSend = formattedMessage;
if (getShouldRestoreHistory()) {
const savedHistory = getSavedHistory();
console.log("Should restore history:", true);
console.log("Saved history:", savedHistory);
if (savedHistory) {
// Prepend the conversation history with a context message
@@ -56,13 +55,9 @@ ${savedHistory}
[Continuing conversation after reconnection:]
User: ${formattedMessage}`;
console.log("Message with history:", messageToSend);
// Clear the restoration flags
clearHistoryRestore();
}
} else {
console.log("Should restore history:", false);
}
// Reset notification state for new user message
@@ -72,7 +67,14 @@ User: ${formattedMessage}`;
characterState.setState("thinking");
try {
await invoke("send_prompt", { message: messageToSend });
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("send_prompt", {
conversationId,
message: messageToSend,
});
} catch (error) {
console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -85,18 +87,18 @@ User: ${formattedMessage}`;
async function handleInterrupt() {
// Save the conversation history FIRST before anything else
const history = claudeStore.getConversationHistory();
console.log("Saving conversation history:", history);
if (history) {
setSavedHistory(history);
setShouldRestoreHistory(true);
console.log("History saved and restoration flag set");
} else {
console.log("No history to save");
}
try {
await invoke("interrupt_claude");
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
characterState.setState("idle");
@@ -106,14 +108,20 @@ User: ${formattedMessage}`;
// Auto-reconnect after a brief delay
setTimeout(async () => {
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
// Get current working directory before reconnecting
const workingDir = await invoke<string>("get_working_directory");
const workingDir = await invoke<string>("get_working_directory", { conversationId });
// Set the flag to skip greeting on next connection
setSkipNextGreeting(true);
// Reconnect to Claude
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDir,
},
+12 -2
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore, hasPermissionPending } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import type { PermissionRequest } from "$lib/types/messages";
@@ -45,12 +46,18 @@
// Stop current session and reconnect with new permissions
try {
await invoke("stop_claude");
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
// Small delay to ensure clean shutdown
await new Promise((resolve) => setTimeout(resolve, 500));
await invoke("start_claude", {
conversationId,
options: {
working_dir: workingDirectory || "/home/naomi",
allowed_tools: newGrantedTools,
@@ -72,7 +79,10 @@ ${JSON.stringify(toolInput, null, 2)}
Please continue where we left off and retry that action now that you have permission.`;
await invoke("send_prompt", { message: contextMessage });
await invoke("send_prompt", {
conversationId,
message: contextMessage,
});
}
} catch (error) {
console.error("Failed to reconnect:", error);
+11 -1
View File
@@ -9,6 +9,7 @@
import { getVersion } from "@tauri-apps/api/app";
import { open } from "@tauri-apps/plugin-dialog";
import { openUrl } from "@tauri-apps/plugin-opener";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { configStore, type HikariConfig } from "$lib/stores/config";
import type { ConnectionStatus } from "$lib/types/messages";
@@ -87,7 +88,12 @@
];
try {
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("start_claude", {
conversationId,
options: {
working_dir: targetDir,
model: currentConfig.model || null,
@@ -105,7 +111,11 @@
async function handleDisconnect() {
try {
await invoke("stop_claude");
const conversationId = get(claudeStore.activeConversationId);
if (!conversationId) {
throw new Error("No active conversation");
}
await invoke("stop_claude", { conversationId });
} catch (error) {
console.error("Failed to stop Claude:", error);
}
+4 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { claudeStore, type TerminalLine } from "$lib/stores/claude";
import { afterUpdate } from "svelte";
import ConversationTabs from "./ConversationTabs.svelte";
let terminalElement: HTMLDivElement;
let shouldAutoScroll = true;
@@ -78,10 +79,12 @@
<span class="text-sm terminal-header-text ml-2">Terminal</span>
</div>
<ConversationTabs />
<div
bind:this={terminalElement}
onscroll={handleScroll}
class="terminal-content h-[calc(100%-40px)] overflow-y-auto p-4 font-mono text-sm"
class="terminal-content h-[calc(100%-76px)] overflow-y-auto p-4 font-mono text-sm"
>
{#if lines.length === 0}
<div class="terminal-waiting italic">
+74 -135
View File
@@ -1,147 +1,86 @@
import { writable, derived } from "svelte/store";
import type { ConnectionStatus, PermissionRequest } from "$lib/types/messages";
import { derived } from "svelte/store";
import { conversationsStore } from "./conversations";
import type { TerminalLine } from "$lib/types/messages";
import { characterState } from "$lib/stores/character";
import {
setShouldRestoreHistory,
setSavedHistory,
getShouldRestoreHistory,
getSavedHistory,
clearHistoryRestore,
} from "./historyRestore";
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
}
// Re-export TerminalLine type for backwards compatibility
export type { TerminalLine };
function createClaudeStore() {
const connectionStatus = writable<ConnectionStatus>("disconnected");
const sessionId = writable<string | null>(null);
const currentWorkingDirectory = writable<string>("");
const terminalLines = writable<TerminalLine[]>([]);
const pendingPermission = writable<PermissionRequest | null>(null);
const isProcessing = writable<boolean>(false);
const grantedTools = writable<Set<string>>(new Set());
const pendingRetryMessage = writable<string | null>(null);
const shouldRestoreHistory = writable<boolean>(false);
const savedConversationHistory = writable<string | null>(null);
// Re-export from conversations store for backwards compatibility
export const claudeStore = {
// Existing subscriptions
connectionStatus: conversationsStore.connectionStatus,
sessionId: conversationsStore.sessionId,
currentWorkingDirectory: conversationsStore.currentWorkingDirectory,
terminalLines: conversationsStore.terminalLines,
pendingPermission: conversationsStore.pendingPermission,
isProcessing: conversationsStore.isProcessing,
grantedTools: conversationsStore.grantedTools,
pendingRetryMessage: conversationsStore.pendingRetryMessage,
let lineIdCounter = 0;
// New conversation-aware subscriptions
conversations: conversationsStore.conversations,
activeConversationId: conversationsStore.activeConversationId,
activeConversation: conversationsStore.activeConversation,
function generateLineId(): string {
return `line-${Date.now()}-${lineIdCounter++}`;
}
// Methods
setConnectionStatus: conversationsStore.setConnectionStatus,
setConnectionStatusForConversation: conversationsStore.setConnectionStatusForConversation,
setCharacterStateForConversation: conversationsStore.setCharacterStateForConversation,
setSessionId: conversationsStore.setSessionId,
setSessionIdForConversation: conversationsStore.setSessionIdForConversation,
setWorkingDirectory: conversationsStore.setWorkingDirectory,
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
setProcessing: conversationsStore.setProcessing,
addLine: conversationsStore.addLine,
addLineToConversation: conversationsStore.addLineToConversation,
updateLine: conversationsStore.updateLine,
appendToLine: conversationsStore.appendToLine,
clearTerminal: conversationsStore.clearTerminal,
getConversationHistory: conversationsStore.getConversationHistory,
requestPermission: conversationsStore.requestPermission,
clearPermission: conversationsStore.clearPermission,
grantTool: conversationsStore.grantTool,
revokeAllTools: conversationsStore.revokeAllTools,
isToolGranted: conversationsStore.isToolGranted,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
return {
connectionStatus: { subscribe: connectionStatus.subscribe },
sessionId: { subscribe: sessionId.subscribe },
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe },
isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe },
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
shouldRestoreHistory: { subscribe: shouldRestoreHistory.subscribe },
savedConversationHistory: { subscribe: savedConversationHistory.subscribe },
// Conversation management
createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation,
switchConversation: conversationsStore.switchConversation,
renameConversation: conversationsStore.renameConversation,
setConnectionStatus: (status: ConnectionStatus) => connectionStatus.set(status),
setSessionId: (id: string | null) => sessionId.set(id),
setWorkingDirectory: (dir: string) => currentWorkingDirectory.set(dir),
setProcessing: (processing: boolean) => isProcessing.set(processing),
getGrantedTools: (): string[] => {
let tools: string[] = [];
conversationsStore.grantedTools.subscribe((t) => (tools = Array.from(t)))();
return tools;
},
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
terminalLines.update((lines) => [...lines, line]);
return line.id;
},
// History restoration methods from main branch
setShouldRestoreHistory: setShouldRestoreHistory,
setSavedConversationHistory: setSavedHistory,
getShouldRestoreHistory: getShouldRestoreHistory,
getSavedConversationHistory: getSavedHistory,
updateLine: (id: string, content: string) => {
terminalLines.update((lines) =>
lines.map((line) => (line.id === id ? { ...line, content } : line))
);
},
appendToLine: (id: string, additionalContent: string) => {
terminalLines.update((lines) =>
lines.map((line) =>
line.id === id ? { ...line, content: line.content + additionalContent } : line
)
);
},
clearTerminal: () => terminalLines.set([]),
getConversationHistory: (): string => {
let lines: TerminalLine[] = [];
terminalLines.subscribe((l) => (lines = l))();
// Filter to just user and assistant messages, skip system/tool noise
const relevantLines = lines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
if (relevantLines.length === 0) return "";
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
clearPermission: () => pendingPermission.set(null),
grantTool: (toolName: string) => {
grantedTools.update((tools) => {
const newTools = new Set(tools);
newTools.add(toolName);
return newTools;
});
},
getGrantedTools: (): string[] => {
let tools: string[] = [];
grantedTools.subscribe((t) => (tools = Array.from(t)))();
return tools;
},
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
setShouldRestoreHistory: (should: boolean) => shouldRestoreHistory.set(should),
setSavedConversationHistory: (history: string | null) => savedConversationHistory.set(history),
getShouldRestoreHistory: (): boolean => {
let should = false;
shouldRestoreHistory.subscribe((s) => (should = s))();
return should;
},
getSavedConversationHistory: (): string | null => {
let history: string | null = null;
savedConversationHistory.subscribe((h) => (history = h))();
return history;
},
reset: () => {
connectionStatus.set("disconnected");
sessionId.set(null);
currentWorkingDirectory.set("");
terminalLines.set([]);
pendingPermission.set(null);
isProcessing.set(false);
grantedTools.set(new Set());
pendingRetryMessage.set(null);
shouldRestoreHistory.set(false);
savedConversationHistory.set(null);
},
};
}
export const claudeStore = createClaudeStore();
reset: () => {
// Reset only the active conversation
conversationsStore.clearTerminal();
conversationsStore.setSessionId(null);
conversationsStore.setWorkingDirectory("");
conversationsStore.setProcessing(false);
conversationsStore.revokeAllTools();
// Also clear history restoration
clearHistoryRestore();
},
};
export const hasPermissionPending = derived(
claudeStore.pendingPermission,
+459
View File
@@ -0,0 +1,459 @@
import { writable, derived, get } from "svelte/store";
import type { TerminalLine, ConnectionStatus, PermissionRequest } from "$lib/types/messages";
import type { CharacterState } from "$lib/types/states";
import { cleanupConversationTracking } from "$lib/tauri";
import { characterState } from "$lib/stores/character";
export interface Conversation {
id: string;
name: string;
terminalLines: TerminalLine[];
sessionId: string | null;
connectionStatus: ConnectionStatus;
workingDirectory: string;
characterState: CharacterState;
isProcessing: boolean;
grantedTools: Set<string>;
createdAt: Date;
lastActivityAt: Date;
}
function createConversationsStore() {
const conversations = writable<Map<string, Conversation>>(new Map());
const activeConversationId = writable<string | null>(null);
const pendingPermission = writable<PermissionRequest | null>(null);
const pendingRetryMessage = writable<string | null>(null);
let conversationCounter = 0;
let lineIdCounter = 0;
function generateConversationId(): string {
return `conv-${Date.now()}-${conversationCounter++}`;
}
function generateLineId(): string {
return `line-${Date.now()}-${lineIdCounter++}`;
}
function createNewConversation(name?: string): Conversation {
const id = generateConversationId();
return {
id,
name: name || `Conversation ${conversationCounter}`,
terminalLines: [],
sessionId: null,
connectionStatus: "disconnected",
workingDirectory: "",
characterState: "idle",
isProcessing: false,
grantedTools: new Set(),
createdAt: new Date(),
lastActivityAt: new Date(),
};
}
// Initialize with first conversation lazily
let initialized = false;
function ensureInitialized() {
if (!initialized) {
initialized = true;
const initialConversation = createNewConversation("Main");
conversations.update((convs) => {
convs.set(initialConversation.id, initialConversation);
return convs;
});
activeConversationId.set(initialConversation.id);
}
}
// Derived store for current conversation
const activeConversation = derived(
[conversations, activeConversationId],
([$conversations, $activeId]) => {
if (!$activeId) return null;
return $conversations.get($activeId) || null;
}
);
// Derived stores for compatibility with existing code
const connectionStatus = derived(
activeConversation,
($conv) => $conv?.connectionStatus || "disconnected"
);
const terminalLines = derived(activeConversation, ($conv) => {
return $conv?.terminalLines || [];
});
const sessionId = derived(activeConversation, ($conv) => $conv?.sessionId || null);
const currentWorkingDirectory = derived(
activeConversation,
($conv) => $conv?.workingDirectory || ""
);
const isProcessing = derived(activeConversation, ($conv) => $conv?.isProcessing || false);
const grantedTools = derived(
activeConversation,
($conv) => $conv?.grantedTools || new Set<string>()
);
return {
// Expose derived stores for compatibility
connectionStatus: { subscribe: connectionStatus.subscribe },
sessionId: { subscribe: sessionId.subscribe },
currentWorkingDirectory: { subscribe: currentWorkingDirectory.subscribe },
terminalLines: { subscribe: terminalLines.subscribe },
pendingPermission: { subscribe: pendingPermission.subscribe },
isProcessing: { subscribe: isProcessing.subscribe },
grantedTools: { subscribe: grantedTools.subscribe },
pendingRetryMessage: { subscribe: pendingRetryMessage.subscribe },
// New conversation-specific stores
conversations: { subscribe: conversations.subscribe },
activeConversationId: { subscribe: activeConversationId.subscribe },
activeConversation: { subscribe: activeConversation.subscribe },
// Connection management
setConnectionStatus: (status: ConnectionStatus) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.connectionStatus = status;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setConnectionStatusForConversation: (conversationId: string, status: ConnectionStatus) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.connectionStatus = status;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setCharacterStateForConversation: (conversationId: string, state: CharacterState) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.characterState = state;
// If this is the active conversation, update the global character state
if (conversationId === get(activeConversationId)) {
characterState.setState(state);
}
}
return convs;
});
},
requestPermission: (request: PermissionRequest) => pendingPermission.set(request),
clearPermission: () => pendingPermission.set(null),
setPendingRetryMessage: (message: string | null) => pendingRetryMessage.set(message),
// Conversation management
createConversation: (name?: string) => {
ensureInitialized();
const newConv = createNewConversation(name);
conversations.update((convs) => {
convs.set(newConv.id, newConv);
return convs;
});
activeConversationId.set(newConv.id);
return newConv.id;
},
deleteConversation: (id: string) => {
ensureInitialized();
const convs = get(conversations);
const activeId = get(activeConversationId);
if (convs.size <= 1) {
// Don't delete the last conversation
return false;
}
// Clean up tracking for this conversation
cleanupConversationTracking(id);
conversations.update((c) => {
c.delete(id);
return c;
});
// If we deleted the active conversation, switch to another
if (activeId === id) {
const remaining = Array.from(get(conversations).keys());
if (remaining.length > 0) {
activeConversationId.set(remaining[0]);
}
}
return true;
},
switchConversation: async (id: string) => {
const convs = get(conversations);
if (!convs.has(id)) return;
const currentId = get(activeConversationId);
const targetConv = convs.get(id);
// If switching to a different conversation
if (currentId !== id) {
activeConversationId.set(id);
// Update the global character state to match the conversation's state
if (targetConv) {
characterState.setState(targetConv.characterState);
}
}
},
renameConversation: (id: string, newName: string) => {
conversations.update((convs) => {
const conv = convs.get(id);
if (conv) {
conv.name = newName;
conv.lastActivityAt = new Date();
}
return convs;
});
},
// Methods that operate on the active conversation
setSessionId: (id: string | null) => {
ensureInitialized();
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.sessionId = id;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setSessionIdForConversation: (conversationId: string, id: string | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.sessionId = id;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setWorkingDirectory: (dir: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setWorkingDirectoryForConversation: (conversationId: string, dir: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.workingDirectory = dir;
conv.lastActivityAt = new Date();
}
return convs;
});
},
setProcessing: (processing: boolean) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.isProcessing = processing;
conv.lastActivityAt = new Date();
}
return convs;
});
},
addLine: (type: TerminalLine["type"], content: string, toolName?: string) => {
ensureInitialized();
const activeId = get(activeConversationId);
if (!activeId) return "";
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
}
return convs;
});
return line.id;
},
addLineToConversation: (
conversationId: string,
type: TerminalLine["type"],
content: string,
toolName?: string
) => {
ensureInitialized();
const line: TerminalLine = {
id: generateLineId(),
type,
content,
timestamp: new Date(),
toolName,
};
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.terminalLines.push(line);
conv.lastActivityAt = new Date();
}
return convs;
});
return line.id;
},
updateLine: (id: string, content: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
const line = conv.terminalLines.find((l) => l.id === id);
if (line) {
line.content = content;
conv.lastActivityAt = new Date();
}
}
return convs;
});
},
appendToLine: (id: string, additionalContent: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
const line = conv.terminalLines.find((l) => l.id === id);
if (line) {
line.content += additionalContent;
conv.lastActivityAt = new Date();
}
}
return convs;
});
},
clearTerminal: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.terminalLines = [];
conv.lastActivityAt = new Date();
}
return convs;
});
},
getConversationHistory: (): string => {
const activeId = get(activeConversationId);
if (!activeId) return "";
const convs = get(conversations);
const conv = convs.get(activeId);
if (!conv) return "";
const relevantLines = conv.terminalLines.filter(
(line) => line.type === "user" || line.type === "assistant"
);
if (relevantLines.length === 0) return "";
return relevantLines
.map((line) => {
const role = line.type === "user" ? "User" : "Assistant";
return `${role}: ${line.content}`;
})
.join("\n\n");
},
grantTool: (toolName: string) => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.grantedTools.add(toolName);
conv.lastActivityAt = new Date();
}
return convs;
});
},
revokeAllTools: () => {
const activeId = get(activeConversationId);
if (!activeId) return;
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.grantedTools.clear();
conv.lastActivityAt = new Date();
}
return convs;
});
},
isToolGranted: (toolName: string): boolean => {
const activeId = get(activeConversationId);
if (!activeId) return false;
const convs = get(conversations);
const conv = convs.get(activeId);
return conv?.grantedTools.has(toolName) || false;
},
// Add initialization helper
initialize: () => {
ensureInitialized();
},
};
}
export const conversationsStore = createConversationsStore();
// Initialize immediately
conversationsStore.initialize();
-5
View File
@@ -4,26 +4,21 @@ let savedHistory: string | null = null;
export function setShouldRestoreHistory(should: boolean) {
shouldRestore = should;
console.log("Setting shouldRestoreHistory to:", should);
}
export function setSavedHistory(history: string | null) {
savedHistory = history;
console.log("Setting savedHistory, length:", history?.length || 0);
}
export function getShouldRestoreHistory(): boolean {
console.log("Getting shouldRestoreHistory:", shouldRestore);
return shouldRestore;
}
export function getSavedHistory(): string | null {
console.log("Getting savedHistory, length:", savedHistory?.length || 0);
return savedHistory;
}
export function clearHistoryRestore() {
console.log("Clearing history restore flags");
shouldRestore = false;
savedHistory = null;
}
-1
View File
@@ -6,7 +6,6 @@ const messageModeStore = writable<string>("chat");
export const messageMode = {
subscribe: messageModeStore.subscribe,
set: (mode: string) => {
console.log("Setting message mode to:", mode);
messageModeStore.set(mode);
},
reset: () => messageModeStore.set("chat"),
+167 -48
View File
@@ -1,5 +1,6 @@
import { listen } from "@tauri-apps/api/event";
import { invoke } from "@tauri-apps/api/core";
import { get } from "svelte/store";
import { claudeStore } from "$lib/stores/claude";
import { characterState } from "$lib/stores/character";
import { configStore } from "$lib/stores/config";
@@ -17,9 +18,10 @@ import {
interface StateChangePayload {
state: CharacterState;
tool_name: string | null;
conversation_id?: string;
}
let hasConnectedThisSession = false;
const connectedConversations = new Set<string>();
let unlisteners: Array<() => void> = [];
let skipNextGreeting = false;
@@ -46,7 +48,7 @@ function generateGreetingPrompt(): string {
return `[System: A new session has started. It's currently ${timeOfDay}. Please greet the user warmly and briefly. Keep it short - just 1-2 sentences.]`;
}
async function sendGreeting() {
async function sendGreeting(conversationId: string) {
// Check if we should skip this greeting
if (skipNextGreeting) {
skipNextGreeting = false; // Reset the flag
@@ -68,10 +70,13 @@ async function sendGreeting() {
handleNewUserMessage();
try {
await invoke("send_prompt", { message: greetingPrompt });
await invoke("send_prompt", {
conversationId,
message: greetingPrompt,
});
} catch (error) {
console.error("Failed to send greeting:", error);
claudeStore.addLine("error", `Failed to send greeting: ${error}`);
claudeStore.addLineToConversation(conversationId, "error", `Failed to send greeting: ${error}`);
characterState.setTemporaryState("error", 3000);
}
}
@@ -80,6 +85,26 @@ interface OutputPayload {
line_type: string;
content: string;
tool_name: string | null;
conversation_id?: string;
}
interface ConnectionPayload {
status: ConnectionStatus;
conversation_id?: string;
}
interface SessionPayload {
session_id: string;
conversation_id?: string;
}
interface WorkingDirectoryPayload {
directory: string;
conversation_id?: string;
}
export function cleanupConversationTracking(conversationId: string) {
connectedConversations.delete(conversationId);
}
export async function initializeTauriListeners() {
@@ -95,43 +120,78 @@ export async function initializeTauriListeners() {
// Initialize achievements listener
await initAchievementsListener();
const connectionUnlisten = await listen<string>("claude:connection", async (event) => {
const status = event.payload as ConnectionStatus;
claudeStore.setConnectionStatus(status);
const connectionUnlisten = await listen<ConnectionPayload>("claude:connection", async (event) => {
const { status, conversation_id } = event.payload;
// Update connection status for the specific conversation
if (conversation_id) {
claudeStore.setConnectionStatusForConversation(conversation_id, status);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setConnectionStatus(status);
}
// Handle notification for connection status
handleConnectionStatusChange(status);
if (status === "connected") {
claudeStore.addLine("system", "Connected to Claude Code");
characterState.setState("idle");
if (!hasConnectedThisSession) {
hasConnectedThisSession = true;
resetSessionStats(); // Reset session stats on new connection
await sendGreeting();
// Get the actual conversation ID (fallback to active if not provided)
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
// Add system message to the correct conversation
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Connected to Claude Code"
);
// Update character state for this conversation
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
// Check if this specific conversation has connected before
if (!connectedConversations.has(targetConversationId)) {
connectedConversations.add(targetConversationId);
resetSessionStats(); // Reset session stats on new connection
await sendGreeting(targetConversationId);
}
}
} else if (status === "disconnected") {
// Only reset session flag if we're not about to reconnect
if (!skipNextGreeting) {
hasConnectedThisSession = false;
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
// Only remove from connected set if we're not about to reconnect
if (!skipNextGreeting && targetConversationId) {
connectedConversations.delete(targetConversationId);
}
// Don't add system message if we're about to reconnect
if (!skipNextGreeting) {
claudeStore.addLine("system", "Disconnected from Claude Code");
if (!skipNextGreeting && targetConversationId) {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code"
);
}
characterState.setState("idle");
// Update character state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
}
} else if (status === "error") {
hasConnectedThisSession = false;
claudeStore.addLine("error", "Connection error");
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
if (targetConversationId) {
connectedConversations.delete(targetConversationId);
claudeStore.addLineToConversation(targetConversationId, "error", "Connection error");
}
characterState.setTemporaryState("error", 3000);
}
});
unlisteners.push(connectionUnlisten);
const stateUnlisten = await listen<StateChangePayload>("claude:state", (event) => {
const { state } = event.payload;
const { state, conversation_id } = event.payload;
const stateMap: Record<string, CharacterState> = {
idle: "idle",
@@ -147,21 +207,48 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle";
if (mappedState === "success" || mappedState === "error") {
characterState.setTemporaryState(mappedState, 3000);
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
} else {
characterState.setState(mappedState);
// Fallback to active conversation if no conversation_id
const activeConversationId = get(claudeStore.activeConversationId);
if (activeConversationId) {
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
}
}
// Only update global character state for active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (!conversation_id || conversation_id === activeConversationId) {
if (mappedState === "success" || mappedState === "error") {
characterState.setTemporaryState(mappedState, 3000);
} else {
characterState.setState(mappedState);
}
}
});
unlisteners.push(stateUnlisten);
const outputUnlisten = await listen<OutputPayload>("claude:output", (event) => {
const { line_type, content, tool_name } = event.payload;
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
);
const { line_type, content, tool_name, conversation_id } = event.payload;
// Always store the output to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
);
} else {
// Fallback to active conversation if no conversation_id provided
claudeStore.addLine(
line_type as "user" | "assistant" | "system" | "tool" | "error",
content,
tool_name || undefined
);
}
});
unlisteners.push(outputUnlisten);
@@ -170,30 +257,64 @@ export async function initializeTauriListeners() {
});
unlisteners.push(streamUnlisten);
const sessionUnlisten = await listen<string>("claude:session", (event) => {
claudeStore.setSessionId(event.payload);
claudeStore.addLine("system", `Session: ${event.payload.substring(0, 8)}...`);
const sessionUnlisten = await listen<SessionPayload>("claude:session", (event) => {
const { session_id, conversation_id } = event.payload;
// Store session ID for the correct conversation
if (conversation_id) {
claudeStore.setSessionIdForConversation(conversation_id, session_id);
claudeStore.addLineToConversation(
conversation_id,
"system",
`Session: ${session_id.substring(0, 8)}...`
);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setSessionId(session_id);
claudeStore.addLine("system", `Session: ${session_id.substring(0, 8)}...`);
}
});
unlisteners.push(sessionUnlisten);
const cwdUnlisten = await listen<string>("claude:cwd", (event) => {
claudeStore.setWorkingDirectory(event.payload);
const cwdUnlisten = await listen<WorkingDirectoryPayload>("claude:cwd", (event) => {
const { directory, conversation_id } = event.payload;
// Store working directory for the correct conversation
if (conversation_id) {
claudeStore.setWorkingDirectoryForConversation(conversation_id, directory);
} else {
// Fallback to active conversation if no conversation_id
claudeStore.setWorkingDirectory(directory);
}
});
unlisteners.push(cwdUnlisten);
const permissionUnlisten = await listen<PermissionPromptEvent>("claude:permission", (event) => {
const { id, tool_name, tool_input, description } = event.payload;
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
const { id, tool_name, tool_input, description, conversation_id } = event.payload;
// Only process permission requests for the active conversation
const activeConversationId = get(claudeStore.activeConversationId);
if (conversation_id === activeConversationId) {
claudeStore.requestPermission({
id,
tool: tool_name,
description,
input: tool_input,
});
}
// Always store the permission message to the correct conversation
if (conversation_id) {
claudeStore.addLineToConversation(
conversation_id,
"system",
`Permission requested for: ${tool_name}`
);
} else if (conversation_id === activeConversationId) {
claudeStore.addLine("system", `Permission requested for: ${tool_name}`);
}
});
unlisteners.push(permissionUnlisten);
console.log("Tauri event listeners initialized");
}
export function cleanupTauriListeners() {
@@ -203,6 +324,4 @@ export function cleanupTauriListeners() {
// Cleanup notification rules
cleanupNotificationRules();
console.log("Tauri event listeners cleaned up");
}
+9
View File
@@ -1,3 +1,11 @@
export interface TerminalLine {
id: string;
type: "user" | "assistant" | "system" | "tool" | "error";
content: string;
timestamp: Date;
toolName?: string;
}
export interface SystemInitMessage {
type: "system";
subtype: "init";
@@ -115,6 +123,7 @@ export interface PermissionPromptEvent {
tool_name: string;
tool_input: Record<string, unknown>;
description: string;
conversation_id?: string;
}
export type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
+5
View File
@@ -2,6 +2,7 @@
import { onMount, onDestroy } from "svelte";
import { initializeTauriListeners, cleanupTauriListeners } from "$lib/tauri";
import { configStore, applyTheme } from "$lib/stores/config";
import { conversationsStore } from "$lib/stores/conversations";
import "$lib/notifications/testNotifications";
import Terminal from "$lib/components/Terminal.svelte";
import InputBar from "$lib/components/InputBar.svelte";
@@ -18,6 +19,10 @@
onMount(async () => {
if (!initialized) {
initialized = true;
// Initialize conversations store first to ensure activeConversationId is set
conversationsStore.initialize();
await initializeTauriListeners();
await configStore.loadConfig();