From 7ebd9dc97a4182b5a295c86ae8326da6633b5142 Mon Sep 17 00:00:00 2001 From: Hikari Date: Fri, 27 Feb 2026 15:07:10 -0800 Subject: [PATCH] feat: new drafts feature and sound spam fix (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Saved Drafts feature**: Users can now save input content as drafts for later use, and manage them from a new panel - **Sound spam fix**: The "Working on it!" sound no longer plays repeatedly when Claude makes multiple tool calls in a row ## Details ### Drafts feature - Rust backend (`drafts.rs`) with `list_drafts`, `save_draft`, `delete_draft`, and `delete_all_drafts` commands, persisted to `hikari-drafts.json` via the Tauri Store plugin - `draftsStore` wrapping all four commands with timestamp formatting - `DraftPanel` overlay with insert, per-item two-step delete confirmation, delete-all with confirmation, empty state, and slide-in animation - **Drafts** button in the top control row (pencil icon) - **Save as Draft** floppy-disk icon button in the button wrapper (disabled when input is empty) ### Sound spam fix - Root cause: `resetSoundState` was called on **every** `thinking` state transition, including mid-task transitions (`coding → thinking → coding`) - Fix: only reset sound state when entering `thinking` from a clean-slate state (`idle`, `success`, or `error`) — states that genuinely mark the end of one task and the start of a new one ## Test plan - [ ] Save a draft and verify it persists across app restarts - [ ] Insert a draft and verify it populates the input - [ ] Delete individual drafts and verify delete-all works - [ ] Verify "Working on it!" plays once per user message regardless of how many tools are called ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: https://git.nhcarrigan.com/nhcarrigan/hikari-desktop/pulls/174 Co-authored-by: Hikari Co-committed-by: Hikari --- src-tauri/src/drafts.rs | 192 ++++++++++++++++++++++ src-tauri/src/lib.rs | 6 + src/lib/components/DraftPanel.svelte | 232 +++++++++++++++++++++++++++ src/lib/components/InputBar.svelte | 83 +++++++++- src/lib/stores/drafts.test.ts | 203 +++++++++++++++++++++++ src/lib/stores/drafts.ts | 87 ++++++++++ src/lib/tauri.ts | 6 +- vitest.setup.ts | 2 + 8 files changed, 807 insertions(+), 4 deletions(-) create mode 100644 src-tauri/src/drafts.rs create mode 100644 src/lib/components/DraftPanel.svelte create mode 100644 src/lib/stores/drafts.test.ts create mode 100644 src/lib/stores/drafts.ts diff --git a/src-tauri/src/drafts.rs b/src-tauri/src/drafts.rs new file mode 100644 index 0000000..aab1b07 --- /dev/null +++ b/src-tauri/src/drafts.rs @@ -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, 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, 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 { + 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')); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0c0d9b3..b0d8ae5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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"); diff --git a/src/lib/components/DraftPanel.svelte b/src/lib/components/DraftPanel.svelte new file mode 100644 index 0000000..38d392e --- /dev/null +++ b/src/lib/components/DraftPanel.svelte @@ -0,0 +1,232 @@ + + +
e.key === "Escape" && onClose()} +> +
e.stopPropagation()} + onkeydown={(e) => e.stopPropagation()} + role="dialog" + aria-labelledby="draft-panel-title" + tabindex="-1" + > +
+

+ Saved Drafts +

+
+ {#if $drafts.length > 0} + {#if confirmingAll} +
+ + +
+ {:else} + + {/if} + {/if} + +
+
+ +
+ {#if $isLoading} +
+
Loading drafts...
+
+ {:else if $drafts.length === 0} +
+ + + +

No saved drafts yet

+

+ Use "Save as Draft" to store messages for later +

+
+ {:else} +
+ {#each $drafts as draft (draft.id)} +
+
+
+

+ {draftsStore.formatTimestamp(draft.saved_at)} +

+

+ {truncateContent(draft.content)} +

+
+
+ + {#if confirmingDeleteId === draft.id} +
+ + +
+ {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+
+
+ + diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 29afd7c..e2686d8 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -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 @@ -175,6 +178,14 @@ } }); + 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 @@ -212,7 +223,7 @@ async function executeSlashCommand(): Promise { const { command, args } = parseSlashCommand(inputValue); if (command) { - inputValue = ""; + clearInput(); showCommandMenu = false; matchingCommands = []; await command.execute(args); @@ -245,7 +256,7 @@ "error", `Unknown command: ${message.split(" ")[0]}. Type /help for available commands.` ); - inputValue = ""; + clearInput(); return; } @@ -261,7 +272,7 @@ userHasTyped = false; isSubmitting = true; - inputValue = ""; + clearInput(); // Capture attachments before clearing const currentAttachments = [...attachments]; @@ -720,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 { + 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()) { @@ -936,6 +963,29 @@ User: ${formattedMessage}`; Clipboard + + @@ -976,6 +1026,29 @@ User: ${formattedMessage}`; {/if} + +