mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2025-10-28 08:31:25 +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:
committed by
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