generated from nhcarrigan/template
7ebd9dc97a
## 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>
193 lines
5.6 KiB
Rust
193 lines
5.6 KiB
Rust
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'));
|
|
}
|
|
}
|