generated from nhcarrigan/template
feat: new drafts feature and sound spam fix (#174)
## 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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user