]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
Better UX for handling multiple attachments in WebUI (#17246)
authorAleksander Grygier <redacted>
Fri, 14 Nov 2025 00:19:08 +0000 (01:19 +0100)
committerGitHub <redacted>
Fri, 14 Nov 2025 00:19:08 +0000 (01:19 +0100)
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentFilePreview.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentImagePreview.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentPreviewDialog.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList.svelte
tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/app/index.ts
tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte [new file with mode: 0644]
tools/server/webui/src/lib/types/chat.d.ts

index 976d6585da66ed072ec30330cef550b41b5052c1..8b6b16d01bbb6c588d7b61812962c1cf3ecf5e3b 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index d4839f839bf7a6bdacd8eb6ba709a412610f0cfa..46f0d0007cbabfc9065d27912f88061b402316ed 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
-       import { X } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
+       import { RemoveButton } from '$lib/components/app';
        import { formatFileSize, getFileTypeLabel, getPreviewText } from '$lib/utils/file-preview';
        import { FileTypeCategory, MimeTypeText } from '$lib/enums/files';
 
                </button>
        {:else}
                <!-- Non-readonly mode (ChatForm) -->
-               <div class="relative rounded-lg border border-border bg-muted p-3 {className} w-64">
-                       <Button
-                               type="button"
-                               variant="ghost"
-                               size="sm"
-                               class="absolute top-2 right-2 h-6 w-6 bg-white/20 p-0 hover:bg-white/30"
-                               onclick={() => onRemove?.(id)}
-                               aria-label="Remove file"
-                       >
-                               <X class="h-3 w-3" />
-                       </Button>
+               <button
+                       class="group relative rounded-lg border border-border bg-muted p-3 {className} {textContent
+                               ? 'max-h-24 max-w-72'
+                               : 'max-w-36'} cursor-pointer text-left"
+                       onclick={onClick}
+               >
+                       <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+                               <RemoveButton {id} {onRemove} />
+                       </div>
 
                        <div class="pr-8">
                                <span class="mb-3 block truncate text-sm font-medium text-foreground">{name}</span>
@@ -85,7 +82,7 @@
                                        <div class="relative">
                                                <div
                                                        class="overflow-hidden font-mono text-xs leading-relaxed break-words whitespace-pre-wrap text-muted-foreground"
-                                                       style="max-height: 3.6em; line-height: 1.2em;"
+                                                       style="max-height: 3rem; line-height: 1.2em;"
                                                >
                                                        {getPreviewText(textContent)}
                                                </div>
                                        </div>
                                {/if}
                        </div>
-               </div>
+               </button>
        {/if}
 {:else}
        <button
-               class="flex items-center gap-2 gap-3 rounded-lg border border-border bg-muted p-3 {className}"
+               class="group flex items-center gap-3 rounded-lg border border-border bg-muted p-3 {className} relative"
                onclick={onClick}
        >
                <div
                </div>
 
                <div class="flex flex-col gap-1">
