Compare commits

...

4 Commits

Author SHA1 Message Date
naomi 97b8243d24 release: v1.9.0
CI / Lint & Test (push) Successful in 16m26s
CI / Build Linux (push) Successful in 20m15s
CI / Build Windows (cross-compile) (push) Successful in 31m2s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m16s
2026-02-27 15:09:37 -08:00
hikari 7ebd9dc97a 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>
2026-02-27 15:07:10 -08:00
naomi fe7027c585 release: v1.8.1
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Lint & Test (push) Successful in 16m51s
CI / Build Linux (push) Successful in 20m11s
CI / Build Windows (cross-compile) (push) Successful in 30m45s
2026-02-26 23:36:01 -08:00
hikari 89a0bdd8f1 fix: assorted bug fixes for lists, sounds, interrupts, and permissions (#173)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 59s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled
## Summary

- **Markdown lists**: Explicitly set `list-style-type: disc` / `decimal` in the Markdown renderer — Tauri's WebView strips browser defaults, leaving bullets and numbers invisible.
- **Notification sounds**: Moved all per-task sounds (success, error, permission, task-start) from a global `characterState` subscription into the per-conversation `claude:state` event handler, so background tabs receive their sounds correctly and tab-switching never replays a sound that already fired. Closes #172
- **Draft text**: Persists `inputValue` per conversation tab so a half-typed prompt survives switching to another tab and back.
- **Interrupt messages**: Replaced vague "Process interrupted" / "Disconnected" strings with source-specific descriptions (keyboard shortcut, stop button, unexpected crash) so it's clear what actually happened.
- **Silent prompt loss**: When Claude Code exits whilst a prompt is in-flight, emits a visible error line telling the user their last prompt was not processed and to reconnect and retry.
- **Double disconnect**: Added an `intentional_stop` flag to `WslBridge` so that `stop()` / `interrupt()` — which kill the process themselves — suppress the duplicate "Disconnected unexpectedly" message that `handle_stdout`'s EOF path was also emitting.
- **Permission modal**: Fixed two cooperating reactivity bugs — `pendingPermissions` was mutated in-place (`.push()`), causing Svelte's derived-store chain to receive the same array reference and skip re-rendering; `PermissionModal.svelte` also used `$state()` (runes mode) where plain `let` is required for correct store-subscription reactivity.

## Test plan

- [ ] Unordered and ordered lists render with visible bullets and numbers in the chat terminal
- [ ] Completion sound plays once when a background tab finishes; switching back to that tab does not replay it
- [ ] Sounds for error, permission request, and task-start also play for background tabs and do not replay on tab switch
- [ ] Typing a prompt, switching tabs, and switching back restores the draft text
- [ ] Pressing Ctrl+C shows "keyboard shortcut (Ctrl+C)"; clicking the stop button shows "via stop button"
- [ ] If Claude exits mid-request, an error message appears prompting the user to resend
- [ ] Clicking stop or pressing Ctrl+C produces exactly one disconnect message (not two)
- [ ] When a tool requires permission, the permission modal appears and the user can approve or dismiss it

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #173
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-02-26 23:34:51 -08:00
21 changed files with 1071 additions and 95 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.8.0",
"version": "1.9.0",
"description": "",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1648,7 +1648,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.8.0"
version = "1.9.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.8.0"
version = "1.9.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+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");
+48 -2
View File
@@ -111,6 +111,9 @@ pub struct WslBridge {
conversation_id: Option<String>,
/// Set to true once the `system:init` message arrives, false at the start of every new session.
received_init: Arc<AtomicBool>,
/// Set to true by stop()/interrupt() before killing the process so handle_stdout knows
/// the disconnect was intentional and should not emit a second Disconnected event.
intentional_stop: Arc<AtomicBool>,
}
impl WslBridge {
@@ -124,6 +127,7 @@ impl WslBridge {
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: None,
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
}
}
@@ -137,6 +141,7 @@ impl WslBridge {
stats: Arc::new(RwLock::new(UsageStats::new())),
conversation_id: Some(conversation_id),
received_init: Arc::new(AtomicBool::new(false)),
intentional_stop: Arc::new(AtomicBool::new(false)),
}
}
@@ -406,8 +411,9 @@ impl WslBridge {
self.stdin = stdin;
*self.process.lock() = Some(child);
// Reset the init flag so the watchdog and stdout handler start fresh.
// Reset flags so the watchdog and stdout handler start fresh.
self.received_init.store(false, Ordering::SeqCst);
self.intentional_stop.store(false, Ordering::SeqCst);
// Note: We no longer reset stats here - stats persist across reconnects
// Stats are only reset when explicitly disconnecting via stop()
@@ -425,8 +431,16 @@ impl WslBridge {
let stats_clone = self.stats.clone();
let conv_id = self.conversation_id.clone();
let received_init_clone = self.received_init.clone();
let intentional_stop_clone = self.intentional_stop.clone();
thread::spawn(move || {
handle_stdout(stdout, app_clone, stats_clone, conv_id, received_init_clone);
handle_stdout(
stdout,
app_clone,
stats_clone,
conv_id,
received_init_clone,
intentional_stop_clone,
);
});
}
@@ -543,6 +557,11 @@ impl WslBridge {
// See: https://github.com/anthropics/claude-code/issues/3455
// Extract the process first so the MutexGuard is dropped before we mutably
// borrow `self` again via estimate_interrupted_request_cost.
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
let maybe_process = self.process.lock().take();
if let Some(mut process) = maybe_process {
// Estimate cost for interrupted request before killing
@@ -674,6 +693,9 @@ impl WslBridge {
}
pub fn stop(&mut self, app: &AppHandle) {
// Signal handle_stdout that this is an intentional stop so it doesn't emit
// a second Disconnected event after stdout closes due to the kill.
self.intentional_stop.store(true, Ordering::SeqCst);
if let Some(mut process) = self.process.lock().take() {
let _ = process.kill();
let _ = process.wait();
@@ -729,6 +751,7 @@ fn handle_stdout(
stats: Arc<RwLock<UsageStats>>,
conversation_id: Option<String>,
received_init: Arc<AtomicBool>,
intentional_stop: Arc<AtomicBool>,
) {
let reader = BufReader::new(stdout);
@@ -749,6 +772,12 @@ fn handle_stdout(
}
}
// If this was an intentional stop (stop()/interrupt() was called), the caller already
// emitted a Disconnected event. Skip all post-loop emissions to prevent duplicates.
if intentional_stop.load(Ordering::SeqCst) {
return;
}
// If stdout closed before system:init arrived the process exited without initialising.
// Emit an error line so the user understands why the connection failed.
if !received_init.load(Ordering::SeqCst) {
@@ -765,6 +794,23 @@ fn handle_stdout(
);
}
// If Claude exited while a prompt was in-flight, the user's message was never processed.
// Emit a specific error so they know to resend their prompt.
let had_pending_request = stats.read().current_request_input.is_some();
if had_pending_request {
let _ = app.emit(
"claude:output",
OutputEvent {
line_type: "error".to_string(),
content: "Claude Code exited before finishing your request — your last prompt was not processed. Please reconnect and try again.".to_string(),
tool_name: None,
conversation_id: conversation_id.clone(),
cost: None,
parent_tool_use_id: None,
},
);
}
emit_connection_status(&app, ConnectionStatus::Disconnected, conversation_id);
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.8.0",
"version": "1.9.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+1 -1
View File
@@ -135,7 +135,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Interrupted");
claudeStore.addLine("system", "Process interrupted via stop button");
characterState.setState("idle");
} catch (error) {
console.error("Failed to interrupt:", error);
+232
View File
@@ -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>
+98 -4
View File
@@ -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
@@ -164,6 +167,25 @@
attachments = storedAttachments;
});
// Per-tab draft persistence — restore the draft text whenever the active
// conversation changes, and save it back on every keystroke.
claudeStore.activeConversationId.subscribe((conversationId) => {
if (conversationId) {
const conv = get(claudeStore.conversations).get(conversationId);
inputValue = conv?.draftText ?? "";
} else {
inputValue = "";
}
});
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
@@ -176,6 +198,12 @@
historyIndex = -1;
tempInput = "";
// Save the current draft so it persists if the user switches tabs.
const activeId = get(claudeStore.activeConversationId);
if (activeId) {
claudeStore.setDraftText(activeId, inputValue);
}
if (isSlashCommand(inputValue)) {
matchingCommands = getMatchingCommands(inputValue);
showCommandMenu = matchingCommands.length > 0;
@@ -195,7 +223,7 @@
async function executeSlashCommand(): Promise<boolean> {
const { command, args } = parseSlashCommand(inputValue);
if (command) {
inputValue = "";
clearInput();
showCommandMenu = false;
matchingCommands = [];
await command.execute(args);
@@ -228,7 +256,7 @@
"error",
`Unknown command: ${message.split(" ")[0]}. Type /help for available commands.`
);
inputValue = "";
clearInput();
return;
}
@@ -244,7 +272,7 @@
userHasTyped = false;
isSubmitting = true;
inputValue = "";
clearInput();
// Capture attachments before clearing
const currentAttachments = [...attachments];
@@ -326,7 +354,7 @@ User: ${formattedMessage}`;
throw new Error("No active conversation");
}
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted - reconnecting...");
claudeStore.addLine("system", "Process interrupted via stop button — reconnecting...");
characterState.setState("idle");
// Show connecting status while we reconnect
@@ -703,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()) {
@@ -919,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>
@@ -959,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"
@@ -1024,6 +1114,10 @@ User: ${formattedMessage}`;
/>
{/if}
{#if showDraftPanel}
<DraftPanel onClose={() => (showDraftPanel = false)} onInsert={handleDraftInsert} />
{/if}
{#if contextMenuShow && textareaElement}
<TextInputContextMenu
x={contextMenuX}
+7 -1
View File
@@ -281,10 +281,16 @@
font-family: "JetBrains Mono", "Fira Code", monospace;
}
.markdown-content :global(ul),
.markdown-content :global(ul) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: disc;
}
.markdown-content :global(ol) {
margin: 0.5em 0;
padding-left: 1.5em;
list-style-type: decimal;
}
.markdown-content :global(li) {
+3 -3
View File
@@ -9,10 +9,10 @@
import { conversationsStore } from "$lib/stores/conversations";
import { configStore } from "$lib/stores/config";
let permissions: PermissionRequest[] = $state([]);
let permissions: PermissionRequest[] = [];
let selectedPermissions = new SvelteSet<string>();
let grantedToolsList: string[] = $state([]);
let workingDirectory = $state("");
let grantedToolsList: string[] = [];
let workingDirectory = "";
conversationsStore.pendingPermissions.subscribe((perms) => {
permissions = perms;
+6 -74
View File
@@ -1,52 +1,8 @@
import { characterState } from "$lib/stores/character";
import { notificationManager } from "./notificationManager";
import type { CharacterState } from "$lib/types/states";
import type { ConnectionStatus } from "$lib/types/messages";
// Track previous states to detect transitions
let previousCharacterState: CharacterState | null = null;
// Track previous connection status to detect transitions
let previousConnectionStatus: ConnectionStatus | null = null;
let taskStartTime: number | null = null;
let hasNotifiedTaskStart = false;
export function handleCharacterStateChange(newState: CharacterState): void {
// Detect state transitions
if (previousCharacterState === newState) return;
// Task completion: any state -> success
if (newState === "success" && previousCharacterState !== null) {
const taskDuration = taskStartTime ? Date.now() - taskStartTime : 0;
// Only notify for tasks that took more than 2 seconds
if (taskDuration > 2000) {
notificationManager.notifySuccess();
}
taskStartTime = null;
}
// Error occurred
if (newState === "error" && previousCharacterState !== "error") {
notificationManager.notifyError();
}
// Permission needed
if (newState === "permission") {
notificationManager.notifyPermission();
}
// Starting long tasks - only notify once per response
if (
(newState === "coding" || newState === "searching") &&
previousCharacterState !== "coding" &&
previousCharacterState !== "searching" &&
!hasNotifiedTaskStart
) {
taskStartTime = Date.now();
hasNotifiedTaskStart = true;
notificationManager.notifyTaskStart();
}
previousCharacterState = newState;
}
export function handleConnectionStatusChange(newStatus: ConnectionStatus): void {
// Only notify on successful connection after being disconnected
@@ -67,37 +23,13 @@ export function handleToolExecution(_toolName: string): void {
// But we could add specific rules here if needed
}
// Reset notification state for a new response
export function handleNewUserMessage(): void {
hasNotifiedTaskStart = false;
}
// No-op: sound tracking is now per-conversation in tauri.ts
export function handleNewUserMessage(): void {}
// Store unsubscribe functions
let unsubscribeCharacterState: (() => void) | null = null;
// No-op: all per-conversation sounds are driven by tauri.ts event listeners
export function initializeNotificationRules(): void {}
// Initialize listeners
export function initializeNotificationRules(): void {
// Clean up any existing subscriptions first
cleanupNotificationRules();
// Subscribe to character state changes
unsubscribeCharacterState = characterState.subscribe((state) => {
handleCharacterStateChange(state);
});
// We'll connect to connection status in the next step
}
// Cleanup function to prevent duplicate listeners
// Cleanup — reset connection tracking on teardown
export function cleanupNotificationRules(): void {
if (unsubscribeCharacterState) {
unsubscribeCharacterState();
unsubscribeCharacterState = null;
}
// Reset state tracking
previousCharacterState = null;
previousConnectionStatus = null;
taskStartTime = null;
hasNotifiedTaskStart = false;
}
+9
View File
@@ -60,6 +60,15 @@ export const claudeStore = {
isToolGranted: conversationsStore.isToolGranted,
setPendingRetryMessage: conversationsStore.setPendingRetryMessage,
// Sound tracking
resetSoundState: conversationsStore.resetSoundState,
setTaskStartTime: conversationsStore.setTaskStartTime,
markSuccessSoundFired: conversationsStore.markSuccessSoundFired,
markTaskStartSoundFired: conversationsStore.markTaskStartSoundFired,
// Draft text (per-tab input persistence)
setDraftText: conversationsStore.setDraftText,
// Conversation management
createConversation: conversationsStore.createConversation,
deleteConversation: conversationsStore.deleteConversation,
+38
View File
@@ -523,3 +523,41 @@ describe("pending retry message", () => {
expect(pendingRetryMessage).toBeNull();
});
});
describe("draft text persistence", () => {
it("initialises draft text as empty string", () => {
const conversation = { draftText: "" };
expect(conversation.draftText).toBe("");
});
it("stores draft text per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Hello world" }],
["conv-2", { draftText: "" }],
]);
expect(conversations.get("conv-1")?.draftText).toBe("Hello world");
expect(conversations.get("conv-2")?.draftText).toBe("");
});
it("updates draft text independently per conversation", () => {
const conversations = new Map([
["conv-1", { draftText: "Draft A" }],
["conv-2", { draftText: "Draft B" }],
]);
const convA = conversations.get("conv-1");
if (convA) convA.draftText = "Updated A";
expect(conversations.get("conv-1")?.draftText).toBe("Updated A");
expect(conversations.get("conv-2")?.draftText).toBe("Draft B");
});
it("clears draft text after submission", () => {
const conversation = { draftText: "My prompt" };
conversation.draftText = "";
expect(conversation.draftText).toBe("");
});
});
+71 -4
View File
@@ -37,6 +37,10 @@ export interface Conversation {
attachments: Attachment[];
summary: ConversationSummary | null;
startedAt: Date;
taskStartTime: number | null;
successSoundFired: boolean;
taskStartSoundFired: boolean;
draftText: string;
}
function createConversationsStore() {
@@ -75,6 +79,10 @@ function createConversationsStore() {
attachments: [],
summary: null,
startedAt: new Date(),
taskStartTime: null,
successSoundFired: false,
taskStartSoundFired: false,
draftText: "",
};
}
@@ -196,7 +204,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(activeId);
if (conv) {
conv.pendingPermissions.push(request);
conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.lastActivityAt = new Date();
}
return convs;
@@ -219,7 +227,7 @@ function createConversationsStore() {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.pendingPermissions.push(request);
conv.pendingPermissions = [...conv.pendingPermissions, request];
conv.lastActivityAt = new Date();
}
return convs;
@@ -364,9 +372,15 @@ function createConversationsStore() {
if (currentId !== id) {
activeConversationId.set(id);
// Update the global character state to match the conversation's state
// Update the global character state to match the conversation's state.
// Map success/error → idle since those are transient states that have
// already been displayed — restoring them would re-trigger sound rules.
if (targetConv) {
characterState.setState(targetConv.characterState);
const stateToRestore =
targetConv.characterState === "success" || targetConv.characterState === "error"
? "idle"
: targetConv.characterState;
characterState.setState(stateToRestore);
}
}
},
@@ -816,6 +830,59 @@ function createConversationsStore() {
});
},
// Sound tracking methods
resetSoundState: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = null;
conv.successSoundFired = false;
conv.taskStartSoundFired = false;
}
return convs;
});
},
setTaskStartTime: (conversationId: string, time: number | null) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartTime = time;
}
return convs;
});
},
markSuccessSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.successSoundFired = true;
}
return convs;
});
},
markTaskStartSoundFired: (conversationId: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.taskStartSoundFired = true;
}
return convs;
});
},
setDraftText: (conversationId: string, text: string) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.draftText = text;
}
return convs;
});
},
// Add initialization helper
initialize: () => {
ensureInitialized();
+203
View File
@@ -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);
});
});
+87
View File
@@ -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();
+63 -1
View File
@@ -21,6 +21,7 @@ import {
handleConnectionStatusChange,
handleNewUserMessage,
} from "$lib/notifications/rules";
import { notificationManager } from "$lib/notifications/notificationManager";
interface StateChangePayload {
state: CharacterState;
@@ -220,7 +221,7 @@ export async function initializeTauriListeners() {
claudeStore.addLineToConversation(
targetConversationId,
"system",
"Disconnected from Claude Code"
"Disconnected from Claude Code unexpectedly — the process may have crashed or been stopped by the system"
);
// Clear todos on real disconnect (not on reconnects for permissions)
@@ -270,6 +271,67 @@ export async function initializeTauriListeners() {
const mappedState = stateMap[state.toLowerCase()] || "idle";
// Per-conversation sound tracking — fires for any tab (active or background).
// All sounds are driven from state-change events rather than a global store
// subscription, so background tabs receive their sounds correctly and
// switching tabs never replays a sound that has already fired.
const resolvedConversationId = conversation_id || get(claudeStore.activeConversationId) || null;
if (resolvedConversationId) {
const conv = get(claudeStore.conversations).get(resolvedConversationId);
if (conv) {
const previousState = conv.characterState;
// New response starting — clear all per-task sound flags.
// 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);
}
// Record when a long-running phase begins (used for the 2-second
// minimum duration check before playing the completion sound).
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching"
) {
claudeStore.setTaskStartTime(resolvedConversationId, Date.now());
}
// Task-start sound — fires once when work enters a long-running phase.
if (
(mappedState === "coding" || mappedState === "searching") &&
previousState !== "coding" &&
previousState !== "searching" &&
!conv.taskStartSoundFired
) {
notificationManager.notifyTaskStart();
claudeStore.markTaskStartSoundFired(resolvedConversationId);
}
// Error sound — fires each time a new error state is entered.
if (mappedState === "error" && previousState !== "error") {
notificationManager.notifyError();
}
// Permission sound — fires each time a permission request arrives.
if (mappedState === "permission") {
notificationManager.notifyPermission();
}
// Completion sound — fires once per task after sufficient duration.
if (mappedState === "success" && !conv.successSoundFired) {
const duration = conv.taskStartTime ? Date.now() - conv.taskStartTime : 0;
if (duration > 2000) {
notificationManager.notifySuccess();
}
claudeStore.markSuccessSoundFired(resolvedConversationId);
}
}
}
// Always update the conversation's state
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
+1 -1
View File
@@ -337,7 +337,7 @@
setSkipNextGreeting(true);
await invoke("interrupt_claude", { conversationId });
claudeStore.addLine("system", "Process interrupted");
claudeStore.addLine("system", "Process interrupted by keyboard shortcut (Ctrl+C)");
} catch (error) {
console.error("Failed to interrupt:", error);
}
+2
View File
@@ -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":