Skip to content

Commit 4fb2cc4

Browse files
committed
feat: Thinking response UI, copy to clipboard, sonner toast alerts
1 parent a555a58 commit 4fb2cc4

File tree

12 files changed

+378
-81
lines changed

12 files changed

+378
-81
lines changed

tools/server/webui/package-lock.json

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/server/webui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
"remark-gfm": "^4.0.1",
7373
"remark-html": "^16.0.1",
7474
"shiki": "^3.8.1",
75+
"svelte-sonner": "^1.0.5",
7576
"unist-util-visit": "^5.0.0"
7677
}
7778
}

tools/server/webui/src/lib/components/chat/ChatForm.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
<script lang="ts">
22
import { Button } from '$lib/components/ui/button';
3-
import { Textarea } from '$lib/components/ui/textarea';
43
import autoResizeTextarea from '$lib/utils/autoresize-textarea';
5-
import { Send, Square, Paperclip, Mic, ArrowUp } from '@lucide/svelte';
4+
import { Square, Paperclip, Mic, ArrowUp } from '@lucide/svelte';
65
76
interface Props {
87
class?: string;

tools/server/webui/src/lib/components/chat/ChatMessage.svelte

Lines changed: 113 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,23 @@
22
import { Card } from '$lib/components/ui/card';
33
import { Button } from '$lib/components/ui/button';
44
import { Tooltip, TooltipContent, TooltipTrigger } from '$lib/components/ui/tooltip';
5-
import { User, Bot, Edit, Copy, Trash2, RefreshCw } from '@lucide/svelte';
5+
import { Textarea } from '$lib/components/ui/textarea';
6+
import { User, Bot, Edit, Copy, Trash2, RefreshCw, Check, X } from '@lucide/svelte';
67
import type { ChatRole } from '$lib/types/chat';
7-
import type { ChatMessageData } from '$lib/types/chat';
8+
import type { DatabaseChatMessage } from '$lib/types/database';
89
import ThinkingSection from './ThinkingSection.svelte';
910
import MarkdownContent from './MarkdownContent.svelte';
1011
import { parseThinkingContent } from '$lib/utils/thinking';
12+
import { copyToClipboard } from '$lib/utils/copy';
1113
1214
interface Props {
1315
class?: string;
14-
message: ChatMessageData;
15-
onEdit?: (message: ChatMessageData) => void;
16-
onDelete?: (message: ChatMessageData) => void;
17-
onCopy?: (message: ChatMessageData) => void;
18-
onRegenerate?: (message: ChatMessageData) => void;
16+
message: DatabaseChatMessage;
17+
onEdit?: (message: DatabaseChatMessage) => void;
18+
onDelete?: (message: DatabaseChatMessage) => void;
19+
onCopy?: (message: DatabaseChatMessage) => void;
20+
onRegenerate?: (message: DatabaseChatMessage) => void;
21+
onUpdateMessage?: (message: DatabaseChatMessage, newContent: string) => void;
1922
}
2023
2124
let {
@@ -24,35 +27,86 @@
2427
onEdit,
2528
onDelete,
2629
onCopy,
27-
onRegenerate
30+
onRegenerate,
31+
onUpdateMessage
2832
}: Props = $props();
2933
34+
// Editing state
35+
let isEditing = $state(false);
36+
let editedContent = $state(message.content);
37+
// Element reference (not reactive)
38+
let textareaElement: HTMLTextAreaElement;
39+
3040
// Parse thinking content for assistant messages
31-
const parsedContent = $derived(() => {
41+
// Use separate derived values to prevent unnecessary re-renders
42+
let thinkingContent = $derived.by(() => {
43+
if (message.role === 'assistant') {
44+
// Prioritize message.thinking (from streaming) over parsed thinking
45+
if (message.thinking) {
46+
return message.thinking;
47+
}
48+
// Fallback to parsing content for complete messages
49+
const parsed = parseThinkingContent(message.content);
50+
return parsed.thinking;
51+
}
52+
return null;
53+
});
54+
55+
let messageContent = $derived.by(() => {
3256
if (message.role === 'assistant') {
57+
// Always parse and clean the content to remove <think>...</think> blocks
3358
const parsed = parseThinkingContent(message.content);
34-
return {
35-
thinking: message.thinking || parsed.thinking,
36-
content: parsed.cleanContent || message.content
37-
};
59+
return parsed.cleanContent;
3860
}
39-
return { thinking: null, content: message.content };
61+
return message.content;
4062
});
4163
4264
// Handle copy to clipboard
43-
function handleCopy() {
44-
navigator.clipboard.writeText(message.content);
65+
async function handleCopy() {
66+
await copyToClipboard(message.content, 'Message copied to clipboard');
4567
onCopy?.(message);
4668
}
4769
4870
// Handle edit action
4971
function handleEdit() {
72+
isEditing = true;
73+
editedContent = message.content;
74+
// Focus the textarea after it's rendered
75+
setTimeout(() => {
76+
if (textareaElement) {
77+
textareaElement.focus();
78+
textareaElement.setSelectionRange(
79+
textareaElement.value.length,
80+
textareaElement.value.length
81+
);
82+
}
83+
}, 0);
5084
onEdit?.(message);
5185
}
5286
53-
// Handle delete action
54-
function handleDelete() {
55-
onDelete?.(message);
87+
// Handle save edited message
88+
function handleSaveEdit() {
89+
if (editedContent.trim() && editedContent !== message.content) {
90+
onUpdateMessage?.(message, editedContent.trim());
91+
}
92+
isEditing = false;
93+
}
94+
95+
// Handle cancel edit
96+
function handleCancelEdit() {
97+
isEditing = false;
98+
editedContent = message.content;
99+
}
100+
101+
// Handle keyboard shortcuts in edit mode
102+
function handleEditKeydown(event: KeyboardEvent) {
103+
if (event.key === 'Enter' && !event.shiftKey) {
104+
event.preventDefault();
105+
handleSaveEdit();
106+
} else if (event.key === 'Escape') {
107+
event.preventDefault();
108+
handleCancelEdit();
109+
}
56110
}
57111
58112
// Handle regenerate action
@@ -137,30 +191,59 @@
137191
role="group"
138192
aria-label="User message with actions"
139193
>
140-
<Card class="bg-primary text-primary-foreground max-w-[80%] rounded-2xl px-2.5 py-1.5">
141-
<div class="text-md whitespace-pre-wrap">
142-
{message.content}
194+
{#if isEditing}
195+
<!-- Editing mode -->
196+
<div class="w-full max-w-[80%]">
197+
<textarea
198+
bind:this={textareaElement}
199+
bind:value={editedContent}
200+
onkeydown={handleEditKeydown}
201+
class="border-primary bg-background text-foreground focus:ring-ring min-h-[60px] w-full resize-none rounded-2xl border-2 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2"
202+
placeholder="Edit your message..."
203+
></textarea>
204+
<div class="mt-2 flex justify-end gap-2">
205+
<Button variant="outline" size="sm" class="h-8 px-3" onclick={handleCancelEdit}>
206+
<X class="mr-1 h-3 w-3" />
207+
Cancel
208+
</Button>
209+
<Button
210+
size="sm"
211+
class="h-8 px-3"
212+
onclick={handleSaveEdit}
213+
disabled={!editedContent.trim() || editedContent === message.content}
214+
>
215+
<Check class="mr-1 h-3 w-3" />
216+
Send
217+
</Button>
218+
</div>
143219
</div>
144-
</Card>
220+
{:else}
221+
<!-- Display mode -->
222+
<Card class="bg-primary text-primary-foreground max-w-[80%] rounded-2xl px-2.5 py-1.5">
223+
<div class="text-md whitespace-pre-wrap">
224+
{message.content}
225+
</div>
226+
</Card>
145227

146-
<div class="relative flex h-6 items-center">
147-
{@render messageActions({ role: 'user' })}
148-
</div>
228+
<div class="relative flex h-6 items-center">
229+
{@render messageActions({ role: 'user' })}
230+
</div>
231+
{/if}
149232
</div>
150233
{:else}
151234
<div
152235
class="text-md leading-7.5 group w-full {className}"
153236
role="group"
154237
aria-label="Assistant message with actions"
155238
>
156-
{#if parsedContent().thinking}
157-
<ThinkingSection thinking={parsedContent().thinking || ''} />
239+
{#if thinkingContent}
240+
<ThinkingSection thinking={thinkingContent} isStreaming={!message.timestamp} />
158241
{/if}
159242
{#if message.role === 'assistant'}
160-
<MarkdownContent content={parsedContent().content} />
243+
<MarkdownContent content={messageContent} />
161244
{:else}
162245
<div class="whitespace-pre-wrap text-sm">
163-
{parsedContent().content}
246+
{messageContent}
164247
</div>
165248
{/if}
166249

tools/server/webui/src/lib/components/chat/ChatMessages.svelte

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
<script lang="ts">
2-
import type { ChatMessageData } from '$lib/types/chat';
2+
import type { DatabaseChatMessage } from '$lib/types/database';
3+
import { updateMessage } from '$lib/stores/chat.svelte';
34
import ChatMessage from './ChatMessage.svelte';
45
interface Props {
56
class?: string;
6-
messages?: ChatMessageData[];
7+
messages?: DatabaseChatMessage[];
78
isLoading?: boolean;
89
}
910
@@ -65,9 +66,15 @@
6566
class="bg-background flex-1 overflow-y-auto p-4"
6667
onscroll={handleScroll}
6768
>
68-
<div class="mb-48 mt-16 space-y-4">
69+
<div class="mb-48 mt-16 space-y-6">
6970
{#each messages as message}
70-
<ChatMessage class="mx-auto w-full max-w-[56rem]" {message} />
71+
<ChatMessage
72+
class="mx-auto w-full max-w-[56rem]"
73+
{message}
74+
onUpdateMessage={async (msg, newContent) => {
75+
await updateMessage(msg.id, newContent);
76+
}}
77+
/>
7178
{/each}
7279

7380
<!-- {#if isLoading}

tools/server/webui/src/lib/components/chat/ChatScreen.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@
2828
<ChatMessages class="mb-36" messages={activeChatMessages()} isLoading={isLoading()} />
2929

3030
<div
31-
class="sticky bottom-0 m-auto max-w-[56rem]"
32-
style="translate: -1rem;"
31+
class="z-999 sticky bottom-0 m-auto max-w-[56rem]"
3332
in:slide={{ duration: 400, axis: 'y' }}
3433
>
3534
<div class="bg-background m-auto rounded-t-3xl border-t pb-4">

tools/server/webui/src/lib/components/chat/MarkdownContent.svelte

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -81,35 +81,9 @@
8181
const rawCode = codeContent.getAttribute('data-raw-code');
8282
if (!rawCode) return;
8383
84-
try {
85-
// Decode HTML entities
86-
const decodedCode = rawCode
87-
.replace(/&amp;/g, '&')
88-
.replace(/&lt;/g, '<')
89-
.replace(/&gt;/g, '>')
90-
.replace(/&quot;/g, '"')
91-
.replace(/&#39;/g, "'");
92-
93-
await navigator.clipboard.writeText(decodedCode);
94-
95-
// Visual feedback
96-
target.innerHTML = `
97-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
98-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
99-
</svg>
100-
`;
101-
102-
// Reset icon after 2 seconds
103-
setTimeout(() => {
104-
target.innerHTML = `
105-
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
106-
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
107-
</svg>
108-
`;
109-
}, 2000);
110-
} catch (error) {
111-
console.error('Failed to copy code:', error);
112-
}
84+
// Use the reusable copy function
85+
const { copyCodeToClipboard } = await import('$lib/utils/copy');
86+
await copyCodeToClipboard(rawCode);
11387
});
11488
});
11589
}

tools/server/webui/src/lib/components/chat/ThinkingSection.svelte

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import MarkdownContent from './MarkdownContent.svelte';
77
88
interface Props {
9-
thinking: string;
9+
thinking: string | null;
1010
isStreaming?: boolean;
1111
class?: string;
1212
}
@@ -16,7 +16,7 @@
1616
let isExpanded = $state(false);
1717
</script>
1818

19-
<Card class="border-muted bg-muted/30 mb-3 {className}">
19+
<Card class="border-muted bg-muted/30 mb-6 gap-0 py-0 {className}">
2020
<Button
2121
variant="ghost"
2222
class="h-auto w-full justify-between p-3 font-normal"
@@ -25,7 +25,7 @@
2525
<div class="text-muted-foreground flex items-center gap-2">
2626
<Brain class="h-4 w-4" />
2727
<span class="text-sm">
28-
{isStreaming ? 'Thinking...' : 'Rozważanie skali ludzkiej w stoiku'}
28+
{isStreaming ? 'Thinking...' : 'Thinking summary'}
2929
</span>
3030
</div>
3131
<ChevronDown
@@ -39,7 +39,7 @@
3939
<div class="border-muted border-t px-3 pb-3" transition:slide={{ duration: 200 }}>
4040
<div class="pt-3">
4141
<MarkdownContent
42-
content={thinking}
42+
content={thinking || ''}
4343
variant="thinking"
4444
class="text-xs leading-relaxed"
4545
/>

0 commit comments

Comments
 (0)