|
4 | 4 | import { Button } from '$lib/components/ui/button'; |
5 | 5 | import { ChatAttachmentsList, ChatMessageThinkingBlock, MarkdownContent } from '$lib/components/app'; |
6 | 6 | import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip'; |
| 7 | + import MessageBranchingControls from './MessageBranchingControls.svelte'; |
| 8 | + import type { MessageSiblingInfo } from '$lib/utils/branching'; |
7 | 9 | import { |
8 | 10 | AlertDialog, |
9 | 11 | AlertDialogAction, |
|
12 | 14 | AlertDialogDescription, |
13 | 15 | AlertDialogFooter, |
14 | 16 | AlertDialogHeader, |
15 | | - AlertDialogTitle, |
16 | | - AlertDialogTrigger |
| 17 | + AlertDialogTitle |
17 | 18 | } from '$lib/components/ui/alert-dialog'; |
18 | 19 | import { copyToClipboard } from '$lib/utils/copy'; |
19 | 20 | import { parseThinkingContent } from '$lib/utils/thinking'; |
| 21 | + import { getDeletionInfo } from '$lib/stores/chat.svelte'; |
20 | 22 | import { isLoading } from '$lib/stores/chat.svelte'; |
21 | 23 | import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; |
22 | 24 | import { fade } from 'svelte/transition'; |
|
25 | 27 | interface Props { |
26 | 28 | class?: string; |
27 | 29 | message: DatabaseMessage; |
28 | | - onEdit?: (message: DatabaseMessage) => void; |
| 30 | + siblingInfo?: MessageSiblingInfo | null; |
29 | 31 | onCopy?: (message: DatabaseMessage) => void; |
30 | | - onRegenerate?: (message: DatabaseMessage) => void; |
31 | | - onUpdateMessage?: (message: DatabaseMessage, newContent: string) => void; |
32 | 32 | onDelete?: (message: DatabaseMessage) => void; |
| 33 | + onNavigateToSibling?: (siblingId: string) => void; |
| 34 | + onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void; |
| 35 | + onRegenerateWithBranching?: (message: DatabaseMessage) => void; |
33 | 36 | } |
34 | 37 |
|
35 | 38 | let { |
36 | 39 | class: className = '', |
37 | 40 | message, |
38 | | - onEdit, |
| 41 | + siblingInfo = null, |
39 | 42 | onCopy, |
40 | | - onRegenerate, |
41 | | - onUpdateMessage, |
42 | | - onDelete |
| 43 | + onDelete, |
| 44 | + onNavigateToSibling, |
| 45 | + onEditWithBranching, |
| 46 | + onRegenerateWithBranching |
43 | 47 | }: Props = $props(); |
44 | 48 |
|
45 | | - let isEditing = $state(false); |
| 49 | + let showDeleteDialog = $state(false); |
46 | 50 | let editedContent = $state(message.content); |
| 51 | + let isEditing = $state(false); |
| 52 | + let deletionInfo = $state<{ totalCount: number; userMessages: number; assistantMessages: number; messageTypes: string[] } | null>(null); |
47 | 53 | let textareaElement: HTMLTextAreaElement | undefined = $state(); |
48 | | - let showDeleteDialog = $state(false); |
49 | 54 |
|
50 | 55 | const processingState = useProcessingState(); |
51 | 56 |
|
|
93 | 98 | ); |
94 | 99 | } |
95 | 100 | }, 0); |
96 | | - onEdit?.(message); |
97 | 101 | } |
98 | 102 |
|
99 | 103 | function handleEditKeydown(event: KeyboardEvent) { |
|
107 | 111 | } |
108 | 112 |
|
109 | 113 | function handleRegenerate() { |
110 | | - onRegenerate?.(message); |
| 114 | + onRegenerateWithBranching?.(message); |
111 | 115 | } |
112 | 116 |
|
113 | 117 | function handleSaveEdit() { |
114 | 118 | if (editedContent.trim() !== message.content) { |
115 | | - onUpdateMessage?.(message, editedContent.trim()); |
| 119 | + onEditWithBranching?.(message, editedContent.trim()); |
116 | 120 | } |
117 | 121 | isEditing = false; |
118 | 122 | } |
119 | 123 |
|
120 | | - function handleDelete() { |
| 124 | + async function handleDelete() { |
| 125 | + deletionInfo = await getDeletionInfo(message.id); |
121 | 126 | showDeleteDialog = true; |
122 | 127 | } |
123 | 128 |
|
|
187 | 192 | </Card> |
188 | 193 | {/if} |
189 | 194 |
|
190 | | - <div class="relative flex h-6 items-center"> |
191 | | - {@render messageActions({ role: 'user' })} |
192 | | - </div> |
| 195 | + {#if message.timestamp} |
| 196 | + {@render timestampAndActions({ role: 'user', justify: 'end', actionsPosition: 'right' })} |
| 197 | + {/if} |
193 | 198 | {/if} |
194 | 199 | </div> |
195 | 200 | {:else} |
|
233 | 238 | {/if} |
234 | 239 |
|
235 | 240 | {#if message.timestamp} |
236 | | - <div class="relative mt-2 flex h-6 items-center"> |
237 | | - {@render messageActions({ role: 'assistant' })} |
238 | | - </div> |
| 241 | + {@render timestampAndActions({ role: 'assistant', justify: 'start', actionsPosition: 'left' })} |
239 | 242 | {/if} |
240 | 243 | </div> |
241 | 244 | {/if} |
|
244 | 247 | <div |
245 | 248 | class="pointer-events-none inset-0 flex items-center gap-1 opacity-0 transition-all duration-150 group-hover:pointer-events-auto group-hover:opacity-100" |
246 | 249 | > |
247 | | - <Tooltip> |
248 | | - <TooltipTrigger> |
249 | | - <Button variant="ghost" size="sm" class="h-6 w-6 p-0" onclick={handleCopy}> |
250 | | - <Copy class="h-3 w-3" /> |
251 | | - </Button> |
252 | | - </TooltipTrigger> |
253 | | - |
254 | | - <TooltipContent> |
255 | | - <p>Copy</p> |
256 | | - </TooltipContent> |
257 | | - </Tooltip> |
| 250 | + {@render actionButton({ icon: Copy, tooltip: 'Copy', onclick: handleCopy })} |
| 251 | + |
258 | 252 | {#if config?.role === 'user'} |
259 | | - <Tooltip> |
260 | | - <TooltipTrigger> |
261 | | - <Button variant="ghost" size="sm" class="h-6 w-6 p-0" onclick={handleEdit}> |
262 | | - <Edit class="h-3 w-3" /> |
263 | | - </Button> |
264 | | - </TooltipTrigger> |
265 | | - |
266 | | - <TooltipContent> |
267 | | - <p>Edit</p> |
268 | | - </TooltipContent> |
269 | | - </Tooltip> |
| 253 | + {@render actionButton({ icon: Edit, tooltip: 'Edit', onclick: handleEdit })} |
270 | 254 | {:else if config?.role === 'assistant'} |
271 | | - <Tooltip> |
272 | | - <TooltipTrigger> |
273 | | - <Button |
274 | | - variant="ghost" |
275 | | - size="sm" |
276 | | - class="h-6 w-6 p-0" |
277 | | - onclick={handleRegenerate} |
278 | | - > |
279 | | - <RefreshCw class="h-3 w-3" /> |
280 | | - </Button> |
281 | | - </TooltipTrigger> |
282 | | - |
283 | | - <TooltipContent> |
284 | | - <p>Regenerate</p> |
285 | | - </TooltipContent> |
286 | | - </Tooltip> |
| 255 | + {@render actionButton({ icon: RefreshCw, tooltip: 'Regenerate', onclick: handleRegenerate })} |
287 | 256 | {/if} |
288 | | - |
289 | | - <Tooltip> |
290 | | - <TooltipTrigger> |
291 | | - <Button variant="ghost" size="sm" class="h-6 w-6 p-0 hover:bg-destructive/10 hover:text-destructive" onclick={handleDelete}> |
292 | | - <Trash2 class="h-3 w-3 text-destructive" /> |
293 | | - </Button> |
294 | | - </TooltipTrigger> |
295 | | - |
296 | | - <TooltipContent> |
297 | | - <p>Delete</p> |
298 | | - </TooltipContent> |
299 | | - </Tooltip> |
| 257 | + |
| 258 | + {@render actionButton({ icon: Trash2, tooltip: 'Delete', onclick: handleDelete })} |
300 | 259 | </div> |
| 260 | +{/snippet} |
| 261 | + |
| 262 | +{#snippet actionButton(config: { icon: any; tooltip: string; onclick: () => void })} |
| 263 | + <Tooltip> |
| 264 | + <TooltipTrigger> |
| 265 | + <Button variant="ghost" size="sm" class="h-6 w-6 p-0" onclick={config.onclick}> |
| 266 | + {@const IconComponent = config.icon} |
| 267 | + <IconComponent class="h-3 w-3" /> |
| 268 | + </Button> |
| 269 | + </TooltipTrigger> |
| 270 | + |
| 271 | + <TooltipContent> |
| 272 | + <p>{config.tooltip}</p> |
| 273 | + </TooltipContent> |
| 274 | + </Tooltip> |
| 275 | +{/snippet} |
| 276 | + |
| 277 | +{#snippet timestampAndActions(config: { role: ChatRole; justify: 'start' | 'end'; actionsPosition: 'left' | 'right' })} |
| 278 | + <div class="relative {config.justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-{config.justify}"> |
| 279 | + <div class="flex items-center text-xs text-muted-foreground group-hover:opacity-0 transition-opacity"> |
| 280 | + {new Date(message.timestamp).toLocaleTimeString(undefined, { |
| 281 | + hour: '2-digit', |
| 282 | + minute: '2-digit' |
| 283 | + })} |
| 284 | + </div> |
301 | 285 |
|
302 | | - {#if messageContent.trim().length > 0} |
303 | | - <div |
304 | | - class="{config?.role === 'user' |
305 | | - ? 'right-0' |
306 | | - : 'left-0'} text-muted-foreground absolute text-xs transition-all duration-150 group-hover:pointer-events-none group-hover:opacity-0" |
307 | | - > |
308 | | - {message.timestamp |
309 | | - ? new Date(message.timestamp).toLocaleTimeString(undefined, { |
310 | | - hour: '2-digit', |
311 | | - minute: '2-digit' |
312 | | - }) |
313 | | - : ''} |
| 286 | + <div class="absolute {config.actionsPosition}-0 flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> |
| 287 | + {#if siblingInfo && siblingInfo.totalSiblings > 1} |
| 288 | + <MessageBranchingControls |
| 289 | + {siblingInfo} |
| 290 | + {onNavigateToSibling} |
| 291 | + /> |
| 292 | + {/if} |
| 293 | + {@render messageActions({ role: config.role })} |
314 | 294 | </div> |
315 | | - {/if} |
| 295 | + </div> |
316 | 296 | {/snippet} |
317 | 297 |
|
318 | 298 | <AlertDialog bind:open={showDeleteDialog}> |
319 | 299 | <AlertDialogContent> |
320 | 300 | <AlertDialogHeader> |
321 | 301 | <AlertDialogTitle>Delete Message</AlertDialogTitle> |
322 | 302 | <AlertDialogDescription> |
323 | | - Are you sure you want to delete this message? This action cannot be undone. |
| 303 | + {#if deletionInfo && deletionInfo.totalCount > 1} |
| 304 | + <div class="space-y-2"> |
| 305 | + <p>This will delete <strong>{deletionInfo.totalCount} messages</strong> including:</p> |
| 306 | + |
| 307 | + <ul class="list-disc list-inside text-sm space-y-1 ml-4"> |
| 308 | + {#if deletionInfo.userMessages > 0} |
| 309 | + <li>{deletionInfo.userMessages} user message{deletionInfo.userMessages > 1 ? 's' : ''}</li> |
| 310 | + {/if} |
| 311 | + |
| 312 | + {#if deletionInfo.assistantMessages > 0} |
| 313 | + <li>{deletionInfo.assistantMessages} assistant response{deletionInfo.assistantMessages > 1 ? 's' : ''}</li> |
| 314 | + {/if} |
| 315 | + </ul> |
| 316 | + |
| 317 | + <p class="text-sm text-muted-foreground mt-2"> |
| 318 | + All messages in this branch and their responses will be permanently removed. This action cannot be undone. |
| 319 | + </p> |
| 320 | + </div> |
| 321 | + {:else} |
| 322 | + Are you sure you want to delete this message? This action cannot be undone. |
| 323 | + {/if} |
324 | 324 | </AlertDialogDescription> |
325 | 325 | </AlertDialogHeader> |
| 326 | + |
326 | 327 | <AlertDialogFooter> |
327 | 328 | <AlertDialogCancel>Cancel</AlertDialogCancel> |
| 329 | + |
328 | 330 | <AlertDialogAction onclick={handleConfirmDelete} class="bg-destructive text-destructive-foreground hover:bg-destructive/90"> |
329 | | - Delete |
| 331 | + {#if deletionInfo && deletionInfo.totalCount > 1} |
| 332 | + Delete {deletionInfo.totalCount} Messages |
| 333 | + {:else} |
| 334 | + Delete |
| 335 | + {/if} |
330 | 336 | </AlertDialogAction> |
331 | 337 | </AlertDialogFooter> |
332 | 338 | </AlertDialogContent> |
|
0 commit comments