From dc40f6b530f1a7647cf9a1e452006f4f10f7f0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ad=C3=A1mek?= Date: Tue, 7 Apr 2026 13:28:23 +0200 Subject: [PATCH 1/3] feat(llm): add stop generation button Allow users to stop an in-progress LLM generation by aborting the SSE connection. The send button transforms into a red stop button during streaming. - AbortController passed to fetch() signal for stream cancellation - On abort, partial content is finalized and saved as a message - Stop button replaces send button during streaming with danger color - Button is always clickable during streaming (not disabled) --- apps/client/src/services/llm_chat.ts | 6 ++- .../src/translations/en/translation.json | 3 +- .../type_widgets/llm_chat/ChatInputBar.css | 4 ++ .../type_widgets/llm_chat/ChatInputBar.tsx | 10 ++-- .../type_widgets/llm_chat/useLlmChat.ts | 53 ++++++++++++++++++- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/apps/client/src/services/llm_chat.ts b/apps/client/src/services/llm_chat.ts index fa0a0279d3..cd6ab3e63f 100644 --- a/apps/client/src/services/llm_chat.ts +++ b/apps/client/src/services/llm_chat.ts @@ -27,7 +27,8 @@ export interface StreamCallbacks { export async function streamChatCompletion( messages: LlmMessage[], config: LlmChatConfig, - callbacks: StreamCallbacks + callbacks: StreamCallbacks, + abortSignal?: AbortSignal ): Promise { const headers = await server.getHeaders(); @@ -37,7 +38,8 @@ export async function streamChatCompletion( ...headers, "Content-Type": "application/json" } as HeadersInit, - body: JSON.stringify({ messages, config }) + body: JSON.stringify({ messages, config }), + signal: abortSignal }); if (!response.ok) { diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 9605212f6e..cb1f825157 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1660,7 +1660,8 @@ "note_context_enabled": "Click to disable note context: {{title}}", "note_context_disabled": "Click to include current note in context", "no_provider_message": "No AI provider configured. Add one to start chatting.", - "add_provider": "Add AI Provider" + "add_provider": "Add AI Provider", + "stop": "Stop" }, "sidebar_chat": { "title": "AI Chat", diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css index 4599e6a511..07adaacf3c 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.css @@ -48,6 +48,10 @@ opacity: 0.4; } +.llm-chat-stop-btn { + color: var(--danger-color, #dc3545); +} + /* Model selector */ .llm-chat-model-selector { display: flex; diff --git a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx index 6491a595b0..b4515d2bb4 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx +++ b/apps/client/src/widgets/type_widgets/llm_chat/ChatInputBar.tsx @@ -228,11 +228,11 @@ export default function ChatInputBar({ )} diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index 63cbf4bbf4..b481a2e93a 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -62,6 +62,8 @@ export interface UseLlmChatReturn { clearMessages: () => void; /** Refresh the provider/models list */ refreshModels: () => void; + /** Stop the current generation */ + stopStreaming: () => void; } export function useLlmChat( @@ -89,6 +91,7 @@ export function useLlmChat( const [isCheckingProvider, setIsCheckingProvider] = useState(true); const messagesEndRef = useRef(null); const textareaRef = useRef(null); + const abortControllerRef = useRef(null); // Refs to get fresh values in getContent (avoids stale closures) const messagesRef = useRef(messages); @@ -251,6 +254,9 @@ export function useLlmChat( streamOptions.enableExtendedThinking = enableExtendedThinking; } + const abortController = new AbortController(); + abortControllerRef.current = abortController; + await streamChatCompletion( apiMessages, streamOptions, @@ -353,9 +359,44 @@ export function useLlmChat( setStreamingThinking(""); setPendingCitations([]); setIsStreaming(false); + abortControllerRef.current = null; } + }, + abortController.signal + ).catch((e) => { + // AbortError is expected when user stops generation + if (e instanceof DOMException && e.name === "AbortError") { + // Finalize whatever we have so far + const finalNewMessages: StoredMessage[] = []; + if (thinkingContent) { + finalNewMessages.push({ + id: randomString(), + role: "assistant", + content: thinkingContent, + createdAt: new Date().toISOString(), + type: "thinking" + }); + } + if (contentBlocks.length > 0) { + finalNewMessages.push({ + id: randomString(), + role: "assistant", + content: contentBlocks, + createdAt: new Date().toISOString(), + citations: citations.length > 0 ? citations : undefined + }); + } + if (finalNewMessages.length > 0) { + setMessages([...newMessages, ...finalNewMessages]); + } + setStreamingContent(""); + setStreamingBlocks([]); + setStreamingThinking(""); + setPendingCitations([]); + setIsStreaming(false); + abortControllerRef.current = null; } - ); + }); }, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]); const handleKeyDown = useCallback((e: KeyboardEvent) => { @@ -365,6 +406,13 @@ export function useLlmChat( } }, [handleSubmit]); + /** Stop the current generation by aborting the SSE connection. */ + const stopStreaming = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }, []); + return { // State messages, @@ -402,6 +450,7 @@ export function useLlmChat( loadFromContent, getContent, clearMessages, - refreshModels + refreshModels, + stopStreaming }; } From 01bee95833b90f956353cf45e8ab47521d908ae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Ad=C3=A1mek?= Date: Wed, 8 Apr 2026 16:17:37 +0200 Subject: [PATCH 2/3] fix: extract finalizeStream helper, re-throw non-AbortError exceptions - Extract duplicated cleanup logic into shared finalizeStream() function - Add else branch to re-throw non-AbortError exceptions instead of swallowing them --- .../type_widgets/llm_chat/useLlmChat.ts | 105 +++++++----------- 1 file changed, 42 insertions(+), 63 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index b481a2e93a..be522928bb 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -257,6 +257,43 @@ export function useLlmChat( const abortController = new AbortController(); abortControllerRef.current = abortController; + /** Shared cleanup: finalize collected content and reset streaming state. */ + function finalizeStream() { + const finalNewMessages: StoredMessage[] = []; + + if (thinkingContent) { + finalNewMessages.push({ + id: randomString(), + role: "assistant", + content: thinkingContent, + createdAt: new Date().toISOString(), + type: "thinking" + }); + } + + if (contentBlocks.length > 0) { + finalNewMessages.push({ + id: randomString(), + role: "assistant", + content: contentBlocks, + createdAt: new Date().toISOString(), + citations: citations.length > 0 ? citations : undefined, + usage + }); + } + + if (finalNewMessages.length > 0) { + setMessages([...newMessages, ...finalNewMessages]); + } + + setStreamingContent(""); + setStreamingBlocks([]); + setStreamingThinking(""); + setPendingCitations([]); + setIsStreaming(false); + abortControllerRef.current = null; + } + await streamChatCompletion( apiMessages, streamOptions, @@ -326,75 +363,17 @@ export function useLlmChat( setIsStreaming(false); }, onDone: () => { - const finalNewMessages: StoredMessage[] = []; - - if (thinkingContent) { - finalNewMessages.push({ - id: randomString(), - role: "assistant", - content: thinkingContent, - createdAt: new Date().toISOString(), - type: "thinking" - }); - } - - if (contentBlocks.length > 0) { - finalNewMessages.push({ - id: randomString(), - role: "assistant", - content: contentBlocks, - createdAt: new Date().toISOString(), - citations: citations.length > 0 ? citations : undefined, - usage - }); - } - - if (finalNewMessages.length > 0) { - const allMessages = [...newMessages, ...finalNewMessages]; - setMessages(allMessages); - } - - setStreamingContent(""); - setStreamingBlocks([]); - setStreamingThinking(""); - setPendingCitations([]); - setIsStreaming(false); - abortControllerRef.current = null; + finalizeStream(); } }, abortController.signal ).catch((e) => { // AbortError is expected when user stops generation if (e instanceof DOMException && e.name === "AbortError") { - // Finalize whatever we have so far - const finalNewMessages: StoredMessage[] = []; - if (thinkingContent) { - finalNewMessages.push({ - id: randomString(), - role: "assistant", - content: thinkingContent, - createdAt: new Date().toISOString(), - type: "thinking" - }); - } - if (contentBlocks.length > 0) { - finalNewMessages.push({ - id: randomString(), - role: "assistant", - content: contentBlocks, - createdAt: new Date().toISOString(), - citations: citations.length > 0 ? citations : undefined - }); - } - if (finalNewMessages.length > 0) { - setMessages([...newMessages, ...finalNewMessages]); - } - setStreamingContent(""); - setStreamingBlocks([]); - setStreamingThinking(""); - setPendingCitations([]); - setIsStreaming(false); - abortControllerRef.current = null; + finalizeStream(); + } else { + // Re-throw other errors so they are not swallowed + throw e; } }); }, [input, isStreaming, messages, selectedModel, enableWebSearch, enableNoteTools, enableExtendedThinking, contextNoteId, supportsExtendedThinking, setMessages]); From 175e200d8864298166dfbb2a154af31389258752 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Sun, 12 Apr 2026 00:47:43 +0300 Subject: [PATCH 3/3] fix(llm): stopping a tool call leaves an infinite spinner --- .../src/widgets/type_widgets/llm_chat/useLlmChat.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts index be522928bb..f52fb6cc61 100644 --- a/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts +++ b/apps/client/src/widgets/type_widgets/llm_chat/useLlmChat.ts @@ -259,6 +259,16 @@ export function useLlmChat( /** Shared cleanup: finalize collected content and reset streaming state. */ function finalizeStream() { + // Mark any in-progress tool calls as stopped so they don't show infinite spinners + for (const [i, block] of contentBlocks.entries()) { + if (block.type === "tool_call" && !block.toolCall.result) { + contentBlocks[i] = { + type: "tool_call", + toolCall: { ...block.toolCall, result: "[Stopped]", isError: true } + }; + } + } + const finalNewMessages: StoredMessage[] = []; if (thinkingContent) {