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