From a569731705558bd7631b12cf7e2562e63901ef2f Mon Sep 17 00:00:00 2001 From: amhunter1 Date: Mon, 20 Apr 2026 15:41:47 +0300 Subject: [PATCH] feat: implement robust chat cancellation with AbortController and UI sync --- renderer/css/style.css | 17 +++++++++ renderer/index.html | 1 + renderer/js/panel/bootstrap.js | 10 ++++++ renderer/js/panel/messaging.js | 63 ++++++++++++++++++++++++++++++++-- 4 files changed, 89 insertions(+), 2 deletions(-) diff --git a/renderer/css/style.css b/renderer/css/style.css index b6978eb..714217f 100644 --- a/renderer/css/style.css +++ b/renderer/css/style.css @@ -762,6 +762,23 @@ html, body { .send-btn:active, .ptt-btn:active { transform: translateY(0); } .send-btn:disabled { background: var(--text-muted); cursor: not-allowed; opacity: 0.5; } +.send-btn.hidden { display: none !important; } + +.stop-btn { + width: 42px; height: 42px; + background: var(--surface2); + border: 1px solid var(--border); + border-radius: 2px; + color: #ef4444; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + font-size: 18px; + transition: all 0.15s; + flex-shrink: 0; +} +.stop-btn:hover { background: rgba(239, 68, 68, 0.15); border-color: #ef4444; } +.stop-btn:active { transform: translateY(0); } +.stop-btn.hidden { display: none !important; } /* ── Bottom toolbar ──────────────────────────────────────────────────────── */ .ptt-btn .waveform { diff --git a/renderer/index.html b/renderer/index.html index 1f17776..3cd7e7e 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -89,6 +89,7 @@ + diff --git a/renderer/js/panel/bootstrap.js b/renderer/js/panel/bootstrap.js index 0bef1f4..2ab6240 100644 --- a/renderer/js/panel/bootstrap.js +++ b/renderer/js/panel/bootstrap.js @@ -73,6 +73,12 @@ export function createPanelController({ } function bindEvents() { + dom.textInput.addEventListener("focus", () => { + if (state.isStreaming()) { + messaging.cancelMessage(); + } + }); + dom.textInput.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); @@ -86,6 +92,10 @@ export function createPanelController({ }); dom.sendBtn.addEventListener("click", messaging.sendMessage); + const stopBtn = doc.getElementById("stop-btn"); + if (stopBtn) { + stopBtn.addEventListener("click", messaging.cancelMessage); + } dom.btnPlanPrev.addEventListener("click", () => api.invoke("previous-step")); dom.btnPlanDone.addEventListener("click", () => api.invoke("mark-step-done")); dom.btnPlanSkip.addEventListener("click", () => api.invoke("skip-current-step")); diff --git a/renderer/js/panel/messaging.js b/renderer/js/panel/messaging.js index 851b95e..30254ac 100644 --- a/renderer/js/panel/messaging.js +++ b/renderer/js/panel/messaging.js @@ -7,6 +7,38 @@ export function createMessagingController({ ui, }) { let syncQueue = Promise.resolve(); + let currentAbortController = null; + let requestTimeoutId = null; + + function cancelMessage() { + if (!state.isStreaming()) return; + + if (currentAbortController) { + currentAbortController.abort(); + currentAbortController = null; + } + + if (requestTimeoutId) { + clearTimeout(requestTimeoutId); + requestTimeoutId = null; + } + + log("ai:cancel-message invoke"); + api.invoke("abort-message").catch(err => log("ipc:abort-message error", err)); + + state.setStreaming(false); + dom.sendBtn.classList.remove("hidden"); + const stopBtn = doc.getElementById("stop-btn"); + if (stopBtn) stopBtn.classList.add("hidden"); + dom.sendBtn.disabled = false; + + ui.renderAgentState("idle"); + ui.removeAllTypingIndicators(); + + if (state.getStreamingBubble()) { + state.clearStreamingSession(); + } + } function syncSession(session) { syncQueue = syncQueue.then(() => syncSessionInternal(session)).catch((error) => { @@ -93,9 +125,23 @@ export function createMessagingController({ dom.textInput.value = ""; dom.textInput.style.height = "auto"; state.setStreaming(true); + + dom.sendBtn.classList.add("hidden"); + const stopBtn = doc.getElementById("stop-btn"); + if (stopBtn) stopBtn.classList.remove("hidden"); dom.sendBtn.disabled = true; + ui.renderAgentState("thinking"); + currentAbortController = new AbortController(); + requestTimeoutId = window.setTimeout(() => { + if (state.isStreaming()) { + log("ai:send-message timeout 60s triggered"); + cancelMessage(); + ui.showToast("Request timed out", true); + } + }, 60000); + let typingId = null; log("ai:send-message start", { hasImages: Boolean(images && images.length), @@ -128,8 +174,6 @@ export function createMessagingController({ } catch (error) { onAIError(error.message); } finally { - state.setStreaming(false); - dom.sendBtn.disabled = false; if (typingId !== null) { ui.removeTypingIndicator(typingId); } @@ -151,7 +195,14 @@ export function createMessagingController({ function onAIDone(parsed) { const result = parsed || {}; + if (requestTimeoutId) { + clearTimeout(requestTimeoutId); + requestTimeoutId = null; + } state.setStreaming(false); + dom.sendBtn.classList.remove("hidden"); + const stopBtn = doc.getElementById("stop-btn"); + if (stopBtn) stopBtn.classList.add("hidden"); dom.sendBtn.disabled = false; ui.renderAgentState("idle"); @@ -189,7 +240,14 @@ export function createMessagingController({ ? { message: errorMessage, code: "unknown_error", action: "open-settings", requestId: "" } : (errorMessage || {}); const safeMessage = payload.message || "Unexpected error"; + if (requestTimeoutId) { + clearTimeout(requestTimeoutId); + requestTimeoutId = null; + } state.setStreaming(false); + dom.sendBtn.classList.remove("hidden"); + const stopBtn = doc.getElementById("stop-btn"); + if (stopBtn) stopBtn.classList.add("hidden"); dom.sendBtn.disabled = false; ui.renderAgentState("idle"); ui.removeAllTypingIndicators(); @@ -222,5 +280,6 @@ export function createMessagingController({ onAIDone, onAIError, sendMessage, + cancelMessage, }; }