fix: properly track isProcessing state to block duplicate message submission

The isProcessing field existed on conversations but was never set to true
in production code, making all submission guards effectively no-ops.

- Add setProcessingForConversation to conversations store and claude.ts
- Set isProcessing=true after send_prompt succeeds in handleSubmit and handleQuickAction
- Set isProcessing=false when claude:state emits idle, success, or error
- Add tests for the new setProcessingForConversation logic
This commit is contained in:
2026-03-09 14:20:33 -07:00
committed by Naomi Carrigan
parent 9ad88fda2d
commit 0f3ad0dbee
5 changed files with 116 additions and 0 deletions
+2
View File
@@ -339,6 +339,7 @@ User: ${formattedMessage}`;
conversationId, conversationId,
message: messageToSend, message: messageToSend,
}); });
claudeStore.setProcessing(true);
} catch (error) { } catch (error) {
console.error("Failed to send prompt:", error); console.error("Failed to send prompt:", error);
claudeStore.addLine("error", `Failed to send: ${error}`); claudeStore.addLine("error", `Failed to send: ${error}`);
@@ -793,6 +794,7 @@ User: ${formattedMessage}`;
conversationId, conversationId,
message: prompt, message: prompt,
}); });
claudeStore.setProcessing(true);
} catch (error) { } catch (error) {
console.error("Failed to send quick action:", error); console.error("Failed to send quick action:", error);
claudeStore.addLine("error", `Failed to send: ${error}`); claudeStore.addLine("error", `Failed to send: ${error}`);
+1
View File
@@ -41,6 +41,7 @@ export const claudeStore = {
setWorkingDirectory: conversationsStore.setWorkingDirectory, setWorkingDirectory: conversationsStore.setWorkingDirectory,
setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation, setWorkingDirectoryForConversation: conversationsStore.setWorkingDirectoryForConversation,
setProcessing: conversationsStore.setProcessing, setProcessing: conversationsStore.setProcessing,
setProcessingForConversation: conversationsStore.setProcessingForConversation,
addLine: conversationsStore.addLine, addLine: conversationsStore.addLine,
addLineToConversation: conversationsStore.addLineToConversation, addLineToConversation: conversationsStore.addLineToConversation,
updateLine: conversationsStore.updateLine, updateLine: conversationsStore.updateLine,
+94
View File
@@ -561,3 +561,97 @@ describe("draft text persistence", () => {
expect(conversation.draftText).toBe(""); 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: ( addLine: (
type: TerminalLine["type"], type: TerminalLine["type"],
content: string, content: string,
+8
View File
@@ -333,13 +333,21 @@ export async function initializeTauriListeners() {
} }
// Always update the conversation's state // Always update the conversation's state
const isTerminalState =
mappedState === "idle" || mappedState === "success" || mappedState === "error";
if (conversation_id) { if (conversation_id) {
claudeStore.setCharacterStateForConversation(conversation_id, mappedState); claudeStore.setCharacterStateForConversation(conversation_id, mappedState);
if (isTerminalState) {
claudeStore.setProcessingForConversation(conversation_id, false);
}
} else { } else {
// Fallback to active conversation if no conversation_id // Fallback to active conversation if no conversation_id
const activeConversationId = get(claudeStore.activeConversationId); const activeConversationId = get(claudeStore.activeConversationId);
if (activeConversationId) { if (activeConversationId) {
claudeStore.setCharacterStateForConversation(activeConversationId, mappedState); claudeStore.setCharacterStateForConversation(activeConversationId, mappedState);
if (isTerminalState) {
claudeStore.setProcessingForConversation(activeConversationId, false);
}
} }
} }