generated from nhcarrigan/template
feat: add session history with auto-save
- Add Rust backend for session persistence using tauri-plugin-store - Create SessionHistoryPanel component for browsing saved sessions - Implement debounced auto-save (2s delay) when messages are added - Add session search, resume, and delete functionality - Add clock icon button in StatusBar to access session history Closes #14
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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<Utc>,
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
pub working_directory: String,
|
||||
pub message_count: usize,
|
||||
pub preview: String, // First ~100 chars of conversation for preview
|
||||
pub messages: Vec<SavedMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SavedMessage {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub message_type: String,
|
||||
pub content: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub tool_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionListItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_activity_at: DateTime<Utc>,
|
||||
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<Vec<SavedSession>, 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<Vec<SessionListItem>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
let mut items: Vec<SessionListItem> = 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<Option<SavedSession>, 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<Vec<SessionListItem>, String> {
|
||||
let sessions = load_all_sessions(&app)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut matching: Vec<SessionListItem> = 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);
|
||||
}
|
||||
}
|
||||
@@ -236,28 +236,32 @@
|
||||
</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"
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{#if $conversations.size > 1}
|
||||
<button
|
||||
onclick={(e) => deleteTab(id, e)}
|
||||
class="w-4 h-4 flex items-center justify-center rounded hover:bg-[var(--bg-secondary)]"
|
||||
title="Close tab"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { sessionsStore, type SessionListItem, type SavedSession } from "$lib/stores/sessions";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import type { TerminalLine } from "$lib/types/messages";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let searchInput = $state("");
|
||||
let selectedSession = $state<SavedSession | null>(null);
|
||||
let showDeleteConfirm = $state<string | null>(null);
|
||||
|
||||
const sessions = $derived(sessionsStore.sessions);
|
||||
const isLoading = $derived(sessionsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
sessionsStore.loadSessions();
|
||||
});
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return `Today at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays === 1) {
|
||||
return `Yesterday at ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays} days ago`;
|
||||
} else {
|
||||
return date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch(): Promise<void> {
|
||||
await sessionsStore.searchSessions(searchInput);
|
||||
}
|
||||
|
||||
async function handleViewSession(session: SessionListItem): Promise<void> {
|
||||
const fullSession = await sessionsStore.loadSession(session.id);
|
||||
selectedSession = fullSession;
|
||||
}
|
||||
|
||||
async function handleResumeSession(session: SessionListItem): Promise<void> {
|
||||
const fullSession = await sessionsStore.loadSession(session.id);
|
||||
if (!fullSession) return;
|
||||
|
||||
const newConvId = conversationsStore.createConversation(fullSession.name);
|
||||
|
||||
for (const msg of fullSession.messages) {
|
||||
const line: TerminalLine = {
|
||||
id: msg.id,
|
||||
type: msg.type as TerminalLine["type"],
|
||||
content: msg.content,
|
||||
timestamp: new Date(msg.timestamp),
|
||||
toolName: msg.tool_name,
|
||||
};
|
||||
conversationsStore.addLineToConversation(newConvId, line.type, line.content, line.toolName);
|
||||
}
|
||||
|
||||
if (fullSession.working_directory) {
|
||||
conversationsStore.setWorkingDirectoryForConversation(
|
||||
newConvId,
|
||||
fullSession.working_directory
|
||||
);
|
||||
}
|
||||
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDeleteSession(sessionId: string): Promise<void> {
|
||||
await sessionsStore.deleteSession(sessionId);
|
||||
showDeleteConfirm = null;
|
||||
if (selectedSession?.id === sessionId) {
|
||||
selectedSession = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBackToList(): void {
|
||||
selectedSession = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4"
|
||||
onclick={onClose}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === "Escape" && onClose()}
|
||||
>
|
||||
<div
|
||||
class="bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-lg shadow-xl max-w-3xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="session-history-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if selectedSession}
|
||||
<button
|
||||
onclick={handleBackToList}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Back to list"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<h2 id="session-history-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{selectedSession ? selectedSession.name : "Session History"}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="p-1 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if selectedSession}
|
||||
<div class="overflow-y-auto flex-1 p-6">
|
||||
<div class="text-sm text-[var(--text-tertiary)] mb-4">
|
||||
<span>{formatDate(selectedSession.created_at)}</span>
|
||||
<span class="mx-2">•</span>
|
||||
<span>{selectedSession.message_count} messages</span>
|
||||
{#if selectedSession.working_directory}
|
||||
<span class="mx-2">•</span>
|
||||
<span class="font-mono text-xs">{selectedSession.working_directory}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
{#each selectedSession.messages as message (message.id)}
|
||||
<div
|
||||
class="p-3 rounded-lg {message.type === 'user'
|
||||
? 'bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/20'
|
||||
: message.type === 'assistant'
|
||||
? 'bg-[var(--bg-secondary)]'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-500/10 border border-red-500/20'
|
||||
: 'bg-[var(--bg-tertiary)]'}"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
class="text-xs font-medium {message.type === 'user'
|
||||
? 'text-[var(--accent-primary)]'
|
||||
: message.type === 'assistant'
|
||||
? 'text-[var(--text-primary)]'
|
||||
: message.type === 'error'
|
||||
? 'text-red-400'
|
||||
: 'text-[var(--text-tertiary)]'}"
|
||||
>
|
||||
{message.type === "user"
|
||||
? "You"
|
||||
: message.type === "assistant"
|
||||
? "Hikari"
|
||||
: message.type === "tool"
|
||||
? message.tool_name || "Tool"
|
||||
: message.type}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap break-words">
|
||||
{message.content.length > 500
|
||||
? message.content.slice(0, 500) + "..."
|
||||
: message.content}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search sessions..."
|
||||
bind:value={searchInput}
|
||||
oninput={handleSearch}
|
||||
class="w-full px-4 py-2 pl-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] placeholder-[var(--text-tertiary)] focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading sessions...</div>
|
||||
</div>
|
||||
{:else if $sessions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center p-8 text-center">
|
||||
<svg
|
||||
class="w-16 h-16 text-[var(--text-tertiary)] mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved sessions yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Your conversations will appear here once saved
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $sessions as session (session.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<button class="flex-1 text-left" onclick={() => handleViewSession(session)}>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h3 class="font-medium text-[var(--text-primary)]">{session.name}</h3>
|
||||
<span class="text-xs text-[var(--text-tertiary)]">
|
||||
{session.message_count} messages
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 mb-2">
|
||||
{session.preview}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 text-xs text-[var(--text-tertiary)]">
|
||||
<span>{formatDate(session.last_activity_at)}</span>
|
||||
{#if session.working_directory}
|
||||
<span>•</span>
|
||||
<span class="font-mono truncate max-w-[200px]">
|
||||
{session.working_directory}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleResumeSession(session)}
|
||||
class="px-3 py-1.5 text-xs font-medium bg-[var(--accent-primary)] text-white rounded hover:bg-[var(--accent-primary)]/80 transition-colors"
|
||||
title="Resume this session"
|
||||
>
|
||||
Resume
|
||||
</button>
|
||||
{#if showDeleteConfirm === session.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDeleteSession(session.id)}
|
||||
class="px-2 py-1 text-xs font-medium bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = null)}
|
||||
class="px-2 py-1 text-xs font-medium bg-[var(--bg-tertiary)] text-[var(--text-secondary)] rounded hover:bg-[var(--bg-secondary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (showDeleteConfirm = session.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete session"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
[role="dialog"] {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.overflow-y-auto {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-color) transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -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 @@
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showSessionHistory = true)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors"
|
||||
title="Session History"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showStats = !showStats)}
|
||||
class="p-1 text-gray-500 hover:text-[var(--accent-primary)] transition-colors {showStats
|
||||
@@ -371,3 +387,7 @@
|
||||
{#if showKeyboardShortcuts}
|
||||
<KeyboardShortcutsModal onClose={() => (showKeyboardShortcuts = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showSessionHistory}
|
||||
<SessionHistoryPanel onClose={() => (showSessionHistory = false)} />
|
||||
{/if}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import { cleanupConversationTracking } from "$lib/tauri";
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { sessionsStore } from "$lib/stores/sessions";
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
@@ -287,6 +288,9 @@ function createConversationsStore() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cancel any pending auto-save for this conversation
|
||||
sessionsStore.cancelAutoSave(id);
|
||||
|
||||
// Clean up tracking for this conversation (including temp files)
|
||||
await cleanupConversationTracking(id);
|
||||
|
||||
@@ -434,6 +438,8 @@ function createConversationsStore() {
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
// Schedule auto-save for this conversation
|
||||
sessionsStore.scheduleAutoSave(conv);
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
@@ -462,6 +468,8 @@ function createConversationsStore() {
|
||||
if (conv) {
|
||||
conv.terminalLines.push(line);
|
||||
conv.lastActivityAt = new Date();
|
||||
// Schedule auto-save for this conversation
|
||||
sessionsStore.scheduleAutoSave(conv);
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Conversation } from "./conversations";
|
||||
|
||||
// Debounce delay for auto-save (in milliseconds)
|
||||
const AUTO_SAVE_DEBOUNCE_MS = 2000;
|
||||
|
||||
export interface SavedMessage {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
export interface SavedSession {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_activity_at: string;
|
||||
working_directory: string;
|
||||
message_count: number;
|
||||
preview: string;
|
||||
messages: SavedMessage[];
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_activity_at: string;
|
||||
working_directory: string;
|
||||
message_count: number;
|
||||
preview: string;
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const sessions = writable<SessionListItem[]>([]);
|
||||
const isLoading = writable(false);
|
||||
const searchQuery = writable("");
|
||||
|
||||
// Track pending auto-save timeouts per conversation
|
||||
const pendingAutoSaves = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
async function loadSessions(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("list_sessions");
|
||||
sessions.set(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to load sessions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConversation(conversation: Conversation): Promise<void> {
|
||||
const messages: SavedMessage[] = conversation.terminalLines.map((line) => ({
|
||||
id: line.id,
|
||||
type: line.type,
|
||||
content: line.content,
|
||||
timestamp: line.timestamp.toISOString(),
|
||||
tool_name: line.toolName,
|
||||
}));
|
||||
|
||||
const userAndAssistantMessages = conversation.terminalLines.filter(
|
||||
(line) => line.type === "user" || line.type === "assistant"
|
||||
);
|
||||
const previewContent =
|
||||
userAndAssistantMessages
|
||||
.slice(0, 3)
|
||||
.map((m) => m.content)
|
||||
.join(" ")
|
||||
.slice(0, 150) + (userAndAssistantMessages.length > 3 ? "..." : "");
|
||||
|
||||
const session: SavedSession = {
|
||||
id: conversation.id,
|
||||
name: conversation.name,
|
||||
created_at: conversation.createdAt.toISOString(),
|
||||
last_activity_at: conversation.lastActivityAt.toISOString(),
|
||||
working_directory: conversation.workingDirectory,
|
||||
message_count: conversation.terminalLines.length,
|
||||
preview: previewContent || "Empty conversation",
|
||||
messages,
|
||||
};
|
||||
|
||||
try {
|
||||
await invoke("save_session", { session });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to save session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession(sessionId: string): Promise<SavedSession | null> {
|
||||
try {
|
||||
const session = await invoke<SavedSession | null>("load_session", {
|
||||
sessionId,
|
||||
});
|
||||
return session;
|
||||
} catch (error) {
|
||||
console.error("Failed to load session:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
await invoke("delete_session", { sessionId });
|
||||
await loadSessions();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function searchSessions(query: string): Promise<void> {
|
||||
searchQuery.set(query);
|
||||
|
||||
if (!query.trim()) {
|
||||
await loadSessions();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const result = await invoke<SessionListItem[]>("search_sessions", {
|
||||
query,
|
||||
});
|
||||
sessions.set(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to search sessions:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllSessions(): Promise<void> {
|
||||
try {
|
||||
await invoke("clear_all_sessions");
|
||||
sessions.set([]);
|
||||
} catch (error) {
|
||||
console.error("Failed to clear sessions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAutoSave(conversation: Conversation): void {
|
||||
// Don't auto-save empty conversations
|
||||
if (conversation.terminalLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any pending auto-save for this conversation
|
||||
const existingTimeout = pendingAutoSaves.get(conversation.id);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
}
|
||||
|
||||
// Schedule a new auto-save
|
||||
const timeout = setTimeout(async () => {
|
||||
pendingAutoSaves.delete(conversation.id);
|
||||
try {
|
||||
await saveConversation(conversation);
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
}
|
||||
}, AUTO_SAVE_DEBOUNCE_MS);
|
||||
|
||||
pendingAutoSaves.set(conversation.id, timeout);
|
||||
}
|
||||
|
||||
function cancelAutoSave(conversationId: string): void {
|
||||
const existingTimeout = pendingAutoSaves.get(conversationId);
|
||||
if (existingTimeout) {
|
||||
clearTimeout(existingTimeout);
|
||||
pendingAutoSaves.delete(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: { subscribe: sessions.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
searchQuery: { subscribe: searchQuery.subscribe },
|
||||
loadSessions,
|
||||
saveConversation,
|
||||
loadSession,
|
||||
deleteSession,
|
||||
searchSessions,
|
||||
clearAllSessions,
|
||||
scheduleAutoSave,
|
||||
cancelAutoSave,
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionsStore = createSessionsStore();
|
||||
Reference in New Issue
Block a user