diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index 8f3a4dd..5df9852 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -111,6 +111,9 @@ pub struct WslBridge { conversation_id: Option, /// Set to true once the `system:init` message arrives, false at the start of every new session. received_init: Arc, + /// 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, } 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>, conversation_id: Option, received_init: Arc, + intentional_stop: Arc, ) { 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); } diff --git a/src/lib/components/CompactMode.svelte b/src/lib/components/CompactMode.svelte index 64fba0a..8109552 100644 --- a/src/lib/components/CompactMode.svelte +++ b/src/lib/components/CompactMode.svelte @@ -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); diff --git a/src/lib/components/InputBar.svelte b/src/lib/components/InputBar.svelte index 62ea928..29afd7c 100644 --- a/src/lib/components/InputBar.svelte +++ b/src/lib/components/InputBar.svelte @@ -164,6 +164,17 @@ 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 handleInputChange() { // If input is empty, allow history navigation again // Otherwise, mark that user has manually typed @@ -176,6 +187,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; @@ -326,7 +343,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 diff --git a/src/lib/components/Markdown.svelte b/src/lib/components/Markdown.svelte index 7560e98..205fb6a 100644 --- a/src/lib/components/Markdown.svelte +++ b/src/lib/components/Markdown.svelte @@ -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) { diff --git a/src/lib/components/PermissionModal.svelte b/src/lib/components/PermissionModal.svelte index 5943ff5..061e4b9 100644 --- a/src/lib/components/PermissionModal.svelte +++ b/src/lib/components/PermissionModal.svelte @@ -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(); - let grantedToolsList: string[] = $state([]); - let workingDirectory = $state(""); + let grantedToolsList: string[] = []; + let workingDirectory = ""; conversationsStore.pendingPermissions.subscribe((perms) => { permissions = perms; diff --git a/src/lib/notifications/rules.ts b/src/lib/notifications/rules.ts index 6756e28..8d62d62 100644 --- a/src/lib/notifications/rules.ts +++ b/src/lib/notifications/rules.ts @@ -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; } diff --git a/src/lib/stores/claude.ts b/src/lib/stores/claude.ts index 630a6c8..8220beb 100644 --- a/src/lib/stores/claude.ts +++ b/src/lib/stores/claude.ts @@ -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, diff --git a/src/lib/stores/conversations.test.ts b/src/lib/stores/conversations.test.ts index ddc2d0a..c587109 100644 --- a/src/lib/stores/conversations.test.ts +++ b/src/lib/stores/conversations.test.ts @@ -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(""); + }); +}); diff --git a/src/lib/stores/conversations.ts b/src/lib/stores/conversations.ts index 3b9be8f..00e2644 100644 --- a/src/lib/stores/conversations.ts +++ b/src/lib/stores/conversations.ts @@ -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(); diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index ad26771..90a299a 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -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,63 @@ 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. + if (mappedState === "thinking") { + 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); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index b4e720e..42ffc92 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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); }