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.
This commit is contained in:
2026-05-21 18:30:43 +02:00
parent 3d9be7dfdb
commit d330b94816
3 changed files with 137 additions and 34 deletions
+49 -14
View File
@@ -10914,6 +10914,7 @@ var ChatView = class extends import_obsidian5.ItemView {
this.lastMessageEl = null; this.lastMessageEl = null;
this.newChatButton = null; this.newChatButton = null;
this.sendButton = null; this.sendButton = null;
this.stopButton = null;
this.inputEl = null; this.inputEl = null;
this.chatContainer = null; this.chatContainer = null;
this.sendButtonClickHandler = null; this.sendButtonClickHandler = null;
@@ -10923,6 +10924,7 @@ var ChatView = class extends import_obsidian5.ItemView {
this.inputKeyDownWrapper = null; this.inputKeyDownWrapper = null;
this.newChatButtonClickWrapper = null; this.newChatButtonClickWrapper = null;
this.listenersAttached = false; this.listenersAttached = false;
this.isCancelled = false;
this.modeSelectorEl = null; this.modeSelectorEl = null;
this.modelSelectorEl = null; this.modelSelectorEl = null;
this.historySelectEl = null; this.historySelectEl = null;
@@ -11074,6 +11076,7 @@ var ChatView = class extends import_obsidian5.ItemView {
} }
this.lastMessageEl = null; this.lastMessageEl = null;
this.sendButton = null; this.sendButton = null;
this.stopButton = null;
this.inputEl = null; this.inputEl = null;
this.chatContainer = null; this.chatContainer = null;
this.showLogsButton = null; this.showLogsButton = null;
@@ -11268,6 +11271,17 @@ var ChatView = class extends import_obsidian5.ItemView {
} else { } else {
inputContainer.appendChild(this.sendButton); 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) { if (this.contentEl.addClass) {
this.contentEl.addClass("ollama-chat-view-content"); this.contentEl.addClass("ollama-chat-view-content");
} else { } else {
@@ -12279,15 +12293,25 @@ ${actualMessage}` : actualMessage;
this.render(); this.render();
this.syncMessagesToSession(); this.syncMessagesToSession();
} catch (error) { } catch (error) {
ErrorHandler.handleError(error, "ChatView.handleUserInput"); if (this.isCancelled) {
const errorMsg = error instanceof Error ? error.message : String(error); this.updateMessageById(assistantMessageId, {
this.updateMessageById(assistantMessageId, { content: "Stopped.",
content: `An error occurred: ${errorMsg}`, isStreaming: false,
isStreaming: false, isThinking: false
isThinking: false });
}); this.syncMessagesToSession();
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 { } finally {
this.isCancelled = false;
this.hideActivityIndicator(); this.hideActivityIndicator();
this.cleanupStreamingResources(); this.cleanupStreamingResources();
} }
@@ -12407,9 +12431,11 @@ ${actualMessage}` : actualMessage;
textEl.textContent = text; textEl.textContent = text;
} }
this.activityIndicatorEl.style.display = "flex"; this.activityIndicatorEl.style.display = "flex";
if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { if (this.sendButton) {
this.sendButton.disabled = true; this.sendButton.style.display = "none";
this.sendButton.textContent = "Working\u2026"; }
if (this.stopButton) {
this.stopButton.style.display = "inline-flex";
} }
if (this.inputEl) { if (this.inputEl) {
this.inputEl.disabled = true; this.inputEl.disabled = true;
@@ -12418,14 +12444,23 @@ ${actualMessage}` : actualMessage;
hideActivityIndicator() { hideActivityIndicator() {
if (!this.activityIndicatorEl) return; if (!this.activityIndicatorEl) return;
this.activityIndicatorEl.style.display = "none"; this.activityIndicatorEl.style.display = "none";
if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { if (this.sendButton) {
this.sendButton.disabled = false; this.sendButton.style.display = "inline-flex";
this.sendButton.textContent = "Send"; }
if (this.stopButton) {
this.stopButton.style.display = "none";
} }
if (this.inputEl) { if (this.inputEl) {
this.inputEl.disabled = false; 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) { createOllamaClient(model, settings) {
return new OllamaClient(settings.ollamaUrl, model, void 0, settings.cacheConfig); return new OllamaClient(settings.ollamaUrl, model, void 0, settings.cacheConfig);
} }
+63 -20
View File
@@ -202,6 +202,7 @@ export class ChatView extends ItemView {
} }
this.lastMessageEl = null; this.lastMessageEl = null;
this.sendButton = null; this.sendButton = null;
this.stopButton = null;
this.inputEl = null; this.inputEl = null;
this.chatContainer = null; this.chatContainer = null;
this.showLogsButton = null; this.showLogsButton = null;
@@ -435,6 +436,19 @@ export class ChatView extends ItemView {
inputContainer.appendChild(this.sendButton); 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 // Append containers to contentEl
if (this.contentEl.addClass) { if (this.contentEl.addClass) {
this.contentEl.addClass('ollama-chat-view-content'); this.contentEl.addClass('ollama-chat-view-content');
@@ -1015,7 +1029,7 @@ export class ChatView extends ItemView {
// Store pending state for apply/cancel // Store pending state for apply/cancel
this.pendingActions = writePreviews; this.pendingActions = writePreviews;
this.pendingReadResults = readResults; this.pendingReadResults = readResults;
this.pendingFollowUpContext = { messages, tools, assistantMessageId }; this.pendingFollowUpContext = { messages, tools, assistantMessageId, allToolCalls: toolCalls, assistantText: fullResponse };
this.updateMessageById(assistantMessageId, { this.updateMessageById(assistantMessageId, {
content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`, content: `${fullResponse}\n\n*Proposed actions:*\n${writePreviews.map((a) => `- ${a.description}`).join('\n')}`,
@@ -1112,7 +1126,7 @@ export class ChatView extends ItemView {
return; return;
} }
const { messages, tools, assistantMessageId } = this.pendingFollowUpContext; const { messages, tools, assistantMessageId, allToolCalls, assistantText } = this.pendingFollowUpContext;
// Execute write tools // Execute write tools
const writeResults = ( const writeResults = (
@@ -1139,8 +1153,8 @@ export class ChatView extends ItemView {
const followUp: OllamaMessage = { const followUp: OllamaMessage = {
role: 'assistant', role: 'assistant',
content: 'I have processed your request using the following tools. Here are the results:', content: assistantText,
tool_calls: this.pendingActions.map((a) => a.toolCall), tool_calls: allToolCalls,
}; };
if (followUpMessages.length > 0) { if (followUpMessages.length > 0) {
@@ -1535,7 +1549,9 @@ export class ChatView extends ItemView {
this.showActivityIndicator('Thinking…'); this.showActivityIndicator('Thinking…');
const nudgeMessages: OllamaMessage[] = [ const nudgeMessages: OllamaMessage[] = [
...messagesWithMemory, ...messagesWithMemory,
...(priorResponse.trim() ? [{ role: 'assistant' as const, content: priorResponse }] : []), ...(priorResponse.trim()
? [{ role: 'assistant' as const, content: priorResponse }]
: []),
{ {
role: 'user', role: 'user',
content: content:
@@ -1641,16 +1657,27 @@ export class ChatView extends ItemView {
this.render(); this.render();
this.syncMessagesToSession(); this.syncMessagesToSession();
} catch (error) { } catch (error) {
ErrorHandler.handleError(error, 'ChatView.handleUserInput'); if (this.isCancelled) {
const errorMsg = error instanceof Error ? error.message : String(error); // User-initiated stop — show clean message, not an error
this.updateMessageById(assistantMessageId, { this.updateMessageById(assistantMessageId, {
content: `An error occurred: ${errorMsg}`, content: 'Stopped.',
isStreaming: false, isStreaming: false,
isThinking: false, isThinking: false,
}); });
this.syncMessagesToSession(); 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 { } finally {
// Clean up streaming resources regardless of outcome // Clean up streaming resources regardless of outcome
this.isCancelled = false;
this.hideActivityIndicator(); this.hideActivityIndicator();
this.cleanupStreamingResources(); this.cleanupStreamingResources();
} }
@@ -1797,6 +1824,7 @@ export class ChatView extends ItemView {
private lastMessageEl: HTMLElement | null = null; private lastMessageEl: HTMLElement | null = null;
private newChatButton: HTMLElement | null = null; private newChatButton: HTMLElement | null = null;
private sendButton: HTMLElement | null = null; private sendButton: HTMLElement | null = null;
private stopButton: HTMLElement | null = null;
private inputEl: HTMLTextAreaElement | null = null; private inputEl: HTMLTextAreaElement | null = null;
private chatContainer: HTMLElement | null = null; private chatContainer: HTMLElement | null = null;
private sendButtonClickHandler: (() => void) | null = null; private sendButtonClickHandler: (() => void) | null = null;
@@ -1806,6 +1834,7 @@ export class ChatView extends ItemView {
private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null; private inputKeyDownWrapper: ((event: KeyboardEvent) => void) | null = null;
private newChatButtonClickWrapper: (() => void) | null = null; private newChatButtonClickWrapper: (() => void) | null = null;
private listenersAttached: boolean = false; private listenersAttached: boolean = false;
private isCancelled: boolean = false;
private settings: PluginSettings; private settings: PluginSettings;
private ollamaClient: OllamaClient; private ollamaClient: OllamaClient;
private agentOllamaClient: OllamaClient; private agentOllamaClient: OllamaClient;
@@ -1832,6 +1861,8 @@ export class ChatView extends ItemView {
messages: OllamaMessage[]; messages: OllamaMessage[];
tools: OllamaTool[]; tools: OllamaTool[];
assistantMessageId: string; assistantMessageId: string;
allToolCalls: OllamaToolCall[];
assistantText: string;
} | null = null; } | null = null;
// Auto-scroll & logs UI // Auto-scroll & logs UI
@@ -1853,9 +1884,11 @@ export class ChatView extends ItemView {
textEl.textContent = text; textEl.textContent = text;
} }
this.activityIndicatorEl.style.display = 'flex'; this.activityIndicatorEl.style.display = 'flex';
if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { if (this.sendButton) {
this.sendButton.disabled = true; this.sendButton.style.display = 'none';
this.sendButton.textContent = 'Working…'; }
if (this.stopButton) {
this.stopButton.style.display = 'inline-flex';
} }
if (this.inputEl) { if (this.inputEl) {
this.inputEl.disabled = true; this.inputEl.disabled = true;
@@ -1865,15 +1898,25 @@ export class ChatView extends ItemView {
private hideActivityIndicator(): void { private hideActivityIndicator(): void {
if (!this.activityIndicatorEl) return; if (!this.activityIndicatorEl) return;
this.activityIndicatorEl.style.display = 'none'; this.activityIndicatorEl.style.display = 'none';
if (this.sendButton && this.sendButton instanceof HTMLButtonElement) { if (this.sendButton) {
this.sendButton.disabled = false; this.sendButton.style.display = 'inline-flex';
this.sendButton.textContent = 'Send'; }
if (this.stopButton) {
this.stopButton.style.display = 'none';
} }
if (this.inputEl) { if (this.inputEl) {
this.inputEl.disabled = false; 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 { private createOllamaClient(model: string, settings: PluginSettings): OllamaClient {
return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig); return new OllamaClient(settings.ollamaUrl, model, undefined, settings.cacheConfig);
} }
@@ -1896,7 +1939,7 @@ export class ChatView extends ItemView {
} }
private isAgenticMode(mode: AgentMode): boolean { 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 { private renderLogEntry(entry: LogEntry, container: HTMLElement): void {
+25
View File
@@ -181,6 +181,31 @@
filter: brightness(0.95); 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 */ /* Thinking indicator */
.ollama-thinking-indicator { .ollama-thinking-indicator {
display: inline-flex; display: inline-flex;