Przeglądaj źródła

webui: Improve copy to clipboard with text attachments (#17969)

* feat: Create copy/paste user message including "pasted text" attachments

* chore: update webui build output

* chore: update webui static output

* fix: UI issues

* chore: update webui static output

* fix: Decode HTML entities using `DOMParser`

* chore: update webui build output

* chore: update webui static output
Aleksander Grygier 1 miesiąc temu
rodzic
commit
3034836d36

BIN
tools/server/public/index.html.gz


+ 6 - 5
tools/server/webui/README.md

@@ -619,11 +619,12 @@ flowchart TB
 
 ### Test Types
 
-| Type          | Tool               | Location                         | Command             |
-| ------------- | ------------------ | -------------------------------- | ------------------- |
-| **E2E**       | Playwright         | `tests/e2e/`                     | `npm run test:e2e`  |
-| **Unit**      | Vitest             | `tests/client/`, `tests/server/` | `npm run test:unit` |
-| **UI/Visual** | Storybook + Vitest | `tests/stories/`                 | `npm run test:ui`   |
+| Type          | Tool               | Location         | Command             |
+| ------------- | ------------------ | ---------------- | ------------------- |
+| **Unit**      | Vitest             | `tests/unit/`    | `npm run test:unit` |
+| **UI/Visual** | Storybook + Vitest | `tests/stories/` | `npm run test:ui`   |
+| **E2E**       | Playwright         | `tests/e2e/`     | `npm run test:e2e`  |
+| **Client**    | Vitest             | `tests/client/`. | `npm run test:unit` |
 
 ### Running Tests
 

+ 2 - 3
tools/server/webui/package.json

@@ -13,12 +13,11 @@
 		"reset": "rm -rf .svelte-kit node_modules",
 		"format": "prettier --write .",
 		"lint": "prettier --check . && eslint .",
-		"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:server -- --run && npm run test:e2e",
+		"test": "npm run test:ui -- --run && npm run test:client -- --run && npm run test:unit -- --run && npm run test:e2e",
 		"test:e2e": "playwright test",
 		"test:client": "vitest --project=client",
-		"test:server": "vitest --project=server",
+		"test:unit": "vitest --project=unit",
 		"test:ui": "vitest --project=ui",
-		"test:unit": "vitest",
 		"storybook": "storybook dev -p 6006",
 		"build-storybook": "storybook build",
 		"cleanup": "rm -rf .svelte-kit build node_modules test-results"

+ 1 - 1
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte

@@ -241,7 +241,7 @@
 				</div>
 			{/if}
 		{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
-			<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="69rem" />
+			<SyntaxHighlightedCode code={displayTextContent} {language} maxWidth="calc(69rem - 2rem)" />
 		{:else if isAudio}
 			<div class="flex items-center justify-center p-8">
 				<div class="w-full max-w-md text-center">

+ 26 - 2
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte

@@ -24,7 +24,7 @@
 		MimeTypeImage,
 		MimeTypeText
 	} from '$lib/enums';
-	import { isIMEComposing } from '$lib/utils';
+	import { isIMEComposing, parseClipboardContent } from '$lib/utils';
 	import {
 		AudioRecorder,
 		convertToWav,
@@ -191,7 +191,6 @@
 
 			if ((!message.trim() && uploadedFiles.length === 0) || disabled || isLoading) return;
 
-			// Check if model is selected first
 			if (!checkModelSelected()) return;
 
 			const messageToSend = message.trim();
@@ -228,6 +227,31 @@
 
 		const text = event.clipboardData.getData(MimeTypeText.PLAIN);
 
+		if (text.startsWith('"')) {
+			const parsed = parseClipboardContent(text);
+
+			if (parsed.textAttachments.length > 0) {
+				event.preventDefault();
+
+				message = parsed.message;
+
+				const attachmentFiles = parsed.textAttachments.map(
+					(att) =>
+						new File([att.content], att.name, {
+							type: MimeTypeText.PLAIN
+						})
+				);
+
+				onFileUpload?.(attachmentFiles);
+
+				setTimeout(() => {
+					textareaRef?.focus();
+				}, 10);
+
+				return;
+			}
+		}
+
 		if (
 			text.length > 0 &&
 			pasteLongTextToFileLength > 0 &&

+ 5 - 2
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte

@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { chatStore } from '$lib/stores/chat.svelte';
-	import { copyToClipboard, isIMEComposing } from '$lib/utils';
+	import { config } from '$lib/stores/settings.svelte';
+	import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
 	import ChatMessageAssistant from './ChatMessageAssistant.svelte';
 	import ChatMessageUser from './ChatMessageUser.svelte';
 	import ChatMessageSystem from './ChatMessageSystem.svelte';
@@ -87,7 +88,9 @@
 	}
 
 	async function handleCopy() {
-		await copyToClipboard(message.content, 'Message copied to clipboard');
+		const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
+		const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
+		await copyToClipboard(clipboardContent, 'Message copied to clipboard');
 		onCopy?.(message);
 	}
 

+ 5 - 0
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte

@@ -57,6 +57,11 @@
 					label: 'Paste long text to file length',
 					type: 'input'
 				},
+				{
+					key: 'copyTextAttachmentsAsPlainText',
+					label: 'Copy text attachments as plain text',
+					type: 'checkbox'
+				},
 				{
 					key: 'enableContinueGeneration',
 					label: 'Enable "Continue" button',

+ 3 - 2
tools/server/webui/src/lib/components/app/misc/SyntaxHighlightedCode.svelte

@@ -72,9 +72,10 @@
 
 <div
 	class="code-preview-wrapper overflow-auto rounded-lg border border-border bg-muted {className}"
-	style="max-height: {maxHeight};"
+	style="max-height: {maxHeight}; max-width: {maxWidth};"
 >
-	<pre class="m-0 overflow-x-auto p-4 max-w-[{maxWidth}]"><code class="hljs text-sm leading-relaxed"
+	<!-- Needs to be formatted as single line for proper rendering -->
+	<pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
 			>{@html highlightedHtml}</code
 		></pre>
 </div>

+ 3 - 0
tools/server/webui/src/lib/constants/settings-config.ts

@@ -12,6 +12,7 @@ export const SETTING_CONFIG_DEFAULT: Record<string, string | number | boolean> =
 	showMessageStats: true,
 	askForTitleConfirmation: false,
 	pasteLongTextToFileLen: 2500,
+	copyTextAttachmentsAsPlainText: false,
 	pdfAsImage: false,
 	disableAutoScroll: false,
 	renderUserContentAsMarkdown: false,
@@ -52,6 +53,8 @@ export const SETTING_CONFIG_INFO: Record<string, string> = {
 		'Choose the color theme for the interface. You can choose between System (follows your device settings), Light, or Dark.',
 	pasteLongTextToFileLen:
 		'On pasting long text, it will be converted to a file. You can control the file length by setting the value of this parameter. Value 0 means disable.',
+	copyTextAttachmentsAsPlainText:
+		'When copying a message with text attachments, combine them into a single plain text string instead of a special format that can be pasted back as attachments.',
 	samplers:
 		'The order at which samplers are applied, in simplified way. Default is "top_k;typ_p;top_p;min_p;temperature": top_k->typ_p->top_p->min_p->temperature',
 	temperature:

+ 262 - 0
tools/server/webui/src/lib/utils/clipboard.ts

@@ -0,0 +1,262 @@
+import { toast } from 'svelte-sonner';
+import { AttachmentType } from '$lib/enums';
+import type {
+	DatabaseMessageExtra,
+	DatabaseMessageExtraTextFile,
+	DatabaseMessageExtraLegacyContext
+} from '$lib/types/database';
+
+/**
+ * Copy text to clipboard with toast notification
+ * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
+ * @param text - Text to copy to clipboard
+ * @param successMessage - Custom success message (optional)
+ * @param errorMessage - Custom error message (optional)
+ * @returns Promise<boolean> - True if successful, false otherwise
+ */
+export async function copyToClipboard(
+	text: string,
+	successMessage = 'Copied to clipboard',
+	errorMessage = 'Failed to copy to clipboard'
+): Promise<boolean> {
+	try {
+		// Try modern clipboard API first (secure contexts only)
+		if (navigator.clipboard && navigator.clipboard.writeText) {
+			await navigator.clipboard.writeText(text);
+			toast.success(successMessage);
+			return true;
+		}
+
+		// Fallback for non-secure contexts
+		const textArea = document.createElement('textarea');
+		textArea.value = text;
+		textArea.style.position = 'fixed';
+		textArea.style.left = '-999999px';
+		textArea.style.top = '-999999px';
+		document.body.appendChild(textArea);
+		textArea.focus();
+		textArea.select();
+
+		const successful = document.execCommand('copy');
+		document.body.removeChild(textArea);
+
+		if (successful) {
+			toast.success(successMessage);
+			return true;
+		} else {
+			throw new Error('execCommand failed');
+		}
+	} catch (error) {
+		console.error('Failed to copy to clipboard:', error);
+		toast.error(errorMessage);
+		return false;
+	}
+}
+
+/**
+ * Copy code with HTML entity decoding and toast notification
+ * @param rawCode - Raw code string that may contain HTML entities
+ * @param successMessage - Custom success message (optional)
+ * @param errorMessage - Custom error message (optional)
+ * @returns Promise<boolean> - True if successful, false otherwise
+ */
+export async function copyCodeToClipboard(
+	rawCode: string,
+	successMessage = 'Code copied to clipboard',
+	errorMessage = 'Failed to copy code'
+): Promise<boolean> {
+	const doc = new DOMParser().parseFromString(rawCode, 'text/html');
+	const decodedCode = doc.body.textContent ?? rawCode;
+
+	return copyToClipboard(decodedCode, successMessage, errorMessage);
+}
+
+/**
+ * Format for text attachments when copied to clipboard
+ */
+export interface ClipboardTextAttachment {
+	type: typeof AttachmentType.TEXT;
+	name: string;
+	content: string;
+}
+
+/**
+ * Parsed result from clipboard content
+ */
+export interface ParsedClipboardContent {
+	message: string;
+	textAttachments: ClipboardTextAttachment[];
+}
+
+/**
+ * Formats a message with text attachments for clipboard copying.
+ *
+ * Default format (asPlainText = false):
+ * ```
+ * "Text message content"
+ * [
+ *   {"type":"TEXT","name":"filename.txt","content":"..."},
+ *   {"type":"TEXT","name":"another.txt","content":"..."}
+ * ]
+ * ```
+ *
+ * Plain text format (asPlainText = true):
+ * ```
+ * Text message content
+ *
+ * file content here
+ *
+ * another file content
+ * ```
+ *
+ * @param content - The message text content
+ * @param extras - Optional array of message attachments
+ * @param asPlainText - If true, format as plain text without JSON structure
+ * @returns Formatted string for clipboard
+ */
+export function formatMessageForClipboard(
+	content: string,
+	extras?: DatabaseMessageExtra[],
+	asPlainText: boolean = false
+): string {
+	// Filter only text attachments (TEXT type and legacy CONTEXT type)
+	const textAttachments =
+		extras?.filter(
+			(extra): extra is DatabaseMessageExtraTextFile | DatabaseMessageExtraLegacyContext =>
+				extra.type === AttachmentType.TEXT || extra.type === AttachmentType.LEGACY_CONTEXT
+		) ?? [];
+
+	if (textAttachments.length === 0) {
+		return content;
+	}
+
+	if (asPlainText) {
+		const parts = [content];
+		for (const att of textAttachments) {
+			parts.push(att.content);
+		}
+		return parts.join('\n\n');
+	}
+
+	const clipboardAttachments: ClipboardTextAttachment[] = textAttachments.map((att) => ({
+		type: AttachmentType.TEXT,
+		name: att.name,
+		content: att.content
+	}));
+
+	return `${JSON.stringify(content)}\n${JSON.stringify(clipboardAttachments, null, 2)}`;
+}
+
+/**
+ * Parses clipboard content to extract message and text attachments.
+ * Supports both plain text and the special format with attachments.
+ *
+ * @param clipboardText - Raw text from clipboard
+ * @returns Parsed content with message and attachments
+ */
+export function parseClipboardContent(clipboardText: string): ParsedClipboardContent {
+	const defaultResult: ParsedClipboardContent = {
+		message: clipboardText,
+		textAttachments: []
+	};
+
+	if (!clipboardText.startsWith('"')) {
+		return defaultResult;
+	}
+
+	try {
+		let stringEndIndex = -1;
+		let escaped = false;
+
+		for (let i = 1; i < clipboardText.length; i++) {
+			const char = clipboardText[i];
+
+			if (escaped) {
+				escaped = false;
+				continue;
+			}
+
+			if (char === '\\') {
+				escaped = true;
+				continue;
+			}
+
+			if (char === '"') {
+				stringEndIndex = i;
+				break;
+			}
+		}
+
+		if (stringEndIndex === -1) {
+			return defaultResult;
+		}
+
+		const jsonStringPart = clipboardText.substring(0, stringEndIndex + 1);
+		const remainingPart = clipboardText.substring(stringEndIndex + 1).trim();
+
+		const message = JSON.parse(jsonStringPart) as string;
+
+		if (!remainingPart || !remainingPart.startsWith('[')) {
+			return {
+				message,
+				textAttachments: []
+			};
+		}
+
+		const attachments = JSON.parse(remainingPart) as unknown[];
+
+		const validAttachments: ClipboardTextAttachment[] = [];
+
+		for (const att of attachments) {
+			if (isValidTextAttachment(att)) {
+				validAttachments.push({
+					type: AttachmentType.TEXT,
+					name: att.name,
+					content: att.content
+				});
+			}
+		}
+
+		return {
+			message,
+			textAttachments: validAttachments
+		};
+	} catch {
+		return defaultResult;
+	}
+}
+
+/**
+ * Type guard to validate a text attachment object
+ * @param obj The object to validate
+ * @returns true if the object is a valid text attachment
+ */
+function isValidTextAttachment(
+	obj: unknown
+): obj is { type: string; name: string; content: string } {
+	if (typeof obj !== 'object' || obj === null) {
+		return false;
+	}
+
+	const record = obj as Record<string, unknown>;
+
+	return (
+		(record.type === AttachmentType.TEXT || record.type === 'TEXT') &&
+		typeof record.name === 'string' &&
+		typeof record.content === 'string'
+	);
+}
+
+/**
+ * Checks if clipboard content contains our special format with attachments
+ * @param clipboardText - Raw text from clipboard
+ * @returns true if the clipboard content contains our special format with attachments
+ */
+export function hasClipboardAttachments(clipboardText: string): boolean {
+	if (!clipboardText.startsWith('"')) {
+		return false;
+	}
+
+	const parsed = parseClipboardContent(clipboardText);
+	return parsed.textAttachments.length > 0;
+}

+ 0 - 71
tools/server/webui/src/lib/utils/copy.ts

@@ -1,71 +0,0 @@
-import { toast } from 'svelte-sonner';
-
-/**
- * Copy text to clipboard with toast notification
- * Uses modern clipboard API when available, falls back to legacy method for non-secure contexts
- * @param text - Text to copy to clipboard
- * @param successMessage - Custom success message (optional)
- * @param errorMessage - Custom error message (optional)
- * @returns Promise<boolean> - True if successful, false otherwise
- */
-export async function copyToClipboard(
-	text: string,
-	successMessage = 'Copied to clipboard',
-	errorMessage = 'Failed to copy to clipboard'
-): Promise<boolean> {
-	try {
-		// Try modern clipboard API first (secure contexts only)
-		if (navigator.clipboard && navigator.clipboard.writeText) {
-			await navigator.clipboard.writeText(text);
-			toast.success(successMessage);
-			return true;
-		}
-
-		// Fallback for non-secure contexts
-		const textArea = document.createElement('textarea');
-		textArea.value = text;
-		textArea.style.position = 'fixed';
-		textArea.style.left = '-999999px';
-		textArea.style.top = '-999999px';
-		document.body.appendChild(textArea);
-		textArea.focus();
-		textArea.select();
-
-		const successful = document.execCommand('copy');
-		document.body.removeChild(textArea);
-
-		if (successful) {
-			toast.success(successMessage);
-			return true;
-		} else {
-			throw new Error('execCommand failed');
-		}
-	} catch (error) {
-		console.error('Failed to copy to clipboard:', error);
-		toast.error(errorMessage);
-		return false;
-	}
-}
-
-/**
- * Copy code with HTML entity decoding and toast notification
- * @param rawCode - Raw code string that may contain HTML entities
- * @param successMessage - Custom success message (optional)
- * @param errorMessage - Custom error message (optional)
- * @returns Promise<boolean> - True if successful, false otherwise
- */
-export async function copyCodeToClipboard(
-	rawCode: string,
-	successMessage = 'Code copied to clipboard',
-	errorMessage = 'Failed to copy code'
-): Promise<boolean> {
-	// Decode HTML entities
-	const decodedCode = rawCode
-		.replace(/&amp;/g, '&')
-		.replace(/&lt;/g, '<')
-		.replace(/&gt;/g, '>')
-		.replace(/&quot;/g, '"')
-		.replace(/&#39;/g, "'");
-
-	return copyToClipboard(decodedCode, successMessage, errorMessage);
-}

+ 9 - 1
tools/server/webui/src/lib/utils/index.ts

@@ -40,7 +40,15 @@ export { setConfigValue, getConfigValue, configToParameterRecord } from './confi
 export { createMessageCountMap, getMessageCount } from './conversation-utils';
 
 // Clipboard utilities
-export { copyToClipboard, copyCodeToClipboard } from './copy';
+export {
+	copyToClipboard,
+	copyCodeToClipboard,
+	formatMessageForClipboard,
+	parseClipboardContent,
+	hasClipboardAttachments,
+	type ClipboardTextAttachment,
+	type ParsedClipboardContent
+} from './clipboard';
 
 // File preview utilities
 export { getFileTypeLabel } from './file-preview';

+ 0 - 7
tools/server/webui/tests/server/demo.spec.ts

@@ -1,7 +0,0 @@
-import { describe, it, expect } from 'vitest';
-
-describe('sum test', () => {
-	it('adds 1 + 2 to equal 3', () => {
-		expect(1 + 2).toBe(3);
-	});
-});

+ 423 - 0
tools/server/webui/tests/unit/clipboard.test.ts

@@ -0,0 +1,423 @@
+import { describe, it, expect } from 'vitest';
+import { AttachmentType } from '$lib/enums';
+import {
+	formatMessageForClipboard,
+	parseClipboardContent,
+	hasClipboardAttachments
+} from '$lib/utils/clipboard';
+
+describe('formatMessageForClipboard', () => {
+	it('returns plain content when no extras', () => {
+		const result = formatMessageForClipboard('Hello world', undefined);
+		expect(result).toBe('Hello world');
+	});
+
+	it('returns plain content when extras is empty array', () => {
+		const result = formatMessageForClipboard('Hello world', []);
+		expect(result).toBe('Hello world');
+	});
+
+	it('handles empty string content', () => {
+		const result = formatMessageForClipboard('', undefined);
+		expect(result).toBe('');
+	});
+
+	it('returns plain content when extras has only non-text attachments', () => {
+		const extras = [
+			{
+				type: AttachmentType.IMAGE as const,
+				name: 'image.png',
+				base64Url: 'data:image/png;base64,...'
+			}
+		];
+		const result = formatMessageForClipboard('Hello world', extras);
+		expect(result).toBe('Hello world');
+	});
+
+	it('filters non-text attachments and keeps only text ones', () => {
+		const extras = [
+			{
+				type: AttachmentType.IMAGE as const,
+				name: 'image.png',
+				base64Url: 'data:image/png;base64,...'
+			},
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file.txt',
+				content: 'Text content'
+			},
+			{
+				type: AttachmentType.PDF as const,
+				name: 'doc.pdf',
+				base64Data: 'data:application/pdf;base64,...',
+				content: 'PDF content',
+				processedAsImages: false
+			}
+		];
+		const result = formatMessageForClipboard('Hello', extras);
+
+		expect(result).toContain('"file.txt"');
+		expect(result).not.toContain('image.png');
+		expect(result).not.toContain('doc.pdf');
+	});
+
+	it('formats message with text attachments', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file1.txt',
+				content: 'File 1 content'
+			},
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file2.txt',
+				content: 'File 2 content'
+			}
+		];
+		const result = formatMessageForClipboard('Hello world', extras);
+
+		expect(result).toContain('"Hello world"');
+		expect(result).toContain('"type": "TEXT"');
+		expect(result).toContain('"name": "file1.txt"');
+		expect(result).toContain('"content": "File 1 content"');
+		expect(result).toContain('"name": "file2.txt"');
+	});
+
+	it('handles content with quotes and special characters', () => {
+		const content = 'Hello "world" with\nnewline';
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'test.txt',
+				content: 'Test content'
+			}
+		];
+		const result = formatMessageForClipboard(content, extras);
+
+		// Should be valid JSON
+		expect(result.startsWith('"')).toBe(true);
+		// The content should be properly escaped
+		const parsed = JSON.parse(result.split('\n')[0]);
+		expect(parsed).toBe(content);
+	});
+
+	it('converts legacy context type to TEXT type', () => {
+		const extras = [
+			{
+				type: AttachmentType.LEGACY_CONTEXT as const,
+				name: 'legacy.txt',
+				content: 'Legacy content'
+			}
+		];
+		const result = formatMessageForClipboard('Hello', extras);
+
+		expect(result).toContain('"type": "TEXT"');
+		expect(result).not.toContain('"context"');
+	});
+
+	it('handles attachment content with special characters', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'code.js',
+				content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;'
+			}
+		];
+		const formatted = formatMessageForClipboard('Check this code', extras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.textAttachments[0].content).toBe(
+			'const x = "hello\\nworld";\nconst y = `template ${var}`;'
+		);
+	});
+
+	it('handles unicode characters in content and attachments', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'unicode.txt',
+				content: '日本語テスト 🎉 émojis'
+			}
+		];
+		const formatted = formatMessageForClipboard('Привет мир 👋', extras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.message).toBe('Привет мир 👋');
+		expect(parsed.textAttachments[0].content).toBe('日本語テスト 🎉 émojis');
+	});
+
+	it('formats as plain text when asPlainText is true', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file1.txt',
+				content: 'File 1 content'
+			},
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file2.txt',
+				content: 'File 2 content'
+			}
+		];
+		const result = formatMessageForClipboard('Hello world', extras, true);
+
+		expect(result).toBe('Hello world\n\nFile 1 content\n\nFile 2 content');
+	});
+
+	it('returns plain content when asPlainText is true but no attachments', () => {
+		const result = formatMessageForClipboard('Hello world', [], true);
+		expect(result).toBe('Hello world');
+	});
+
+	it('plain text mode does not use JSON format', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'test.txt',
+				content: 'Test content'
+			}
+		];
+		const result = formatMessageForClipboard('Hello', extras, true);
+
+		expect(result).not.toContain('"type"');
+		expect(result).not.toContain('[');
+		expect(result).toBe('Hello\n\nTest content');
+	});
+});
+
+describe('parseClipboardContent', () => {
+	it('returns plain text as message when not in special format', () => {
+		const result = parseClipboardContent('Hello world');
+
+		expect(result.message).toBe('Hello world');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('handles empty string input', () => {
+		const result = parseClipboardContent('');
+
+		expect(result.message).toBe('');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('handles whitespace-only input', () => {
+		const result = parseClipboardContent('   \n\t  ');
+
+		expect(result.message).toBe('   \n\t  ');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('returns plain text as message when starts with quote but invalid format', () => {
+		const result = parseClipboardContent('"Unclosed quote');
+
+		expect(result.message).toBe('"Unclosed quote');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('returns original text when JSON array is malformed', () => {
+		const input = '"Hello"\n[invalid json';
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('"Hello"\n[invalid json');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('parses message with text attachments', () => {
+		const input = `"Hello world"
+[
+  {"type":"TEXT","name":"file1.txt","content":"File 1 content"},
+  {"type":"TEXT","name":"file2.txt","content":"File 2 content"}
+]`;
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('Hello world');
+		expect(result.textAttachments).toHaveLength(2);
+		expect(result.textAttachments[0].name).toBe('file1.txt');
+		expect(result.textAttachments[0].content).toBe('File 1 content');
+		expect(result.textAttachments[1].name).toBe('file2.txt');
+		expect(result.textAttachments[1].content).toBe('File 2 content');
+	});
+
+	it('handles escaped quotes in message', () => {
+		const input = `"Hello \\"world\\" with quotes"
+[
+  {"type":"TEXT","name":"file.txt","content":"test"}
+]`;
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('Hello "world" with quotes');
+		expect(result.textAttachments).toHaveLength(1);
+	});
+
+	it('handles newlines in message', () => {
+		const input = `"Hello\\nworld"
+[
+  {"type":"TEXT","name":"file.txt","content":"test"}
+]`;
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('Hello\nworld');
+		expect(result.textAttachments).toHaveLength(1);
+	});
+
+	it('returns message only when no array follows', () => {
+		const input = '"Just a quoted string"';
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('Just a quoted string');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('filters out invalid attachment objects', () => {
+		const input = `"Hello"
+[
+  {"type":"TEXT","name":"valid.txt","content":"valid"},
+  {"type":"INVALID","name":"invalid.txt","content":"invalid"},
+  {"name":"missing-type.txt","content":"missing"},
+  {"type":"TEXT","content":"missing name"}
+]`;
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('Hello');
+		expect(result.textAttachments).toHaveLength(1);
+		expect(result.textAttachments[0].name).toBe('valid.txt');
+	});
+
+	it('handles empty attachments array', () => {
+		const input = '"Hello"\n[]';
+
+		const result = parseClipboardContent(input);
+
+		expect(result.message).toBe('Hello');
+		expect(result.textAttachments).toHaveLength(0);
+	});
+
+	it('roundtrips correctly with formatMessageForClipboard', () => {
+		const originalContent = 'Hello "world" with\nspecial characters';
+		const originalExtras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file1.txt',
+				content: 'Content with\nnewlines and "quotes"'
+			},
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file2.txt',
+				content: 'Another file'
+			}
+		];
+
+		const formatted = formatMessageForClipboard(originalContent, originalExtras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.message).toBe(originalContent);
+		expect(parsed.textAttachments).toHaveLength(2);
+		expect(parsed.textAttachments[0].name).toBe('file1.txt');
+		expect(parsed.textAttachments[0].content).toBe('Content with\nnewlines and "quotes"');
+		expect(parsed.textAttachments[1].name).toBe('file2.txt');
+		expect(parsed.textAttachments[1].content).toBe('Another file');
+	});
+});
+
+describe('hasClipboardAttachments', () => {
+	it('returns false for plain text', () => {
+		expect(hasClipboardAttachments('Hello world')).toBe(false);
+	});
+
+	it('returns false for empty string', () => {
+		expect(hasClipboardAttachments('')).toBe(false);
+	});
+
+	it('returns false for quoted string without attachments', () => {
+		expect(hasClipboardAttachments('"Hello world"')).toBe(false);
+	});
+
+	it('returns true for valid format with attachments', () => {
+		const input = `"Hello"
+[{"type":"TEXT","name":"file.txt","content":"test"}]`;
+
+		expect(hasClipboardAttachments(input)).toBe(true);
+	});
+
+	it('returns false for format with empty attachments array', () => {
+		const input = '"Hello"\n[]';
+
+		expect(hasClipboardAttachments(input)).toBe(false);
+	});
+
+	it('returns false for malformed JSON', () => {
+		expect(hasClipboardAttachments('"Hello"\n[broken')).toBe(false);
+	});
+});
+
+describe('roundtrip edge cases', () => {
+	it('preserves empty message with attachments', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'file.txt',
+				content: 'Content only'
+			}
+		];
+		const formatted = formatMessageForClipboard('', extras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.message).toBe('');
+		expect(parsed.textAttachments).toHaveLength(1);
+		expect(parsed.textAttachments[0].content).toBe('Content only');
+	});
+
+	it('preserves attachment with empty content', () => {
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'empty.txt',
+				content: ''
+			}
+		];
+		const formatted = formatMessageForClipboard('Message', extras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.message).toBe('Message');
+		expect(parsed.textAttachments).toHaveLength(1);
+		expect(parsed.textAttachments[0].content).toBe('');
+	});
+
+	it('preserves multiple backslashes', () => {
+		const content = 'Path: C:\\\\Users\\\\test\\\\file.txt';
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'path.txt',
+				content: 'D:\\\\Data\\\\file'
+			}
+		];
+		const formatted = formatMessageForClipboard(content, extras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.message).toBe(content);
+		expect(parsed.textAttachments[0].content).toBe('D:\\\\Data\\\\file');
+	});
+
+	it('preserves tabs and various whitespace', () => {
+		const content = 'Line1\t\tTabbed\n  Spaced\r\nCRLF';
+		const extras = [
+			{
+				type: AttachmentType.TEXT as const,
+				name: 'whitespace.txt',
+				content: '\t\t\n\n   '
+			}
+		];
+		const formatted = formatMessageForClipboard(content, extras);
+		const parsed = parseClipboardContent(formatted);
+
+		expect(parsed.message).toBe(content);
+		expect(parsed.textAttachments[0].content).toBe('\t\t\n\n   ');
+	});
+});

+ 1 - 1
tools/server/webui/src/lib/utils/latex-protection.test.ts → tools/server/webui/tests/unit/latex-protection.test.ts

@@ -1,6 +1,6 @@
 /* eslint-disable no-irregular-whitespace */
 import { describe, it, expect, test } from 'vitest';
-import { maskInlineLaTeX, preprocessLaTeX } from './latex-protection';
+import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
 
 describe('maskInlineLaTeX', () => {
 	it('should protect LaTeX $x + y$ but not money $3.99', () => {

+ 1 - 1
tools/server/webui/src/lib/utils/model-names.test.ts → tools/server/webui/tests/unit/model-names.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest';
-import { isValidModelName, normalizeModelName } from './model-names';
+import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
 
 describe('normalizeModelName', () => {
 	it('preserves Hugging Face org/model format (single slash)', () => {

+ 2 - 2
tools/server/webui/vite.config.ts

@@ -125,9 +125,9 @@ export default defineConfig({
 			{
 				extends: './vite.config.ts',
 				test: {
-					name: 'server',
+					name: 'unit',
 					environment: 'node',
-					include: ['tests/server/**/*.{test,spec}.{js,ts}']
+					include: ['tests/unit/**/*.{test,spec}.{js,ts}']
 				}
 			},
 			{