-                       <span class="max-w-36 truncate text-sm font-medium text-foreground md:max-w-72">
+                       <span
+                               class="max-w-24 truncate text-sm font-medium text-foreground group-hover:pr-6 md:max-w-32"
+                       >
                                {name}
                        </span>
 
                </div>
 
                {#if !readonly}
-                       <Button
-                               type="button"
-                               variant="ghost"
-                               size="sm"
-                               class="h-6 w-6 p-0"
-                               onclick={(e) => {
-                                       e.stopPropagation();
-                                       onRemove?.(id);
-                               }}
-                       >
-                               <X class="h-3 w-3" />
-                       </Button>
+                       <div class="absolute top-2 right-2 opacity-0 transition-opacity group-hover:opacity-100">
+                               <RemoveButton {id} {onRemove} />
+                       </div>
                {/if}
        </button>
 {/if}
index 1541c0783bce17bf1efc1ae23b564d5054938c56..da9ceb63710a1672a9f1948914e76530ec280aaf 100644 (file)
@@ -1,6 +1,5 @@
 <script lang="ts">
-       import { X } from '@lucide/svelte';
-       import { Button } from '$lib/components/ui/button';
+       import { RemoveButton } from '$lib/components/app';
 
        interface Props {
                id: string;
                class: className = '',
                // Default to small size for form previews
                width = 'w-auto',
-               height = 'h-24',
+               height = 'h-16',
                imageClass = ''
        }: Props = $props();
 </script>
 
-<div class="relative overflow-hidden rounded-lg border border-border bg-muted {className}">
+<div class="group relative overflow-hidden rounded-lg border border-border bg-muted {className}">
        {#if onClick}
                <button
                        type="button"
 
        {#if !readonly}
                <div
-                       class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity hover:opacity-100"
+                       class="absolute top-1 right-1 flex items-center justify-center opacity-0 transition-opacity group-hover:opacity-100"
                >
-                       <Button
-                               type="button"
-                               variant="ghost"
-                               size="sm"
-                               class="h-6 w-6 bg-white/20 p-0 text-white hover:bg-white/30"
-                               onclick={() => onRemove?.(id)}
-                       >
-                               <X class="h-3 w-3" />
-                       </Button>
+                       <RemoveButton {id} {onRemove} class="text-white" />
                </div>
        {/if}
 </div>
index 3c1ee7fc5d96d95ff9ee1fe06b98553f0ee45612..8a3389b6579d15c55a0d182ad60255bdb58ad01b 100644 (file)
 <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">
+                       <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" />
index e378139d1b62667299afc5089e8ba756f82e7d89..a2aea0232a9f1753062248ec4142847e8b481e5a 100644 (file)
@@ -1,11 +1,16 @@
 <script lang="ts">
        import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
+       import { Button } from '$lib/components/ui/button';
+       import { ChevronLeft, ChevronRight } from '@lucide/svelte';
        import { FileTypeCategory } from '$lib/enums/files';
        import { getFileTypeCategory } from '$lib/utils/file-type';
        import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
+       import ChatAttachmentsViewAllDialog from './ChatAttachmentsViewAllDialog.svelte';
+       import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
 
        interface Props {
                class?: string;
+               style?: string;
                // For ChatMessage - stored attachments
                attachments?: DatabaseMessageExtra[];
                readonly?: boolean;
                imageClass?: string;
                imageHeight?: string;
                imageWidth?: string;
+               // Limit display to single row with "+ X more" button
+               limitToSingleRow?: boolean;
        }
 
        let {
                class: className = '',
+               style = '',
                attachments = [],
                readonly = false,
                onFileRemove,
                // Default to small size for form previews
                imageClass = '',
                imageHeight = 'h-24',
-               imageWidth = 'w-auto'
+               imageWidth = 'w-auto',
+               limitToSingleRow = false
        }: Props = $props();
 
        let displayItems = $derived(getDisplayItems());
 
-       // Preview dialog state
+       let canScrollLeft = $state(false);
+       let canScrollRight = $state(false);
+       let isScrollable = $state(false);
        let previewDialogOpen = $state(false);
-       let previewItem = $state<{
-               uploadedFile?: ChatUploadedFile;
-               attachment?: DatabaseMessageExtra;
-               preview?: string;
-               name?: string;
-               type?: string;
-               size?: number;
-               textContent?: string;
-       } | null>(null);
-
-       function getDisplayItems() {
-               const items: Array<{
-                       id: string;
-                       name: string;
-                       size?: number;
-                       preview?: string;
-                       type: string;
-                       isImage: boolean;
-                       uploadedFile?: ChatUploadedFile;
-                       attachment?: DatabaseMessageExtra;
-                       attachmentIndex?: number;
-                       textContent?: string;
-               }> = [];
+       let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+       let scrollContainer: HTMLDivElement | undefined = $state();
+       let showViewAll = $derived(limitToSingleRow && displayItems.length > 0 && isScrollable);
+       let viewAllDialogOpen = $state(false);
+
+       function getDisplayItems(): ChatAttachmentDisplayItem[] {
+               const items: ChatAttachmentDisplayItem[] = [];
 
                // Add uploaded files (ChatForm)
                for (const file of uploadedFiles) {
                        }
                }
 
-               return items;
+               return items.reverse();
        }
 
-       function openPreview(item: (typeof displayItems)[0], event?: Event) {
-               if (event) {
-                       event.preventDefault();
-                       event.stopPropagation();
-               }
+       function openPreview(item: ChatAttachmentDisplayItem, event?: MouseEvent) {
+               event?.stopPropagation();
+               event?.preventDefault();
 
                previewItem = {
                        uploadedFile: item.uploadedFile,
                };
                previewDialogOpen = true;
        }
+
+       function scrollLeft(event?: MouseEvent) {
+               event?.stopPropagation();
+               event?.preventDefault();
+
+               if (!scrollContainer) return;
+
+               scrollContainer.scrollBy({ left: scrollContainer.clientWidth * -0.67, behavior: 'smooth' });
+       }
+
+       function scrollRight(event?: MouseEvent) {
+               event?.stopPropagation();
+               event?.preventDefault();
+
+               if (!scrollContainer) return;
+
+               scrollContainer.scrollBy({ left: scrollContainer.clientWidth * 0.67, behavior: 'smooth' });
+       }
+
+       function updateScrollButtons() {
+               if (!scrollContainer) return;
+
+               const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
+
+               canScrollLeft = scrollLeft > 0;
+               canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
+               isScrollable = scrollWidth > clientWidth;
+       }
+
+       $effect(() => {
+               if (scrollContainer && displayItems.length) {
+                       scrollContainer.scrollLeft = 0;
+
+                       setTimeout(() => {
+                               updateScrollButtons();
+                       }, 0);
+               }
+       });
 </script>
 
 {#if displayItems.length > 0}
-       <div class="flex flex-wrap items-start {readonly ? 'justify-end' : ''} gap-3 {className}">
-               {#each displayItems as item (item.id)}
-                       {#if item.isImage && 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)}
-                               />
-                       {:else}
-                               <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)}
-                               />
-                       {/if}
-               {/each}
+       <div class={className} {style}>
+               <div class="relative">
+                       <button
+                               class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {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 flex items-start gap-3 overflow-x-auto"
+                               bind:this={scrollContainer}
+                               onscroll={updateScrollButtons}
+                       >
+                               {#each displayItems as item (item.id)}
+                                       {#if item.isImage && item.preview}
+                                               <ChatAttachmentImagePreview
+                                                       class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
+                                                       id={item.id}
+                                                       name={item.name}
+                                                       preview={item.preview}
+                                                       {readonly}
+                                                       onRemove={onFileRemove}
+                                                       height={imageHeight}
+                                                       width={imageWidth}
+                                                       {imageClass}
+                                                       onClick={(event) => openPreview(item, event)}
+                                               />
+                                       {:else}
+                                               <ChatAttachmentFilePreview
+                                                       class="flex-shrink-0 cursor-pointer {limitToSingleRow ? 'first:ml-4 last:mr-4' : ''}"
+                                                       id={item.id}
+                                                       name={item.name}
+                                                       type={item.type}
+                                                       size={item.size}
+                                                       {readonly}
+                                                       onRemove={onFileRemove}
+                                                       textContent={item.textContent}
+                                                       onClick={(event) => openPreview(item, event)}
+                                               />
+                                       {/if}
+                               {/each}
+                       </div>
+
+                       <button
+                               class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-foreground/15 shadow-md backdrop-blur-xs transition-opacity hover:bg-foreground/35 {canScrollRight
+                                       ? 'opacity-100'
+                                       : 'pointer-events-none opacity-0'}"
+                               onclick={scrollRight}
+                               aria-label="Scroll right"
+                       >
+                               <ChevronRight class="h-4 w-4" />
+                       </button>
+               </div>
+
+               {#if showViewAll}
+                       <div class="mt-2 -mr-2 flex justify-end px-4">
+                               <Button
+                                       type="button"
+                                       variant="ghost"
+                                       size="sm"
+                                       class="h-6 text-xs text-muted-foreground hover:text-foreground"
+                                       onclick={() => (viewAllDialogOpen = true)}
+                               >
+                                       View all
+                               </Button>
+                       </div>
+               {/if}
        </div>
 {/if}
 
                textContent={previewItem.textContent}
        />
 {/if}
+
+<ChatAttachmentsViewAllDialog
+       bind:open={viewAllDialogOpen}
+       {uploadedFiles}
+       {attachments}
+       {readonly}
+       {onFileRemove}
+       imageHeight="h-64"
+       {imageClass}
+/>
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte b/tools/server/webui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsViewAllDialog.svelte
new file mode 100644 (file)
index 0000000..a56e265
--- /dev/null
@@ -0,0 +1,203 @@
+<script lang="ts">
+       import * as Dialog from '$lib/components/ui/dialog';
+       import { ChatAttachmentImagePreview, ChatAttachmentFilePreview } from '$lib/components/app';
+       import { FileTypeCategory } from '$lib/enums/files';
+       import { getFileTypeCategory } from '$lib/utils/file-type';
+       import ChatAttachmentPreviewDialog from './ChatAttachmentPreviewDialog.svelte';
+       import type { ChatAttachmentDisplayItem, ChatAttachmentPreviewItem } from '$lib/types/chat';
+
+       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 previewDialogOpen = $state(false);
+       let previewItem = $state<ChatAttachmentPreviewItem | null>(null);
+
+       let displayItems = $derived(getDisplayItems());
+       let imageItems = $derived(displayItems.filter((item) => item.isImage));
+       let fileItems = $derived(displayItems.filter((item) => !item.isImage));
+
+       function getDisplayItems(): ChatAttachmentDisplayItem[] {
+               const items: ChatAttachmentDisplayItem[] = [];
+
+               for (const file of uploadedFiles) {
+                       items.push({
+                               id: file.id,
+                               name: file.name,
+                               size: file.size,
+                               preview: file.preview,
+                               type: file.type,
+                               isImage: getFileTypeCategory(file.type) === FileTypeCategory.IMAGE,
+                               uploadedFile: file,
+                               textContent: file.textContent
+                       });
+               }
+
+               for (const [index, attachment] of attachments.entries()) {
+                       if (attachment.type === 'imageFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       preview: attachment.base64Url,
+                                       type: 'image',
+                                       isImage: true,
+                                       attachment,
+                                       attachmentIndex: index
+                               });
+                       } else if (attachment.type === 'textFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: 'text',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index,
+                                       textContent: attachment.content
+                               });
+                       } else if (attachment.type === 'context') {
+                               // Legacy format from old webui - treat as text file
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: 'text',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index,
+                                       textContent: attachment.content
+                               });
+                       } else if (attachment.type === 'audioFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: attachment.mimeType || 'audio',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index
+                               });
+                       } else if (attachment.type === 'pdfFile') {
+                               items.push({
+                                       id: `attachment-${index}`,
+                                       name: attachment.name,
+                                       type: 'application/pdf',
+                                       isImage: false,
+                                       attachment,
+                                       attachmentIndex: index,
+                                       textContent: attachment.content
+                               });
+                       }
+               }
+
+               return items.reverse();
+       }
+
+       function openPreview(item: (typeof displayItems)[0], event?: Event) {
+               if (event) {
+                       event.preventDefault();
+                       event.stopPropagation();
+               }
+
+               previewItem = {
+                       uploadedFile: item.uploadedFile,
+                       attachment: item.attachment,
+                       preview: item.preview,
+                       name: item.name,
+                       type: item.type,
+                       size: item.size,
+                       textContent: item.textContent
+               };
+               previewDialogOpen = true;
+       }
+</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}
+
+                               {#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}
+                       </div>
+               </Dialog.Content>
+       </Dialog.Portal>
+</Dialog.Root>
+
+{#if previewItem}
+       <ChatAttachmentPreviewDialog
+               bind:open={previewDialogOpen}
+               uploadedFile={previewItem.uploadedFile}
+               attachment={previewItem.attachment}
+               preview={previewItem.preview}
+               name={previewItem.name}
+               type={previewItem.type}
+               size={previewItem.size}
+               textContent={previewItem.textContent}
+       />
+{/if}
index 67a7fff54cb6bdfde0b90b20148e0fdf4234d504..6c9a11849c3598ef9efe7ae842f824d05fd772a2 100644 (file)
        onsubmit={handleSubmit}
        class="{INPUT_CLASSES} border-radius-bottom-none mx-auto max-w-[48rem] overflow-hidden rounded-3xl backdrop-blur-md {className}"
 >
-       <ChatAttachmentsList bind:uploadedFiles {onFileRemove} class="mb-3 px-5 pt-5" />
+       <ChatAttachmentsList
+               bind:uploadedFiles
+               {onFileRemove}
+               limitToSingleRow
+               class="py-5"
+               style="scroll-padding: 1rem;"
+       />
 
        <div
                class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
index 16563537cc2924c0225ea42ba7aeed40372c792b..0c754faa8823372591a630b44402ef38c5823e7a 100644 (file)
                ondrop={handleDrop}
                role="main"
        >
-               <div class="w-full max-w-2xl px-4">
+               <div class="w-full max-w-[48rem] px-4">
                        <div class="mb-8 text-center" in:fade={{ duration: 300 }}>
                                <h1 class="mb-2 text-3xl font-semibold tracking-tight">llama.cpp</h1>
 
        <AlertDialog.Portal>
                <AlertDialog.Overlay />
 
-               <AlertDialog.Content class="max-w-md">
+               <AlertDialog.Content class="flex max-w-md flex-col">
                        <AlertDialog.Header>
                                <AlertDialog.Title>File Upload Error</AlertDialog.Title>
 
                                </AlertDialog.Description>
                        </AlertDialog.Header>
 
-                       <div class="space-y-4">
+                       <div class="!max-h-[50vh] min-h-0 flex-1 space-y-4 overflow-y-auto">
                                {#if fileErrorData.generallyUnsupported.length > 0}
                                        <div class="space-y-2">
                                                <h4 class="text-sm font-medium text-destructive">Unsupported File Types</h4>
 
                                {#if fileErrorData.modalityUnsupported.length > 0}
                                        <div class="space-y-2">
-                                               <h4 class="text-sm font-medium text-destructive">Model Compatibility Issues</h4>
-
                                                <div class="space-y-1">
                                                        {#each fileErrorData.modalityUnsupported as file (file.name)}
                                                                <div class="rounded-md bg-destructive/10 px-3 py-2">
                                                </div>
                                        </div>
                                {/if}
+                       </div>
 
-                               <div class="rounded-md bg-muted/50 p-3">
-                                       <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
+                       <div class="rounded-md bg-muted/50 p-3">
+                               <h4 class="mb-2 text-sm font-medium">This model supports:</h4>
 
-                                       <p class="text-sm text-muted-foreground">
-                                               {fileErrorData.supportedTypes.join(', ')}
-                                       </p>
-                               </div>
+                               <p class="text-sm text-muted-foreground">
+                                       {fileErrorData.supportedTypes.join(', ')}
+                               </p>
                        </div>
 
                        <AlertDialog.Footer>
index 392132f442fd366b7d6d412a9a40da1c7487949d..a695f99747494fd1cadeb3efb139313138c26a7c 100644 (file)
@@ -2,6 +2,7 @@ export { default as ChatAttachmentsList } from './chat/ChatAttachments/ChatAttac
 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 ChatForm } from './chat/ChatForm/ChatForm.svelte';
 export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
@@ -42,6 +43,8 @@ export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.sve
 
 export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
 
+export { default as RemoveButton } from './misc/RemoveButton.svelte';
+
 export { default as ServerStatus } from './server/ServerStatus.svelte';
 export { default as ServerErrorSplash } from './server/ServerErrorSplash.svelte';
 export { default as ServerLoadingSplash } from './server/ServerLoadingSplash.svelte';
diff --git a/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte b/tools/server/webui/src/lib/components/app/misc/RemoveButton.svelte
new file mode 100644 (file)
index 0000000..1736855
--- /dev/null
@@ -0,0 +1,26 @@
+<script lang="ts">
+       import { X } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+
+       interface Props {
+               id: string;
+               onRemove?: (id: string) => void;
+               class?: string;
+       }
+
+       let { id, onRemove, class: className = '' }: Props = $props();
+</script>
+
+<Button
+       type="button"
+       variant="ghost"
+       size="sm"
+       class="h-6 w-6 bg-white/20 p-0 hover:bg-white/30 {className}"
+       onclick={(e) => {
+               e.stopPropagation();
+               onRemove?.(id);
+       }}
+       aria-label="Remove file"
+>
+       <X class="h-3 w-3" />
+</Button>
index d803a5aa0f22fd6cdd9427f3114df2827d51210b..ee3990b04b976baf6410e619bf2c74b737523f8e 100644 (file)
@@ -11,6 +11,29 @@ export interface ChatUploadedFile {
        textContent?: string;
 }
 
+export interface ChatAttachmentDisplayItem {
+       id: string;
+       name: string;
+       size?: number;
+       preview?: string;
+       type: string;
+       isImage: boolean;
+       uploadedFile?: ChatUploadedFile;
+       attachment?: DatabaseMessageExtra;
+       attachmentIndex?: number;
+       textContent?: string;
+}
+
+export interface ChatAttachmentPreviewItem {
+       uploadedFile?: ChatUploadedFile;
+       attachment?: DatabaseMessageExtra;
+       preview?: string;
+       name?: string;
+       type?: string;
+       size?: number;
+       textContent?: string;
+}
+
 export interface ChatMessageSiblingInfo {
        message: DatabaseMessage;
        siblingIds: string[];