generated from nhcarrigan/template
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
97b8243d24
|
|||
| 7ebd9dc97a | |||
|
fe7027c585
|
|||
| 89a0bdd8f1 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hikari-desktop",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Generated
+1
-1
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hikari-desktop"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hikari-desktop"
|
||||
version = "1.8.0"
|
||||
version = "1.9.0"
|
||||
description = "Hikari - Claude Code Visual Assistant"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
const DRAFTS_STORE_FILE: &str = "hikari-drafts.json";
|
||||
const DRAFTS_STORE_KEY: &str = "drafts";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Draft {
|
||||
pub id: String,
|
||||
pub content: String,
|
||||
pub saved_at: String,
|
||||
}
|
||||
|
||||
fn load_all_drafts(app: &AppHandle) -> Result<Vec<Draft>, String> {
|
||||
let store = app
|
||||
.store(DRAFTS_STORE_FILE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
match store.get(DRAFTS_STORE_KEY) {
|
||||
Some(value) => serde_json::from_value(value.clone()).map_err(|e| e.to_string()),
|
||||
None => Ok(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_all_drafts(app: &AppHandle, drafts: &[Draft]) -> Result<(), String> {
|
||||
let store = app
|
||||
.store(DRAFTS_STORE_FILE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let value = serde_json::to_value(drafts).map_err(|e| e.to_string())?;
|
||||
store.set(DRAFTS_STORE_KEY, value);
|
||||
store.save().map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_drafts(app: AppHandle) -> Result<Vec<Draft>, String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
// Sort newest first — ISO 8601 timestamps sort lexicographically
|
||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
Ok(drafts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_draft(app: AppHandle, content: String) -> Result<Draft, String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
|
||||
let draft = Draft {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
content,
|
||||
saved_at: Utc::now().to_rfc3339(),
|
||||
};
|
||||
|
||||
drafts.push(draft.clone());
|
||||
save_all_drafts(&app, &drafts)?;
|
||||
|
||||
Ok(draft)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_draft(app: AppHandle, draft_id: String) -> Result<(), String> {
|
||||
let mut drafts = load_all_drafts(&app)?;
|
||||
drafts.retain(|d| d.id != draft_id);
|
||||
save_all_drafts(&app, &drafts)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_all_drafts(app: AppHandle) -> Result<(), String> {
|
||||
save_all_drafts(&app, &[])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_draft(id: &str, content: &str, saved_at: &str) -> Draft {
|
||||
Draft {
|
||||
id: id.to_string(),
|
||||
content: content.to_string(),
|
||||
saved_at: saved_at.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_serialization() {
|
||||
let draft = make_draft("test-id", "Hello world", "2026-01-01T00:00:00+00:00");
|
||||
let json = serde_json::to_string(&draft).expect("Failed to serialize");
|
||||
let parsed: Draft = serde_json::from_str(&json).expect("Failed to deserialize");
|
||||
|
||||
assert_eq!(parsed.id, draft.id);
|
||||
assert_eq!(parsed.content, draft.content);
|
||||
assert_eq!(parsed.saved_at, draft.saved_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_draft_clone() {
|
||||
let original = make_draft("clone-id", "Clone me", "2026-01-01T00:00:00+00:00");
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.id, cloned.id);
|
||||
assert_eq!(original.content, cloned.content);
|
||||
assert_eq!(original.saved_at, cloned.saved_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_newest_first() {
|
||||
let mut drafts = [
|
||||
make_draft("a", "First", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("b", "Third", "2026-01-03T00:00:00+00:00"),
|
||||
make_draft("c", "Second", "2026-01-02T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
drafts.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
|
||||
assert_eq!(drafts[0].id, "b");
|
||||
assert_eq!(drafts[1].id, "c");
|
||||
assert_eq!(drafts[2].id, "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_retain_excludes_deleted() {
|
||||
let mut drafts = vec![
|
||||
make_draft("keep-1", "Keep me", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("delete-me", "Delete me", "2026-01-02T00:00:00+00:00"),
|
||||
make_draft("keep-2", "Keep me too", "2026-01-03T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
let target_id = "delete-me".to_string();
|
||||
drafts.retain(|d| d.id != target_id);
|
||||
|
||||
assert_eq!(drafts.len(), 2);
|
||||
assert!(drafts.iter().all(|d| d.id != "delete-me"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_by_id() {
|
||||
let drafts = [
|
||||
make_draft("draft-1", "First draft", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("draft-2", "Second draft", "2026-01-02T00:00:00+00:00"),
|
||||
make_draft("draft-3", "Third draft", "2026-01-03T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
let found = drafts.iter().find(|d| d.id == "draft-2");
|
||||
assert!(found.is_some());
|
||||
assert_eq!(found.unwrap().content, "Second draft");
|
||||
|
||||
let not_found = drafts.iter().find(|d| d.id == "draft-999");
|
||||
assert!(not_found.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiline_content() {
|
||||
let content = "Line 1\nLine 2\nLine 3";
|
||||
let draft = make_draft("multi", content, "2026-01-01T00:00:00+00:00");
|
||||
|
||||
assert!(draft.content.contains('\n'));
|
||||
assert_eq!(draft.content.split('\n').count(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_after_delete_all() {
|
||||
let mut drafts = vec![
|
||||
make_draft("a", "A", "2026-01-01T00:00:00+00:00"),
|
||||
make_draft("b", "B", "2026-01-02T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
drafts.clear();
|
||||
|
||||
assert!(drafts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uuid_format() {
|
||||
// UUIDs should be non-empty and contain hyphens
|
||||
let id = Uuid::new_v4().to_string();
|
||||
assert!(!id.is_empty());
|
||||
assert!(id.contains('-'));
|
||||
assert_eq!(id.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timestamp_is_rfc3339() {
|
||||
let ts = Utc::now().to_rfc3339();
|
||||
// RFC 3339 timestamps contain T and + or Z
|
||||
assert!(ts.contains('T'));
|
||||
assert!(ts.ends_with("+00:00") || ts.ends_with('Z'));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ mod config;
|
||||
mod cost_tracking;
|
||||
mod debug_logger;
|
||||
mod discord_rpc;
|
||||
mod drafts;
|
||||
mod git;
|
||||
mod notifications;
|
||||
mod process_ext;
|
||||
@@ -28,6 +29,7 @@ use commands::load_saved_achievements;
|
||||
use commands::*;
|
||||
use debug_logger::TauriLogLayer;
|
||||
use discord_rpc::DiscordRpcManager;
|
||||
use drafts::*;
|
||||
use git::*;
|
||||
use notifications::*;
|
||||
use quick_actions::*;
|
||||
@@ -214,6 +216,10 @@ pub fn run() {
|
||||
remove_mcp_server,
|
||||
add_mcp_server,
|
||||
get_mcp_server_details,
|
||||
list_drafts,
|
||||
save_draft,
|
||||
delete_draft,
|
||||
delete_all_drafts,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -111,6 +111,9 @@ pub struct WslBridge {
|
||||
conversation_id: Option<String>,
|
||||
/// Set to true once the `system:init` message arrives, false at the start of every new session.
|
||||
received_init: Arc<AtomicBool>,
|
||||
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
|
||||
/// the disconnect was intentional and should not emit a second Disconnected event.
|
||||
intentional_stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl WslBridge {
|
||||
@@ -124,6 +127,7 @@ impl WslBridge {
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: None,
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
intentional_stop: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +141,7 @@ impl WslBridge {
|
||||
stats: Arc::new(RwLock::new(UsageStats::new())),
|
||||
conversation_id: Some(conversation_id),
|
||||
received_init: Arc::new(AtomicBool::new(false)),
|
||||
intentional_stop: Arc::new(AtomicBool::new(false)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +411,9 @@ impl WslBridge {
|
||||
self.stdin = stdin;
|
||||
*self.process.lock() = Some(child);
|
||||
|
||||
// Reset the init flag so the watchdog and stdout handler start fresh.
|
||||
// Reset flags so the watchdog and stdout handler start fresh.
|
||||
self.received_init.store(false, Ordering::SeqCst);
|
||||
self.intentional_stop.store(false, Ordering::SeqCst);
|
||||
|
||||
// Note: We no longer reset stats here - stats persist across reconnects
|
||||
// Stats are only reset when explicitly disconnecting via stop()
|
||||
@@ -425,8 +431,16 @@ impl WslBridge {
|
||||
let stats_clone = self.stats.clone();
|
||||
let conv_id = self.conversation_id.clone();
|
||||
let received_init_clone = self.received_init.clone();
|
||||
let intentional_stop_clone = self.intentional_stop.clone();
|
||||
thread::spawn(move || {
|
||||
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
|
||||
handle_stdout(
|
||||
stdout,
|
||||
app_clone,
|
||||
stats_clone,
|
||||
conv_id,
|
||||
received_init_clone,
|
||||
intentional_stop_clone,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -543,6 +557,11 @@ impl WslBridge {
|
||||
// See: https://github.com/anthropics/claude-code/issues/3455
|
||||
// Extract the process first so the MutexGuard is dropped before we mutably
|
||||
// borrow `self` again via estimate_interrupted_request_cost.
|
||||
|
||||
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||||
// a second Disconnected event after stdout closes due to the kill.
|
||||
self.intentional_stop.store(true, Ordering::SeqCst);
|
||||
|
||||
let maybe_process = self.process.lock().take();
|
||||
if let Some(mut process) = maybe_process {
|
||||
// Estimate cost for interrupted request before killing
|
||||
@@ -674,6 +693,9 @@ impl WslBridge {
|
||||
}
|
||||
|
||||
pub fn stop(&mut self, app: &AppHandle) {
|
||||
// Signal handle_stdout that this is an intentional stop so it doesn't emit
|
||||
// a second Disconnected event after stdout closes due to the kill.
|
||||
self.intentional_stop.store(true, Ordering::SeqCst);
|
||||
if let Some(mut process) = self.process.lock().take() {
|
||||
let _ = process.kill();
|
||||
let _ = process.wait();
|
||||
@@ -729,6 +751,7 @@ fn handle_stdout(
|
||||
stats: Arc<RwLock<UsageStats>>,
|
||||
conversation_id: Option<String>,
|
||||
received_init: Arc<AtomicBool>,
|
||||
intentional_stop: Arc<AtomicBool>,
|
||||
) {
|
||||
let reader = BufReader::new(stdout);
|
||||
|
||||
@@ -749,6 +772,12 @@ fn handle_stdout(
|
||||
}
|
||||
}
|
||||
|
||||
// If this was an intentional stop (stop()/interrupt() was called), the caller already
|
||||
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
|
||||
if intentional_stop.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If stdout closed before system:init arrived the process exited without initialising.
|
||||
// Emit an error line so the user understands why the connection failed.
|
||||
if !received_init.load(Ordering::SeqCst) {
|
||||
@@ -765,6 +794,23 @@ fn handle_stdout(
|
||||
);
|
||||
}
|
||||
|
||||
// If Claude exited while a prompt was in-flight, the user's message was never processed.
|
||||
// Emit a specific error so they know to resend their prompt.
|
||||
let had_pending_request = stats.read().current_request_input.is_some();
|
||||
if had_pending_request {
|
||||
let _ = app.emit(
|
||||
"claude:output",
|
||||
OutputEvent {
|
||||
line_type: "error".to_string(),
|
||||
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
|
||||
tool_name: None,
|
||||
conversation_id: conversation_id.clone(),
|
||||
cost: None,
|
||||
parent_tool_use_id: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "hikari-desktop",
|
||||
"version": "1.8.0",
|
||||
"version": "1.9.0",
|
||||
"identifier": "com.naomi.hikari-desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
@@ -135,7 +135,7 @@
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Interrupted");
|
||||
claudeStore.addLine("system", "Process interrupted via stop button");
|
||||
characterState.setState("idle");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { draftsStore, type Draft } from "$lib/stores/drafts";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onInsert: (content: string) => void;
|
||||
}
|
||||
|
||||
const { onClose, onInsert }: Props = $props();
|
||||
|
||||
let confirmingDeleteId = $state<string | null>(null);
|
||||
let confirmingAll = $state(false);
|
||||
|
||||
const drafts = $derived(draftsStore.drafts);
|
||||
const isLoading = $derived(draftsStore.isLoading);
|
||||
|
||||
onMount(() => {
|
||||
draftsStore.loadDrafts();
|
||||
});
|
||||
|
||||
function handleInsert(draft: Draft): void {
|
||||
onInsert(draft.content);
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function handleDelete(draftId: string): Promise<void> {
|
||||
await draftsStore.deleteDraft(draftId);
|
||||
confirmingDeleteId = null;
|
||||
}
|
||||
|
||||
async function handleDeleteAll(): Promise<void> {
|
||||
await draftsStore.deleteAllDrafts();
|
||||
confirmingAll = false;
|
||||
}
|
||||
|
||||
function truncateContent(content: string): string {
|
||||
return content.length > 120 ? content.slice(0, 120) + "…" : content;
|
||||
}
|
||||
</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-2xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-labelledby="draft-panel-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="flex items-center justify-between p-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<h2 id="draft-panel-title" class="text-xl font-semibold text-[var(--text-primary)]">
|
||||
Saved Drafts
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if $drafts.length > 0}
|
||||
{#if confirmingAll}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={handleDeleteAll}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Confirm Delete All
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (confirmingAll = false)}
|
||||
class="px-3 py-1.5 text-sm font-medium bg-[var(--bg-secondary)] text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => (confirmingAll = true)}
|
||||
class="px-3 py-1.5 text-sm font-medium text-red-400 hover:text-red-300 transition-colors border border-red-400/30 rounded-lg hover:border-red-300/50 hover:bg-red-400/10"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if $isLoading}
|
||||
<div class="flex items-center justify-center p-8">
|
||||
<div class="text-[var(--text-tertiary)]">Loading drafts...</div>
|
||||
</div>
|
||||
{:else if $drafts.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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-[var(--text-secondary)]">No saved drafts yet</p>
|
||||
<p class="text-sm text-[var(--text-tertiary)] mt-1">
|
||||
Use "Save as Draft" to store messages for later
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divide-y divide-[var(--border-color)]">
|
||||
{#each $drafts as draft (draft.id)}
|
||||
<div class="p-4 hover:bg-[var(--bg-secondary)] transition-colors group">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-[var(--text-tertiary)] mb-1">
|
||||
{draftsStore.formatTimestamp(draft.saved_at)}
|
||||
</p>
|
||||
<p
|
||||
class="text-sm text-[var(--text-secondary)] font-mono whitespace-pre-wrap break-words"
|
||||
>
|
||||
{truncateContent(draft.content)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onclick={() => handleInsert(draft)}
|
||||
class="btn-trans-gradient px-3 py-1.5 text-xs font-medium rounded"
|
||||
title="Insert this draft"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
{#if confirmingDeleteId === draft.id}
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
onclick={() => handleDelete(draft.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={() => (confirmingDeleteId = 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={() => (confirmingDeleteId = draft.id)}
|
||||
class="p-1.5 text-[var(--text-tertiary)] hover:text-red-400 transition-colors"
|
||||
title="Delete draft"
|
||||
>
|
||||
<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>
|
||||
</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);
|
||||
}
|
||||
</style>
|
||||
@@ -34,7 +34,9 @@
|
||||
import SnippetLibraryPanel from "$lib/components/SnippetLibraryPanel.svelte";
|
||||
import QuickActionsPanel from "$lib/components/QuickActionsPanel.svelte";
|
||||
import ClipboardHistoryPanel from "$lib/components/ClipboardHistoryPanel.svelte";
|
||||
import DraftPanel from "$lib/components/DraftPanel.svelte";
|
||||
import TextInputContextMenu from "$lib/components/TextInputContextMenu.svelte";
|
||||
import { draftsStore } from "$lib/stores/drafts";
|
||||
import type { Attachment } from "$lib/types/messages";
|
||||
|
||||
const INPUT_HISTORY_KEY = "hikari-input-history";
|
||||
@@ -52,6 +54,7 @@
|
||||
let showSnippetLibrary = $state(false);
|
||||
let showQuickActions = $state(false);
|
||||
let showClipboardHistory = $state(false);
|
||||
let showDraftPanel = $state(false);
|
||||
let streamerModeActive = $state(false);
|
||||
|
||||
// Cost estimation for pre-submission display
|
||||
@@ -164,6 +167,25 @@
|
||||
attachments = storedAttachments;
|
||||
});
|
||||
|
||||
// Per-tab draft persistence — restore the draft text whenever the active
|
||||
// conversation changes, and save it back on every keystroke.
|
||||
claudeStore.activeConversationId.subscribe((conversationId) => {
|
||||
if (conversationId) {
|
||||
const conv = get(claudeStore.conversations).get(conversationId);
|
||||
inputValue = conv?.draftText ?? "";
|
||||
} else {
|
||||
inputValue = "";
|
||||
}
|
||||
});
|
||||
|
||||
function clearInput() {
|
||||
inputValue = "";
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, "");
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputChange() {
|
||||
// If input is empty, allow history navigation again
|
||||
// Otherwise, mark that user has manually typed
|
||||
@@ -176,6 +198,12 @@
|
||||
historyIndex = -1;
|
||||
tempInput = "";
|
||||
|
||||
// Save the current draft so it persists if the user switches tabs.
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, inputValue);
|
||||
}
|
||||
|
||||
if (isSlashCommand(inputValue)) {
|
||||
matchingCommands = getMatchingCommands(inputValue);
|
||||
showCommandMenu = matchingCommands.length > 0;
|
||||
@@ -195,7 +223,7 @@
|
||||
async function executeSlashCommand(): Promise<boolean> {
|
||||
const { command, args } = parseSlashCommand(inputValue);
|
||||
if (command) {
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
showCommandMenu = false;
|
||||
matchingCommands = [];
|
||||
await command.execute(args);
|
||||
@@ -228,7 +256,7 @@
|
||||
"error",
|
||||
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
|
||||
);
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -244,7 +272,7 @@
|
||||
userHasTyped = false;
|
||||
|
||||
isSubmitting = true;
|
||||
inputValue = "";
|
||||
clearInput();
|
||||
|
||||
// Capture attachments before clearing
|
||||
const currentAttachments = [...attachments];
|
||||
@@ -326,7 +354,7 @@ User: ${formattedMessage}`;
|
||||
throw new Error("No active conversation");
|
||||
}
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted - reconnecting...");
|
||||
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
|
||||
characterState.setState("idle");
|
||||
|
||||
// Show connecting status while we reconnect
|
||||
@@ -703,6 +731,22 @@ User: ${formattedMessage}`;
|
||||
userHasTyped = true;
|
||||
}
|
||||
|
||||
function handleDraftInsert(content: string): void {
|
||||
inputValue = content;
|
||||
userHasTyped = true;
|
||||
const activeId = get(claudeStore.activeConversationId);
|
||||
if (activeId) {
|
||||
claudeStore.setDraftText(activeId, content);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAsDraft(): Promise<void> {
|
||||
const content = inputValue.trim();
|
||||
if (!content) return;
|
||||
await draftsStore.saveDraft(content);
|
||||
clearInput();
|
||||
}
|
||||
|
||||
function handleClipboardInsert(content: string): void {
|
||||
// Insert clipboard content at cursor position or append to input
|
||||
if (inputValue.trim()) {
|
||||
@@ -919,6 +963,29 @@ User: ${formattedMessage}`;
|
||||
<span>Clipboard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDraftPanel = true)}
|
||||
class="control-button"
|
||||
title="Saved Drafts"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Drafts</span>
|
||||
</button>
|
||||
|
||||
<CliVersion />
|
||||
<SystemClock />
|
||||
</div>
|
||||
@@ -959,6 +1026,29 @@ User: ${formattedMessage}`;
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleSaveAsDraft}
|
||||
disabled={!inputValue.trim()}
|
||||
class="attach-button"
|
||||
title="Save as Draft"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
|
||||
<polyline points="17 21 17 13 7 13 7 21" />
|
||||
<polyline points="7 3 7 8 15 8" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button" onclick={handleFilePicker} class="attach-button" title="Attach files">
|
||||
<svg
|
||||
width="20"
|
||||
@@ -1024,6 +1114,10 @@ User: ${formattedMessage}`;
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDraftPanel}
|
||||
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
|
||||
{/if}
|
||||
|
||||
{#if contextMenuShow && textareaElement}
|
||||
<TextInputContextMenu
|
||||
x={contextMenuX}
|
||||
|
||||
@@ -281,10 +281,16 @@
|
||||
font-family: "JetBrains Mono", "Fira Code", monospace;
|
||||
}
|
||||
|
||||
.markdown-content :global(ul),
|
||||
.markdown-content :global(ul) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.markdown-content :global(ol) {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 1.5em;
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.markdown-content :global(li) {
|
||||
|
||||
@@ -9,10 +9,10 @@
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import { configStore } from "$lib/stores/config";
|
||||
|
||||
let permissions: PermissionRequest[] = $state([]);
|
||||
let permissions: PermissionRequest[] = [];
|
||||
let selectedPermissions = new SvelteSet<string>();
|
||||
let grantedToolsList: string[] = $state([]);
|
||||
let workingDirectory = $state("");
|
||||
let grantedToolsList: string[] = [];
|
||||
let workingDirectory = "";
|
||||
|
||||
conversationsStore.pendingPermissions.subscribe((perms) => {
|
||||
permissions = perms;
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
import { characterState } from "$lib/stores/character";
|
||||
import { notificationManager } from "./notificationManager";
|
||||
import type { CharacterState } from "$lib/types/states";
|
||||
import type { ConnectionStatus } from "$lib/types/messages";
|
||||
|
||||
// Track previous states to detect transitions
|
||||
let previousCharacterState: CharacterState | null = null;
|
||||
// Track previous connection status to detect transitions
|
||||
let previousConnectionStatus: ConnectionStatus | null = null;
|
||||
let taskStartTime: number | null = null;
|
||||
let hasNotifiedTaskStart = false;
|
||||
|
||||
export function handleCharacterStateChange(newState: CharacterState): void {
|
||||
// Detect state transitions
|
||||
if (previousCharacterState === newState) return;
|
||||
|
||||
// Task completion: any state -> success
|
||||
if (newState === "success" && previousCharacterState !== null) {
|
||||
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
|
||||
// Only notify for tasks that took more than 2 seconds
|
||||
if (taskDuration > 2000) {
|
||||
notificationManager.notifySuccess();
|
||||
}
|
||||
taskStartTime = null;
|
||||
}
|
||||
|
||||
// Error occurred
|
||||
if (newState === "error" && previousCharacterState !== "error") {
|
||||
notificationManager.notifyError();
|
||||
}
|
||||
|
||||
// Permission needed
|
||||
if (newState === "permission") {
|
||||
notificationManager.notifyPermission();
|
||||
}
|
||||
|
||||
// Starting long tasks - only notify once per response
|
||||
if (
|
||||
(newState === "coding" || newState === "searching") &&
|
||||
previousCharacterState !== "coding" &&
|
||||
previousCharacterState !== "searching" &&
|
||||
!hasNotifiedTaskStart
|
||||
) {
|
||||
taskStartTime = Date.now();
|
||||
hasNotifiedTaskStart = true;
|
||||
notificationManager.notifyTaskStart();
|
||||
}
|
||||
|
||||
previousCharacterState = newState;
|
||||
}
|
||||
|
||||
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
|
||||
// Only notify on successful connection after being disconnected
|
||||
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
|
||||
// But we could add specific rules here if needed
|
||||
}
|
||||
|
||||
// Reset notification state for a new response
|
||||
export function handleNewUserMessage(): void {
|
||||
hasNotifiedTaskStart = false;
|
||||
}
|
||||
// No-op: sound tracking is now per-conversation in tauri.ts
|
||||
export function handleNewUserMessage(): void {}
|
||||
|
||||
// Store unsubscribe functions
|
||||
let unsubscribeCharacterState: (() => void) | null = null;
|
||||
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
|
||||
export function initializeNotificationRules(): void {}
|
||||
|
||||
// Initialize listeners
|
||||
export function initializeNotificationRules(): void {
|
||||
// Clean up any existing subscriptions first
|
||||
cleanupNotificationRules();
|
||||
|
||||
// Subscribe to character state changes
|
||||
unsubscribeCharacterState = characterState.subscribe((state) => {
|
||||
handleCharacterStateChange(state);
|
||||
});
|
||||
|
||||
// We'll connect to connection status in the next step
|
||||
}
|
||||
|
||||
// Cleanup function to prevent duplicate listeners
|
||||
// Cleanup — reset connection tracking on teardown
|
||||
export function cleanupNotificationRules(): void {
|
||||
if (unsubscribeCharacterState) {
|
||||
unsubscribeCharacterState();
|
||||
unsubscribeCharacterState = null;
|
||||
}
|
||||
|
||||
// Reset state tracking
|
||||
previousCharacterState = null;
|
||||
previousConnectionStatus = null;
|
||||
taskStartTime = null;
|
||||
hasNotifiedTaskStart = false;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,15 @@ export const claudeStore = {
|
||||
isToolGranted: conversationsStore.isToolGranted,
|
||||
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
|
||||
|
||||
// Sound tracking
|
||||
resetSoundState: conversationsStore.resetSoundState,
|
||||
setTaskStartTime: conversationsStore.setTaskStartTime,
|
||||
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
|
||||
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
|
||||
|
||||
// Draft text (per-tab input persistence)
|
||||
setDraftText: conversationsStore.setDraftText,
|
||||
|
||||
// Conversation management
|
||||
createConversation: conversationsStore.createConversation,
|
||||
deleteConversation: conversationsStore.deleteConversation,
|
||||
|
||||
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
|
||||
expect(pendingRetryMessage).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft text persistence", () => {
|
||||
it("initialises draft text as empty string", () => {
|
||||
const conversation = { draftText: "" };
|
||||
expect(conversation.draftText).toBe("");
|
||||
});
|
||||
|
||||
it("stores draft text per conversation", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { draftText: "Hello world" }],
|
||||
["conv-2", { draftText: "" }],
|
||||
]);
|
||||
|
||||
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
|
||||
expect(conversations.get("conv-2")?.draftText).toBe("");
|
||||
});
|
||||
|
||||
it("updates draft text independently per conversation", () => {
|
||||
const conversations = new Map([
|
||||
["conv-1", { draftText: "Draft A" }],
|
||||
["conv-2", { draftText: "Draft B" }],
|
||||
]);
|
||||
|
||||
const convA = conversations.get("conv-1");
|
||||
if (convA) convA.draftText = "Updated A";
|
||||
|
||||
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
|
||||
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
|
||||
});
|
||||
|
||||
it("clears draft text after submission", () => {
|
||||
const conversation = { draftText: "My prompt" };
|
||||
|
||||
conversation.draftText = "";
|
||||
|
||||
expect(conversation.draftText).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,10 @@ export interface Conversation {
|
||||
attachments: Attachment[];
|
||||
summary: ConversationSummary | null;
|
||||
startedAt: Date;
|
||||
taskStartTime: number | null;
|
||||
successSoundFired: boolean;
|
||||
taskStartSoundFired: boolean;
|
||||
draftText: string;
|
||||
}
|
||||
|
||||
function createConversationsStore() {
|
||||
@@ -75,6 +79,10 @@ function createConversationsStore() {
|
||||
attachments: [],
|
||||
summary: null,
|
||||
startedAt: new Date(),
|
||||
taskStartTime: null,
|
||||
successSoundFired: false,
|
||||
taskStartSoundFired: false,
|
||||
draftText: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -196,7 +204,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(activeId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions.push(request);
|
||||
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -219,7 +227,7 @@ function createConversationsStore() {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.pendingPermissions.push(request);
|
||||
conv.pendingPermissions = [...conv.pendingPermissions, request];
|
||||
conv.lastActivityAt = new Date();
|
||||
}
|
||||
return convs;
|
||||
@@ -364,9 +372,15 @@ function createConversationsStore() {
|
||||
if (currentId !== id) {
|
||||
activeConversationId.set(id);
|
||||
|
||||
// Update the global character state to match the conversation's state
|
||||
// Update the global character state to match the conversation's state.
|
||||
// Map success/error → idle since those are transient states that have
|
||||
// already been displayed — restoring them would re-trigger sound rules.
|
||||
if (targetConv) {
|
||||
characterState.setState(targetConv.characterState);
|
||||
const stateToRestore =
|
||||
targetConv.characterState === "success" || targetConv.characterState === "error"
|
||||
? "idle"
|
||||
: targetConv.characterState;
|
||||
characterState.setState(stateToRestore);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -816,6 +830,59 @@ function createConversationsStore() {
|
||||
});
|
||||
},
|
||||
|
||||
// Sound tracking methods
|
||||
resetSoundState: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.taskStartTime = null;
|
||||
conv.successSoundFired = false;
|
||||
conv.taskStartSoundFired = false;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setTaskStartTime: (conversationId: string, time: number | null) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.taskStartTime = time;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
markSuccessSoundFired: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.successSoundFired = true;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
markTaskStartSoundFired: (conversationId: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.taskStartSoundFired = true;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
setDraftText: (conversationId: string, text: string) => {
|
||||
conversations.update((convs) => {
|
||||
const conv = convs.get(conversationId);
|
||||
if (conv) {
|
||||
conv.draftText = text;
|
||||
}
|
||||
return convs;
|
||||
});
|
||||
},
|
||||
|
||||
// Add initialization helper
|
||||
initialize: () => {
|
||||
ensureInitialized();
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { setMockInvokeResult } from "../../../vitest.setup";
|
||||
import { draftsStore, type Draft } from "./drafts";
|
||||
|
||||
const makeDraft = (id: string, content: string, saved_at: string): Draft => ({
|
||||
id,
|
||||
content,
|
||||
saved_at,
|
||||
});
|
||||
|
||||
describe("Draft interface", () => {
|
||||
it("defines all required fields", () => {
|
||||
const draft: Draft = {
|
||||
id: "draft-123",
|
||||
content: "Hello world",
|
||||
saved_at: "2026-01-01T00:00:00+00:00",
|
||||
};
|
||||
|
||||
expect(draft.id).toBe("draft-123");
|
||||
expect(draft.content).toBe("Hello world");
|
||||
expect(draft.saved_at).toBe("2026-01-01T00:00:00+00:00");
|
||||
});
|
||||
|
||||
it("supports multiline content", () => {
|
||||
const draft: Draft = {
|
||||
id: "multi",
|
||||
content: "Line 1\nLine 2\nLine 3",
|
||||
saved_at: "2026-01-01T00:00:00+00:00",
|
||||
};
|
||||
|
||||
expect(draft.content.includes("\n")).toBe(true);
|
||||
expect(draft.content.split("\n")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("draftsStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("store structure", () => {
|
||||
it("has all expected methods", () => {
|
||||
expect(typeof draftsStore.loadDrafts).toBe("function");
|
||||
expect(typeof draftsStore.saveDraft).toBe("function");
|
||||
expect(typeof draftsStore.deleteDraft).toBe("function");
|
||||
expect(typeof draftsStore.deleteAllDrafts).toBe("function");
|
||||
expect(typeof draftsStore.formatTimestamp).toBe("function");
|
||||
});
|
||||
|
||||
it("has subscribable stores", () => {
|
||||
expect(typeof draftsStore.drafts.subscribe).toBe("function");
|
||||
expect(typeof draftsStore.isLoading.subscribe).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadDrafts", () => {
|
||||
it("loads drafts from backend", async () => {
|
||||
const mockDrafts: Draft[] = [
|
||||
makeDraft("draft-1", "Hello world", "2026-01-01T00:00:00+00:00"),
|
||||
];
|
||||
|
||||
setMockInvokeResult("list_drafts", mockDrafts);
|
||||
|
||||
await draftsStore.loadDrafts();
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("list_drafts");
|
||||
expect(get(draftsStore.drafts)).toEqual(mockDrafts);
|
||||
});
|
||||
|
||||
it("handles load errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("list_drafts", new Error("Failed to load"));
|
||||
|
||||
await draftsStore.loadDrafts();
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to load drafts:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("sets isLoading during load", async () => {
|
||||
const loadingStates: boolean[] = [];
|
||||
const unsubscribe = draftsStore.isLoading.subscribe((val) => loadingStates.push(val));
|
||||
|
||||
setMockInvokeResult("list_drafts", []);
|
||||
await draftsStore.loadDrafts();
|
||||
|
||||
unsubscribe();
|
||||
expect(loadingStates).toContain(true);
|
||||
expect(loadingStates.at(-1)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveDraft", () => {
|
||||
it("saves draft and reloads list", async () => {
|
||||
const mockDraft = makeDraft("new-id", "My draft content", "2026-01-01T00:00:00+00:00");
|
||||
|
||||
setMockInvokeResult("save_draft", mockDraft);
|
||||
setMockInvokeResult("list_drafts", [mockDraft]);
|
||||
|
||||
const result = await draftsStore.saveDraft("My draft content");
|
||||
|
||||
expect(result).toEqual(mockDraft);
|
||||
expect(invoke).toHaveBeenCalledWith("save_draft", { content: "My draft content" });
|
||||
});
|
||||
|
||||
it("returns null on error", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("save_draft", new Error("Save failed"));
|
||||
|
||||
const result = await draftsStore.saveDraft("content");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to save draft:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteDraft", () => {
|
||||
it("deletes draft by ID and reloads", async () => {
|
||||
setMockInvokeResult("delete_draft", undefined);
|
||||
setMockInvokeResult("list_drafts", []);
|
||||
|
||||
const result = await draftsStore.deleteDraft("draft-123");
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_draft", { draftId: "draft-123" });
|
||||
});
|
||||
|
||||
it("handles delete errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_draft", new Error("Delete failed"));
|
||||
|
||||
const result = await draftsStore.deleteDraft("draft-123");
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete draft:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteAllDrafts", () => {
|
||||
it("deletes all drafts and clears store", async () => {
|
||||
// First populate the store
|
||||
setMockInvokeResult("list_drafts", [makeDraft("d1", "Draft 1", "2026-01-01T00:00:00+00:00")]);
|
||||
await draftsStore.loadDrafts();
|
||||
expect(get(draftsStore.drafts)).toHaveLength(1);
|
||||
|
||||
setMockInvokeResult("delete_all_drafts", undefined);
|
||||
const result = await draftsStore.deleteAllDrafts();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(invoke).toHaveBeenCalledWith("delete_all_drafts");
|
||||
expect(get(draftsStore.drafts)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("handles delete-all errors gracefully", async () => {
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
setMockInvokeResult("delete_all_drafts", new Error("Delete all failed"));
|
||||
|
||||
const result = await draftsStore.deleteAllDrafts();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(consoleSpy).toHaveBeenCalledWith("Failed to delete all drafts:", expect.any(Error));
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTimestamp", () => {
|
||||
it("formats a valid ISO timestamp", () => {
|
||||
const result = draftsStore.formatTimestamp("2026-01-15T14:30:00+00:00");
|
||||
// Should produce a human-readable string (locale-dependent)
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to raw string on invalid timestamp", () => {
|
||||
const invalid = "not-a-date";
|
||||
const result = draftsStore.formatTimestamp(invalid);
|
||||
expect(result).toBe(invalid);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("draft content handling", () => {
|
||||
it("supports content with special characters", () => {
|
||||
const draft = makeDraft(
|
||||
"special",
|
||||
"echo \"Hello\" && echo 'World'",
|
||||
"2026-01-01T00:00:00+00:00"
|
||||
);
|
||||
expect(draft.content).toContain('"');
|
||||
expect(draft.content).toContain("'");
|
||||
expect(draft.content).toContain("&&");
|
||||
});
|
||||
|
||||
it("supports very long content", () => {
|
||||
const longContent = "a".repeat(1000);
|
||||
const draft = makeDraft("long", longContent, "2026-01-01T00:00:00+00:00");
|
||||
expect(draft.content).toHaveLength(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export interface Draft {
|
||||
id: string;
|
||||
content: string;
|
||||
saved_at: string;
|
||||
}
|
||||
|
||||
function createDraftsStore() {
|
||||
const drafts = writable<Draft[]>([]);
|
||||
const isLoading = writable(false);
|
||||
|
||||
async function loadDrafts(): Promise<void> {
|
||||
isLoading.set(true);
|
||||
try {
|
||||
const list = await invoke<Draft[]>("list_drafts");
|
||||
drafts.set(list);
|
||||
} catch (error) {
|
||||
console.error("Failed to load drafts:", error);
|
||||
} finally {
|
||||
isLoading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDraft(content: string): Promise<Draft | null> {
|
||||
try {
|
||||
const draft = await invoke<Draft>("save_draft", { content });
|
||||
await loadDrafts();
|
||||
return draft;
|
||||
} catch (error) {
|
||||
console.error("Failed to save draft:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDraft(draftId: string): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_draft", { draftId });
|
||||
await loadDrafts();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete draft:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllDrafts(): Promise<boolean> {
|
||||
try {
|
||||
await invoke("delete_all_drafts");
|
||||
drafts.set([]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete all drafts:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(isoString: string): string {
|
||||
const date = new Date(isoString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return isoString;
|
||||
}
|
||||
try {
|
||||
return date.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return isoString;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
drafts: { subscribe: drafts.subscribe },
|
||||
isLoading: { subscribe: isLoading.subscribe },
|
||||
loadDrafts,
|
||||
saveDraft,
|
||||
deleteDraft,
|
||||
deleteAllDrafts,
|
||||
formatTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export const draftsStore = createDraftsStore();
|
||||
+63
-1
@@ -21,6 +21,7 @@ import {
|
||||
handleConnectionStatusChange,
|
||||
handleNewUserMessage,
|
||||
} from "$lib/notifications/rules";
|
||||
import { notificationManager } from "$lib/notifications/notificationManager";
|
||||
|
||||
interface StateChangePayload {
|
||||
state: CharacterState;
|
||||
@@ -220,7 +221,7 @@ export async function initializeTauriListeners() {
|
||||
claudeStore.addLineToConversation(
|
||||
targetConversationId,
|
||||
"system",
|
||||
"Disconnected from Claude Code"
|
||||
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
|
||||
);
|
||||
|
||||
// Clear todos on real disconnect (not on reconnects for permissions)
|
||||
@@ -270,6 +271,67 @@ export async function initializeTauriListeners() {
|
||||
|
||||
const mappedState = stateMap[state.toLowerCase()] || "idle";
|
||||
|
||||
// Per-conversation sound tracking — fires for any tab (active or background).
|
||||
// All sounds are driven from state-change events rather than a global store
|
||||
// subscription, so background tabs receive their sounds correctly and
|
||||
// switching tabs never replays a sound that has already fired.
|
||||
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
|
||||
if (resolvedConversationId) {
|
||||
const conv = get(claudeStore.conversations).get(resolvedConversationId);
|
||||
if (conv) {
|
||||
const previousState = conv.characterState;
|
||||
|
||||
// New response starting — clear all per-task sound flags.
|
||||
// Only reset when entering from a clean-slate state, not mid-task.
|
||||
// Transitioning from coding/searching/mcp/typing → thinking means we're
|
||||
// still within the same task (between tool calls), so the sound must not replay.
|
||||
const cleanSlateStates: CharacterState[] = ["idle", "success", "error"];
|
||||
if (mappedState === "thinking" && cleanSlateStates.includes(previousState)) {
|
||||
claudeStore.resetSoundState(resolvedConversationId);
|
||||
}
|
||||
|
||||
// Record when a long-running phase begins (used for the 2-second
|
||||
// minimum duration check before playing the completion sound).
|
||||
if (
|
||||
(mappedState === "coding" || mappedState === "searching") &&
|
||||
previousState !== "coding" &&
|
||||
previousState !== "searching"
|
||||
) {
|
||||
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
|
||||
}
|
||||
|
||||
// Task-start sound — fires once when work enters a long-running phase.
|
||||
if (
|
||||
(mappedState === "coding" || mappedState === "searching") &&
|
||||
previousState !== "coding" &&
|
||||
previousState !== "searching" &&
|
||||
!conv.taskStartSoundFired
|
||||
) {
|
||||
notificationManager.notifyTaskStart();
|
||||
claudeStore.markTaskStartSoundFired(resolvedConversationId);
|
||||
}
|
||||
|
||||
// Error sound — fires each time a new error state is entered.
|
||||
if (mappedState === "error" && previousState !== "error") {
|
||||
notificationManager.notifyError();
|
||||
}
|
||||
|
||||
// Permission sound — fires each time a permission request arrives.
|
||||
if (mappedState === "permission") {
|
||||
notificationManager.notifyPermission();
|
||||
}
|
||||
|
||||
// Completion sound — fires once per task after sufficient duration.
|
||||
if (mappedState === "success" && !conv.successSoundFired) {
|
||||
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
|
||||
if (duration > 2000) {
|
||||
notificationManager.notifySuccess();
|
||||
}
|
||||
claudeStore.markSuccessSoundFired(resolvedConversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always update the conversation's state
|
||||
if (conversation_id) {
|
||||
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
setSkipNextGreeting(true);
|
||||
|
||||
await invoke("interrupt_claude", { conversationId });
|
||||
claudeStore.addLine("system", "Process interrupted");
|
||||
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
|
||||
} catch (error) {
|
||||
console.error("Failed to interrupt:", error);
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
return Promise.resolve([]);
|
||||
case "list_clipboard_entries":
|
||||
return Promise.resolve([]);
|
||||
case "list_drafts":
|
||||
return Promise.resolve([]);
|
||||
case "cleanup_temp_files":
|
||||
return Promise.resolve();
|
||||
case "validate_directory":
|
||||
|
||||
Reference in New Issue
Block a user