mirror of
				https://github.com/ggml-org/llama.cpp.git
				synced 2025-10-31 08:51:55 +00:00 
			
		
		
		
	Conversation action dialogs as singletons from Chat Sidebar + apply conditional rendering for Actions Dropdown for Chat Conversation Items (#16369)
* fix: Render Conversation action dialogs as singletons from Chat Sidebar level * chore: update webui build output * fix: Render Actions Dropdown conditionally only when user hovers conversation item + remove unused markup * chore: Update webui static build * fix: Always truncate conversation names * chore: Update webui static build
This commit is contained in:
		 Aleksander Grygier
					Aleksander Grygier
				
			
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			 GitHub
						GitHub
					
				
			
						parent
						
							2a9b63383a
						
					
				
				
					commit
					764799279f
				
			
										
											Binary file not shown.
										
									
								
							| @@ -1,9 +1,12 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { goto } from '$app/navigation'; | 	import { goto } from '$app/navigation'; | ||||||
| 	import { page } from '$app/state'; | 	import { page } from '$app/state'; | ||||||
| 	import { ChatSidebarConversationItem } from '$lib/components/app'; | 	import { Trash2 } from '@lucide/svelte'; | ||||||
|  | 	import { ChatSidebarConversationItem, ConfirmationDialog } from '$lib/components/app'; | ||||||
| 	import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'; | 	import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte'; | ||||||
| 	import * as Sidebar from '$lib/components/ui/sidebar'; | 	import * as Sidebar from '$lib/components/ui/sidebar'; | ||||||
|  | 	import * as AlertDialog from '$lib/components/ui/alert-dialog'; | ||||||
|  | 	import Input from '$lib/components/ui/input/input.svelte'; | ||||||
| 	import { | 	import { | ||||||
| 		conversations, | 		conversations, | ||||||
| 		deleteConversation, | 		deleteConversation, | ||||||
| @@ -16,6 +19,10 @@ | |||||||
| 	let currentChatId = $derived(page.params.id); | 	let currentChatId = $derived(page.params.id); | ||||||
| 	let isSearchModeActive = $state(false); | 	let isSearchModeActive = $state(false); | ||||||
| 	let searchQuery = $state(''); | 	let searchQuery = $state(''); | ||||||
|  | 	let showDeleteDialog = $state(false); | ||||||
|  | 	let showEditDialog = $state(false); | ||||||
|  | 	let selectedConversation = $state<DatabaseConversation | null>(null); | ||||||
|  | 	let editedName = $state(''); | ||||||
|  |  | ||||||
| 	let filteredConversations = $derived.by(() => { | 	let filteredConversations = $derived.by(() => { | ||||||
| 		if (searchQuery.trim().length > 0) { | 		if (searchQuery.trim().length > 0) { | ||||||
| @@ -27,12 +34,41 @@ | |||||||
| 		return conversations(); | 		return conversations(); | ||||||
| 	}); | 	}); | ||||||
|  |  | ||||||
| 	async function editConversation(id: string, name: string) { | 	async function handleDeleteConversation(id: string) { | ||||||
| 		await updateConversationName(id, name); | 		const conversation = conversations().find((conv) => conv.id === id); | ||||||
|  | 		if (conversation) { | ||||||
|  | 			selectedConversation = conversation; | ||||||
|  | 			showDeleteDialog = true; | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async function handleDeleteConversation(id: string) { | 	async function handleEditConversation(id: string) { | ||||||
| 		await deleteConversation(id); | 		const conversation = conversations().find((conv) => conv.id === id); | ||||||
|  | 		if (conversation) { | ||||||
|  | 			selectedConversation = conversation; | ||||||
|  | 			editedName = conversation.name; | ||||||
|  | 			showEditDialog = true; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	function handleConfirmDelete() { | ||||||
|  | 		if (selectedConversation) { | ||||||
|  | 			showDeleteDialog = false; | ||||||
|  |  | ||||||
|  | 			setTimeout(() => { | ||||||
|  | 				deleteConversation(selectedConversation.id); | ||||||
|  | 				selectedConversation = null; | ||||||
|  | 			}, 100); // Wait for animation to finish | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	function handleConfirmEdit() { | ||||||
|  | 		if (!editedName.trim() || !selectedConversation) return; | ||||||
|  |  | ||||||
|  | 		showEditDialog = false; | ||||||
|  |  | ||||||
|  | 		updateConversationName(selectedConversation.id, editedName); | ||||||
|  | 		selectedConversation = null; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	export function handleMobileSidebarItemClick() { | 	export function handleMobileSidebarItemClick() { | ||||||
| @@ -98,7 +134,7 @@ | |||||||
| 							{handleMobileSidebarItemClick} | 							{handleMobileSidebarItemClick} | ||||||
| 							isActive={currentChatId === conversation.id} | 							isActive={currentChatId === conversation.id} | ||||||
| 							onSelect={selectConversation} | 							onSelect={selectConversation} | ||||||
| 							onEdit={editConversation} | 							onEdit={handleEditConversation} | ||||||
| 							onDelete={handleDeleteConversation} | 							onDelete={handleDeleteConversation} | ||||||
| 						/> | 						/> | ||||||
| 					</Sidebar.MenuItem> | 					</Sidebar.MenuItem> | ||||||
| @@ -119,7 +155,53 @@ | |||||||
| 		</Sidebar.GroupContent> | 		</Sidebar.GroupContent> | ||||||
| 	</Sidebar.Group> | 	</Sidebar.Group> | ||||||
|  |  | ||||||
| 	<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"> | 	<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div> | ||||||
| 		<p class="text-xs text-muted-foreground">Conversations are stored locally in your browser.</p> |  | ||||||
| 	</div> |  | ||||||
| </ScrollArea> | </ScrollArea> | ||||||
|  |  | ||||||
|  | <ConfirmationDialog | ||||||
|  | 	bind:open={showDeleteDialog} | ||||||
|  | 	title="Delete Conversation" | ||||||
|  | 	description={selectedConversation | ||||||
|  | 		? `Are you sure you want to delete "${selectedConversation.name}"? This action cannot be undone and will permanently remove all messages in this conversation.` | ||||||
|  | 		: ''} | ||||||
|  | 	confirmText="Delete" | ||||||
|  | 	cancelText="Cancel" | ||||||
|  | 	variant="destructive" | ||||||
|  | 	icon={Trash2} | ||||||
|  | 	onConfirm={handleConfirmDelete} | ||||||
|  | 	onCancel={() => { | ||||||
|  | 		showDeleteDialog = false; | ||||||
|  | 		selectedConversation = null; | ||||||
|  | 	}} | ||||||
|  | /> | ||||||
|  |  | ||||||
|  | <AlertDialog.Root bind:open={showEditDialog}> | ||||||
|  | 	<AlertDialog.Content> | ||||||
|  | 		<AlertDialog.Header> | ||||||
|  | 			<AlertDialog.Title>Edit Conversation Name</AlertDialog.Title> | ||||||
|  | 			<AlertDialog.Description> | ||||||
|  | 				<Input | ||||||
|  | 					class="mt-4 text-foreground" | ||||||
|  | 					onkeydown={(e) => { | ||||||
|  | 						if (e.key === 'Enter') { | ||||||
|  | 							e.preventDefault(); | ||||||
|  | 							handleConfirmEdit(); | ||||||
|  | 						} | ||||||
|  | 					}} | ||||||
|  | 					placeholder="Enter a new name" | ||||||
|  | 					type="text" | ||||||
|  | 					bind:value={editedName} | ||||||
|  | 				/> | ||||||
|  | 			</AlertDialog.Description> | ||||||
|  | 		</AlertDialog.Header> | ||||||
|  | 		<AlertDialog.Footer> | ||||||
|  | 			<AlertDialog.Cancel | ||||||
|  | 				onclick={() => { | ||||||
|  | 					showEditDialog = false; | ||||||
|  | 					selectedConversation = null; | ||||||
|  | 				}}>Cancel</AlertDialog.Cancel | ||||||
|  | 			> | ||||||
|  | 			<AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action> | ||||||
|  | 		</AlertDialog.Footer> | ||||||
|  | 	</AlertDialog.Content> | ||||||
|  | </AlertDialog.Root> | ||||||
|   | |||||||
| @@ -1,8 +1,6 @@ | |||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { Trash2, Pencil, MoreHorizontal } from '@lucide/svelte'; | 	import { Trash2, Pencil, MoreHorizontal } from '@lucide/svelte'; | ||||||
| 	import { ActionDropdown, ConfirmationDialog } from '$lib/components/app'; | 	import { ActionDropdown } from '$lib/components/app'; | ||||||
| 	import * as AlertDialog from '$lib/components/ui/alert-dialog'; |  | ||||||
| 	import Input from '$lib/components/ui/input/input.svelte'; |  | ||||||
| 	import { onMount } from 'svelte'; | 	import { onMount } from 'svelte'; | ||||||
|  |  | ||||||
| 	interface Props { | 	interface Props { | ||||||
| @@ -10,9 +8,8 @@ | |||||||
| 		conversation: DatabaseConversation; | 		conversation: DatabaseConversation; | ||||||
| 		handleMobileSidebarItemClick?: () => void; | 		handleMobileSidebarItemClick?: () => void; | ||||||
| 		onDelete?: (id: string) => void; | 		onDelete?: (id: string) => void; | ||||||
| 		onEdit?: (id: string, name: string) => void; | 		onEdit?: (id: string) => void; | ||||||
| 		onSelect?: (id: string) => void; | 		onSelect?: (id: string) => void; | ||||||
| 		showLastModified?: boolean; |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	let { | 	let { | ||||||
| @@ -21,46 +18,20 @@ | |||||||
| 		onDelete, | 		onDelete, | ||||||
| 		onEdit, | 		onEdit, | ||||||
| 		onSelect, | 		onSelect, | ||||||
| 		isActive = false, | 		isActive = false | ||||||
| 		showLastModified = false |  | ||||||
| 	}: Props = $props(); | 	}: Props = $props(); | ||||||
|  |  | ||||||
| 	let editedName = $state(''); | 	let renderActionsDropdown = $state(false); | ||||||
| 	let showDeleteDialog = $state(false); | 	let dropdownOpen = $state(false); | ||||||
| 	let showDropdown = $state(false); |  | ||||||
| 	let showEditDialog = $state(false); |  | ||||||
|  |  | ||||||
| 	function formatLastModified(timestamp: number) { |  | ||||||
| 		const now = Date.now(); |  | ||||||
| 		const diff = now - timestamp; |  | ||||||
| 		const minutes = Math.floor(diff / (1000 * 60)); |  | ||||||
| 		const hours = Math.floor(diff / (1000 * 60 * 60)); |  | ||||||
| 		const days = Math.floor(diff / (1000 * 60 * 60 * 24)); |  | ||||||
|  |  | ||||||
| 		if (minutes < 1) return 'Just now'; |  | ||||||
| 		if (minutes < 60) return `${minutes}m ago`; |  | ||||||
| 		if (hours < 24) return `${hours}h ago`; |  | ||||||
| 		return `${days}d ago`; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	function handleConfirmDelete() { |  | ||||||
| 		onDelete?.(conversation.id); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	function handleConfirmEdit() { |  | ||||||
| 		if (!editedName.trim()) return; |  | ||||||
| 		showEditDialog = false; |  | ||||||
| 		onEdit?.(conversation.id, editedName); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	function handleEdit(event: Event) { | 	function handleEdit(event: Event) { | ||||||
| 		event.stopPropagation(); | 		event.stopPropagation(); | ||||||
| 		editedName = conversation.name; | 		onEdit?.(conversation.id); | ||||||
| 		showEditDialog = true; |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	function handleSelect() { | 	function handleDelete(event: Event) { | ||||||
| 		onSelect?.(conversation.id); | 		event.stopPropagation(); | ||||||
|  | 		onDelete?.(conversation.id); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	function handleGlobalEditEvent(event: Event) { | 	function handleGlobalEditEvent(event: Event) { | ||||||
| @@ -70,6 +41,26 @@ | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | 	function handleMouseLeave() { | ||||||
|  | 		if (!dropdownOpen) { | ||||||
|  | 			renderActionsDropdown = false; | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	function handleMouseOver() { | ||||||
|  | 		renderActionsDropdown = true; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	function handleSelect() { | ||||||
|  | 		onSelect?.(conversation.id); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	$effect(() => { | ||||||
|  | 		if (!dropdownOpen) { | ||||||
|  | 			renderActionsDropdown = false; | ||||||
|  | 		} | ||||||
|  | 	}); | ||||||
|  |  | ||||||
| 	onMount(() => { | 	onMount(() => { | ||||||
| 		document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener); | 		document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener); | ||||||
|  |  | ||||||
| @@ -82,99 +73,46 @@ | |||||||
| 	}); | 	}); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <!-- svelte-ignore a11y_mouse_events_have_key_events --> | ||||||
| <button | <button | ||||||
| 	class="group flex w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive | 	class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive | ||||||
| 		? 'bg-foreground/5 text-accent-foreground' | 		? 'bg-foreground/5 text-accent-foreground' | ||||||
| 		: ''}" | 		: ''}" | ||||||
| 	onclick={handleSelect} | 	onclick={handleSelect} | ||||||
|  | 	onmouseover={handleMouseOver} | ||||||
|  | 	onmouseleave={handleMouseLeave} | ||||||
| > | > | ||||||
| 	<!-- svelte-ignore a11y_click_events_have_key_events --> | 	<!-- svelte-ignore a11y_click_events_have_key_events --> | ||||||
| 	<!-- svelte-ignore a11y_no_static_element_interactions --> | 	<!-- svelte-ignore a11y_no_static_element_interactions --> | ||||||
| 	<div | 	<span class="truncate text-sm font-medium" onclick={handleMobileSidebarItemClick}> | ||||||
| 		class="text flex min-w-0 flex-1 items-center space-x-3" | 		{conversation.name} | ||||||
| 		onclick={handleMobileSidebarItemClick} | 	</span> | ||||||
| 	> |  | ||||||
| 		<div class="min-w-0 flex-1"> |  | ||||||
| 			<p class="truncate text-sm font-medium">{conversation.name}</p> |  | ||||||
|  |  | ||||||
| 			{#if showLastModified} | 	{#if renderActionsDropdown} | ||||||
| 				<div class="mt-2 flex flex-wrap items-center space-y-2 space-x-2"> | 		<div class="actions flex items-center"> | ||||||
| 					<span class="w-full text-xs text-muted-foreground"> | 			<ActionDropdown | ||||||
| 						{formatLastModified(conversation.lastModified)} | 				triggerIcon={MoreHorizontal} | ||||||
| 					</span> | 				triggerTooltip="More actions" | ||||||
| 				</div> | 				bind:open={dropdownOpen} | ||||||
| 			{/if} | 				actions={[ | ||||||
| 		</div> | 					{ | ||||||
| 	</div> | 						icon: Pencil, | ||||||
|  | 						label: 'Edit', | ||||||
| 	<div class="actions flex items-center"> | 						onclick: handleEdit, | ||||||
| 		<ActionDropdown | 						shortcut: ['shift', 'cmd', 'e'] | ||||||
| 			triggerIcon={MoreHorizontal} |  | ||||||
| 			triggerTooltip="More actions" |  | ||||||
| 			bind:open={showDropdown} |  | ||||||
| 			actions={[ |  | ||||||
| 				{ |  | ||||||
| 					icon: Pencil, |  | ||||||
| 					label: 'Edit', |  | ||||||
| 					onclick: handleEdit, |  | ||||||
| 					shortcut: ['shift', 'cmd', 'e'] |  | ||||||
| 				}, |  | ||||||
| 				{ |  | ||||||
| 					icon: Trash2, |  | ||||||
| 					label: 'Delete', |  | ||||||
| 					onclick: (e) => { |  | ||||||
| 						e.stopPropagation(); |  | ||||||
| 						showDeleteDialog = true; |  | ||||||
| 					}, | 					}, | ||||||
| 					variant: 'destructive', | 					{ | ||||||
| 					shortcut: ['shift', 'cmd', 'd'], | 						icon: Trash2, | ||||||
| 					separator: true | 						label: 'Delete', | ||||||
| 				} | 						onclick: handleDelete, | ||||||
| 			]} | 						variant: 'destructive', | ||||||
| 		/> | 						shortcut: ['shift', 'cmd', 'd'], | ||||||
|  | 						separator: true | ||||||
| 		<ConfirmationDialog | 					} | ||||||
| 			bind:open={showDeleteDialog} | 				]} | ||||||
| 			title="Delete Conversation" | 			/> | ||||||
| 			description={`Are you sure you want to delete "${conversation.name}"? This action cannot be undone and will permanently remove all messages in this conversation.`} | 		</div> | ||||||
| 			confirmText="Delete" | 	{/if} | ||||||
| 			cancelText="Cancel" |  | ||||||
| 			variant="destructive" |  | ||||||
| 			icon={Trash2} |  | ||||||
| 			onConfirm={handleConfirmDelete} |  | ||||||
| 			onCancel={() => (showDeleteDialog = false)} |  | ||||||
| 		/> |  | ||||||
|  |  | ||||||
| 		<AlertDialog.Root bind:open={showEditDialog}> |  | ||||||
| 			<AlertDialog.Content> |  | ||||||
| 				<AlertDialog.Header> |  | ||||||
| 					<AlertDialog.Title>Edit Conversation Name</AlertDialog.Title> |  | ||||||
|  |  | ||||||
| 					<AlertDialog.Description> |  | ||||||
| 						<Input |  | ||||||
| 							class="mt-4 text-foreground" |  | ||||||
| 							onkeydown={(e) => { |  | ||||||
| 								if (e.key === 'Enter') { |  | ||||||
| 									e.preventDefault(); |  | ||||||
| 									handleConfirmEdit(); |  | ||||||
| 									showEditDialog = false; |  | ||||||
| 								} |  | ||||||
| 							}} |  | ||||||
| 							placeholder="Enter a new name" |  | ||||||
| 							type="text" |  | ||||||
| 							bind:value={editedName} |  | ||||||
| 						/> |  | ||||||
| 					</AlertDialog.Description> |  | ||||||
| 				</AlertDialog.Header> |  | ||||||
|  |  | ||||||
| 				<AlertDialog.Footer> |  | ||||||
| 					<AlertDialog.Cancel>Cancel</AlertDialog.Cancel> |  | ||||||
|  |  | ||||||
| 					<AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action> |  | ||||||
| 				</AlertDialog.Footer> |  | ||||||
| 			</AlertDialog.Content> |  | ||||||
| 		</AlertDialog.Root> |  | ||||||
| 	</div> |  | ||||||
| </button> | </button> | ||||||
|  |  | ||||||
| <style> | <style> | ||||||
|   | |||||||
| @@ -140,6 +140,8 @@ | |||||||
| 	}); | 	}); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
|  | <svelte:window onkeydown={handleKeydown} /> | ||||||
|  |  | ||||||
| <ModeWatcher /> | <ModeWatcher /> | ||||||
|  |  | ||||||
| <Toaster richColors /> | <Toaster richColors /> | ||||||
| @@ -172,5 +174,3 @@ | |||||||
| 		</Sidebar.Inset> | 		</Sidebar.Inset> | ||||||
| 	</div> | 	</div> | ||||||
| </Sidebar.Provider> | </Sidebar.Provider> | ||||||
|  |  | ||||||
| <svelte:window onkeydown={handleKeydown} /> |  | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user