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
2025-10-01 18:18:10 +02:00
committed by GitHub
parent 2a9b63383a
commit 764799279f
4 changed files with 153 additions and 133 deletions

Binary file not shown.

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />