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,
};
}