]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/blob
2f4b297d8556d4410f21ff16f95b34f671066389
[pkg/ggml/sources/llama.cpp] /
1 <script lang="ts">
2 import {
3 Download,
4 GitBranch,
5 ListChecks,
6 Loader2,
7 MoreHorizontal,
8 Pencil,
9 Pin,
10 PinOff,
11 Square,
12 Trash2
13 } from '@lucide/svelte';
14 import { DropdownMenuActions } from '$lib/components/app';
15 import { TruncatedText } from '$lib/components/app';
16 import { Checkbox } from '$lib/components/ui/checkbox';
17 import * as Tooltip from '$lib/components/ui/tooltip';
18 import { FORK_TREE_DEPTH_PADDING, ICON_CLASS_DEFAULT } from '$lib/constants';
19 import { RouterService } from '$lib/services/router.service';
20 import { getAllLoadingChats } from '$lib/stores/chat.svelte';
21 import { conversationsStore } from '$lib/stores/conversations.svelte';
22 import { onMount } from 'svelte';
23
24 interface Props {
25 isActive?: boolean;
26 depth?: number;
27 conversation: DatabaseConversation;
28 isSelectionMode?: boolean;
29 isSelected?: boolean;
30 onDelete?: (id: string) => void;
31 onEdit?: (id: string) => void;
32 onSelect?: (id: string) => void;
33 onStop?: (id: string) => void;
34 onToggleSelect?: (id: string) => void;
35 onEnterSelectionMode?: (id: string) => void;
36 onSelectionClick?: (id: string, options: { shiftKey: boolean }) => void;
37 onRowMouseDown?: (id: string, event: MouseEvent) => void;
38 }
39
40 let {
41 conversation,
42 depth = 0,
43 isActive = false,
44 isSelected = false,
45 isSelectionMode = false,
46 onDelete,
47 onEdit,
48 onEnterSelectionMode,
49 onRowMouseDown,
50 onSelect,
51 onSelectionClick,
52 onStop,
53 onToggleSelect
54 }: Props = $props();
55
56 let renderActionsDropdown = $state(false);
57 let dropdownOpen = $state(false);
58
59 let isLoading = $derived(getAllLoadingChats().includes(conversation.id));
60
61 function handleEdit(event: Event) {
62 event.stopPropagation();
63 onEdit?.(conversation.id);
64 }
65
66 function handleDelete(event: Event) {
67 event.stopPropagation();
68 onDelete?.(conversation.id);
69 }
70
71 function handleStop(event: Event) {
72 event.stopPropagation();
73 onStop?.(conversation.id);
74 }
75
76 function handleTogglePin() {
77 conversationsStore.toggleConversationPin(conversation.id);
78 }
79
80 function handleEnterSelectionMode(event: Event) {
81 event.stopPropagation();
82 onEnterSelectionMode?.(conversation.id);
83 }
84
85 function handleGlobalEditEvent(event: Event) {
86 const customEvent = event as CustomEvent<{ conversationId: string }>;
87
88 if (customEvent.detail.conversationId === conversation.id && isActive) {
89 handleEdit(event);
90 }
91 }
92
93 function handleMouseLeave() {
94 if (!dropdownOpen) {
95 renderActionsDropdown = false;
96 }
97 }
98
99 function handleMouseOver() {
100 if (isSelectionMode) return;
101
102 renderActionsDropdown = true;
103 }
104
105 function handleSelect(event: MouseEvent) {
106 if (isSelectionMode) {
107 onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
108 } else {
109 onSelect?.(conversation.id);
110 }
111 }
112
113 function handleCheckboxClick(event: MouseEvent) {
114 event.stopPropagation();
115
116 if (isSelectionMode) {
117 onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
118 } else {
119 onToggleSelect?.(conversation.id);
120 }
121 }
122
123 function handleRowMouseDown(event: MouseEvent) {
124 onRowMouseDown?.(conversation.id, event);
125 }
126
127 function handleCheckboxKeydown(event: KeyboardEvent) {
128 if (event.key !== ' ' && event.key !== 'Enter') return;
129
130 event.stopPropagation();
131 event.preventDefault();
132
133 if (isSelectionMode) {
134 onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
135 } else {
136 onToggleSelect?.(conversation.id);
137 }
138 }
139
140 $effect(() => {
141 if (!dropdownOpen) {
142 renderActionsDropdown = false;
143 }
144 });
145
146 onMount(() => {
147 document.addEventListener('edit-active-conversation', handleGlobalEditEvent as EventListener);
148
149 return () => {
150 document.removeEventListener(
151 'edit-active-conversation',
152 handleGlobalEditEvent as EventListener
153 );
154 };
155 });
156 </script>
157
158 <!-- svelte-ignore a11y_mouse_events_have_key_events -->
159 <button
160 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
161 ? 'bg-foreground/5 text-accent-foreground'
162 : ''} {isSelected ? 'bg-primary/10 hover:bg-primary/15' : ''} {isSelectionMode
163 ? 'is-selection-mode'
164 : ''} px-2"
165 data-conversation-row={conversation.id}
166 onclick={(e) => handleSelect(e)}
167 onmouseover={handleMouseOver}
168 onmouseleave={handleMouseLeave}
169 onmousedown={(e) => handleRowMouseDown(e)}
170 onfocusin={handleMouseOver}
171 onfocusout={(e) => {
172 if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
173 handleMouseLeave();
174 }
175 }}
176 >
177 <div
178 class="flex min-w-0 flex-1 items-center gap-2"
179 style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
180 >
181 {#if isSelectionMode}
182 <div
183 class="shrink-0"
184 onclick={(e) => handleCheckboxClick(e)}
185 onkeydown={handleCheckboxKeydown}
186 role="checkbox"
187 aria-checked={isSelected}
188 aria-label={isSelected ? `Deselect ${conversation.name}` : `Select ${conversation.name}`}
189 tabindex="-1"
190 >
191 <Checkbox
192 checked={isSelected}
193 aria-label={isSelected ? `Deselect ${conversation.name}` : `Select ${conversation.name}`}
194 />
195 </div>
196 {/if}
197
198 {#if depth > 0}
199 <Tooltip.Root>
200 <Tooltip.Trigger>
201 <!-- prevent another nested button element -->
202 {#snippet child({ props })}
203 <a
204 {...props}
205 href={RouterService.chat(conversation.forkedFromConversationId)}
206 class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
207 >
208 <GitBranch class="h-3.5 w-3.5" />
209 </a>
210 {/snippet}
211 </Tooltip.Trigger>
212
213 <Tooltip.Content>
214 <p>See parent conversation</p>
215 </Tooltip.Content>
216 </Tooltip.Root>
217 {/if}
218
219 {#if isLoading}
220 <Tooltip.Root>
221 <Tooltip.Trigger>
222 <div
223 class="stop-button flex {ICON_CLASS_DEFAULT} shrink-0 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:text-foreground"
224 onclick={handleStop}
225 onkeydown={(e) => e.key === 'Enter' && handleStop(e)}
226 role="button"
227 tabindex="0"
228 aria-label="Stop generation"
229 >
230 <Loader2 class="loading-icon h-3.5 w-3.5 animate-spin" />
231
232 <Square class="stop-icon hidden h-3 w-3 fill-current text-destructive" />
233 </div>
234 </Tooltip.Trigger>
235
236 <Tooltip.Content>
237 <p>Stop generation</p>
238 </Tooltip.Content>
239 </Tooltip.Root>
240 {/if}
241
242 <TruncatedText text={conversation.name} class="text-sm font-medium" showTooltip={false} />
243 </div>
244
245 {#if !isSelectionMode && renderActionsDropdown}
246 <div class="actions flex items-center">
247 <DropdownMenuActions
248 triggerIcon={MoreHorizontal}
249 triggerTooltip="More actions"
250 bind:open={dropdownOpen}
251 actions={[
252 {
253 icon: conversation.pinned ? PinOff : Pin,
254 label: conversation.pinned ? 'Unpin' : 'Pin',
255 onclick: (e: Event) => {
256 e.stopPropagation();
257 handleTogglePin();
258 }
259 },
260 {
261 icon: Pencil,
262 label: 'Edit',
263 onclick: handleEdit,
264 shortcut: ['shift', 'cmd', 'e']
265 },
266 {
267 icon: Download,
268 label: 'Export',
269 onclick: (e: Event) => {
270 e.stopPropagation();
271 conversationsStore.downloadConversation(conversation.id);
272 },
273 shortcut: ['shift', 'cmd', 's']
274 },
275 {
276 icon: ListChecks,
277 label: 'Select',
278 onclick: handleEnterSelectionMode
279 },
280 {
281 icon: Trash2,
282 label: 'Delete',
283 onclick: handleDelete,
284 separator: true,
285 shortcut: ['shift', 'cmd', 'd'],
286 variant: 'destructive'
287 }
288 ]}
289 />
290 </div>
291 {/if}
292 </button>
293
294 <style>
295 button {
296 :global([data-slot='dropdown-menu-trigger']:not([data-state='open'])) {
297 opacity: 0;
298 }
299
300 &:is(:hover) :global([data-slot='dropdown-menu-trigger']),
301 &:focus-within :global([data-slot='dropdown-menu-trigger']) {
302 opacity: 1;
303 }
304 @media (max-width: 768px) {
305 :global([data-slot='dropdown-menu-trigger']) {
306 opacity: 1 !important;
307 }
308 }
309
310 &.is-selection-mode :global([data-slot='dropdown-menu-trigger']) {
311 display: none !important;
312 }
313
314 .stop-button {
315 :global(.stop-icon) {
316 display: none;
317 }
318
319 :global(.loading-icon) {
320 display: block;
321 }
322 }
323
324 &:is(:hover) .stop-button {
325 :global(.stop-icon) {
326 display: block;
327 }
328
329 :global(.loading-icon) {
330 display: none;
331 }
332 }
333 }
334 </style>