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");
|
||||
|
||||
@@ -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
|
||||
@@ -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<boolean> {
|
||||
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<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()) {
|
||||
@@ -936,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>
|
||||
@@ -976,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"
|
||||
@@ -1041,6 +1114,10 @@ User: ${formattedMessage}`;
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showDraftPanel}
|
||||
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
|
||||
{/if}
|
||||
|
||||
{#if contextMenuShow && textareaElement}
|
||||
<TextInputContextMenu
|
||||
x={contextMenuX}
|
||||
|
||||
@@ -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();
|
||||
+5
-1
@@ -282,7 +282,11 @@ export async function initializeTauriListeners() {
|
||||
const previousState = conv.characterState;
|
||||
|
||||
// New response starting — clear all per-task sound flags.
|
||||
if (mappedState === "thinking") {
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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