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,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();
|
||||
Reference in New Issue
Block a user