diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8957ffa..ef86871 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod bridge_manager; mod commands; mod config; mod notifications; +mod sessions; mod stats; mod temp_manager; mod tray; @@ -16,6 +17,7 @@ use bridge_manager::create_shared_bridge_manager; use commands::load_saved_achievements; use commands::*; use notifications::*; +use sessions::*; use tauri::Manager; use temp_manager::create_shared_temp_manager; use tray::{setup_tray, should_minimize_to_tray}; @@ -102,6 +104,12 @@ pub fn run() { cleanup_all_temp_files, cleanup_orphaned_temp_files, get_file_size, + list_sessions, + save_session, + load_session, + delete_session, + search_sessions, + clear_all_sessions, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs new file mode 100644 index 0000000..d8c0c54 --- /dev/null +++ b/src-tauri/src/sessions.rs @@ -0,0 +1,167 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +const SESSIONS_STORE_KEY: &str = "sessions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedSession { + pub id: String, + pub name: String, + pub created_at: DateTime, + pub last_activity_at: DateTime, + pub working_directory: String, + pub message_count: usize, + pub preview: String, // First ~100 chars of conversation for preview + pub messages: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SavedMessage { + pub id: String, + #[serde(rename = "type")] + pub message_type: String, + pub content: String, + pub timestamp: DateTime, + pub tool_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionListItem { + pub id: String, + pub name: String, + pub created_at: DateTime, + pub last_activity_at: DateTime, + pub working_directory: String, + pub message_count: usize, + pub preview: String, +} + +impl From<&SavedSession> for SessionListItem { + fn from(session: &SavedSession) -> Self { + SessionListItem { + id: session.id.clone(), + name: session.name.clone(), + created_at: session.created_at, + last_activity_at: session.last_activity_at, + working_directory: session.working_directory.clone(), + message_count: session.message_count, + preview: session.preview.clone(), + } + } +} + +fn load_all_sessions(app: &AppHandle) -> Result, String> { + let store = app + .store("hikari-sessions.json") + .map_err(|e| e.to_string())?; + + match store.get(SESSIONS_STORE_KEY) { + Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()), + None => Ok(Vec::new()), + } +} + +fn save_all_sessions(app: &AppHandle, sessions: &[SavedSession]) -> Result<(), String> { + let store = app + .store("hikari-sessions.json") + .map_err(|e| e.to_string())?; + + let value = serde_json::to_value(sessions).map_err(|e| e.to_string())?; + store.set(SESSIONS_STORE_KEY, value); + store.save().map_err(|e| e.to_string())?; + + Ok(()) +} + +#[tauri::command] +pub async fn list_sessions(app: AppHandle) -> Result, String> { + let sessions = load_all_sessions(&app)?; + let mut items: Vec = sessions.iter().map(SessionListItem::from).collect(); + + // Sort by last activity, most recent first + items.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + Ok(items) +} + +#[tauri::command] +pub async fn save_session(app: AppHandle, session: SavedSession) -> Result<(), String> { + let mut sessions = load_all_sessions(&app)?; + + // Update existing or add new + if let Some(existing) = sessions.iter_mut().find(|s| s.id == session.id) { + *existing = session; + } else { + sessions.push(session); + } + + save_all_sessions(&app, &sessions) +} + +#[tauri::command] +pub async fn load_session(app: AppHandle, session_id: String) -> Result, String> { + let sessions = load_all_sessions(&app)?; + Ok(sessions.into_iter().find(|s| s.id == session_id)) +} + +#[tauri::command] +pub async fn delete_session(app: AppHandle, session_id: String) -> Result<(), String> { + let mut sessions = load_all_sessions(&app)?; + sessions.retain(|s| s.id != session_id); + save_all_sessions(&app, &sessions) +} + +#[tauri::command] +pub async fn search_sessions(app: AppHandle, query: String) -> Result, String> { + let sessions = load_all_sessions(&app)?; + let query_lower = query.to_lowercase(); + + let mut matching: Vec = sessions + .iter() + .filter(|s| { + s.name.to_lowercase().contains(&query_lower) + || s.preview.to_lowercase().contains(&query_lower) + || s.working_directory.to_lowercase().contains(&query_lower) + || s.messages + .iter() + .any(|m| m.content.to_lowercase().contains(&query_lower)) + }) + .map(SessionListItem::from) + .collect(); + + // Sort by last activity, most recent first + matching.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); + + Ok(matching) +} + +#[tauri::command] +pub async fn clear_all_sessions(app: AppHandle) -> Result<(), String> { + save_all_sessions(&app, &[]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_session_list_item_from_saved_session() { + let session = SavedSession { + id: "test-id".to_string(), + name: "Test Session".to_string(), + created_at: Utc::now(), + last_activity_at: Utc::now(), + working_directory: "/home/test".to_string(), + message_count: 5, + preview: "Hello world".to_string(), + messages: vec![], + }; + + let item = SessionListItem::from(&session); + assert_eq!(item.id, "test-id"); + assert_eq!(item.name, "Test Session"); + assert_eq!(item.message_count, 5); + } +} diff --git a/src/lib/components/ConversationTabs.svelte b/src/lib/components/ConversationTabs.svelte index 6f2d8c0..34c9d87 100644 --- a/src/lib/components/ConversationTabs.svelte +++ b/src/lib/components/ConversationTabs.svelte @@ -236,28 +236,32 @@ {/if} - {#if $conversations.size > 1} - - {/if} + + + + + {/if} + {/each} diff --git a/src/lib/components/SessionHistoryPanel.svelte b/src/lib/components/SessionHistoryPanel.svelte new file mode 100644 index 0000000..130be84 --- /dev/null +++ b/src/lib/components/SessionHistoryPanel.svelte @@ -0,0 +1,369 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="session-history-title" + tabindex="-1" + > +
+
+ {#if selectedSession} + + {/if} +

+ {selectedSession ? selectedSession.name : "Session History"} +

+
+ +
+ + {#if selectedSession} +
+
+ {formatDate(selectedSession.created_at)} + + {selectedSession.message_count} messages + {#if selectedSession.working_directory} + + {selectedSession.working_directory} + {/if} +
+
+ {#each selectedSession.messages as message (message.id)} +
+
+ + {message.type === "user" + ? "You" + : message.type === "assistant" + ? "Hikari" + : message.type === "tool" + ? message.tool_name || "Tool" + : message.type} + +
+

+ {message.content.length > 500 + ? message.content.slice(0, 500) + "..." + : message.content} +

+
+ {/each} +
+
+ {:else} +
+
+ + + + +
+
+ +
+ {#if $isLoading} +
+
Loading sessions...
+
+ {:else if $sessions.length === 0} +
+ + + +

No saved sessions yet

+

+ Your conversations will appear here once saved +

+
+ {:else} +
+ {#each $sessions as session (session.id)} +
+
+ +
+ + {#if showDeleteConfirm === session.id} +
+ + +
+ {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 6793090..543f674 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -19,6 +19,7 @@ import HelpPanel from "./HelpPanel.svelte"; import KeyboardShortcutsModal from "./KeyboardShortcutsModal.svelte"; import { achievementProgress } from "$lib/stores/achievements"; + import SessionHistoryPanel from "./SessionHistoryPanel.svelte"; const DISCORD_URL = "https://chat.nhcarrigan.com"; const DONATE_URL = "https://donate.nhcarrigan.com"; @@ -33,6 +34,7 @@ let showAbout = $state(false); let showHelp = $state(false); let showKeyboardShortcuts = $state(false); + let showSessionHistory = $state(false); const progress = $derived($achievementProgress); let currentConfig: HikariConfig = $state({ model: null, @@ -217,6 +219,20 @@ {/if} +