* use child snippets for landing and chat message elements * make ... icon visible in conversation history menu * conversation history forward tab fix * add snippet fix for fork icon in conversation history * focus/keyboard fix for attachment x icon and scroll left/right * formatting * fix scroll down issue * simply Statistics and pointer events in scrolldown * create storybook tests and move to folder * improve tests to actually assert on element
239 lines
5.5 KiB
Svelte
239 lines
5.5 KiB
Svelte
<script lang="ts">
|
|
import {
|
|
Trash2,
|
|
Pencil,
|
|
MoreHorizontal,
|
|
Download,
|
|
Loader2,
|
|
Square,
|
|
GitBranch
|
|
} from '@lucide/svelte';
|
|
import { DropdownMenuActions } from '$lib/components/app';
|
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
|
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
|
|
import { RouterService } from '$lib/services/router.service';
|
|
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
|
import { TruncatedText } from '$lib/components/app';
|
|
import { onMount } from 'svelte';
|
|
|
|
interface Props {
|
|
isActive?: boolean;
|
|
depth?: number;
|
|
conversation: DatabaseConversation;
|
|
onDelete?: (id: string) => void;
|
|
onEdit?: (id: string) => void;
|
|
onSelect?: (id: string) => void;
|
|
onStop?: (id: string) => void;
|
|
}
|
|
|
|
let {
|
|
conversation,
|
|
onDelete,
|
|
onEdit,
|
|
onSelect,
|
|
onStop,
|
|
isActive = false,
|
|
depth = 0
|
|
}: Props = $props();
|
|
|
|
let renderActionsDropdown = $state(false);
|
|
let dropdownOpen = $state(false);
|
|
|
|
let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
|
|
|
|
function handleEdit(event: Event) {
|
|
event.stopPropagation();
|
|
onEdit?.(conversation.id);
|
|
}
|
|
|
|
function handleDelete(event: Event) {
|
|
event.stopPropagation();
|
|
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);
|
|
}
|
|
}
|
|
|
|
function handleMouseLeave() {
|
|
if (!dropdownOpen) {
|
|
renderActionsDropdown = false;
|
|
}
|
|
}
|
|
|
|
function handleMouseOver() {
|
|
renderActionsDropdown = true;
|
|
}
|
|
|
|
function handleSelect() {
|
|
onSelect?.(conversation.id);
|
|
}
|
|
|
|
$effect(() => {
|
|
if (!dropdownOpen) {
|
|
renderActionsDropdown = false;
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener);
|
|
|
|
return () => {
|
|
document.removeEventListener(
|
|
'edit-active-conversation',
|
|
handleGlobalEditEvent as EventListener
|
|
);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
|
<button
|
|
class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
|
|
? 'bg-foreground/5 text-accent-foreground'
|
|
: ''} px-3"
|
|
onclick={handleSelect}
|
|
onmouseover={handleMouseOver}
|
|
onmouseleave={handleMouseLeave}
|
|
onfocusin={handleMouseOver}
|
|
onfocusout={(e) => {
|
|
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
handleMouseLeave();
|
|
}
|
|
}}
|
|
>
|
|
<div
|
|
class="flex min-w-0 flex-1 items-center gap-2"
|
|
style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
|
|
>
|
|
{#if depth > 0}
|
|
<Tooltip.Root>
|
|
<Tooltip.Trigger>
|
|
<!-- prevent another nested button element -->
|
|
{#snippet child({ props })}
|
|
<a
|
|
{...props}
|
|
href={RouterService.chat(conversation.forkedFromConversationId)}
|
|
class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
|
|
>
|
|
<GitBranch class="h-3.5 w-3.5" />
|
|
</a>
|
|
{/snippet}
|
|
</Tooltip.Trigger>
|
|
|
|
<Tooltip.Content>
|
|
<p>See parent conversation</p>
|
|
</Tooltip.Content>
|
|
</Tooltip.Root>
|
|
{/if}
|
|
|
|
{#if isLoading}
|
|
<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}
|
|
|
|
<TruncatedText text={conversation.name} class="text-sm font-medium" showTooltip={false} />
|
|
</div>
|
|
|
|
{#if renderActionsDropdown}
|
|
<div class="actions flex items-center">
|
|
<DropdownMenuActions
|
|
triggerIcon={MoreHorizontal}
|
|
triggerTooltip="More actions"
|
|
bind:open={dropdownOpen}
|
|
actions={[
|
|
{
|
|
icon: Pencil,
|
|
label: 'Edit',
|
|
onclick: handleEdit,
|
|
shortcut: ['shift', 'cmd', 'e']
|
|
},
|
|
{
|
|
icon: Download,
|
|
label: 'Export',
|
|
onclick: (e: Event) => {
|
|
e.stopPropagation();
|
|
conversationsStore.downloadConversation(conversation.id);
|
|
},
|
|
shortcut: ['shift', 'cmd', 's']
|
|
},
|
|
{
|
|
icon: Trash2,
|
|
label: 'Delete',
|
|
onclick: handleDelete,
|
|
variant: 'destructive',
|
|
shortcut: ['shift', 'cmd', 'd'],
|
|
separator: true
|
|
}
|
|
]}
|
|
/>
|
|
</div>
|
|
{/if}
|
|
</button>
|
|
|
|
<style>
|
|
button {
|
|
:global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
|
|
opacity: 0;
|
|
}
|
|
|
|
&:is(:hover) :global([data-slot='dropdown-menu-trigger']),
|
|
&:focus-within :global([data-slot='dropdown-menu-trigger']) {
|
|
opacity: 1;
|
|
}
|
|
@media (max-width: 768px) {
|
|
:global([data-slot='dropdown-menu-trigger']) {
|
|
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>
|