1<script lang="ts">
2 import { Check, X } from '@lucide/svelte';
3 import { Card } from '$lib/components/ui/card';
4 import { Button } from '$lib/components/ui/button';
5 import { MarkdownContent } from '$lib/components/app';
6 import { INPUT_CLASSES } from '$lib/constants/input-classes';
7 import { config } from '$lib/stores/settings.svelte';
8 import ChatMessageActions from './ChatMessageActions.svelte';
9
10 interface Props {
11 class?: string;
12 message: DatabaseMessage;
13 isEditing: boolean;
14 editedContent: string;
15 siblingInfo?: ChatMessageSiblingInfo | null;
16 showDeleteDialog: boolean;
17 deletionInfo: {
18 totalCount: number;
19 userMessages: number;
20 assistantMessages: number;
21 messageTypes: string[];
22 } | null;
23 onCancelEdit: () => void;
24 onSaveEdit: () => void;
25 onEditKeydown: (event: KeyboardEvent) => void;
26 onEditedContentChange: (content: string) => void;
27 onCopy: () => void;
28 onEdit: () => void;
29 onDelete: () => void;
30 onConfirmDelete: () => void;
31 onNavigateToSibling?: (siblingId: string) => void;
32 onShowDeleteDialogChange: (show: boolean) => void;
33 textareaElement?: HTMLTextAreaElement;
34 }
35
36 let {
37 class: className = '',
38 message,
39 isEditing,
40 editedContent,
41 siblingInfo = null,
42 showDeleteDialog,
43 deletionInfo,
44 onCancelEdit,
45 onSaveEdit,
46 onEditKeydown,
47 onEditedContentChange,
48 onCopy,
49 onEdit,
50 onDelete,
51 onConfirmDelete,
52 onNavigateToSibling,
53 onShowDeleteDialogChange,
54 textareaElement = $bindable()
55 }: Props = $props();
56
57 let isMultiline = $state(false);
58 let messageElement: HTMLElement | undefined = $state();
59 let isExpanded = $state(false);
60 let contentHeight = $state(0);
61 const MAX_HEIGHT = 200; // pixels
62 const currentConfig = config();
63
64 let showExpandButton = $derived(contentHeight > MAX_HEIGHT);
65
66 $effect(() => {
67 if (!messageElement || !message.content.trim()) return;
68
69 if (message.content.includes('\n')) {
70 isMultiline = true;
71 }
72
73 const resizeObserver = new ResizeObserver((entries) => {
74 for (const entry of entries) {
75 const element = entry.target as HTMLElement;
76 const estimatedSingleLineHeight = 24;
77
78 isMultiline = element.offsetHeight > estimatedSingleLineHeight * 1.5;
79 contentHeight = element.scrollHeight;
80 }
81 });
82
83 resizeObserver.observe(messageElement);
84
85 return () => {
86 resizeObserver.disconnect();
87 };
88 });
89
90 function toggleExpand() {
91 isExpanded = !isExpanded;
92 }
93</script>
94
95<div
96 aria-label="System message with actions"
97 class="group flex flex-col items-end gap-3 md:gap-2 {className}"
98 role="group"
99>
100 {#if isEditing}
101 <div class="w-full max-w-[80%]">
102 <textarea
103 bind:this={textareaElement}
104 bind:value={editedContent}
105 class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
106 onkeydown={onEditKeydown}
107 oninput={(e) => onEditedContentChange(e.currentTarget.value)}
108 placeholder="Edit system message..."
109 ></textarea>
110
111 <div class="mt-2 flex justify-end gap-2">
112 <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="outline">
113 <X class="mr-1 h-3 w-3" />
114 Cancel
115 </Button>
116
117 <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
118 <Check class="mr-1 h-3 w-3" />
119 Send
120 </Button>
121 </div>
122 </div>
123 {:else}
124 {#if message.content.trim()}
125 <div class="relative max-w-[80%]">
126 <button
127 class="group/expand w-full text-left {!isExpanded && showExpandButton
128 ? 'cursor-pointer'
129 : 'cursor-auto'}"
130 onclick={showExpandButton && !isExpanded ? toggleExpand : undefined}
131 type="button"
132 >
133 <Card
134 class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
135 data-multiline={isMultiline ? '' : undefined}
136 style="border: 2px dashed hsl(var(--border));"
137 >
138 <div
139 class="relative overflow-hidden transition-all duration-300 {isExpanded
140 ? 'cursor-text select-text'
141 : 'select-none'}"
142 style={!isExpanded && showExpandButton
143 ? `max-height: ${MAX_HEIGHT}px;`
144 : 'max-height: none;'}
145 >
146 {#if currentConfig.renderUserContentAsMarkdown}
147 <div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
148 <MarkdownContent class="markdown-system-content" content={message.content} />
149 </div>
150 {:else}
151 <span
152 bind:this={messageElement}
153 class="text-md whitespace-pre-wrap {isExpanded ? 'cursor-text' : ''}"
154 >
155 {message.content}
156 </span>
157 {/if}
158
159 {#if !isExpanded && showExpandButton}
160 <div
161 class="pointer-events-none absolute right-0 bottom-0 left-0 h-48 bg-gradient-to-t from-muted to-transparent"
162 ></div>
163 <div
164 class="pointer-events-none absolute right-0 bottom-4 left-0 flex justify-center opacity-0 transition-opacity group-hover/expand:opacity-100"
165 >
166 <Button
167 class="rounded-full px-4 py-1.5 text-xs shadow-md"
168 size="sm"
169 variant="outline"
170 >
171 Show full system message
172 </Button>
173 </div>
174 {/if}
175 </div>
176
177 {#if isExpanded && showExpandButton}
178 <div class="mb-2 flex justify-center">
179 <Button
180 class="rounded-full px-4 py-1.5 text-xs"
181 onclick={(e) => {
182 e.stopPropagation();
183 toggleExpand();
184 }}
185 size="sm"
186 variant="outline"
187 >
188 Collapse System Message
189 </Button>
190 </div>
191 {/if}
192 </Card>
193 </button>
194 </div>
195 {/if}
196
197 {#if message.timestamp}
198 <div class="max-w-[80%]">
199 <ChatMessageActions
200 actionsPosition="right"
201 {deletionInfo}
202 justify="end"
203 {onConfirmDelete}
204 {onCopy}
205 {onDelete}
206 {onEdit}
207 {onNavigateToSibling}
208 {onShowDeleteDialogChange}
209 {siblingInfo}
210 {showDeleteDialog}
211 role="user"
212 />
213 </div>
214 {/if}
215 {/if}
216</div>