Browse Source

Improved file naming & structure for UI components (#17405)

* refactor: Component iles naming & structure

* chore: update webui build output

* refactor: Dialog titles + components namig

* chore: update webui build output

* refactor: Imports

* chore: update webui build output
Aleksander Grygier 1 month ago
parent
commit
4c91f2633f
33 changed files with 962 additions and 847 deletions
  1. BIN
      tools/server/public/index.html.gz
  2. 273 0
      tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreview.svelte
  3. 0 314
      tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte
  4. 0 0
      tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte
  5. 0 0
      tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte
  6. 6 7
      tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
  7. 52 65
      tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte
  8. 0 0
      tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte
  9. 0 0
      tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte
  10. 5 3
      tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
  11. 6 3
      tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
  12. 8 8
      tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
  13. 2 2
      tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
  14. 0 0
      tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte
  15. 107 132
      tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte
  16. 3 3
      tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte
  17. 3 3
      tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte
  18. 0 0
      tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte
  19. 0 249
      tools/server/webui/src/lib/components/app/chat/ChatSettings/ConversationSelectionDialog.svelte
  20. 2 2
      tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
  21. 78 0
      tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte
  22. 51 0
      tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte
  23. 0 0
      tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte
  24. 37 0
      tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte
  25. 0 0
      tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte
  26. 68 0
      tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte
  27. 0 0
      tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte
  28. 0 0
      tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte
  29. 35 28
      tools/server/webui/src/lib/components/app/index.ts
  30. 205 0
      tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte
  31. 2 2
      tools/server/webui/src/routes/+layout.svelte
  32. 19 0
      tools/server/webui/src/stories/ChatSettings.stories.svelte
  33. 0 26
      tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte

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


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

@@ -0,0 +1,273 @@
+<script lang="ts">
+	import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
+	import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
+	import { convertPDFToImage } from '$lib/utils/pdf-processing';
+	import { Button } from '$lib/components/ui/button';
+	import { getFileTypeCategory } from '$lib/utils/file-type';
+
+	interface Props {
+		// Either an uploaded file or a stored attachment
+		uploadedFile?: ChatUploadedFile;
+		attachment?: DatabaseMessageExtra;
+		// For uploaded files
+		preview?: string;
+		name?: string;
+		type?: string;
+		textContent?: string;
+	}
+
+	let { uploadedFile, attachment, preview, name, type, textContent }: Props = $props();
+
+	let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+	let displayPreview = $derived(
+		uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
+	);
+
+	let displayType = $derived(
+		uploadedFile?.type ||
+			(attachment?.type === 'imageFile'
+				? 'image'
+				: attachment?.type === 'textFile'
+					? 'text'
+					: attachment?.type === 'audioFile'
+						? attachment.mimeType || 'audio'
+						: attachment?.type === 'pdfFile'
+							? MimeTypeApplication.PDF
+							: type || 'unknown')
+	);
+
+	let displayTextContent = $derived(
+		uploadedFile?.textContent ||
+			(attachment?.type === 'textFile'
+				? attachment.content
+				: attachment?.type === 'pdfFile'
+					? attachment.content
+					: textContent)
+	);
+
+	let isAudio = $derived(
+		getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
+	);
+
+	let isImage = $derived(
+		getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
+	);
+
+	let isPdf = $derived(displayType === MimeTypeApplication.PDF);
+
+	let isText = $derived(
+		getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
+	);
+
+	let IconComponent = $derived(() => {
+		if (isImage) return Image;
+		if (isText || isPdf) return FileText;
+		if (isAudio) return Music;
+
+		return FileIcon;
+	});
+
+	let pdfViewMode = $state<'text' | 'pages'>('pages');
+
+	let pdfImages = $state<string[]>([]);
+
+	let pdfImagesLoading = $state(false);
+
+	let pdfImagesError = $state<string | null>(null);
+
+	async function loadPdfImages() {
+		if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
+
+		pdfImagesLoading = true;
+		pdfImagesError = null;
+
+		try {
+			let file: File | null = null;
+
+			if (uploadedFile?.file) {
+				file = uploadedFile.file;
+			} else if (attachment?.type === 'pdfFile') {
+				// Check if we have pre-processed images
+				if (attachment.images && Array.isArray(attachment.images)) {
+					pdfImages = attachment.images;
+					return;
+				}
+
+				// Convert base64 back to File for processing
+				if (attachment.base64Data) {
+					const base64Data = attachment.base64Data;
+					const byteCharacters = atob(base64Data);
+					const byteNumbers = new Array(byteCharacters.length);
+					for (let i = 0; i < byteCharacters.length; i++) {
+						byteNumbers[i] = byteCharacters.charCodeAt(i);
+					}
+					const byteArray = new Uint8Array(byteNumbers);
+					file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
+				}
+			}
+
+			if (file) {
+				pdfImages = await convertPDFToImage(file);
+			} else {
+				throw new Error('No PDF file available for conversion');
+			}
+		} catch (error) {
+			pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
+		} finally {
+			pdfImagesLoading = false;
+		}
+	}
+
+	export function reset() {
+		pdfImages = [];
+		pdfImagesLoading = false;
+		pdfImagesError = null;
+		pdfViewMode = 'pages';
+	}
+
+	$effect(() => {
+		if (isPdf && pdfViewMode === 'pages') {
+			loadPdfImages();
+		}
+	});
+</script>
+
+<div class="space-y-4">
+	<div class="flex items-center justify-end gap-6">
+		{#if isPdf}
+			<div class="flex items-center gap-2">
+				<Button
+					variant={pdfViewMode === 'text' ? 'default' : 'outline'}
+					size="sm"
+					onclick={() => (pdfViewMode = 'text')}
+					disabled={pdfImagesLoading}
+				>
+					<FileText class="mr-1 h-4 w-4" />
+
+					Text
+				</Button>
+
+				<Button
+					variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
+					size="sm"
+					onclick={() => {
+						pdfViewMode = 'pages';
+						loadPdfImages();
+					}}
+					disabled={pdfImagesLoading}
+				>
+					{#if pdfImagesLoading}
+						<div
+							class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
+						></div>
+					{:else}
+						<Eye class="mr-1 h-4 w-4" />
+					{/if}
+
+					Pages
+				</Button>
+			</div>
+		{/if}
+	</div>
+
+	<div class="flex-1 overflow-auto">
+		{#if isImage && displayPreview}
+			<div class="flex items-center justify-center">
+				<img
+					src={displayPreview}
+					alt={displayName}
+					class="max-h-full rounded-lg object-contain shadow-lg"
+				/>
+			</div>
+		{:else if isPdf && pdfViewMode === 'pages'}
+			{#if pdfImagesLoading}
+				<div class="flex items-center justify-center p-8">
+					<div class="text-center">
+						<div
+							class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
+						></div>
+
+						<p class="text-muted-foreground">Converting PDF to images...</p>
+					</div>
+				</div>
+			{:else if pdfImagesError}
+				<div class="flex items-center justify-center p-8">
+					<div class="text-center">
+						<FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+						<p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
+
+						<p class="text-sm text-muted-foreground">{pdfImagesError}</p>
+
+						<Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
+					</div>
+				</div>
+			{:else if pdfImages.length > 0}
+				<div class="max-h-[70vh] space-y-4 overflow-auto">
+					{#each pdfImages as image, index (image)}
+						<div class="text-center">
+							<p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
+
+							<img
+								src={image}
+								alt="PDF Page {index + 1}"
+								class="mx-auto max-w-full rounded-lg shadow-lg"
+							/>
+						</div>
+					{/each}
+				</div>
+			{:else}
+				<div class="flex items-center justify-center p-8">
+					<div class="text-center">
+						<FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+						<p class="mb-4 text-muted-foreground">No PDF pages available</p>
+					</div>
+				</div>
+			{/if}
+		{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
+			<div
+				class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
+			>
+				{displayTextContent}
+			</div>
+		{:else if isAudio}
+			<div class="flex items-center justify-center p-8">
+				<div class="w-full max-w-md text-center">
+					<Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+
+					{#if attachment?.type === 'audioFile'}
+						<audio
+							controls
+							class="mb-4 w-full"
+							src="data:{attachment.mimeType};base64,{attachment.base64Data}"
+						>
+							Your browser does not support the audio element.
+						</audio>
+					{:else if uploadedFile?.preview}
+						<audio controls class="mb-4 w-full" src={uploadedFile.preview}>
+							Your browser does not support the audio element.
+						</audio>
+					{:else}
+						<p class="mb-4 text-muted-foreground">Audio preview not available</p>
+					{/if}
+
+					<p class="text-sm text-muted-foreground">
+						{displayName}
+					</p>
+				</div>
+			</div>
+		{:else}
+			<div class="flex items-center justify-center p-8">
+				<div class="text-center">
+					{#if IconComponent}
+						<IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
+					{/if}
+
+					<p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
+				</div>
+			</div>
+		{/if}
+	</div>
+</div>

+ 0 - 314
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte

@@ -1,314 +0,0 @@
-<script lang="ts">
-	import * as Dialog from '$lib/components/ui/dialog';
-	import { FileText, Image, Music, FileIcon, Eye } from '@lucide/svelte';
-	import { FileTypeCategory, MimeTypeApplication } from '$lib/enums/files';
-	import { convertPDFToImage } from '$lib/utils/pdf-processing';
-	import { Button } from '$lib/components/ui/button';
-	import { getFileTypeCategory } from '$lib/utils/file-type';
-	import { formatFileSize } from '$lib/utils/file-preview';
-
-	interface Props {
-		open: boolean;
-		// Either an uploaded file or a stored attachment
-		uploadedFile?: ChatUploadedFile;
-		attachment?: DatabaseMessageExtra;
-		// For uploaded files
-		preview?: string;
-		name?: string;
-		type?: string;
-		size?: number;
-		textContent?: string;
-	}
-
-	let {
-		open = $bindable(),
-		uploadedFile,
-		attachment,
-		preview,
-		name,
-		type,
-		size,
-		textContent
-	}: Props = $props();
-
-	let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
-
-	let displayPreview = $derived(
-		uploadedFile?.preview || (attachment?.type === 'imageFile' ? attachment.base64Url : preview)
-	);
-
-	let displayType = $derived(
-		uploadedFile?.type ||
-			(attachment?.type === 'imageFile'
-				? 'image'
-				: attachment?.type === 'textFile'
-					? 'text'
-					: attachment?.type === 'audioFile'
-						? attachment.mimeType || 'audio'
-						: attachment?.type === 'pdfFile'
-							? MimeTypeApplication.PDF
-							: type || 'unknown')
-	);
-
-	let displaySize = $derived(uploadedFile?.size || size);
-
-	let displayTextContent = $derived(
-		uploadedFile?.textContent ||
-			(attachment?.type === 'textFile'
-				? attachment.content
-				: attachment?.type === 'pdfFile'
-					? attachment.content
-					: textContent)
-	);
-
-	let isAudio = $derived(
-		getFileTypeCategory(displayType) === FileTypeCategory.AUDIO || displayType === 'audio'
-	);
-
-	let isImage = $derived(
-		getFileTypeCategory(displayType) === FileTypeCategory.IMAGE || displayType === 'image'
-	);
-
-	let isPdf = $derived(displayType === MimeTypeApplication.PDF);
-
-	let isText = $derived(
-		getFileTypeCategory(displayType) === FileTypeCategory.TEXT || displayType === 'text'
-	);
-
-	let IconComponent = $derived(() => {
-		if (isImage) return Image;
-		if (isText || isPdf) return FileText;
-		if (isAudio) return Music;
-
-		return FileIcon;
-	});
-
-	let pdfViewMode = $state<'text' | 'pages'>('pages');
-
-	let pdfImages = $state<string[]>([]);
-
-	let pdfImagesLoading = $state(false);
-
-	let pdfImagesError = $state<string | null>(null);
-
-	async function loadPdfImages() {
-		if (!isPdf || pdfImages.length > 0 || pdfImagesLoading) return;
-
-		pdfImagesLoading = true;
-		pdfImagesError = null;
-
-		try {
-			let file: File | null = null;
-
-			if (uploadedFile?.file) {
-				file = uploadedFile.file;
-			} else if (attachment?.type === 'pdfFile') {
-				// Check if we have pre-processed images
-				if (attachment.images && Array.isArray(attachment.images)) {
-					pdfImages = attachment.images;
-					return;
-				}
-
-				// Convert base64 back to File for processing
-				if (attachment.base64Data) {
-					const base64Data = attachment.base64Data;
-					const byteCharacters = atob(base64Data);
-					const byteNumbers = new Array(byteCharacters.length);
-					for (let i = 0; i < byteCharacters.length; i++) {
-						byteNumbers[i] = byteCharacters.charCodeAt(i);
-					}
-					const byteArray = new Uint8Array(byteNumbers);
-					file = new File([byteArray], displayName, { type: MimeTypeApplication.PDF });
-				}
-			}
-
-			if (file) {
-				pdfImages = await convertPDFToImage(file);
-			} else {
-				throw new Error('No PDF file available for conversion');
-			}
-		} catch (error) {
-			pdfImagesError = error instanceof Error ? error.message : 'Failed to load PDF images';
-		} finally {
-			pdfImagesLoading = false;
-		}
-	}
-
-	$effect(() => {
-		if (open) {
-			pdfImages = [];
-			pdfImagesLoading = false;
-			pdfImagesError = null;
-			pdfViewMode = 'pages';
-		}
-	});
-
-	$effect(() => {
-		if (open && isPdf && pdfViewMode === 'pages') {
-			loadPdfImages();
-		}
-	});
-</script>
-
-<Dialog.Root bind:open>
-	<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden !p-10 sm:w-auto sm:max-w-6xl">
-		<Dialog.Header class="flex-shrink-0">
-			<div class="flex items-center justify-between gap-6">
-				<div class="flex items-center gap-3">
-					{#if IconComponent}
-						<IconComponent class="h-5 w-5 text-muted-foreground" />
-					{/if}
-
-					<div>
-						<Dialog.Title class="text-left">{displayName}</Dialog.Title>
-
-						<div class="flex items-center gap-2 text-sm text-muted-foreground">
-							<span>{displayType}</span>
-
-							{#if displaySize}
-								<span>•</span>
-
-								<span>{formatFileSize(displaySize)}</span>
-							{/if}
-						</div>
-					</div>
-				</div>
-
-				{#if isPdf}
-					<div class="flex items-center gap-2">
-						<Button
-							variant={pdfViewMode === 'text' ? 'default' : 'outline'}
-							size="sm"
-							onclick={() => (pdfViewMode = 'text')}
-							disabled={pdfImagesLoading}
-						>
-							<FileText class="mr-1 h-4 w-4" />
-
-							Text
-						</Button>
-
-						<Button
-							variant={pdfViewMode === 'pages' ? 'default' : 'outline'}
-							size="sm"
-							onclick={() => {
-								pdfViewMode = 'pages';
-								loadPdfImages();
-							}}
-							disabled={pdfImagesLoading}
-						>
-							{#if pdfImagesLoading}
-								<div
-									class="mr-1 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"
-								></div>
-							{:else}
-								<Eye class="mr-1 h-4 w-4" />
-							{/if}
-
-							Pages
-						</Button>
-					</div>
-				{/if}
-			</div>
-		</Dialog.Header>
-
-		<div class="flex-1 overflow-auto">
-			{#if isImage && displayPreview}
-				<div class="flex items-center justify-center">
-					<img
-						src={displayPreview}
-						alt={displayName}
-						class="max-h-full rounded-lg object-contain shadow-lg"
-					/>
-				</div>
-			{:else if isPdf && pdfViewMode === 'pages'}
-				{#if pdfImagesLoading}
-					<div class="flex items-center justify-center p-8">
-						<div class="text-center">
-							<div
-								class="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
-							></div>
-
-							<p class="text-muted-foreground">Converting PDF to images...</p>
-						</div>
-					</div>
-				{:else if pdfImagesError}
-					<div class="flex items-center justify-center p-8">
-						<div class="text-center">
-							<FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
-							<p class="mb-4 text-muted-foreground">Failed to load PDF images</p>
-
-							<p class="text-sm text-muted-foreground">{pdfImagesError}</p>
-
-							<Button class="mt-4" onclick={() => (pdfViewMode = 'text')}>View as Text</Button>
-						</div>
-					</div>
-				{:else if pdfImages.length > 0}
-					<div class="max-h-[70vh] space-y-4 overflow-auto">
-						{#each pdfImages as image, index (image)}
-							<div class="text-center">
-								<p class="mb-2 text-sm text-muted-foreground">Page {index + 1}</p>
-
-								<img
-									src={image}
-									alt="PDF Page {index + 1}"
-									class="mx-auto max-w-full rounded-lg shadow-lg"
-								/>
-							</div>
-						{/each}
-					</div>
-				{:else}
-					<div class="flex items-center justify-center p-8">
-						<div class="text-center">
-							<FileText class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
-							<p class="mb-4 text-muted-foreground">No PDF pages available</p>
-						</div>
-					</div>
-				{/if}
-			{:else if (isText || (isPdf && pdfViewMode === 'text')) && displayTextContent}
-				<div
-					class="max-h-[60vh] overflow-auto rounded-lg bg-muted p-4 font-mono text-sm break-words whitespace-pre-wrap"
-				>
-					{displayTextContent}
-				</div>
-			{:else if isAudio}
-				<div class="flex items-center justify-center p-8">
-					<div class="w-full max-w-md text-center">
-						<Music class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-
-						{#if attachment?.type === 'audioFile'}
-							<audio
-								controls
-								class="mb-4 w-full"
-								src="data:{attachment.mimeType};base64,{attachment.base64Data}"
-							>
-								Your browser does not support the audio element.
-							</audio>
-						{:else if uploadedFile?.preview}
-							<audio controls class="mb-4 w-full" src={uploadedFile.preview}>
-								Your browser does not support the audio element.
-							</audio>
-						{:else}
-							<p class="mb-4 text-muted-foreground">Audio preview not available</p>
-						{/if}
-
-						<p class="text-sm text-muted-foreground">
-							{displayName}
-						</p>
-					</div>
-				</div>
-			{:else}
-				<div class="flex items-center justify-center p-8">
-					<div class="text-center">
-						{#if IconComponent}
-							<IconComponent class="mx-auto mb-4 h-16 w-16 text-muted-foreground" />
-						{/if}
-
-						<p class="mb-4 text-muted-foreground">Preview not available for this file type</p>
-					</div>
-				</div>
-			{/if}
-		</div>
-	</Dialog.Content>
-</Dialog.Root>

+ 0 - 0
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte → tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte


+ 0 - 0
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte → tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte


+ 6 - 7
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte

@@ -1,11 +1,10 @@
 <script lang="ts">
 <script lang="ts">
-	import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
+	import { ChatAttachmentThumbnailImage, ChatAttachmentThumbnailFile } from '$lib/components/app';
 	import { Button } from '$lib/components/ui/button';
 	import { Button } from '$lib/components/ui/button';
 	import { ChevronLeft, ChevronRight } from '@lucide/svelte';
 	import { ChevronLeft, ChevronRight } from '@lucide/svelte';
 	import { FileTypeCategory } from '$lib/enums/files';
 	import { FileTypeCategory } from '$lib/enums/files';
 	import { getFileTypeCategory } from '$lib/utils/file-type';
 	import { getFileTypeCategory } from '$lib/utils/file-type';
-	import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
-	import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
+	import { DialogChatAttachmentPreview, DialogChatAttachmentsViewAll } from '$lib/components/app';
 	import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
 	import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
 
 
 	interface Props {
 	interface Props {
@@ -200,7 +199,7 @@
 			>
 			>
 				{#each displayItems as item (item.id)}
 				{#each displayItems as item (item.id)}
 					{#if item.isImage && item.preview}
 					{#if item.isImage && item.preview}
-						<ChatAttachmentImagePreview
+						<ChatAttachmentThumbnailImage
 							class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
 							class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
 							id={item.id}
 							id={item.id}
 							name={item.name}
 							name={item.name}
@@ -213,7 +212,7 @@
 							onClick={(event) => openPreview(item, event)}
 							onClick={(event) => openPreview(item, event)}
 						/>
 						/>
 					{:else}
 					{:else}
-						<ChatAttachmentFilePreview
+						<ChatAttachmentThumbnailFile
 							class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
 							class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
 							id={item.id}
 							id={item.id}
 							name={item.name}
 							name={item.name}
@@ -256,7 +255,7 @@
 {/if}
 {/if}
 
 
 {#if previewItem}
 {#if previewItem}
-	<ChatAttachmentPreviewDialog
+	<DialogChatAttachmentPreview
 		bind:open={previewDialogOpen}
 		bind:open={previewDialogOpen}
 		uploadedFile={previewItem.uploadedFile}
 		uploadedFile={previewItem.uploadedFile}
 		attachment={previewItem.attachment}
 		attachment={previewItem.attachment}
@@ -268,7 +267,7 @@
 	/>
 	/>
 {/if}
 {/if}
 
 
-<ChatAttachmentsViewAllDialog
+<DialogChatAttachmentsViewAll
 	bind:open={viewAllDialogOpen}
 	bind:open={viewAllDialogOpen}
 	{uploadedFiles}
 	{uploadedFiles}
 	{attachments}
 	{attachments}

+ 52 - 65
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte → tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAll.svelte

@@ -1,13 +1,14 @@
 <script lang="ts">
 <script lang="ts">
-	import * as Dialog from '$lib/components/ui/dialog';
-	import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
+	import {
+		ChatAttachmentThumbnailImage,
+		ChatAttachmentThumbnailFile,
+		DialogChatAttachmentPreview
+	} from '$lib/components/app';
 	import { FileTypeCategory } from '$lib/enums/files';
 	import { FileTypeCategory } from '$lib/enums/files';
 	import { getFileTypeCategory } from '$lib/utils/file-type';
 	import { getFileTypeCategory } from '$lib/utils/file-type';
-	import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
 	import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
 	import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
 
 
 	interface Props {
 	interface Props {
-		open?: boolean;
 		uploadedFiles?: ChatUploadedFile[];
 		uploadedFiles?: ChatUploadedFile[];
 		attachments?: DatabaseMessageExtra[];
 		attachments?: DatabaseMessageExtra[];
 		readonly?: boolean;
 		readonly?: boolean;
@@ -18,7 +19,6 @@
 	}
 	}
 
 
 	let {
 	let {
-		open = $bindable(false),
 		uploadedFiles = [],
 		uploadedFiles = [],
 		attachments = [],
 		attachments = [],
 		readonly = false,
 		readonly = false,
@@ -127,70 +127,57 @@
 	}
 	}
 </script>
 </script>
 
 
-<Dialog.Root bind:open>
-	<Dialog.Portal>
-		<Dialog.Overlay />
-
-		<Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
-			<Dialog.Header>
-				<Dialog.Title>All Attachments ({displayItems.length})</Dialog.Title>
-				<Dialog.Description class="text-sm text-muted-foreground">
-					View and manage all attached files
-				</Dialog.Description>
-			</Dialog.Header>
-
-			<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
-				{#if fileItems.length > 0}
-					<div>
-						<h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
-						<div class="flex flex-wrap items-start gap-3">
-							{#each fileItems as item (item.id)}
-								<ChatAttachmentFilePreview
-									class="cursor-pointer"
-									id={item.id}
-									name={item.name}
-									type={item.type}
-									size={item.size}
-									{readonly}
-									onRemove={onFileRemove}
-									textContent={item.textContent}
-									onClick={(event) => openPreview(item, event)}
-								/>
-							{/each}
-						</div>
-					</div>
-				{/if}
+<div class="space-y-4">
+	<div class="min-h-0 flex-1 space-y-6 overflow-y-auto px-1">
+		{#if fileItems.length > 0}
+			<div>
+				<h3 class="mb-3 text-sm font-medium text-foreground">Files ({fileItems.length})</h3>
+				<div class="flex flex-wrap items-start gap-3">
+					{#each fileItems as item (item.id)}
+						<ChatAttachmentThumbnailFile
+							class="cursor-pointer"
+							id={item.id}
+							name={item.name}
+							type={item.type}
+							size={item.size}
+							{readonly}
+							onRemove={onFileRemove}
+							textContent={item.textContent}
+							onClick={(event) => openPreview(item, event)}
+						/>
+					{/each}
+				</div>
+			</div>
+		{/if}
 
 
-				{#if imageItems.length > 0}
-					<div>
-						<h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
-						<div class="flex flex-wrap items-start gap-3">
-							{#each imageItems as item (item.id)}
-								{#if item.preview}
-									<ChatAttachmentImagePreview
-										class="cursor-pointer"
-										id={item.id}
-										name={item.name}
-										preview={item.preview}
-										{readonly}
-										onRemove={onFileRemove}
-										height={imageHeight}
-										width={imageWidth}
-										{imageClass}
-										onClick={(event) => openPreview(item, event)}
-									/>
-								{/if}
-							{/each}
-						</div>
-					</div>
-				{/if}
+		{#if imageItems.length > 0}
+			<div>
+				<h3 class="mb-3 text-sm font-medium text-foreground">Images ({imageItems.length})</h3>
+				<div class="flex flex-wrap items-start gap-3">
+					{#each imageItems as item (item.id)}
+						{#if item.preview}
+							<ChatAttachmentThumbnailImage
+								class="cursor-pointer"
+								id={item.id}
+								name={item.name}
+								preview={item.preview}
+								{readonly}
+								onRemove={onFileRemove}
+								height={imageHeight}
+								width={imageWidth}
+								{imageClass}
+								onClick={(event) => openPreview(item, event)}
+							/>
+						{/if}
+					{/each}
+				</div>
 			</div>
 			</div>
-		</Dialog.Content>
-	</Dialog.Portal>
-</Dialog.Root>
+		{/if}
+	</div>
+</div>
 
 
 {#if previewItem}
 {#if previewItem}
-	<ChatAttachmentPreviewDialog
+	<DialogChatAttachmentPreview
 		bind:open={previewDialogOpen}
 		bind:open={previewDialogOpen}
 		uploadedFile={previewItem.uploadedFile}
 		uploadedFile={previewItem.uploadedFile}
 		attachment={previewItem.attachment}
 		attachment={previewItem.attachment}

+ 0 - 0
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionFileAttachments.svelte → tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte


+ 0 - 0
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActionRecord.svelte → tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte


+ 5 - 3
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions.svelte → tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte

@@ -1,9 +1,11 @@
 <script lang="ts">
 <script lang="ts">
 	import { Square, ArrowUp } from '@lucide/svelte';
 	import { Square, ArrowUp } from '@lucide/svelte';
 	import { Button } from '$lib/components/ui/button';
 	import { Button } from '$lib/components/ui/button';
-	import ChatFormActionFileAttachments from './ChatFormActionFileAttachments.svelte';
-	import ChatFormActionRecord from './ChatFormActionRecord.svelte';
-	import ChatFormModelSelector from './ChatFormModelSelector.svelte';
+	import {
+		ChatFormActionFileAttachments,
+		ChatFormActionRecord,
+		ChatFormModelSelector
+	} from '$lib/components/app';
 	import { config } from '$lib/stores/settings.svelte';
 	import { config } from '$lib/stores/settings.svelte';
 	import type { FileTypeCategory } from '$lib/enums/files';
 	import type { FileTypeCategory } from '$lib/enums/files';
 
 

+ 6 - 3
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte

@@ -1,7 +1,10 @@
 <script lang="ts">
 <script lang="ts">
 	import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
 	import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
-	import { ActionButton, ConfirmationDialog } from '$lib/components/app';
-	import ChatMessageBranchingControls from './ChatMessageBranchingControls.svelte';
+	import {
+		ActionButton,
+		ChatMessageBranchingControls,
+		DialogConfirmation
+	} from '$lib/components/app';
 
 
 	interface Props {
 	interface Props {
 		role: 'user' | 'assistant';
 		role: 'user' | 'assistant';
@@ -80,7 +83,7 @@
 	</div>
 	</div>
 </div>
 </div>
 
 
-<ConfirmationDialog
+<DialogConfirmation
 	bind:open={showDeleteDialog}
 	bind:open={showDeleteDialog}
 	title="Delete Message"
 	title="Delete Message"
 	description={deletionInfo && deletionInfo.totalCount > 1
 	description={deletionInfo && deletionInfo.totalCount > 1

+ 8 - 8
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte

@@ -5,13 +5,13 @@
 		ChatScreenHeader,
 		ChatScreenHeader,
 		ChatScreenWarning,
 		ChatScreenWarning,
 		ChatMessages,
 		ChatMessages,
-		ChatProcessingInfo,
-		EmptyFileAlertDialog,
-		ChatErrorDialog,
+		ChatScreenProcessingInfo,
+		DialogEmptyFileAlert,
+		DialogChatError,
 		ServerErrorSplash,
 		ServerErrorSplash,
 		ServerInfo,
 		ServerInfo,
 		ServerLoadingSplash,
 		ServerLoadingSplash,
-		ConfirmationDialog
+		DialogConfirmation
 	} from '$lib/components/app';
 	} from '$lib/components/app';
 	import * as AlertDialog from '$lib/components/ui/alert-dialog';
 	import * as AlertDialog from '$lib/components/ui/alert-dialog';
 	import {
 	import {
@@ -299,7 +299,7 @@
 			class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
 			class="pointer-events-none sticky right-0 bottom-0 left-0 mt-auto"
 			in:slide={{ duration: 150, axis: 'y' }}
 			in:slide={{ duration: 150, axis: 'y' }}
 		>
 		>
-			<ChatProcessingInfo />
+			<ChatScreenProcessingInfo />
 
 
 			{#if serverWarning()}
 			{#if serverWarning()}
 				<ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
 				<ChatScreenWarning class="pointer-events-auto mx-auto max-w-[48rem] px-4" />
@@ -432,7 +432,7 @@
 	</AlertDialog.Portal>
 	</AlertDialog.Portal>
 </AlertDialog.Root>
 </AlertDialog.Root>
 
 
-<ConfirmationDialog
+<DialogConfirmation
 	bind:open={showDeleteDialog}
 	bind:open={showDeleteDialog}
 	title="Delete Conversation"
 	title="Delete Conversation"
 	description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
 	description="Are you sure you want to delete this conversation? This action cannot be undone and will permanently remove all messages in this conversation."
@@ -444,7 +444,7 @@
 	onCancel={() => (showDeleteDialog = false)}
 	onCancel={() => (showDeleteDialog = false)}
 />
 />
 
 
-<EmptyFileAlertDialog
+<DialogEmptyFileAlert
 	bind:open={showEmptyFileDialog}
 	bind:open={showEmptyFileDialog}
 	emptyFiles={emptyFileNames}
 	emptyFiles={emptyFileNames}
 	onOpenChange={(open) => {
 	onOpenChange={(open) => {
@@ -454,7 +454,7 @@
 	}}
 	}}
 />
 />
 
 
-<ChatErrorDialog
+<DialogChatError
 	message={activeErrorDialog?.message ?? ''}
 	message={activeErrorDialog?.message ?? ''}
 	onOpenChange={handleErrorDialogOpenChange}
 	onOpenChange={handleErrorDialogOpenChange}
 	open={Boolean(activeErrorDialog)}
 	open={Boolean(activeErrorDialog)}

+ 2 - 2
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte

@@ -1,6 +1,6 @@
 <script lang="ts">
 <script lang="ts">
 	import { Settings } from '@lucide/svelte';
 	import { Settings } from '@lucide/svelte';
-	import { ChatSettingsDialog } from '$lib/components/app';
+	import { DialogChatSettings } from '$lib/components/app';
 	import { Button } from '$lib/components/ui/button';
 	import { Button } from '$lib/components/ui/button';
 
 
 	let settingsOpen = $state(false);
 	let settingsOpen = $state(false);
@@ -20,4 +20,4 @@
 	</div>
 	</div>
 </header>
 </header>
 
 
-<ChatSettingsDialog open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
+<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />

+ 0 - 0
tools/server/webui/src/lib/components/app/chat/ChatProcessingInfo.svelte → tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenProcessingInfo.svelte


+ 107 - 132
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte → tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettings.svelte

@@ -12,20 +12,21 @@
 		ChevronRight,
 		ChevronRight,
 		Database
 		Database
 	} from '@lucide/svelte';
 	} from '@lucide/svelte';
-	import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
-	import ImportExportTab from './ImportExportTab.svelte';
-	import * as Dialog from '$lib/components/ui/dialog';
+	import {
+		ChatSettingsFooter,
+		ChatSettingsImportExportTab,
+		ChatSettingsFields
+	} from '$lib/components/app';
 	import { ScrollArea } from '$lib/components/ui/scroll-area';
 	import { ScrollArea } from '$lib/components/ui/scroll-area';
 	import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
 	import { config, updateMultipleConfig } from '$lib/stores/settings.svelte';
 	import { setMode } from 'mode-watcher';
 	import { setMode } from 'mode-watcher';
 	import type { Component } from 'svelte';
 	import type { Component } from 'svelte';
 
 
 	interface Props {
 	interface Props {
-		onOpenChange?: (open: boolean) => void;
-		open?: boolean;
+		onSave?: () => void;
 	}
 	}
 
 
-	let { onOpenChange, open = false }: Props = $props();
+	let { onSave }: Props = $props();
 
 
 	const settingSections: Array<{
 	const settingSections: Array<{
 		fields: SettingsFieldConfig[];
 		fields: SettingsFieldConfig[];
@@ -269,7 +270,6 @@
 		settingSections.find((section) => section.title === activeSection) || settingSections[0]
 		settingSections.find((section) => section.title === activeSection) || settingSections[0]
 	);
 	);
 	let localConfig: SettingsConfigType = $state({ ...config() });
 	let localConfig: SettingsConfigType = $state({ ...config() });
-	let originalTheme: string = $state('');
 
 
 	let canScrollLeft = $state(false);
 	let canScrollLeft = $state(false);
 	let canScrollRight = $state(false);
 	let canScrollRight = $state(false);
@@ -285,18 +285,10 @@
 		localConfig[key] = value;
 		localConfig[key] = value;
 	}
 	}
 
 
-	function handleClose() {
-		if (localConfig.theme !== originalTheme) {
-			setMode(originalTheme as 'light' | 'dark' | 'system');
-		}
-		onOpenChange?.(false);
-	}
-
 	function handleReset() {
 	function handleReset() {
 		localConfig = { ...config() };
 		localConfig = { ...config() };
 
 
 		setMode(localConfig.theme as 'light' | 'dark' | 'system');
 		setMode(localConfig.theme as 'light' | 'dark' | 'system');
-		originalTheme = localConfig.theme as string;
 	}
 	}
 
 
 	function handleSave() {
 	function handleSave() {
@@ -347,7 +339,7 @@
 		}
 		}
 
 
 		updateMultipleConfig(processedConfig);
 		updateMultipleConfig(processedConfig);
-		onOpenChange?.(false);
+		onSave?.();
 	}
 	}
 
 
 	function scrollToCenter(element: HTMLElement) {
 	function scrollToCenter(element: HTMLElement) {
@@ -383,14 +375,11 @@
 		canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
 		canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
 	}
 	}
 
 
-	$effect(() => {
-		if (open) {
-			localConfig = { ...config() };
-			originalTheme = config().theme as string;
+	export function reset() {
+		localConfig = { ...config() };
 
 
-			setTimeout(updateScrollButtons, 100);
-		}
-	});
+		setTimeout(updateScrollButtons, 100);
+	}
 
 
 	$effect(() => {
 	$effect(() => {
 		if (scrollContainer) {
 		if (scrollContainer) {
@@ -399,120 +388,106 @@
 	});
 	});
 </script>
 </script>
 
 
-<Dialog.Root {open} onOpenChange={handleClose}>
-	<Dialog.Content
-		class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
-			md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
-		style="max-width: 48rem;"
-	>
-		<div class="flex flex-1 flex-col overflow-hidden md:flex-row">
-			<!-- Desktop Sidebar -->
-			<div class="hidden w-64 border-r border-border/30 p-6 md:block">
-				<nav class="space-y-1 py-2">
-					<Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
-
-					{#each settingSections as section (section.title)}
-						<button
-							class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
-							section.title
-								? 'bg-accent text-accent-foreground'
-								: 'text-muted-foreground'}"
-							onclick={() => (activeSection = section.title)}
-						>
-							<section.icon class="h-4 w-4" />
-
-							<span class="ml-2">{section.title}</span>
-						</button>
-					{/each}
-				</nav>
-			</div>
-
-			<!-- Mobile Header with Horizontal Scrollable Menu -->
-			<div class="flex flex-col md:hidden">
-				<div class="border-b border-border/30 py-4">
-					<Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
-
-					<!-- Horizontal Scrollable Category Menu with Navigation -->
-					<div class="relative flex items-center" style="scroll-padding: 1rem;">
-						<button
-							class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
-								? 'opacity-100'
-								: 'pointer-events-none opacity-0'}"
-							onclick={scrollLeft}
-							aria-label="Scroll left"
-						>
-							<ChevronLeft class="h-4 w-4" />
-						</button>
-
-						<div
-							class="scrollbar-hide overflow-x-auto py-2"
-							bind:this={scrollContainer}
-							onscroll={updateScrollButtons}
-						>
-							<div class="flex min-w-max gap-2">
-								{#each settingSections as section (section.title)}
-									<button
-										class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
-										section.title
-											? 'bg-accent text-accent-foreground'
-											: 'text-muted-foreground'}"
-										onclick={(e: MouseEvent) => {
-											activeSection = section.title;
-											scrollToCenter(e.currentTarget as HTMLElement);
-										}}
-									>
-										<section.icon class="h-4 w-4 flex-shrink-0" />
-										<span>{section.title}</span>
-									</button>
-								{/each}
-							</div>
-						</div>
-
-						<button
-							class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
-								? 'opacity-100'
-								: 'pointer-events-none opacity-0'}"
-							onclick={scrollRight}
-							aria-label="Scroll right"
-						>
-							<ChevronRight class="h-4 w-4" />
-						</button>
+<div class="flex h-full flex-col overflow-hidden md:flex-row">
+	<!-- Desktop Sidebar -->
+	<div class="hidden w-64 border-r border-border/30 p-6 md:block">
+		<nav class="space-y-1 py-2">
+			{#each settingSections as section (section.title)}
+				<button
+					class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {activeSection ===
+					section.title
+						? 'bg-accent text-accent-foreground'
+						: 'text-muted-foreground'}"
+					onclick={() => (activeSection = section.title)}
+				>
+					<section.icon class="h-4 w-4" />
+
+					<span class="ml-2">{section.title}</span>
+				</button>
+			{/each}
+		</nav>
+	</div>
+
+	<!-- Mobile Header with Horizontal Scrollable Menu -->
+	<div class="flex flex-col md:hidden">
+		<div class="border-b border-border/30 py-4">
+			<!-- Horizontal Scrollable Category Menu with Navigation -->
+			<div class="relative flex items-center" style="scroll-padding: 1rem;">
+				<button
+					class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
+						? 'opacity-100'
+						: 'pointer-events-none opacity-0'}"
+					onclick={scrollLeft}
+					aria-label="Scroll left"
+				>
+					<ChevronLeft class="h-4 w-4" />
+				</button>
+
+				<div
+					class="scrollbar-hide overflow-x-auto py-2"
+					bind:this={scrollContainer}
+					onscroll={updateScrollButtons}
+				>
+					<div class="flex min-w-max gap-2">
+						{#each settingSections as section (section.title)}
+							<button
+								class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
+								section.title
+									? 'bg-accent text-accent-foreground'
+									: 'text-muted-foreground'}"
+								onclick={(e: MouseEvent) => {
+									activeSection = section.title;
+									scrollToCenter(e.currentTarget as HTMLElement);
+								}}
+							>
+								<section.icon class="h-4 w-4 flex-shrink-0" />
+								<span>{section.title}</span>
+							</button>
+						{/each}
 					</div>
 					</div>
 				</div>
 				</div>
+
+				<button
+					class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
+						? 'opacity-100'
+						: 'pointer-events-none opacity-0'}"
+					onclick={scrollRight}
+					aria-label="Scroll right"
+				>
+					<ChevronRight class="h-4 w-4" />
+				</button>
 			</div>
 			</div>
+		</div>
+	</div>
 
 
-			<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
-				<div class="space-y-6 p-4 md:p-6">
-					<div class="grid">
-						<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
-							<currentSection.icon class="h-5 w-5" />
-
-							<h3 class="text-lg font-semibold">{currentSection.title}</h3>
-						</div>
-
-						{#if currentSection.title === 'Import/Export'}
-							<ImportExportTab />
-						{:else}
-							<div class="space-y-6">
-								<ChatSettingsFields
-									fields={currentSection.fields}
-									{localConfig}
-									onConfigChange={handleConfigChange}
-									onThemeChange={handleThemeChange}
-								/>
-							</div>
-						{/if}
-					</div>
+	<ScrollArea class="max-h-[calc(100dvh-13.5rem)] flex-1 md:max-h-[calc(100vh-13.5rem)]">
+		<div class="space-y-6 p-4 md:p-6">
+			<div class="grid">
+				<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
+					<currentSection.icon class="h-5 w-5" />
 
 
-					<div class="mt-8 border-t pt-6">
-						<p class="text-xs text-muted-foreground">
-							Settings are saved in browser's localStorage
-						</p>
-					</div>
+					<h3 class="text-lg font-semibold">{currentSection.title}</h3>
 				</div>
 				</div>
-			</ScrollArea>
+
+				{#if currentSection.title === 'Import/Export'}
+					<ChatSettingsImportExportTab />
+				{:else}
+					<div class="space-y-6">
+						<ChatSettingsFields
+							fields={currentSection.fields}
+							{localConfig}
+							onConfigChange={handleConfigChange}
+							onThemeChange={handleThemeChange}
+						/>
+					</div>
+				{/if}
+			</div>
+
+			<div class="mt-8 border-t pt-6">
+				<p class="text-xs text-muted-foreground">Settings are saved in browser's localStorage</p>
+			</div>
 		</div>
 		</div>
+	</ScrollArea>
+</div>
 
 
-		<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
-	</Dialog.Content>
-</Dialog.Root>
+<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />

+ 3 - 3
tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsFields.svelte

@@ -9,7 +9,7 @@
 	import { supportsVision } from '$lib/stores/server.svelte';
 	import { supportsVision } from '$lib/stores/server.svelte';
 	import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
 	import { getParameterInfo, resetParameterToServerDefault } from '$lib/stores/settings.svelte';
 	import { ParameterSyncService } from '$lib/services/parameter-sync';
 	import { ParameterSyncService } from '$lib/services/parameter-sync';
-	import ParameterSourceIndicator from './ParameterSourceIndicator.svelte';
+	import { ChatSettingsParameterSourceIndicator } from '$lib/components/app';
 	import type { Component } from 'svelte';
 	import type { Component } from 'svelte';
 
 
 	interface Props {
 	interface Props {
@@ -63,7 +63,7 @@
 					{/if}
 					{/if}
 				</Label>
 				</Label>
 				{#if isCustomRealTime}
 				{#if isCustomRealTime}
-					<ParameterSourceIndicator />
+					<ChatSettingsParameterSourceIndicator />
 				{/if}
 				{/if}
 			</div>
 			</div>
 
 
@@ -145,7 +145,7 @@
 					{/if}
 					{/if}
 				</Label>
 				</Label>
 				{#if isCustomRealTime}
 				{#if isCustomRealTime}
-					<ParameterSourceIndicator />
+					<ChatSettingsParameterSourceIndicator />
 				{/if}
 				{/if}
 			</div>
 			</div>
 
 

+ 3 - 3
tools/server/webui/src/lib/components/app/chat/ChatSettings/ImportExportTab.svelte → tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsImportExportTab.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 <script lang="ts">
 	import { Download, Upload } from '@lucide/svelte';
 	import { Download, Upload } from '@lucide/svelte';
 	import { Button } from '$lib/components/ui/button';
 	import { Button } from '$lib/components/ui/button';
-	import ConversationSelectionDialog from './ConversationSelectionDialog.svelte';
+	import { DialogConversationSelection } from '$lib/components/app';
 	import { DatabaseStore } from '$lib/stores/database';
 	import { DatabaseStore } from '$lib/stores/database';
 	import type { ExportedConversations } from '$lib/types/database';
 	import type { ExportedConversations } from '$lib/types/database';
 	import { createMessageCountMap } from '$lib/utils/conversation-utils';
 	import { createMessageCountMap } from '$lib/utils/conversation-utils';
@@ -236,7 +236,7 @@
 	</div>
 	</div>
 </div>
 </div>
 
 
-<ConversationSelectionDialog
+<DialogConversationSelection
 	conversations={availableConversations}
 	conversations={availableConversations}
 	{messageCountMap}
 	{messageCountMap}
 	mode="export"
 	mode="export"
@@ -245,7 +245,7 @@
 	onConfirm={handleExportConfirm}
 	onConfirm={handleExportConfirm}
 />
 />
 
 
-<ConversationSelectionDialog
+<DialogConversationSelection
 	conversations={availableConversations}
 	conversations={availableConversations}
 	{messageCountMap}
 	{messageCountMap}
 	mode="import"
 	mode="import"

+ 0 - 0
tools/server/webui/src/lib/components/app/chat/ChatSettings/ParameterSourceIndicator.svelte → tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte


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

@@ -1,249 +0,0 @@
-<script lang="ts">
-	import { Search, X } from '@lucide/svelte';
-	import * as Dialog from '$lib/components/ui/dialog';
-	import { Button } from '$lib/components/ui/button';
-	import { Input } from '$lib/components/ui/input';
-	import { Checkbox } from '$lib/components/ui/checkbox';
-	import { ScrollArea } from '$lib/components/ui/scroll-area';
-	import { SvelteSet } from 'svelte/reactivity';
-
-	interface Props {
-		conversations: DatabaseConversation[];
-		messageCountMap?: Map<string, number>;
-		mode: 'export' | 'import';
-		onCancel: () => void;
-		onConfirm: (selectedConversations: DatabaseConversation[]) => void;
-		open?: boolean;
-	}
-
-	let {
-		conversations,
-		messageCountMap = new Map(),
-		mode,
-		onCancel,
-		onConfirm,
-		open = $bindable(false)
-	}: Props = $props();
-
-	let searchQuery = $state('');
-	let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
-	let lastClickedId = $state<string | null>(null);
-
-	let filteredConversations = $derived(
-		conversations.filter((conv) => {
-			const name = conv.name || 'Untitled conversation';
-			return name.toLowerCase().includes(searchQuery.toLowerCase());
-		})
-	);
-
-	let allSelected = $derived(
-		filteredConversations.length > 0 &&
-			filteredConversations.every((conv) => selectedIds.has(conv.id))
-	);
-
-	let someSelected = $derived(
-		filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
-	);
-
-	function toggleConversation(id: string, shiftKey: boolean = false) {
-		const newSet = new SvelteSet(selectedIds);
-
-		if (shiftKey && lastClickedId !== null) {
-			const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
-			const currentIndex = filteredConversations.findIndex((c) => c.id === id);
-
-			if (lastIndex !== -1 && currentIndex !== -1) {
-				const start = Math.min(lastIndex, currentIndex);
-				const end = Math.max(lastIndex, currentIndex);
-
-				const shouldSelect = !newSet.has(id);
-
-				for (let i = start; i <= end; i++) {
-					if (shouldSelect) {
-						newSet.add(filteredConversations[i].id);
-					} else {
-						newSet.delete(filteredConversations[i].id);
-					}
-				}
-
-				selectedIds = newSet;
-				return;
-			}
-		}
-
-		if (newSet.has(id)) {
-			newSet.delete(id);
-		} else {
-			newSet.add(id);
-		}
-
-		selectedIds = newSet;
-		lastClickedId = id;
-	}
-
-	function toggleAll() {
-		if (allSelected) {
-			const newSet = new SvelteSet(selectedIds);
-
-			filteredConversations.forEach((conv) => newSet.delete(conv.id));
-			selectedIds = newSet;
-		} else {
-			const newSet = new SvelteSet(selectedIds);
-
-			filteredConversations.forEach((conv) => newSet.add(conv.id));
-			selectedIds = newSet;
-		}
-	}
-
-	function handleConfirm() {
-		const selected = conversations.filter((conv) => selectedIds.has(conv.id));
-		onConfirm(selected);
-	}
-
-	function handleCancel() {
-		selectedIds = new SvelteSet(conversations.map((c) => c.id));
-		searchQuery = '';
-		lastClickedId = null;
-
-		onCancel();
-	}
-
-	let previousOpen = $state(false);
-
-	$effect(() => {
-		if (open && !previousOpen) {
-			selectedIds = new SvelteSet(conversations.map((c) => c.id));
-			searchQuery = '';
-			lastClickedId = null;
-		} else if (!open && previousOpen) {
-			onCancel();
-		}
-
-		previousOpen = open;
-	});
-</script>
-
-<Dialog.Root bind:open>
-	<Dialog.Portal>
-		<Dialog.Overlay class="z-[1000000]" />
-
-		<Dialog.Content class="z-[1000001] max-w-2xl">
-			<Dialog.Header>
-				<Dialog.Title>
-					Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
-				</Dialog.Title>
-
-				<Dialog.Description>
-					{#if mode === 'export'}
-						Choose which conversations you want to export. Selected conversations will be downloaded
-						as a JSON file.
-					{:else}
-						Choose which conversations you want to import. Selected conversations will be merged
-						with your existing conversations.
-					{/if}
-				</Dialog.Description>
-			</Dialog.Header>
-
-			<div class="space-y-4">
-				<div class="relative">
-					<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
-
-					<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
-
-					{#if searchQuery}
-						<button
-							class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
-							onclick={() => (searchQuery = '')}
-							type="button"
-						>
-							<X class="h-4 w-4" />
-						</button>
-					{/if}
-				</div>
-
-				<div class="flex items-center justify-between text-sm text-muted-foreground">
-					<span>
-						{selectedIds.size} of {conversations.length} selected
-						{#if searchQuery}
-							({filteredConversations.length} shown)
-						{/if}
-					</span>
-				</div>
-
-				<div class="overflow-hidden rounded-md border">
-					<ScrollArea class="h-[400px]">
-						<table class="w-full">
-							<thead class="sticky top-0 z-10 bg-muted">
-								<tr class="border-b">
-									<th class="w-12 p-3 text-left">
-										<Checkbox
-											checked={allSelected}
-											indeterminate={someSelected}
-											onCheckedChange={toggleAll}
-										/>
-									</th>
-
-									<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
-
-									<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
-								</tr>
-							</thead>
-							<tbody>
-								{#if filteredConversations.length === 0}
-									<tr>
-										<td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
-											{#if searchQuery}
-												No conversations found matching "{searchQuery}"
-											{:else}
-												No conversations available
-											{/if}
-										</td>
-									</tr>
-								{:else}
-									{#each filteredConversations as conv (conv.id)}
-										<tr
-											class="cursor-pointer border-b transition-colors hover:bg-muted/50"
-											onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
-										>
-											<td class="p-3">
-												<Checkbox
-													checked={selectedIds.has(conv.id)}
-													onclick={(e) => {
-														e.preventDefault();
-														e.stopPropagation();
-														toggleConversation(conv.id, e.shiftKey);
-													}}
-												/>
-											</td>
-
-											<td class="p-3 text-sm">
-												<div
-													class="max-w-[17rem] truncate"
-													title={conv.name || 'Untitled conversation'}
-												>
-													{conv.name || 'Untitled conversation'}
-												</div>
-											</td>
-
-											<td class="p-3 text-sm text-muted-foreground">
-												{messageCountMap.get(conv.id) ?? 0}
-											</td>
-										</tr>
-									{/each}
-								{/if}
-							</tbody>
-						</table>
-					</ScrollArea>
-				</div>
-			</div>
-
-			<Dialog.Footer>
-				<Button variant="outline" onclick={handleCancel}>Cancel</Button>
-
-				<Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
-					{mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
-				</Button>
-			</Dialog.Footer>
-		</Dialog.Content>
-	</Dialog.Portal>
-</Dialog.Root>

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

@@ -2,7 +2,7 @@
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
 	import { page } from '$app/state';
 	import { page } from '$app/state';
 	import { Trash2 } from '@lucide/svelte';
 	import { Trash2 } from '@lucide/svelte';
-	import { ChatSidebarConversationItem, ConfirmationDialog } from '$lib/components/app';
+	import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
 	import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
 	import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
 	import * as Sidebar from '$lib/components/ui/sidebar';
 	import * as Sidebar from '$lib/components/ui/sidebar';
 	import * as AlertDialog from '$lib/components/ui/alert-dialog';
 	import * as AlertDialog from '$lib/components/ui/alert-dialog';
@@ -158,7 +158,7 @@
 	<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
 	<div class="bottom-0 z-10 bg-sidebar bg-sidebar/50 px-4 py-4 backdrop-blur-lg md:sticky"></div>
 </ScrollArea>
 </ScrollArea>
 
 
-<ConfirmationDialog
+<DialogConfirmation
 	bind:open={showDeleteDialog}
 	bind:open={showDeleteDialog}
 	title="Delete Conversation"
 	title="Delete Conversation"
 	description={selectedConversation
 	description={selectedConversation

+ 78 - 0
tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentPreview.svelte

@@ -0,0 +1,78 @@
+<script lang="ts">
+	import * as Dialog from '$lib/components/ui/dialog';
+	import { ChatAttachmentPreview } from '$lib/components/app';
+	import { formatFileSize } from '$lib/utils/file-preview';
+
+	interface Props {
+		open: boolean;
+		// Either an uploaded file or a stored attachment
+		uploadedFile?: ChatUploadedFile;
+		attachment?: DatabaseMessageExtra;
+		// For uploaded files
+		preview?: string;
+		name?: string;
+		type?: string;
+		size?: number;
+		textContent?: string;
+	}
+
+	let {
+		open = $bindable(),
+		uploadedFile,
+		attachment,
+		preview,
+		name,
+		type,
+		size,
+		textContent
+	}: Props = $props();
+
+	let chatAttachmentPreviewRef: ChatAttachmentPreview | undefined = $state();
+
+	let displayName = $derived(uploadedFile?.name || attachment?.name || name || 'Unknown File');
+
+	let displayType = $derived(
+		uploadedFile?.type ||
+			(attachment?.type === 'imageFile'
+				? 'image'
+				: attachment?.type === 'textFile'
+					? 'text'
+					: attachment?.type === 'audioFile'
+						? attachment.mimeType || 'audio'
+						: attachment?.type === 'pdfFile'
+							? 'application/pdf'
+							: type || 'unknown')
+	);
+
+	let displaySize = $derived(uploadedFile?.size || size);
+
+	$effect(() => {
+		if (open && chatAttachmentPreviewRef) {
+			chatAttachmentPreviewRef.reset();
+		}
+	});
+</script>
+
+<Dialog.Root bind:open>
+	<Dialog.Content class="grid max-h-[90vh] max-w-5xl overflow-hidden sm:w-auto sm:max-w-6xl">
+		<Dialog.Header>
+			<Dialog.Title>{displayName}</Dialog.Title>
+			<Dialog.Description>
+				{displayType}
+				{#if displaySize}
+					• {formatFileSize(displaySize)}
+				{/if}
+			</Dialog.Description>
+		</Dialog.Header>
+
+		<ChatAttachmentPreview
+			bind:this={chatAttachmentPreviewRef}
+			{uploadedFile}
+			{attachment}
+			{preview}
+			{name}
+			{type}
+			{textContent}
+		/>
+	</Dialog.Content>
+</Dialog.Root>

+ 51 - 0
tools/server/webui/src/lib/components/app/dialogs/DialogChatAttachmentsViewAll.svelte

@@ -0,0 +1,51 @@
+<script lang="ts">
+	import * as Dialog from '$lib/components/ui/dialog';
+	import { ChatAttachmentsViewAll } from '$lib/components/app';
+
+	interface Props {
+		open?: boolean;
+		uploadedFiles?: ChatUploadedFile[];
+		attachments?: DatabaseMessageExtra[];
+		readonly?: boolean;
+		onFileRemove?: (fileId: string) => void;
+		imageHeight?: string;
+		imageWidth?: string;
+		imageClass?: string;
+	}
+
+	let {
+		open = $bindable(false),
+		uploadedFiles = [],
+		attachments = [],
+		readonly = false,
+		onFileRemove,
+		imageHeight = 'h-24',
+		imageWidth = 'w-auto',
+		imageClass = ''
+	}: Props = $props();
+
+	let totalCount = $derived(uploadedFiles.length + attachments.length);
+</script>
+
+<Dialog.Root bind:open>
+	<Dialog.Portal>
+		<Dialog.Overlay />
+
+		<Dialog.Content class="flex !max-h-[90vh] !max-w-6xl flex-col">
+			<Dialog.Header>
+				<Dialog.Title>All Attachments ({totalCount})</Dialog.Title>
+				<Dialog.Description>View and manage all attached files</Dialog.Description>
+			</Dialog.Header>
+
+			<ChatAttachmentsViewAll
+				{uploadedFiles}
+				{attachments}
+				{readonly}
+				{onFileRemove}
+				{imageHeight}
+				{imageWidth}
+				{imageClass}
+			/>
+		</Dialog.Content>
+	</Dialog.Portal>
+</Dialog.Root>

+ 0 - 0
tools/server/webui/src/lib/components/app/dialogs/ChatErrorDialog.svelte → tools/server/webui/src/lib/components/app/dialogs/DialogChatError.svelte


+ 37 - 0
tools/server/webui/src/lib/components/app/dialogs/DialogChatSettings.svelte

@@ -0,0 +1,37 @@
+<script lang="ts">
+	import * as Dialog from '$lib/components/ui/dialog';
+	import { ChatSettings } from '$lib/components/app';
+
+	interface Props {
+		onOpenChange?: (open: boolean) => void;
+		open?: boolean;
+	}
+
+	let { onOpenChange, open = false }: Props = $props();
+
+	let chatSettingsRef: ChatSettings | undefined = $state();
+
+	function handleClose() {
+		onOpenChange?.(false);
+	}
+
+	function handleSave() {
+		onOpenChange?.(false);
+	}
+
+	$effect(() => {
+		if (open && chatSettingsRef) {
+			chatSettingsRef.reset();
+		}
+	});
+</script>
+
+<Dialog.Root {open} onOpenChange={handleClose}>
+	<Dialog.Content
+		class="z-999999 flex h-[100dvh] max-h-[100dvh] min-h-[100dvh] flex-col gap-0 rounded-none p-0
+			md:h-[64vh] md:max-h-[64vh] md:min-h-0 md:rounded-lg"
+		style="max-width: 48rem;"
+	>
+		<ChatSettings bind:this={chatSettingsRef} onSave={handleSave} />
+	</Dialog.Content>
+</Dialog.Root>

+ 0 - 0
tools/server/webui/src/lib/components/app/dialogs/ConfirmationDialog.svelte → tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte


+ 68 - 0
tools/server/webui/src/lib/components/app/dialogs/DialogConversationSelection.svelte

@@ -0,0 +1,68 @@
+<script lang="ts">
+	import * as Dialog from '$lib/components/ui/dialog';
+	import { ConversationSelection } from '$lib/components/app';
+
+	interface Props {
+		conversations: DatabaseConversation[];
+		messageCountMap?: Map<string, number>;
+		mode: 'export' | 'import';
+		onCancel: () => void;
+		onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+		open?: boolean;
+	}
+
+	let {
+		conversations,
+		messageCountMap = new Map(),
+		mode,
+		onCancel,
+		onConfirm,
+		open = $bindable(false)
+	}: Props = $props();
+
+	let conversationSelectionRef: ConversationSelection | undefined = $state();
+
+	let previousOpen = $state(false);
+
+	$effect(() => {
+		if (open && !previousOpen && conversationSelectionRef) {
+			conversationSelectionRef.reset();
+		} else if (!open && previousOpen) {
+			onCancel();
+		}
+
+		previousOpen = open;
+	});
+</script>
+
+<Dialog.Root bind:open>
+	<Dialog.Portal>
+		<Dialog.Overlay class="z-[1000000]" />
+
+		<Dialog.Content class="z-[1000001] max-w-2xl">
+			<Dialog.Header>
+				<Dialog.Title>
+					Select Conversations to {mode === 'export' ? 'Export' : 'Import'}
+				</Dialog.Title>
+				<Dialog.Description>
+					{#if mode === 'export'}
+						Choose which conversations you want to export. Selected conversations will be downloaded
+						as a JSON file.
+					{:else}
+						Choose which conversations you want to import. Selected conversations will be merged
+						with your existing conversations.
+					{/if}
+				</Dialog.Description>
+			</Dialog.Header>
+
+			<ConversationSelection
+				bind:this={conversationSelectionRef}
+				{conversations}
+				{messageCountMap}
+				{mode}
+				{onCancel}
+				{onConfirm}
+			/>
+		</Dialog.Content>
+	</Dialog.Portal>
+</Dialog.Root>

+ 0 - 0
tools/server/webui/src/lib/components/app/dialogs/ConversationTitleUpdateDialog.svelte → tools/server/webui/src/lib/components/app/dialogs/DialogConversationTitleUpdate.svelte


+ 0 - 0
tools/server/webui/src/lib/components/app/dialogs/EmptyFileAlertDialog.svelte → tools/server/webui/src/lib/components/app/dialogs/DialogEmptyFileAlert.svelte


+ 35 - 28
tools/server/webui/src/lib/components/app/index.ts

@@ -1,56 +1,63 @@
+// Chat
+
+export { default as ChatAttachmentPreview } from './chat/ChatAttachments/ChatAttachmentPreview.svelte';
+export { default as ChatAttachmentThumbnailFile } from './chat/ChatAttachments/ChatAttachmentThumbnailFile.svelte';
+export { default as ChatAttachmentThumbnailImage } from './chat/ChatAttachments/ChatAttachmentThumbnailImage.svelte';
 export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
 export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttachmentsList.svelte';
-export { default as ChatAttachmentFilePreview } from './chat/ChatAttachments/ChatAttachmentFilePreview.svelte';
-export { default as ChatAttachmentImagePreview } from './chat/ChatAttachments/ChatAttachmentImagePreview.svelte';
-export { default as ChatAttachmentPreviewDialog } from './chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte';
-export { default as ChatAttachmentsViewAllDialog } from './chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte';
+export { default as ChatAttachmentsViewAll } from './chat/ChatAttachments/ChatAttachmentsViewAll.svelte';
 
 
 export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
 export { default as ChatForm } from './chat/ChatForm/ChatForm.svelte';
-export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
-export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions.svelte';
-export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActionFileAttachments.svelte';
-export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActionRecord.svelte';
-export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
-export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
+export { default as ChatFormActionFileAttachments } from './chat/ChatForm/ChatFormActions/ChatFormActionFileAttachments.svelte';
+export { default as ChatFormActionRecord } from './chat/ChatForm/ChatFormActions/ChatFormActionRecord.svelte';
+export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/ChatFormActions.svelte';
 export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
 export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
+export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
+export { default as ChatFormModelSelector } from './chat/ChatForm/ChatFormModelSelector.svelte';
+export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
 
 
 export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
 export { default as ChatMessage } from './chat/ChatMessages/ChatMessage.svelte';
 export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
 export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
+export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
 export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
 export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
-export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
-
-export { default as ChatProcessingInfo } from './chat/ChatProcessingInfo.svelte';
 
 
+export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
 export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
 export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
+export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
 export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte';
 export { default as ChatScreenWarning } from './chat/ChatScreen/ChatScreenWarning.svelte';
-export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
 
 
-export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
+export { default as ChatSettings } from './chat/ChatSettings/ChatSettings.svelte';
 export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
 export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
 export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
 export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
-export { default as ImportExportTab } from './chat/ChatSettings/ImportExportTab.svelte';
-export { default as ConversationSelectionDialog } from './chat/ChatSettings/ConversationSelectionDialog.svelte';
-export { default as ParameterSourceIndicator } from './chat/ChatSettings/ParameterSourceIndicator.svelte';
+export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
+export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
 
 
 export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
 export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
 export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
 export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';
 export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
 export { default as ChatSidebarSearch } from './chat/ChatSidebar/ChatSidebarSearch.svelte';
-export { default as ChatErrorDialog } from './dialogs/ChatErrorDialog.svelte';
-export { default as EmptyFileAlertDialog } from './dialogs/EmptyFileAlertDialog.svelte';
 
 
-export { default as ConversationTitleUpdateDialog } from './dialogs/ConversationTitleUpdateDialog.svelte';
+// Dialogs
 
 
-export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
+export { default as DialogChatAttachmentPreview } from './dialogs/DialogChatAttachmentPreview.svelte';
+export { default as DialogChatAttachmentsViewAll } from './dialogs/DialogChatAttachmentsViewAll.svelte';
+export { default as DialogChatError } from './dialogs/DialogChatError.svelte';
+export { default as DialogChatSettings } from './dialogs/DialogChatSettings.svelte';
+export { default as DialogConfirmation } from './dialogs/DialogConfirmation.svelte';
+export { default as DialogConversationSelection } from './dialogs/DialogConversationSelection.svelte';
+export { default as DialogConversationTitleUpdate } from './dialogs/DialogConversationTitleUpdate.svelte';
+export { default as DialogEmptyFileAlert } from './dialogs/DialogEmptyFileAlert.svelte';
 
 
-export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
+// Miscellanous
 
 
+export { default as ActionButton } from './misc/ActionButton.svelte';
+export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
+export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
+export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
+export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
 export { default as RemoveButton } from './misc/RemoveButton.svelte';
 export { default as RemoveButton } from './misc/RemoveButton.svelte';
 
 
+// Server
+
 export { default as ServerStatus } from './server/ServerStatus.svelte';
 export { default as ServerStatus } from './server/ServerStatus.svelte';
 export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
 export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
 export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
 export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
 export { default as ServerInfo } from './server/ServerInfo.svelte';
 export { default as ServerInfo } from './server/ServerInfo.svelte';
-
-// Shared components
-export { default as ActionButton } from './misc/ActionButton.svelte';
-export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
-export { default as ConfirmationDialog } from './dialogs/ConfirmationDialog.svelte';

+ 205 - 0
tools/server/webui/src/lib/components/app/misc/ConversationSelection.svelte

@@ -0,0 +1,205 @@
+<script lang="ts">
+	import { Search, X } from '@lucide/svelte';
+	import { Button } from '$lib/components/ui/button';
+	import { Input } from '$lib/components/ui/input';
+	import { Checkbox } from '$lib/components/ui/checkbox';
+	import { ScrollArea } from '$lib/components/ui/scroll-area';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	interface Props {
+		conversations: DatabaseConversation[];
+		messageCountMap?: Map<string, number>;
+		mode: 'export' | 'import';
+		onCancel: () => void;
+		onConfirm: (selectedConversations: DatabaseConversation[]) => void;
+	}
+
+	let { conversations, messageCountMap = new Map(), mode, onCancel, onConfirm }: Props = $props();
+
+	let searchQuery = $state('');
+	let selectedIds = $state.raw<SvelteSet<string>>(new SvelteSet(conversations.map((c) => c.id)));
+	let lastClickedId = $state<string | null>(null);
+
+	let filteredConversations = $derived(
+		conversations.filter((conv) => {
+			const name = conv.name || 'Untitled conversation';
+			return name.toLowerCase().includes(searchQuery.toLowerCase());
+		})
+	);
+
+	let allSelected = $derived(
+		filteredConversations.length > 0 &&
+			filteredConversations.every((conv) => selectedIds.has(conv.id))
+	);
+
+	let someSelected = $derived(
+		filteredConversations.some((conv) => selectedIds.has(conv.id)) && !allSelected
+	);
+
+	function toggleConversation(id: string, shiftKey: boolean = false) {
+		const newSet = new SvelteSet(selectedIds);
+
+		if (shiftKey && lastClickedId !== null) {
+			const lastIndex = filteredConversations.findIndex((c) => c.id === lastClickedId);
+			const currentIndex = filteredConversations.findIndex((c) => c.id === id);
+
+			if (lastIndex !== -1 && currentIndex !== -1) {
+				const start = Math.min(lastIndex, currentIndex);
+				const end = Math.max(lastIndex, currentIndex);
+
+				const shouldSelect = !newSet.has(id);
+
+				for (let i = start; i <= end; i++) {
+					if (shouldSelect) {
+						newSet.add(filteredConversations[i].id);
+					} else {
+						newSet.delete(filteredConversations[i].id);
+					}
+				}
+
+				selectedIds = newSet;
+				return;
+			}
+		}
+
+		if (newSet.has(id)) {
+			newSet.delete(id);
+		} else {
+			newSet.add(id);
+		}
+
+		selectedIds = newSet;
+		lastClickedId = id;
+	}
+
+	function toggleAll() {
+		if (allSelected) {
+			const newSet = new SvelteSet(selectedIds);
+
+			filteredConversations.forEach((conv) => newSet.delete(conv.id));
+			selectedIds = newSet;
+		} else {
+			const newSet = new SvelteSet(selectedIds);
+
+			filteredConversations.forEach((conv) => newSet.add(conv.id));
+			selectedIds = newSet;
+		}
+	}
+
+	function handleConfirm() {
+		const selected = conversations.filter((conv) => selectedIds.has(conv.id));
+		onConfirm(selected);
+	}
+
+	function handleCancel() {
+		selectedIds = new SvelteSet(conversations.map((c) => c.id));
+		searchQuery = '';
+		lastClickedId = null;
+
+		onCancel();
+	}
+
+	export function reset() {
+		selectedIds = new SvelteSet(conversations.map((c) => c.id));
+		searchQuery = '';
+		lastClickedId = null;
+	}
+</script>
+
+<div class="space-y-4">
+	<div class="relative">
+		<Search class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
+
+		<Input bind:value={searchQuery} placeholder="Search conversations..." class="pr-9 pl-9" />
+
+		{#if searchQuery}
+			<button
+				class="absolute top-1/2 right-3 -translate-y-1/2 text-muted-foreground hover:text-foreground"
+				onclick={() => (searchQuery = '')}
+				type="button"
+			>
+				<X class="h-4 w-4" />
+			</button>
+		{/if}
+	</div>
+
+	<div class="flex items-center justify-between text-sm text-muted-foreground">
+		<span>
+			{selectedIds.size} of {conversations.length} selected
+			{#if searchQuery}
+				({filteredConversations.length} shown)
+			{/if}
+		</span>
+	</div>
+
+	<div class="overflow-hidden rounded-md border">
+		<ScrollArea class="h-[400px]">
+			<table class="w-full">
+				<thead class="sticky top-0 z-10 bg-muted">
+					<tr class="border-b">
+						<th class="w-12 p-3 text-left">
+							<Checkbox
+								checked={allSelected}
+								indeterminate={someSelected}
+								onCheckedChange={toggleAll}
+							/>
+						</th>
+
+						<th class="p-3 text-left text-sm font-medium">Conversation Name</th>
+
+						<th class="w-32 p-3 text-left text-sm font-medium">Messages</th>
+					</tr>
+				</thead>
+				<tbody>
+					{#if filteredConversations.length === 0}
+						<tr>
+							<td colspan="3" class="p-8 text-center text-sm text-muted-foreground">
+								{#if searchQuery}
+									No conversations found matching "{searchQuery}"
+								{:else}
+									No conversations available
+								{/if}
+							</td>
+						</tr>
+					{:else}
+						{#each filteredConversations as conv (conv.id)}
+							<tr
+								class="cursor-pointer border-b transition-colors hover:bg-muted/50"
+								onclick={(e) => toggleConversation(conv.id, e.shiftKey)}
+							>
+								<td class="p-3">
+									<Checkbox
+										checked={selectedIds.has(conv.id)}
+										onclick={(e) => {
+											e.preventDefault();
+											e.stopPropagation();
+											toggleConversation(conv.id, e.shiftKey);
+										}}
+									/>
+								</td>
+
+								<td class="p-3 text-sm">
+									<div class="max-w-[17rem] truncate" title={conv.name || 'Untitled conversation'}>
+										{conv.name || 'Untitled conversation'}
+									</div>
+								</td>
+
+								<td class="p-3 text-sm text-muted-foreground">
+									{messageCountMap.get(conv.id) ?? 0}
+								</td>
+							</tr>
+						{/each}
+					{/if}
+				</tbody>
+			</table>
+		</ScrollArea>
+	</div>
+
+	<div class="flex justify-end gap-2">
+		<Button variant="outline" onclick={handleCancel}>Cancel</Button>
+
+		<Button onclick={handleConfirm} disabled={selectedIds.size === 0}>
+			{mode === 'export' ? 'Export' : 'Import'} ({selectedIds.size})
+		</Button>
+	</div>
+</div>

+ 2 - 2
tools/server/webui/src/routes/+layout.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 <script lang="ts">
 	import '../app.css';
 	import '../app.css';
 	import { page } from '$app/state';
 	import { page } from '$app/state';
-	import { ChatSidebar, ConversationTitleUpdateDialog } from '$lib/components/app';
+	import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
 	import {
 	import {
 		activeMessages,
 		activeMessages,
 		isLoading,
 		isLoading,
@@ -150,7 +150,7 @@
 
 
 <Toaster richColors />
 <Toaster richColors />
 
 
-<ConversationTitleUpdateDialog
+<DialogConversationTitleUpdate
 	bind:open={titleUpdateDialogOpen}
 	bind:open={titleUpdateDialogOpen}
 	currentTitle={titleUpdateCurrentTitle}
 	currentTitle={titleUpdateCurrentTitle}
 	newTitle={titleUpdateNewTitle}
 	newTitle={titleUpdateNewTitle}

+ 19 - 0
tools/server/webui/src/stories/ChatSettings.stories.svelte

@@ -0,0 +1,19 @@
+<script module>
+	import { defineMeta } from '@storybook/addon-svelte-csf';
+	import { ChatSettings } from '$lib/components/app';
+	import { fn } from 'storybook/test';
+
+	const { Story } = defineMeta({
+		title: 'Components/ChatSettings',
+		component: ChatSettings,
+		parameters: {
+			layout: 'fullscreen'
+		},
+		args: {
+			onClose: fn(),
+			onSave: fn()
+		}
+	});
+</script>
+
+<Story name="Default" />

+ 0 - 26
tools/server/webui/src/stories/ChatSettingsDialog.stories.svelte

@@ -1,26 +0,0 @@
-<script module>
-	import { defineMeta } from '@storybook/addon-svelte-csf';
-	import { ChatSettingsDialog } from '$lib/components/app';
-	import { fn } from 'storybook/test';
-
-	const { Story } = defineMeta({
-		title: 'Components/ChatSettingsDialog',
-		component: ChatSettingsDialog,
-		parameters: {
-			layout: 'fullscreen'
-		},
-		argTypes: {
-			open: {
-				control: 'boolean',
-				description: 'Whether the dialog is open'
-			}
-		},
-		args: {
-			onOpenChange: fn()
-		}
-	});
-</script>
-
-<Story name="Open" args={{ open: true }} />
-
-<Story name="Closed" args={{ open: false }} />