From d330b9481663e9a3e0cf7f6647d4a11a3804f059 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Thu, 21 May 2026 18:30:43 +0200 Subject: [PATCH] Add stop button to cancel in-flight requests Swap the Send button for a Stop button while a response is streaming, letting users abort the current operation cleanly. Cancelled requests now display "Stopped." instead of an error message. Also includes two smaller fixes: preserve the original assistant text and full tool_calls array when executing write tools for follow-up context, and add 'research' to agentic modes. --- main.js | 63 ++++++++++++++++++++++++++++-------- src/chat-view.ts | 83 ++++++++++++++++++++++++++++++++++++------------ styles.css | 25 +++++++++++++++ 3 files changed, 137 insertions(+), 34 deletions(-) diff --git a/main.js b/main.js index a8c7b46..957ad28 100644 --- a/main.js +++ b/main.js @@ -10914,6 +10914,7 @@ var ChatView = class extends import_obsidian5.ItemView { this.lastMessageEl = null; this.newChatButton = null; this.sendButton = null; + this.stopButton = null; this.inputEl = null; this.chatContainer = null; this.sendButtonClickHandler = null; @@ -10923,6 +10924,7 @@ var ChatView = class extends import_obsidian5.ItemView { this.inputKeyDownWrapper = null; this.newChatButtonClickWrapper = null; this.listenersAttached = false; + this.isCancelled = false; this.modeSelectorEl = null; this.modelSelectorEl = null; this.historySelectEl = null; @@ -11074,6 +11076,7 @@ var ChatView = class extends import_obsidian5.ItemView { } this.lastMessageEl = null; this.sendButton = null; + this.stopButton = null; this.inputEl = null; this.chatContainer = null; this.showLogsButton = null; @@ -11268,6 +11271,17 @@ var ChatView = class extends import_obsidian5.ItemView { } else { inputContainer.appendChild(this.sendButton); } + if (!this.stopButton) { + this.stopButton = inputContainer.createEl("button", { + cls: "ollama-stop-button", + text: "Stop" + }); + this.stopButton.addEventListener("click", () => { + this.cancelCurrentOperation(); + }); + } + inputContainer.appendChild(this.stopButton); + this.stopButton.style.display = "none"; if (this.contentEl.addClass) { this.contentEl.addClass("ollama-chat-view-content"); } else { @@ -12279,15 +12293,25 @@ ${actualMessage}` : actualMessage; this.render(); this.syncMessagesToSession(); } catch (error) { - ErrorHandler.handleError(error, "ChatView.handleUserInput"); - const errorMsg = error instanceof Error ? error.message : String(error); - this.updateMessageById(assistantMessageId, { - content: `An error occurred: ${errorMsg}`, - isStreaming: false, - isThinking: false - }); - this.syncMessagesToSession(); + if (this.isCancelled) { + this.updateMessageById(assistantMessageId, { + content: "Stopped.", + isStreaming: false, + isThinking: false + }); + this.syncMessagesToSession(); + } else { + ErrorHandler.handleError(error, "ChatView.handleUserInput"); + const errorMsg = error instanceof Error ? error.message : String(error); + this.updateMessageById(assistantMessageId, { + content: `An error occurred: ${errorMsg}`, + isStreaming: false, + isThinking: false + }); + this.syncMessagesToSession(); + } } finally { + this.isCancelled = false; this.hideActivityIndicator(); this.cleanupStreamingResources(); } @@ -12407,9 +12431,11 @@ ${actualMessage}` : actualMessage; textEl.textContent = text; } this.activityIndicatorEl.style.display = "flex"; - if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { - this.sendButton.disabled = true; - this.sendButton.textContent = "Working\u2026"; + if (this.sendButton) { + this.sendButton.style.display = "none"; + } + if (this.stopButton) { + this.stopButton.style.display = "inline-flex"; } if (this.inputEl) { this.inputEl.disabled = true; @@ -12418,14 +12444,23 @@ ${actualMessage}` : actualMessage; hideActivityIndicator() { if (!this.activityIndicatorEl) return; this.activityIndicatorEl.style.display = "none"; - if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { - this.sendButton.disabled = false; - this.sendButton.textContent = "Send"; + if (this.sendButton) { + this.sendButton.style.display = "inline-flex"; + } + if (this.stopButton) { + this.stopButton.style.display = "none"; } if (this.inputEl) { this.inputEl.disabled = false; + this.inputEl.focus(); } } + cancelCurrentOperation() { + this.isCancelled = true; + this.ollamaClient.cancelStream(); + this.agentOllamaClient.cancelStream(); + new import_obsidian5.Notice("Stopping\u2026"); + } createOllamaClient(model, settings) { return new OllamaClient(settings.ollamaUrl, model, void 0, settings.cacheConfig); } diff --git a/src/chat-view.ts b/src/chat-view.ts index 0df601f..ef0f990 100755 --- a/src/chat-view.ts +++ b/src/chat-view.ts @@ -202,6 +202,7 @@ export class ChatView extends ItemView { } this.lastMessageEl = null; this.sendButton = null; + this.stopButton = null; this.inputEl = null; this.chatContainer = null; this.showLogsButton = null; @@ -435,6 +436,19 @@ export class ChatView extends ItemView { inputContainer.appendChild(this.sendButton); } + // Setup stop button + if (!this.stopButton) { + this.stopButton = inputContainer.createEl('button', { + cls: 'ollama-stop-button', + text: 'Stop', + }); + this.stopButton.addEventListener('click', () => { + this.cancelCurrentOperation(); + }); + } + inputContainer.appendChild(this.stopButton); + this.stopButton.style.display = 'none'; + // Append containers to contentEl if (this.contentEl.addClass) { this.contentEl.addClass('ollama-chat-view-content'); @@ -1015,7 +1029,7 @@ export class ChatView extends ItemView { // Store pending state for apply/cancel this.pendingActions = writePreviews; this.pendingReadResults = readResults; - this.pendingFollowUpContext = { messages, tools, assistantMessageId }; + this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse }; this.updateMessageById(assistantMessageId, { content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`, @@ -1112,7 +1126,7 @@ export class ChatView extends ItemView { return; } - const { messages, tools, assistantMessageId } = this.pendingFollowUpContext; + const { messages, tools, assistantMessageId, allToolCalls, assistantText } = this.pendingFollowUpContext; // Execute write tools const writeResults = ( @@ -1139,8 +1153,8 @@ export class ChatView extends ItemView { const followUp: OllamaMessage = { role: 'assistant', - content: 'I have processed your request using the following tools. Here are the results:', - tool_calls: this.pendingActions.map((a) => a.toolCall), + content: assistantText, + tool_calls: allToolCalls, }; if (followUpMessages.length > 0) { @@ -1535,7 +1549,9 @@ export class ChatView extends ItemView { this.showActivityIndicator('Thinking…'); const nudgeMessages: OllamaMessage[] = [ ...messagesWithMemory, - ...(priorResponse.trim() ? [{ role: 'assistant' as const, content: priorResponse }] : []), + ...(priorResponse.trim() + ? [{ role: 'assistant' as const, content: priorResponse }] + : []), { role: 'user', content: @@ -1641,16 +1657,27 @@ export class ChatView extends ItemView { this.render(); this.syncMessagesToSession(); } catch (error) { - ErrorHandler.handleError(error, 'ChatView.handleUserInput'); - const errorMsg = error instanceof Error ? error.message : String(error); - this.updateMessageById(assistantMessageId, { - content: `An error occurred: ${errorMsg}`, - isStreaming: false, - isThinking: false, - }); - this.syncMessagesToSession(); + if (this.isCancelled) { + // User-initiated stop — show clean message, not an error + this.updateMessageById(assistantMessageId, { + content: 'Stopped.', + isStreaming: false, + isThinking: false, + }); + this.syncMessagesToSession(); + } else { + ErrorHandler.handleError(error, 'ChatView.handleUserInput'); + const errorMsg = error instanceof Error ? error.message : String(error); + this.updateMessageById(assistantMessageId, { + content: `An error occurred: ${errorMsg}`, + isStreaming: false, + isThinking: false, + }); + this.syncMessagesToSession(); + } } finally { // Clean up streaming resources regardless of outcome + this.isCancelled = false; this.hideActivityIndicator(); this.cleanupStreamingResources(); } @@ -1797,6 +1824,7 @@ export class ChatView extends ItemView { private lastMessageEl: HTMLElement | null = null; private newChatButton: HTMLElement | null = null; private sendButton: HTMLElement | null = null; + private stopButton: HTMLElement | null = null; private inputEl: HTMLTextAreaElement | null = null; private chatContainer: HTMLElement | null = null; private sendButtonClickHandler: (() => void) | null = null; @@ -1806,6 +1834,7 @@ export class ChatView extends ItemView { private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null; private newChatButtonClickWrapper: (() => void) | null = null; private listenersAttached: boolean = false; + private isCancelled: boolean = false; private settings: PluginSettings; private ollamaClient: OllamaClient; private agentOllamaClient: OllamaClient; @@ -1832,6 +1861,8 @@ export class ChatView extends ItemView { messages: OllamaMessage[]; tools: OllamaTool[]; assistantMessageId: string; + allToolCalls: OllamaToolCall[]; + assistantText: string; } | null = null; // Auto-scroll & logs UI @@ -1853,9 +1884,11 @@ export class ChatView extends ItemView { textEl.textContent = text; } this.activityIndicatorEl.style.display = 'flex'; - if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { - this.sendButton.disabled = true; - this.sendButton.textContent = 'Working…'; + if (this.sendButton) { + this.sendButton.style.display = 'none'; + } + if (this.stopButton) { + this.stopButton.style.display = 'inline-flex'; } if (this.inputEl) { this.inputEl.disabled = true; @@ -1865,15 +1898,25 @@ export class ChatView extends ItemView { private hideActivityIndicator(): void { if (!this.activityIndicatorEl) return; this.activityIndicatorEl.style.display = 'none'; - if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { - this.sendButton.disabled = false; - this.sendButton.textContent = 'Send'; + if (this.sendButton) { + this.sendButton.style.display = 'inline-flex'; + } + if (this.stopButton) { + this.stopButton.style.display = 'none'; } if (this.inputEl) { this.inputEl.disabled = false; + this.inputEl.focus(); } } + private cancelCurrentOperation(): void { + this.isCancelled = true; + this.ollamaClient.cancelStream(); + this.agentOllamaClient.cancelStream(); + new Notice('Stopping…'); + } + private createOllamaClient(model: string, settings: PluginSettings): OllamaClient { return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig); } @@ -1896,7 +1939,7 @@ export class ChatView extends ItemView { } private isAgenticMode(mode: AgentMode): boolean { - return mode === 'edit' || mode === 'organize' || mode === 'workflow'; + return mode === 'edit' || mode === 'organize' || mode === 'research' || mode === 'workflow'; } private renderLogEntry(entry: LogEntry, container: HTMLElement): void { diff --git a/styles.css b/styles.css index c529f2a..2defb2f 100644 --- a/styles.css +++ b/styles.css @@ -181,6 +181,31 @@ filter: brightness(0.95); } +/* Stop button */ +.ollama-stop-button { + display: none; + align-items: center; + justify-content: center; + padding: var(--size-4-1) var(--size-4-3); + border-radius: var(--ollama-radius); + border: none; + background-color: var(--text-error); + color: var(--text-on-accent); + font-weight: var(--font-semibold); + font-size: var(--font-ui-small); + cursor: pointer; + transition: filter 0.15s ease; + white-space: nowrap; +} + +.ollama-stop-button:hover { + filter: brightness(1.1); +} + +.ollama-stop-button:active { + filter: brightness(0.95); +} + /* Thinking indicator */ .ollama-thinking-indicator { display: inline-flex;