generated from nhcarrigan/template
fix: assorted bug fixes for lists, sounds, interrupts, and permissions (#173)
## 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>
This commit was merged in pull request #173.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user