Repository: ggml-org/llama.cpp
Pull request: webui: Stop generation from chat sidebar

Close #17805

## Demo

### Default

<img width="310" height="115" alt="Zrzut ekranu 2025-12-5 o 23 20 38" src="https://github.com/user-attachments/assets/f4964acb-a7e9-4f84-b6fa-e77fb168211a" />

### Hover Conversation Item

<img width="296" height="127" alt="Zrzut ekranu 2025-12-5 o 23 20 41" src="https://github.com/user-attachments/assets/3028b51b-07b8-4cfa-bfb1-8bc80ebfc2d6" />

### Hover Stop Icon

<img width="294" height="142" alt="Zrzut ekranu 2025-12-5 o 23 20 45" src="https://github.com/user-attachments/assets/8f2034d3-2808-4cfb-b65e-43f642685179" />


Changed files:
filename: tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
patch: @@ -8,6 +8,7 @@
 	import * as AlertDialog from '$lib/components/ui/alert-dialog';
 	import Input from '$lib/components/ui/input/input.svelte';
 	import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+	import { chatStore } from '$lib/stores/chat.svelte';
 	import ChatSidebarActions from './ChatSidebarActions.svelte';
 
 	const sidebar = Sidebar.useSidebar();
@@ -98,6 +99,10 @@
 
 		await goto(`#/chat/${id}`);
 	}
+
+	function handleStopGeneration(id: string) {
+		chatStore.stopGenerationForChat(id);
+	}
 </script>
 
 <ScrollArea class="h-[100vh]">
@@ -132,6 +137,7 @@
 							onSelect={selectConversation}
 							onEdit={handleEditConversation}
 							onDelete={handleDeleteConversation}
+							onStop={handleStopGeneration}
 						/>
 					</Sidebar.MenuItem>
 				{/each}

filename: tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
patch: @@ -1,6 +1,7 @@
 <script lang="ts">
-	import { Trash2, Pencil, MoreHorizontal, Download, Loader2 } from '@lucide/svelte';
+	import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte';
 	import { ActionDropdown } from '$lib/components/app';
+	import * as Tooltip from '$lib/components/ui/tooltip';
 	import { getAllLoadingChats } from '$lib/stores/chat.svelte';
 	import { conversationsStore } from '$lib/stores/conversations.svelte';
 	import { onMount } from 'svelte';
@@ -12,6 +13,7 @@
 		onDelete?: (id: string) => void;
 		onEdit?: (id: string) => void;
 		onSelect?: (id: string) => void;
+		onStop?: (id: string) => void;
 	}
 
 	let {
@@ -20,6 +22,7 @@
 		onDelete,
 		onEdit,
 		onSelect,
+		onStop,
 		isActive = false
 	}: Props = $props();
 
@@ -38,8 +41,14 @@
 		onDelete?.(conversation.id);
 	}
 
+	function handleStop(event: Event) {
+		event.stopPropagation();
+		onStop?.(conversation.id);
+	}
+
 	function handleGlobalEditEvent(event: Event) {
 		const customEvent = event as CustomEvent<{ conversationId: string }>;
+
 		if (customEvent.detail.conversationId === conversation.id && isActive) {
 			handleEdit(event);
 		}
@@ -88,8 +97,28 @@
 >
 	<div class="flex min-w-0 flex-1 items-center gap-2">
 		{#if isLoading}
-			<Loader2 class="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
+			<Tooltip.Root>
+				<Tooltip.Trigger>
+					<div
+						class="stop-button flex h-4 w-4 shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
+						onclick={handleStop}
+						onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
+						role="button"
+						tabindex="0"
+						aria-label="Stop generation"
+					>
+						<Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
+
+						<Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
+					</div>
+				</Tooltip.Trigger>
+
+				<Tooltip.Content>
+					<p>Stop generation</p>
+				</Tooltip.Content>
+			</Tooltip.Root>
 		{/if}
+
 		<!-- svelte-ignore a11y_click_events_have_key_events -->
 		<!-- svelte-ignore a11y_no_static_element_interactions -->
 		<span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}>
@@ -147,5 +176,25 @@
 				opacity: 1 !important;
 			}
 		}
+
+		.stop-button {
+			:global(.stop-icon) {
+				display: none;
+			}
+
+			:global(.loading-icon) {
+				display: block;
+			}
+		}
+
+		&:is(:hover) .stop-button {
+			:global(.stop-icon) {
+				display: block;
+			}
+
+			:global(.loading-icon) {
+				display: none;
+			}
+		}
 	}
 </style>

filename: tools/server/webui/src/lib/stores/chat.svelte.ts
patch: @@ -701,13 +701,17 @@ class ChatStore {
 
 		if (!activeConv) return;
 
-		await this.savePartialResponseIfNeeded(activeConv.id);
+		await this.stopGenerationForChat(activeConv.id);
+	}
+
+	async stopGenerationForChat(convId: string): Promise<void> {
+		await this.savePartialResponseIfNeeded(convId);
 
 		this.stopStreaming();
-		this.abortRequest(activeConv.id);
-		this.setChatLoading(activeConv.id, false);
-		this.clearChatStreaming(activeConv.id);
-		this.clearProcessingState(activeConv.id);
+		this.abortRequest(convId);
+		this.setChatLoading(convId, false);
+		this.clearChatStreaming(convId);
+		this.clearProcessingState(convId);
 	}
 
 	/**