feat: new drafts feature and sound spam fix (#174)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 58s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled

## 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: #174
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #174.
This commit is contained in:
2026-02-27 15:07:10 -08:00
committed by Naomi Carrigan
parent fe7027c585
commit 7ebd9dc97a
8 changed files with 807 additions and 4 deletions
+192
View File
@@ -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
View File
@@ -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");