fix: resolve message submission and stuck processing bugs (#199)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m2s
CI / Build Linux (push) Has been cancelled
CI / Build Windows (cross-compile) (push) Has been cancelled
CI / Lint & Test (push) Has been cancelled

## Summary

- **Fix `isProcessing` tracking**: The `isProcessing` store field was initialised as `false` and never set to `true` in production, making all submission guards no-ops. Now `setProcessing(true)` is called after `send_prompt` succeeds in both `handleSubmit` and `handleQuickAction`, and `setProcessingForConversation(id, false)` is called when the backend emits an idle/success/error state.
- **Fix auto-granted tools dropped on permission reconnect** (closes #198): `PermissionModal.svelte` was passing only session-granted tools when reconnecting after a permission approval, silently dropping `config.auto_granted_tools`. Fixed to merge both sets, matching the behaviour of every other `start_claude` call site.
- **Add mid-session watchdog**: A watchdog thread now kills the Claude Code process if a user message is sent but no `Result` arrives within 5 minutes. This triggers the existing disconnect/error flow so the user is notified and can reconnect. A generation counter ensures watchdogs from previous sessions exit cleanly when a new session starts.

## Test plan

- [ ] Send a message and verify the textarea is disabled and the stop button is visible while Claude is processing
- [ ] Verify the textarea re-enables after Claude finishes responding
- [ ] Enable a tool in default permissions (e.g. Read), start a session, trigger a permission approval for another tool, approve it — verify the previously auto-granted tool is no longer re-prompted
- [ ] Verify all existing tests pass (`./check-all.sh`)

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #199
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #199.
This commit is contained in:
2026-03-09 16:53:09 -07:00
committed by Naomi Carrigan
parent ff0ba7b6d0
commit 2816e33257
8 changed files with 445 additions and 10 deletions
+5 -3
View File
@@ -248,7 +248,7 @@
const hasAttachments = attachments.length > 0;
// Need either a message or attachments to submit
if ((!message && !hasAttachments) || isSubmitting) return;
if ((!message && !hasAttachments) || isSubmitting || isProcessing) return;
// Check for slash commands first (these work even when disconnected)
if (message && isSlashCommand(message)) {
@@ -339,6 +339,7 @@ User: ${formattedMessage}`;
conversationId,
message: messageToSend,
});
claudeStore.setProcessing(true);
} catch (error) {
console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -768,7 +769,7 @@ User: ${formattedMessage}`;
async function handleQuickAction(prompt: string): Promise<void> {
// Quick actions send the prompt directly
if (!isConnected || isSubmitting) return;
if (!isConnected || isSubmitting || isProcessing) return;
// Add to history
addToHistory(prompt);
@@ -793,6 +794,7 @@ User: ${formattedMessage}`;
conversationId,
message: prompt,
});
claudeStore.setProcessing(true);
} catch (error) {
console.error("Failed to send quick action:", error);
claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -1018,7 +1020,7 @@ User: ${formattedMessage}`;
placeholder={isConnected
? "Ask Hikari anything... (type / for commands)"
: "Connect to Claude first..."}
disabled={isSubmitting}
disabled={isSubmitting || isProcessing}
rows={1}
style="height: {textareaHeight}px; font-size: var(--terminal-font-size, 14px); font-family: var(--terminal-font-family, monospace);"
class="w-full px-4 py-3 bg-[var(--bg-secondary)] border border-[var(--border-color)]
+1 -1
View File
@@ -86,7 +86,7 @@
api_key: config.api_key || null,
custom_instructions: config.custom_instructions || null,
mcp_servers_json: config.mcp_servers_json || null,
allowed_tools: newGrantedTools,
allowed_tools: [...new Set([...newGrantedTools, ...config.auto_granted_tools])],
use_worktree: config.use_worktree ?? false,
disable_1m_context: config.disable_1m_context ?? false,
},
+154
View File
@@ -0,0 +1,154 @@
/**
* PermissionModal Component Tests
*
* Tests the pure helper functions used by the PermissionModal component.
*
* What this component does:
* - Displays pending permission requests from Claude Code
* - Allows the user to approve or dismiss permission requests
* - On approval, reconnects Claude with the newly granted tools merged with
* `auto_granted_tools` from config (bug fix: issue #198)
* - Restores conversation context after reconnecting
*
* Manual testing checklist:
* - [ ] Permission modal appears when Claude requests a tool not in allowed_tools
* - [ ] All permissions are pre-selected by default when modal opens
* - [ ] "Select All" and "Select None" buttons work correctly
* - [ ] "Already Granted" badge appears for tools already in the session grant list
* - [ ] Approving permissions reconnects Claude and restores conversation context
* - [ ] After reconnecting, auto_granted_tools are still respected (no re-prompting)
* - [ ] Dismissing the modal clears pending permissions without reconnecting
* - [ ] Enter key approves selected permissions
* - [ ] Escape key dismisses the modal
* - [ ] Character enters "permission" state when modal appears
* - [ ] Input details are shown in a collapsible "View details" section
*
* Note: The `handleApproveAndReconnect` function cannot be unit tested here
* because it depends on Tauri IPC calls (`invoke("stop_claude")`,
* `invoke("start_claude")`, `invoke("send_prompt")`). The critical bug fix
* (including `auto_granted_tools` in the reconnect's `allowed_tools`) is
* covered by the `buildAllowedToolsList` tests below, which replicate the
* exact merging logic from the component.
*/
import { describe, it, expect } from "vitest";
/**
* Replicates the allowed-tools merging logic from PermissionModal's
* handleApproveAndReconnect. This is the fix for issue #198: previously,
* `auto_granted_tools` were not included when reconnecting, causing them to
* be silently dropped and prompting the user again on subsequent requests.
*/
function buildAllowedToolsList(
sessionGrantedTools: string[],
newlyGrantedTools: string[],
autoGrantedTools: string[]
): string[] {
return [...new Set([...sessionGrantedTools, ...newlyGrantedTools, ...autoGrantedTools])];
}
/**
* Replicates the formatInput helper from PermissionModal, used to display
* the tool input JSON in the permission details section.
*/
function formatInput(input: Record<string, unknown>): string {
try {
return JSON.stringify(input, null, 2);
} catch {
return String(input);
}
}
/**
* Replicates the isToolAlreadyGranted helper from PermissionModal.
*/
function isToolAlreadyGranted(toolName: string, grantedToolsList: string[]): boolean {
return grantedToolsList.includes(toolName);
}
// ---
describe("buildAllowedToolsList", () => {
it("merges session-granted, newly-granted, and auto-granted tools", () => {
const result = buildAllowedToolsList(["Bash"], ["Glob"], ["Read"]);
expect(result).toContain("Bash");
expect(result).toContain("Glob");
expect(result).toContain("Read");
});
it("deduplicates tools that appear in multiple lists", () => {
const result = buildAllowedToolsList(["Read", "Bash"], ["Read"], ["Read", "Write"]);
const readCount = result.filter((t) => t === "Read").length;
expect(readCount).toBe(1);
});
it("preserves auto_granted_tools even when session list is empty", () => {
const result = buildAllowedToolsList([], ["Bash"], ["Read", "Glob"]);
expect(result).toContain("Read");
expect(result).toContain("Glob");
expect(result).toContain("Bash");
});
it("returns only auto_granted_tools when no other grants exist", () => {
const result = buildAllowedToolsList([], [], ["Read"]);
expect(result).toEqual(["Read"]);
});
it("returns an empty array when all lists are empty", () => {
const result = buildAllowedToolsList([], [], []);
expect(result).toEqual([]);
});
it("reproduces the bug scenario from issue #198", () => {
// Scenario: user has Read in auto_granted_tools.
// Session starts correctly with Read allowed.
// User approves Bash via permission modal.
// Before fix: reconnect only passed [Bash], dropping Read.
// After fix: reconnect passes [Bash, Read].
const sessionGrantedTools: string[] = []; // no prior session grants
const newlyGrantedTools = ["Bash"]; // just approved via modal
const autoGrantedTools = ["Read"]; // configured default
const result = buildAllowedToolsList(sessionGrantedTools, newlyGrantedTools, autoGrantedTools);
expect(result).toContain("Bash");
expect(result).toContain("Read"); // Must be present — this was the bug!
});
});
describe("formatInput", () => {
it("formats a simple object as pretty-printed JSON", () => {
const result = formatInput({ file_path: "/home/naomi/test.ts" });
expect(result).toBe(JSON.stringify({ file_path: "/home/naomi/test.ts" }, null, 2));
});
it("formats a nested object correctly", () => {
const input = { command: "ls", args: ["-la", "/home"] };
const result = formatInput(input);
expect(result).toContain('"command": "ls"');
expect(result).toContain('"args"');
});
it("formats an empty object as '{}'", () => {
const result = formatInput({});
expect(result).toBe("{}");
});
});
describe("isToolAlreadyGranted", () => {
it("returns true when the tool is in the granted list", () => {
expect(isToolAlreadyGranted("Read", ["Read", "Bash"])).toBe(true);
});
it("returns false when the tool is not in the granted list", () => {
expect(isToolAlreadyGranted("Write", ["Read", "Bash"])).toBe(false);
});
it("returns false for an empty granted list", () => {
expect(isToolAlreadyGranted("Read", [])).toBe(false);
});
it("is case-sensitive", () => {
expect(isToolAlreadyGranted("read", ["Read"])).toBe(false);
});
});
+1
View File
@@ -41,6 +41,7 @@ export const claudeStore = {
setWorkingDirectory: conversationsStore.setWorkingDirectory,
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
setProcessing: conversationsStore.setProcessing,
setProcessingForConversation: conversationsStore.setProcessingForConversation,
addLine: conversationsStore.addLine,
addLineToConversation: conversationsStore.addLineToConversation,
updateLine: conversationsStore.updateLine,
+96
View File
@@ -561,3 +561,99 @@ describe("draft text persistence", () => {
expect(conversation.draftText).toBe("");
});
});
describe("isProcessing state management", () => {
it("starts as false by default", () => {
const conversation = { id: "conv-1", isProcessing: false };
expect(conversation.isProcessing).toBe(false);
});
it("setProcessingForConversation sets processing true for the target conversation", () => {
const conversations = new Map([
["conv-1", { isProcessing: false, lastActivityAt: new Date(0) }],
["conv-2", { isProcessing: false, lastActivityAt: new Date(0) }],
]);
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
const conv = conversations.get(conversationId);
if (conv) {
conv.isProcessing = processing;
conv.lastActivityAt = new Date();
}
};
setProcessingForConversation("conv-1", true);
expect(conversations.get("conv-1")?.isProcessing).toBe(true);
expect(conversations.get("conv-2")?.isProcessing).toBe(false);
});
it("setProcessingForConversation resets processing to false", () => {
const conversations = new Map([
["conv-1", { isProcessing: true, lastActivityAt: new Date(0) }],
]);
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
const conv = conversations.get(conversationId);
if (conv) {
conv.isProcessing = processing;
conv.lastActivityAt = new Date();
}
};
setProcessingForConversation("conv-1", false);
expect(conversations.get("conv-1")?.isProcessing).toBe(false);
});
it("setProcessingForConversation does nothing for unknown conversation", () => {
const conversations = new Map([
["conv-1", { isProcessing: false, lastActivityAt: new Date(0) }],
]);
const setProcessingForConversation = (conversationId: string, processing: boolean) => {
const conv = conversations.get(conversationId);
if (conv) {
conv.isProcessing = processing;
conv.lastActivityAt = new Date();
}
};
setProcessingForConversation("unknown", true);
expect(conversations.get("conv-1")?.isProcessing).toBe(false);
});
it("isProcessing is cleared when idle state arrives", () => {
const conversation = { isProcessing: true, characterState: "thinking" };
const terminalStates = ["idle", "success", "error"];
const handleStateChange = (state: string) => {
conversation.characterState = state;
if (terminalStates.includes(state)) {
conversation.isProcessing = false;
}
};
handleStateChange("idle");
expect(conversation.isProcessing).toBe(false);
});
it("isProcessing stays true during non-terminal states", () => {
const conversation = { isProcessing: true, characterState: "thinking" };
const terminalStates = ["idle", "success", "error"];
const handleStateChange = (state: string) => {
conversation.characterState = state;
if (terminalStates.includes(state)) {
conversation.isProcessing = false;
}
};
for (const state of ["thinking", "typing", "coding", "searching", "mcp"]) {
handleStateChange(state);
expect(conversation.isProcessing).toBe(true);
}
});
});
+11
View File
@@ -560,6 +560,17 @@ function createConversationsStore() {
});
},
setProcessingForConversation: (conversationId: string, processing: boolean) => {
conversations.update((convs) => {
const conv = convs.get(conversationId);
if (conv) {
conv.isProcessing = processing;
conv.lastActivityAt = new Date();
}
return convs;
});
},
addLine: (
type: TerminalLine["type"],
content: string,
+10 -1
View File
@@ -236,9 +236,10 @@ export async function initializeTauriListeners() {
);
}
// Update character state for this conversation
// Update character state and processing state for this conversation
if (targetConversationId) {
claudeStore.setCharacterStateForConversation(targetConversationId, "idle");
claudeStore.setProcessingForConversation(targetConversationId, false);
}
} else if (status === "error") {
const targetConversationId = conversation_id || get(claudeStore.activeConversationId);
@@ -333,13 +334,21 @@ export async function initializeTauriListeners() {
}
// Always update the conversation's state
const isTerminalState =
mappedState === "idle" || mappedState === "success" || mappedState === "error";
if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
if (isTerminalState) {
claudeStore.setProcessingForConversation(conversation_id, false);
}
} else {
// Fallback to active conversation if no conversation_id
const activeConversationId = get(claudeStore.activeConversationId);
if (activeConversationId) {
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
if (isTerminalState) {
claudeStore.setProcessingForConversation(activeConversationId, false);
}
}
}