]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Add editing attachments in user messages (#18147)
authorAleksander Grygier <redacted>
Fri, 19 Dec 2025 10:14:07 +0000 (11:14 +0100)
committerGitHub <redacted>
Fri, 19 Dec 2025 10:14:07 +0000 (11:14 +0100)
* feat: Enable editing attachments in user messages

* feat: Improvements for data handling & UI

* docs: Update Architecture diagrams

* chore: update webui build output

* refactor: Exports

* chore: update webui build output

* feat: Add handling paste for Chat Message Edit Form

* chore: update webui build output

* refactor: Cleanup

* chore: update webui build output

14 files changed:
tools/server/public/index.html.gz
tools/server/webui/docs/architecture/high-level-architecture-simplified.md
tools/server/webui/docs/architecture/high-level-architecture.md
tools/server/webui/package-lock.json
tools/server/webui/package.json
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte [new file with mode: 0644]
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
tools/server/webui/src/lib/components/ui/switch/index.ts [new file with mode: 0644]
tools/server/webui/src/lib/components/ui/switch/switch.svelte [new file with mode: 0644]
tools/server/webui/src/lib/stores/chat.svelte.ts

index 9e44f03260aa9d5afd793cf361aa64faef60fe11..b5266edee70c73d2b3a987dc31b8febdeff4ba9b 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index 50f2e1df0a0464d2a1ced203cdca1cd4fa063cb1..a6cb1e9c3940910a54b553e5903760c5f2da4f54 100644 (file)
@@ -11,6 +11,8 @@ flowchart TB
         C_Screen["ChatScreen"]
         C_Form["ChatForm"]
         C_Messages["ChatMessages"]
+        C_Message["ChatMessage"]
+        C_MessageEditForm["ChatMessageEditForm"]
         C_ModelsSelector["ModelsSelector"]
         C_Settings["ChatSettings"]
     end
@@ -54,7 +56,9 @@ flowchart TB
 
     %% Component hierarchy
     C_Screen --> C_Form & C_Messages & C_Settings
-    C_Form & C_Messages --> C_ModelsSelector
+    C_Messages --> C_Message
+    C_Message --> C_MessageEditForm
+    C_Form & C_MessageEditForm --> C_ModelsSelector
 
     %% Components → Hooks → Stores
     C_Form & C_Messages --> H1 & H2
@@ -93,7 +97,7 @@ flowchart TB
     classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
 
     class R1,R2,RL routeStyle
-    class C_Sidebar,C_Screen,C_Form,C_Messages,C_ModelsSelector,C_Settings componentStyle
+    class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageEditForm,C_ModelsSelector,C_Settings componentStyle
     class H1,H2 hookStyle
     class S1,S2,S3,S4,S5 storeStyle
     class SV1,SV2,SV3,SV4,SV5 serviceStyle
index 730da10a59e7a855ecc0a1aebbe47d597c04b37c..c5ec4d6909595f471cd2243745a38e40cf575ec2 100644 (file)
@@ -16,6 +16,8 @@ end
             C_Form["ChatForm"]
             C_Messages["ChatMessages"]
             C_Message["ChatMessage"]
+            C_MessageUser["ChatMessageUser"]
+            C_MessageEditForm["ChatMessageEditForm"]
             C_Attach["ChatAttachments"]
             C_ModelsSelector["ModelsSelector"]
             C_Settings["ChatSettings"]
@@ -38,7 +40,7 @@ end
             S1Error["<b>Error Handling:</b><br/>showErrorDialog()<br/>dismissErrorDialog()<br/>isAbortError()"]
             S1Msg["<b>Message Operations:</b><br/>addMessage()<br/>sendMessage()<br/>updateMessage()<br/>deleteMessage()<br/>getDeletionInfo()"]
             S1Regen["<b>Regeneration:</b><br/>regenerateMessage()<br/>regenerateMessageWithBranching()<br/>continueAssistantMessage()"]
-            S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()"]
+            S1Edit["<b>Editing:</b><br/>editAssistantMessage()<br/>editUserMessagePreserveResponses()<br/>editMessageWithBranching()<br/>clearEditMode()<br/>isEditModeActive()<br/>getAddFilesHandler()<br/>setEditModeActive()"]
             S1Utils["<b>Utilities:</b><br/>getApiOptions()<br/>parseTimingData()<br/>getOrCreateAbortController()<br/>getConversationModel()"]
         end
         subgraph S2["conversationsStore"]
@@ -88,6 +90,10 @@ end
                 RE7["getChatStreaming()"]
                 RE8["getAllLoadingChats()"]
                 RE9["getAllStreamingChats()"]
+                RE9a["isEditModeActive()"]
+                RE9b["getAddFilesHandler()"]
+                RE9c["setEditModeActive()"]
+                RE9d["clearEditMode()"]
             end
             subgraph ConvExports["conversationsStore"]
                 RE10["conversations()"]
@@ -182,7 +188,10 @@ end
     %% Component hierarchy
     C_Screen --> C_Form & C_Messages & C_Settings
     C_Messages --> C_Message
-    C_Message --> C_ModelsSelector
+    C_Message --> C_MessageUser
+    C_MessageUser --> C_MessageEditForm
+    C_MessageEditForm --> C_ModelsSelector
+    C_MessageEditForm --> C_Attach
     C_Form --> C_ModelsSelector
     C_Form --> C_Attach
     C_Message --> C_Attach
@@ -190,6 +199,7 @@ end
     %% Components use Hooks
     C_Form --> H1
     C_Message --> H1 & H2
+    C_MessageEditForm --> H1
     C_Screen --> H2
 
     %% Hooks use Stores
@@ -244,7 +254,7 @@ end
     classDef apiStyle fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
 
     class R1,R2,RL routeStyle
-    class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message componentStyle
+    class C_Sidebar,C_Screen,C_Form,C_Messages,C_Message,C_MessageUser,C_MessageEditForm componentStyle
     class C_ModelsSelector,C_Settings componentStyle
     class C_Attach componentStyle
     class H1,H2,H3 methodStyle
index 0d1a03aca34b2fb2d1d222d3f8a7fd29404d9835..6fa9d39c719b644a586023599e3dc537eb9553b3 100644 (file)
@@ -25,7 +25,7 @@
                                "@chromatic-com/storybook": "^4.1.2",
                                "@eslint/compat": "^1.2.5",
                                "@eslint/js": "^9.18.0",
-                               "@internationalized/date": "^3.8.2",
+                               "@internationalized/date": "^3.10.1",
                                "@lucide/svelte": "^0.515.0",
                                "@playwright/test": "^1.49.1",
                                "@storybook/addon-a11y": "^10.0.7",
                        }
                },
                "node_modules/@internationalized/date": {
-                       "version": "3.8.2",
-                       "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.8.2.tgz",
-                       "integrity": "sha512-/wENk7CbvLbkUvX1tu0mwq49CVkkWpkXubGel6birjRPyo6uQ4nQpnq5xZu823zRCwwn82zgHrvgF1vZyvmVgA==",
+                       "version": "3.10.1",
+                       "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.1.tgz",
+                       "integrity": "sha512-oJrXtQiAXLvT9clCf1K4kxp3eKsQhIaZqxEyowkBcsvZDdZkbWrVmnGknxs5flTD0VGsxrxKgBCZty1EzoiMzA==",
                        "dev": true,
                        "license": "Apache-2.0",
                        "dependencies": {
index 1c970ae7a89727a2ad9d0c4b5d3e8f778e36f231..1a8c273749607c5b4a4241aedcbb623ada0e00ef 100644 (file)
@@ -26,7 +26,7 @@
                "@chromatic-com/storybook": "^4.1.2",
                "@eslint/compat": "^1.2.5",
                "@eslint/js": "^9.18.0",
-               "@internationalized/date": "^3.8.2",
+               "@internationalized/date": "^3.10.1",
                "@lucide/svelte": "^0.515.0",
                "@playwright/test": "^1.49.1",
                "@storybook/addon-a11y": "^10.0.7",
index 3ad14ed3ab08dd778193e192a7a102be8c5a0b87..fd2f7f60e579f2e2011a46e98434618ff208b4c4 100644 (file)
@@ -8,6 +8,7 @@
                ChatFormTextarea
        } from '$lib/components/app';
        import { INPUT_CLASSES } from '$lib/constants/input-classes';
+       import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
        import { config } from '$lib/stores/settings.svelte';
        import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
        import { isRouterMode } from '$lib/stores/server.svelte';
@@ -66,7 +67,7 @@
        let message = $state('');
        let pasteLongTextToFileLength = $derived.by(() => {
                const n = Number(currentConfig.pasteLongTextToFileLen);
-               return Number.isNaN(n) ? 2500 : n;
+               return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
        });
        let previousIsLoading = $state(isLoading);
        let recordingSupported = $state(false);
index 0969a937ed24147b408dec2645925fb29d418501..220276fc9e3135e5210835490297780ee800d6ba 100644 (file)
                onCopy?: (message: DatabaseMessage) => void;
                onContinueAssistantMessage?: (message: DatabaseMessage) => void;
                onDelete?: (message: DatabaseMessage) => void;
-               onEditWithBranching?: (message: DatabaseMessage, newContent: string) => void;
+               onEditWithBranching?: (
+                       message: DatabaseMessage,
+                       newContent: string,
+                       newExtras?: DatabaseMessageExtra[]
+               ) => void;
                onEditWithReplacement?: (
                        message: DatabaseMessage,
                        newContent: string,
                        shouldBranch: boolean
                ) => void;
-               onEditUserMessagePreserveResponses?: (message: DatabaseMessage, newContent: string) => void;
+               onEditUserMessagePreserveResponses?: (
+                       message: DatabaseMessage,
+                       newContent: string,
+                       newExtras?: DatabaseMessageExtra[]
+               ) => void;
                onNavigateToSibling?: (siblingId: string) => void;
                onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
                siblingInfo?: ChatMessageSiblingInfo | null;
@@ -45,6 +53,8 @@
                messageTypes: string[];
        } | null>(null);
        let editedContent = $state(message.content);
+       let editedExtras = $state<DatabaseMessageExtra[]>(message.extra ? [...message.extra] : []);
+       let editedUploadedFiles = $state<ChatUploadedFile[]>([]);
        let isEditing = $state(false);
        let showDeleteDialog = $state(false);
        let shouldBranchAfterEdit = $state(false);
        function handleCancelEdit() {
                isEditing = false;
                editedContent = message.content;
+               editedExtras = message.extra ? [...message.extra] : [];
+               editedUploadedFiles = [];
+       }
+
+       function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) {
+               editedExtras = extras;
+       }
+
+       function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
+               editedUploadedFiles = files;
        }
 
        async function handleCopy() {
        function handleEdit() {
                isEditing = true;
                editedContent = message.content;
+               editedExtras = message.extra ? [...message.extra] : [];
+               editedUploadedFiles = [];
 
                setTimeout(() => {
                        if (textareaElement) {
                onContinueAssistantMessage?.(message);
        }
 
-       function handleSaveEdit() {
+       async function handleSaveEdit() {
                if (message.role === 'user' || message.role === 'system') {
-                       onEditWithBranching?.(message, editedContent.trim());
+                       const finalExtras = await getMergedExtras();
+                       onEditWithBranching?.(message, editedContent.trim(), finalExtras);
                } else {
                        // For assistant messages, preserve exact content including trailing whitespace
                        // This is important for the Continue feature to work properly
 
                isEditing = false;
                shouldBranchAfterEdit = false;
+               editedUploadedFiles = [];
        }
 
-       function handleSaveEditOnly() {
+       async function handleSaveEditOnly() {
                if (message.role === 'user') {
                        // For user messages, trim to avoid accidental whitespace
-                       onEditUserMessagePreserveResponses?.(message, editedContent.trim());
+                       const finalExtras = await getMergedExtras();
+                       onEditUserMessagePreserveResponses?.(message, editedContent.trim(), finalExtras);
                }
 
                isEditing = false;
+               editedUploadedFiles = [];
+       }
+
+       async function getMergedExtras(): Promise<DatabaseMessageExtra[]> {
+               if (editedUploadedFiles.length === 0) {
+                       return editedExtras;
+               }
+
+               const { parseFilesToMessageExtras } = await import('$lib/utils/browser-only');
+               const result = await parseFilesToMessageExtras(editedUploadedFiles);
+               const newExtras = result?.extras || [];
+
+               return [...editedExtras, ...newExtras];
        }
 
        function handleShowDeleteDialogChange(show: boolean) {
                class={className}
                {deletionInfo}
                {editedContent}
+               {editedExtras}
+               {editedUploadedFiles}
                {isEditing}
                {message}
                onCancelEdit={handleCancelEdit}
                onEdit={handleEdit}
                onEditKeydown={handleEditKeydown}
                onEditedContentChange={handleEditedContentChange}
+               onEditedExtrasChange={handleEditedExtrasChange}
+               onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
                {onNavigateToSibling}
                onSaveEdit={handleSaveEdit}
                onSaveEditOnly={handleSaveEditOnly}
diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte
new file mode 100644 (file)
index 0000000..f812ea2
--- /dev/null
@@ -0,0 +1,391 @@
+<script lang="ts">
+       import { X, ArrowUp, Paperclip, AlertTriangle } from '@lucide/svelte';
+       import { Button } from '$lib/components/ui/button';
+       import { Switch } from '$lib/components/ui/switch';
+       import { ChatAttachmentsList, DialogConfirmation, ModelsSelector } from '$lib/components/app';
+       import { INPUT_CLASSES } from '$lib/constants/input-classes';
+       import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
+       import { AttachmentType, FileTypeCategory, MimeTypeText } from '$lib/enums';
+       import { config } from '$lib/stores/settings.svelte';
+       import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
+       import { setEditModeActive, clearEditMode } from '$lib/stores/chat.svelte';
+       import { conversationsStore } from '$lib/stores/conversations.svelte';
+       import { modelsStore } from '$lib/stores/models.svelte';
+       import { isRouterMode } from '$lib/stores/server.svelte';
+       import {
+               autoResizeTextarea,
+               getFileTypeCategory,
+               getFileTypeCategoryByExtension,
+               parseClipboardContent
+       } from '$lib/utils';
+
+       interface Props {
+               messageId: string;
+               editedContent: string;
+               editedExtras?: DatabaseMessageExtra[];
+               editedUploadedFiles?: ChatUploadedFile[];
+               originalContent: string;
+               originalExtras?: DatabaseMessageExtra[];
+               showSaveOnlyOption?: boolean;
+               onCancelEdit: () => void;
+               onSaveEdit: () => void;
+               onSaveEditOnly?: () => void;
+               onEditKeydown: (event: KeyboardEvent) => void;
+               onEditedContentChange: (content: string) => void;
+               onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+               onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
+               textareaElement?: HTMLTextAreaElement;
+       }
+
+       let {
+               messageId,
+               editedContent,
+               editedExtras = [],
+               editedUploadedFiles = [],
+               originalContent,
+               originalExtras = [],
+               showSaveOnlyOption = false,
+               onCancelEdit,
+               onSaveEdit,
+               onSaveEditOnly,
+               onEditKeydown,
+               onEditedContentChange,
+               onEditedExtrasChange,
+               onEditedUploadedFilesChange,
+               textareaElement = $bindable()
+       }: Props = $props();
+
+       let fileInputElement: HTMLInputElement | undefined = $state();
+       let saveWithoutRegenerate = $state(false);
+       let showDiscardDialog = $state(false);
+       let isRouter = $derived(isRouterMode());
+       let currentConfig = $derived(config());
+
+       let pasteLongTextToFileLength = $derived.by(() => {
+               const n = Number(currentConfig.pasteLongTextToFileLen);
+
+               return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
+       });
+
+       let hasUnsavedChanges = $derived.by(() => {
+               if (editedContent !== originalContent) return true;
+               if (editedUploadedFiles.length > 0) return true;
+
+               const extrasChanged =
+                       editedExtras.length !== originalExtras.length ||
+                       editedExtras.some((extra, i) => extra !== originalExtras[i]);
+
+               if (extrasChanged) return true;
+
+               return false;
+       });
+
+       let hasAttachments = $derived(
+               (editedExtras && editedExtras.length > 0) ||
+                       (editedUploadedFiles && editedUploadedFiles.length > 0)
+       );
+
+       let canSubmit = $derived(editedContent.trim().length > 0 || hasAttachments);
+
+       function getEditedAttachmentsModalities(): ModelModalities {
+               const modalities: ModelModalities = { vision: false, audio: false };
+
+               for (const extra of editedExtras) {
+                       if (extra.type === AttachmentType.IMAGE) {
+                               modalities.vision = true;
+                       }
+
+                       if (
+                               extra.type === AttachmentType.PDF &&
+                               'processedAsImages' in extra &&
+                               extra.processedAsImages
+                       ) {
+                               modalities.vision = true;
+                       }
+
+                       if (extra.type === AttachmentType.AUDIO) {
+                               modalities.audio = true;
+                       }
+               }
+
+               for (const file of editedUploadedFiles) {
+                       const category = getFileTypeCategory(file.type) || getFileTypeCategoryByExtension(file.name);
+                       if (category === FileTypeCategory.IMAGE) {
+                               modalities.vision = true;
+                       }
+                       if (category === FileTypeCategory.AUDIO) {
+                               modalities.audio = true;
+                       }
+               }
+
+               return modalities;
+       }
+
+       function getRequiredModalities(): ModelModalities {
+               const beforeModalities = conversationsStore.getModalitiesUpToMessage(messageId);
+               const editedModalities = getEditedAttachmentsModalities();
+
+               return {
+                       vision: beforeModalities.vision || editedModalities.vision,
+                       audio: beforeModalities.audio || editedModalities.audio
+               };
+       }
+
+       const { handleModelChange } = useModelChangeValidation({
+               getRequiredModalities,
+               onValidationFailure: async (previousModelId) => {
+                       if (previousModelId) {
+                               await modelsStore.selectModelById(previousModelId);
+                       }
+               }
+       });
+
+       function handleFileInputChange(event: Event) {
+               const input = event.target as HTMLInputElement;
+               if (!input.files || input.files.length === 0) return;
+
+               const files = Array.from(input.files);
+
+               processNewFiles(files);
+               input.value = '';
+       }
+
+       function handleGlobalKeydown(event: KeyboardEvent) {
+               if (event.key === 'Escape') {
+                       event.preventDefault();
+                       attemptCancel();
+               }
+       }
+
+       function attemptCancel() {
+               if (hasUnsavedChanges) {
+                       showDiscardDialog = true;
+               } else {
+                       onCancelEdit();
+               }
+       }
+
+       function handleRemoveExistingAttachment(index: number) {
+               if (!onEditedExtrasChange) return;
+
+               const newExtras = [...editedExtras];
+
+               newExtras.splice(index, 1);
+               onEditedExtrasChange(newExtras);
+       }
+
+       function handleRemoveUploadedFile(fileId: string) {
+               if (!onEditedUploadedFilesChange) return;
+
+               const newFiles = editedUploadedFiles.filter((f) => f.id !== fileId);
+
+               onEditedUploadedFilesChange(newFiles);
+       }
+
+       function handleSubmit() {
+               if (!canSubmit) return;
+
+               if (saveWithoutRegenerate && onSaveEditOnly) {
+                       onSaveEditOnly();
+               } else {
+                       onSaveEdit();
+               }
+
+               saveWithoutRegenerate = false;
+       }
+
+       async function processNewFiles(files: File[]) {
+               if (!onEditedUploadedFilesChange) return;
+
+               const { processFilesToChatUploaded } = await import('$lib/utils/browser-only');
+               const processed = await processFilesToChatUploaded(files);
+
+               onEditedUploadedFilesChange([...editedUploadedFiles, ...processed]);
+       }
+
+       function handlePaste(event: ClipboardEvent) {
+               if (!event.clipboardData) return;
+
+               const files = Array.from(event.clipboardData.items)
+                       .filter((item) => item.kind === 'file')
+                       .map((item) => item.getAsFile())
+                       .filter((file): file is File => file !== null);
+
+               if (files.length > 0) {
+                       event.preventDefault();
+                       processNewFiles(files);
+
+                       return;
+               }
+
+               const text = event.clipboardData.getData(MimeTypeText.PLAIN);
+
+               if (text.startsWith('"')) {
+                       const parsed = parseClipboardContent(text);
+
+                       if (parsed.textAttachments.length > 0) {
+                               event.preventDefault();
+                               onEditedContentChange(parsed.message);
+
+                               const attachmentFiles = parsed.textAttachments.map(
+                                       (att) =>
+                                               new File([att.content], att.name, {
+                                                       type: MimeTypeText.PLAIN
+                                               })
+                               );
+
+                               processNewFiles(attachmentFiles);
+
+                               setTimeout(() => {
+                                       textareaElement?.focus();
+                               }, 10);
+
+                               return;
+                       }
+               }
+
+               if (
+                       text.length > 0 &&
+                       pasteLongTextToFileLength > 0 &&
+                       text.length > pasteLongTextToFileLength
+               ) {
+                       event.preventDefault();
+
+                       const textFile = new File([text], 'Pasted', {
+                               type: MimeTypeText.PLAIN
+                       });
+
+                       processNewFiles([textFile]);
+               }
+       }
+
+       $effect(() => {
+               if (textareaElement) {
+                       autoResizeTextarea(textareaElement);
+               }
+       });
+
+       $effect(() => {
+               setEditModeActive(processNewFiles);
+
+               return () => {
+                       clearEditMode();
+               };
+       });
+</script>
+
+<svelte:window onkeydown={handleGlobalKeydown} />
+
+<input
+       bind:this={fileInputElement}
+       type="file"
+       multiple
+       class="hidden"
+       onchange={handleFileInputChange}
+/>
+
+<div
+       class="{INPUT_CLASSES} w-full max-w-[80%] overflow-hidden rounded-3xl backdrop-blur-md"
+       data-slot="edit-form"
+>
+       <ChatAttachmentsList
+               attachments={editedExtras}
+               uploadedFiles={editedUploadedFiles}
+               readonly={false}
+               onFileRemove={(fileId) => {
+                       if (fileId.startsWith('attachment-')) {
+                               const index = parseInt(fileId.replace('attachment-', ''), 10);
+                               if (!isNaN(index) && index >= 0 && index < editedExtras.length) {
+                                       handleRemoveExistingAttachment(index);
+                               }
+                       } else {
+                               handleRemoveUploadedFile(fileId);
+                       }
+               }}
+               limitToSingleRow
+               class="py-5"
+               style="scroll-padding: 1rem;"
+       />
+
+       <div class="relative min-h-[48px] px-5 py-3">
+               <textarea
+                       bind:this={textareaElement}
+                       bind:value={editedContent}
+                       class="field-sizing-content max-h-80 min-h-10 w-full resize-none bg-transparent text-sm outline-none"
+                       onkeydown={onEditKeydown}
+                       oninput={(e) => {
+                               autoResizeTextarea(e.currentTarget);
+                               onEditedContentChange(e.currentTarget.value);
+                       }}
+                       onpaste={handlePaste}
+                       placeholder="Edit your message..."
+               ></textarea>
+
+               <div class="flex w-full items-center gap-3" style="container-type: inline-size">
+                       <Button
+                               class="h-8 w-8 shrink-0 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
+                               onclick={() => fileInputElement?.click()}
+                               type="button"
+                               title="Add attachment"
+                       >
+                               <span class="sr-only">Attach files</span>
+
+                               <Paperclip class="h-4 w-4" />
+                       </Button>
+
+                       <div class="flex-1"></div>
+
+                       {#if isRouter}
+                               <ModelsSelector
+                                       forceForegroundText={true}
+                                       useGlobalSelection={true}
+                                       onModelChange={handleModelChange}
+                               />
+                       {/if}
+
+                       <Button
+                               class="h-8 w-8 shrink-0 rounded-full p-0"
+                               onclick={handleSubmit}
+                               disabled={!canSubmit}
+                               type="button"
+                               title={saveWithoutRegenerate ? 'Save changes' : 'Send and regenerate'}
+                       >
+                               <span class="sr-only">{saveWithoutRegenerate ? 'Save' : 'Send'}</span>
+
+                               <ArrowUp class="h-5 w-5" />
+                       </Button>
+               </div>
+       </div>
+</div>
+
+<div class="mt-2 flex w-full max-w-[80%] items-center justify-between">
+       {#if showSaveOnlyOption && onSaveEditOnly}
+               <div class="flex items-center gap-2">
+                       <Switch id="save-only-switch" bind:checked={saveWithoutRegenerate} class="scale-75" />
+
+                       <label for="save-only-switch" class="cursor-pointer text-xs text-muted-foreground">
+                               Update without re-sending
+                       </label>
+               </div>
+       {:else}
+               <div></div>
+       {/if}
+
+       <Button class="h-7 px-3 text-xs" onclick={attemptCancel} size="sm" variant="ghost">
+               <X class="mr-1 h-3 w-3" />
+
+               Cancel
+       </Button>
+</div>
+
+<DialogConfirmation
+       bind:open={showDiscardDialog}
+       title="Discard changes?"
+       description="You have unsaved changes. Are you sure you want to discard them?"
+       confirmText="Discard"
+       cancelText="Keep editing"
+       variant="destructive"
+       icon={AlertTriangle}
+       onConfirm={onCancelEdit}
+       onCancel={() => (showDiscardDialog = false)}
+/>
index 3d2b8dd35b4d974e48002c626cb9a75f5fcf54e1..041c6bd251372cfca1134abff534edd31bfaeab2 100644 (file)
@@ -1,18 +1,17 @@
 <script lang="ts">
-       import { Check, X, Send } from '@lucide/svelte';
        import { Card } from '$lib/components/ui/card';
-       import { Button } from '$lib/components/ui/button';
        import { ChatAttachmentsList, MarkdownContent } from '$lib/components/app';
-       import { INPUT_CLASSES } from '$lib/constants/input-classes';
        import { config } from '$lib/stores/settings.svelte';
-       import { autoResizeTextarea } from '$lib/utils';
        import ChatMessageActions from './ChatMessageActions.svelte';
+       import ChatMessageEditForm from './ChatMessageEditForm.svelte';
 
        interface Props {
                class?: string;
                message: DatabaseMessage;
                isEditing: boolean;
                editedContent: string;
+               editedExtras?: DatabaseMessageExtra[];
+               editedUploadedFiles?: ChatUploadedFile[];
                siblingInfo?: ChatMessageSiblingInfo | null;
                showDeleteDialog: boolean;
                deletionInfo: {
@@ -26,6 +25,8 @@
                onSaveEditOnly?: () => void;
                onEditKeydown: (event: KeyboardEvent) => void;
                onEditedContentChange: (content: string) => void;
+               onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+               onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
                onCopy: () => void;
                onEdit: () => void;
                onDelete: () => void;
@@ -40,6 +41,8 @@
                message,
                isEditing,
                editedContent,
+               editedExtras = [],
+               editedUploadedFiles = [],
                siblingInfo = null,
                showDeleteDialog,
                deletionInfo,
@@ -48,6 +51,8 @@
                onSaveEditOnly,
                onEditKeydown,
                onEditedContentChange,
+               onEditedExtrasChange,
+               onEditedUploadedFilesChange,
                onCopy,
                onEdit,
                onDelete,
        let messageElement: HTMLElement | undefined = $state();
        const currentConfig = config();
 
-       $effect(() => {
-               if (isEditing && textareaElement) {
-                       autoResizeTextarea(textareaElement);
-               }
-       });
-
        $effect(() => {
                if (!messageElement || !message.content.trim()) return;
 
        role="group"
 >
        {#if isEditing}
-               <div class="w-full max-w-[80%]">
-                       <textarea
-                               bind:this={textareaElement}
-                               bind:value={editedContent}
-                               class="min-h-[60px] w-full resize-none rounded-2xl px-3 py-2 text-sm {INPUT_CLASSES}"
-                               onkeydown={onEditKeydown}
-                               oninput={(e) => {
-                                       autoResizeTextarea(e.currentTarget);
-                                       onEditedContentChange(e.currentTarget.value);
-                               }}
-                               placeholder="Edit your message..."
-                       ></textarea>
-
-                       <div class="mt-2 flex justify-end gap-2">
-                               <Button class="h-8 px-3" onclick={onCancelEdit} size="sm" variant="ghost">
-                                       <X class="mr-1 h-3 w-3" />
-                                       Cancel
-                               </Button>
-
-                               {#if onSaveEditOnly}
-                                       <Button
-                                               class="h-8 px-3"
-                                               onclick={onSaveEditOnly}
-                                               disabled={!editedContent.trim()}
-                                               size="sm"
-                                               variant="outline"
-                                       >
-                                               <Check class="mr-1 h-3 w-3" />
-                                               Save
-                                       </Button>
-                               {/if}
-
-                               <Button class="h-8 px-3" onclick={onSaveEdit} disabled={!editedContent.trim()} size="sm">
-                                       <Send class="mr-1 h-3 w-3" />
-                                       Send
-                               </Button>
-                       </div>
-               </div>
+               <ChatMessageEditForm
+                       bind:textareaElement
+                       messageId={message.id}
+                       {editedContent}
+                       {editedExtras}
+                       {editedUploadedFiles}
+                       originalContent={message.content}
+                       originalExtras={message.extra}
+                       showSaveOnlyOption={!!onSaveEditOnly}
+                       {onCancelEdit}
+                       {onSaveEdit}
+                       {onSaveEditOnly}
+                       {onEditKeydown}
+                       {onEditedContentChange}
+                       {onEditedExtrasChange}
+                       {onEditedUploadedFilesChange}
+               />
        {:else}
                {#if message.extra && message.extra.length > 0}
                        <div class="mb-2 max-w-[80%]">
index 2e5f57cb61430ede2d05d0ad271290bf037bf970..c203f10098d8f880c8187113ba9ba9da7491ba15 100644 (file)
                await conversationsStore.navigateToSibling(siblingId);
        }
 
-       async function handleEditWithBranching(message: DatabaseMessage, newContent: string) {
+       async function handleEditWithBranching(
+               message: DatabaseMessage,
+               newContent: string,
+               newExtras?: DatabaseMessageExtra[]
+       ) {
                onUserAction?.();
 
-               await chatStore.editMessageWithBranching(message.id, newContent);
+               await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
 
                refreshAllMessages();
        }
 
        async function handleEditUserMessagePreserveResponses(
                message: DatabaseMessage,
-               newContent: string
+               newContent: string,
+               newExtras?: DatabaseMessageExtra[]
        ) {
                onUserAction?.();
 
-               await chatStore.editUserMessagePreserveResponses(message.id, newContent);
+               await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
 
                refreshAllMessages();
        }
index ae40b35d3308d3c80d3b77675e12947862a94b7e..27439551a1f50e33a323c6ad2bb00f603dbd8aaa 100644 (file)
                AUTO_SCROLL_INTERVAL,
                INITIAL_SCROLL_DELAY
        } from '$lib/constants/auto-scroll';
-       import { chatStore, errorDialog, isLoading } from '$lib/stores/chat.svelte';
+       import {
+               chatStore,
+               errorDialog,
+               isLoading,
+               isEditing,
+               getAddFilesHandler
+       } from '$lib/stores/chat.svelte';
        import {
                conversationsStore,
                activeMessages,
                dragCounter = 0;
 
                if (event.dataTransfer?.files) {
-                       processFiles(Array.from(event.dataTransfer.files));
+                       const files = Array.from(event.dataTransfer.files);
+
+                       if (isEditing()) {
+                               const handler = getAddFilesHandler();
+
+                               if (handler) {
+                                       handler(files);
+                                       return;
+                               }
+                       }
+
+                       processFiles(files);
                }
        }
 
 
                        <div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
                                <ChatForm
-                                       disabled={hasPropsError}
+                                       disabled={hasPropsError || isEditing()}
                                        isLoading={isCurrentConversationLoading}
                                        onFileRemove={handleFileRemove}
                                        onFileUpload={handleFileUpload}
diff --git a/tools/server/webui/src/lib/components/ui/switch/index.ts b/tools/server/webui/src/lib/components/ui/switch/index.ts
new file mode 100644 (file)
index 0000000..129f8f5
--- /dev/null
@@ -0,0 +1,7 @@
+import Root from './switch.svelte';
+
+export {
+       Root,
+       //
+       Root as Switch
+};
diff --git a/tools/server/webui/src/lib/components/ui/switch/switch.svelte b/tools/server/webui/src/lib/components/ui/switch/switch.svelte
new file mode 100644 (file)
index 0000000..5a5975e
--- /dev/null
@@ -0,0 +1,29 @@
+<script lang="ts">
+       import { Switch as SwitchPrimitive } from 'bits-ui';
+       import { cn, type WithoutChildrenOrChild } from '$lib/components/ui/utils.js';
+
+       let {
+               ref = $bindable(null),
+               class: className,
+               checked = $bindable(false),
+               ...restProps
+       }: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
+</script>
+
+<SwitchPrimitive.Root
+       bind:ref
+       bind:checked
+       data-slot="switch"
+       class={cn(
+               'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
+               className
+       )}
+       {...restProps}
+>
+       <SwitchPrimitive.Thumb
+               data-slot="switch-thumb"
+               class={cn(
+                       'pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground'
+               )}
+       />
+</SwitchPrimitive.Root>
index e0431ee643883e5e0052d3f9d60f3d25dacf73db..010889452496feb40d1960d492ee7ac9c3a0aee4 100644 (file)
@@ -74,6 +74,8 @@ class ChatStore {
        private processingStates = new SvelteMap<string, ApiProcessingState | null>();
        private activeConversationId = $state<string | null>(null);
        private isStreamingActive = $state(false);
+       private isEditModeActive = $state(false);
+       private addFilesHandler: ((files: File[]) => void) | null = $state(null);
 
        // ─────────────────────────────────────────────────────────────────────────────
        // Loading State
@@ -965,6 +967,160 @@ class ChatStore {
        // Editing
        // ─────────────────────────────────────────────────────────────────────────────
 
+       clearEditMode(): void {
+               this.isEditModeActive = false;
+               this.addFilesHandler = null;
+       }
+
+       async continueAssistantMessage(messageId: string): Promise<void> {
+               const activeConv = conversationsStore.activeConversation;
+               if (!activeConv || this.isLoading) return;
+
+               const result = this.getMessageByIdWithRole(messageId, 'assistant');
+               if (!result) return;
+               const { message: msg, index: idx } = result;
+
+               if (this.isChatLoading(activeConv.id)) return;
+
+               try {
+                       this.errorDialogState = null;
+                       this.setChatLoading(activeConv.id, true);
+                       this.clearChatStreaming(activeConv.id);
+
+                       const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
+                       const dbMessage = allMessages.find((m) => m.id === messageId);
+
+                       if (!dbMessage) {
+                               this.setChatLoading(activeConv.id, false);
+
+                               return;
+                       }
+
+                       const originalContent = dbMessage.content;
+                       const originalThinking = dbMessage.thinking || '';
+
+                       const conversationContext = conversationsStore.activeMessages.slice(0, idx);
+                       const contextWithContinue = [
+                               ...conversationContext,
+                               { role: 'assistant' as const, content: originalContent }
+                       ];
+
+                       let appendedContent = '',
+                               appendedThinking = '',
+                               hasReceivedContent = false;
+
+                       const abortController = this.getOrCreateAbortController(msg.convId);
+
+                       await ChatService.sendMessage(
+                               contextWithContinue,
+                               {
+                                       ...this.getApiOptions(),
+
+                                       onChunk: (chunk: string) => {
+                                               hasReceivedContent = true;
+                                               appendedContent += chunk;
+                                               const fullContent = originalContent + appendedContent;
+                                               this.setChatStreaming(msg.convId, fullContent, msg.id);
+                                               conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
+                                       },
+
+                                       onReasoningChunk: (reasoningChunk: string) => {
+                                               hasReceivedContent = true;
+                                               appendedThinking += reasoningChunk;
+                                               conversationsStore.updateMessageAtIndex(idx, {
+                                                       thinking: originalThinking + appendedThinking
+                                               });
+                                       },
+
+                                       onTimings: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
+                                               const tokensPerSecond =
+                                                       timings?.predicted_ms && timings?.predicted_n
+                                                               ? (timings.predicted_n / timings.predicted_ms) * 1000
+                                                               : 0;
+                                               this.updateProcessingStateFromTimings(
+                                                       {
+                                                               prompt_n: timings?.prompt_n || 0,
+                                                               prompt_ms: timings?.prompt_ms,
+                                                               predicted_n: timings?.predicted_n || 0,
+                                                               predicted_per_second: tokensPerSecond,
+                                                               cache_n: timings?.cache_n || 0,
+                                                               prompt_progress: promptProgress
+                                                       },
+                                                       msg.convId
+                                               );
+                                       },
+
+                                       onComplete: async (
+                                               finalContent?: string,
+                                               reasoningContent?: string,
+                                               timings?: ChatMessageTimings
+                                       ) => {
+                                               const fullContent = originalContent + (finalContent || appendedContent);
+                                               const fullThinking = originalThinking + (reasoningContent || appendedThinking);
+                                               await DatabaseService.updateMessage(msg.id, {
+                                                       content: fullContent,
+                                                       thinking: fullThinking,
+                                                       timestamp: Date.now(),
+                                                       timings
+                                               });
+                                               conversationsStore.updateMessageAtIndex(idx, {
+                                                       content: fullContent,
+                                                       thinking: fullThinking,
+                                                       timestamp: Date.now(),
+                                                       timings
+                                               });
+                                               conversationsStore.updateConversationTimestamp();
+                                               this.setChatLoading(msg.convId, false);
+                                               this.clearChatStreaming(msg.convId);
+                                               this.clearProcessingState(msg.convId);
+                                       },
+
+                                       onError: async (error: Error) => {
+                                               if (this.isAbortError(error)) {
+                                                       if (hasReceivedContent && appendedContent) {
+                                                               await DatabaseService.updateMessage(msg.id, {
+                                                                       content: originalContent + appendedContent,
+                                                                       thinking: originalThinking + appendedThinking,
+                                                                       timestamp: Date.now()
+                                                               });
+                                                               conversationsStore.updateMessageAtIndex(idx, {
+                                                                       content: originalContent + appendedContent,
+                                                                       thinking: originalThinking + appendedThinking,
+                                                                       timestamp: Date.now()
+                                                               });
+                                                       }
+                                                       this.setChatLoading(msg.convId, false);
+                                                       this.clearChatStreaming(msg.convId);
+                                                       this.clearProcessingState(msg.convId);
+                                                       return;
+                                               }
+                                               console.error('Continue generation error:', error);
+                                               conversationsStore.updateMessageAtIndex(idx, {
+                                                       content: originalContent,
+                                                       thinking: originalThinking
+                                               });
+                                               await DatabaseService.updateMessage(msg.id, {
+                                                       content: originalContent,
+                                                       thinking: originalThinking
+                                               });
+                                               this.setChatLoading(msg.convId, false);
+                                               this.clearChatStreaming(msg.convId);
+                                               this.clearProcessingState(msg.convId);
+                                               this.showErrorDialog(
+                                                       error.name === 'TimeoutError' ? 'timeout' : 'server',
+                                                       error.message
+                                               );
+                                       }
+                               },
+                               msg.convId,
+                               abortController.signal
+                       );
+               } catch (error) {
+                       if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
+                       if (activeConv) this.setChatLoading(activeConv.id, false);
+               }
+       }
+
        async editAssistantMessage(
                messageId: string,
                newContent: string,
@@ -995,11 +1151,10 @@ class ChatStore {
                                );
                                await conversationsStore.updateCurrentNode(newMessage.id);
                        } else {
-                               await DatabaseService.updateMessage(msg.id, { content: newContent, timestamp: Date.now() });
+                               await DatabaseService.updateMessage(msg.id, { content: newContent });
                                await conversationsStore.updateCurrentNode(msg.id);
                                conversationsStore.updateMessageAtIndex(idx, {
-                                       content: newContent,
-                                       timestamp: Date.now()
+                                       content: newContent
                                });
                        }
                        conversationsStore.updateConversationTimestamp();
@@ -1009,7 +1164,11 @@ class ChatStore {
                }
        }
 
-       async editUserMessagePreserveResponses(messageId: string, newContent: string): Promise<void> {
+       async editUserMessagePreserveResponses(
+               messageId: string,
+               newContent: string,
+               newExtras?: DatabaseMessageExtra[]
+       ): Promise<void> {
                const activeConv = conversationsStore.activeConversation;
                if (!activeConv) return;
 
@@ -1018,11 +1177,18 @@ class ChatStore {
                const { message: msg, index: idx } = result;
 
                try {
-                       await DatabaseService.updateMessage(messageId, {
-                               content: newContent,
-                               timestamp: Date.now()
-                       });
-                       conversationsStore.updateMessageAtIndex(idx, { content: newContent, timestamp: Date.now() });
+                       const updateData: Partial<DatabaseMessage> = {
+                               content: newContent
+                       };
+
+                       // Update extras if provided (including empty array to clear attachments)
+                       // Deep clone to avoid Proxy objects from Svelte reactivity
+                       if (newExtras !== undefined) {
+                               updateData.extra = JSON.parse(JSON.stringify(newExtras));
+                       }
+
+                       await DatabaseService.updateMessage(messageId, updateData);
+                       conversationsStore.updateMessageAtIndex(idx, updateData);
 
                        const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
                        const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
@@ -1040,7 +1206,11 @@ class ChatStore {
                }
        }
 
-       async editMessageWithBranching(messageId: string, newContent: string): Promise<void> {
+       async editMessageWithBranching(
+               messageId: string,
+               newContent: string,
+               newExtras?: DatabaseMessageExtra[]
+       ): Promise<void> {
                const activeConv = conversationsStore.activeConversation;
                if (!activeConv || this.isLoading) return;
 
@@ -1062,6 +1232,15 @@ class ChatStore {
                        const parentId = msg.parent || rootMessage?.id;
                        if (!parentId) return;
 
+                       // Use newExtras if provided, otherwise copy existing extras
+                       // Deep clone to avoid Proxy objects from Svelte reactivity
+                       const extrasToUse =
+                               newExtras !== undefined
+                                       ? JSON.parse(JSON.stringify(newExtras))
+                                       : msg.extra
+                                               ? JSON.parse(JSON.stringify(msg.extra))
+                                               : undefined;
+
                        const newMessage = await DatabaseService.createMessageBranch(
                                {
                                        convId: msg.convId,
@@ -1072,7 +1251,7 @@ class ChatStore {
                                        thinking: msg.thinking || '',
                                        toolCalls: msg.toolCalls || '',
                                        children: [],
-                                       extra: msg.extra ? JSON.parse(JSON.stringify(msg.extra)) : undefined,
+                                       extra: extrasToUse,
                                        model: msg.model
                                },
                                parentId
@@ -1191,168 +1370,35 @@ class ChatStore {
                }
        }
 
-       async continueAssistantMessage(messageId: string): Promise<void> {
-               const activeConv = conversationsStore.activeConversation;
-               if (!activeConv || this.isLoading) return;
-
-               const result = this.getMessageByIdWithRole(messageId, 'assistant');
-               if (!result) return;
-               const { message: msg, index: idx } = result;
-
-               if (this.isChatLoading(activeConv.id)) return;
-
-               try {
-                       this.errorDialogState = null;
-                       this.setChatLoading(activeConv.id, true);
-                       this.clearChatStreaming(activeConv.id);
-
-                       const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
-                       const dbMessage = allMessages.find((m) => m.id === messageId);
-
-                       if (!dbMessage) {
-                               this.setChatLoading(activeConv.id, false);
-
-                               return;
-                       }
-
-                       const originalContent = dbMessage.content;
-                       const originalThinking = dbMessage.thinking || '';
-
-                       const conversationContext = conversationsStore.activeMessages.slice(0, idx);
-                       const contextWithContinue = [
-                               ...conversationContext,
-                               { role: 'assistant' as const, content: originalContent }
-                       ];
-
-                       let appendedContent = '',
-                               appendedThinking = '',
-                               hasReceivedContent = false;
-
-                       const abortController = this.getOrCreateAbortController(msg.convId);
-
-                       await ChatService.sendMessage(
-                               contextWithContinue,
-                               {
-                                       ...this.getApiOptions(),
-
-                                       onChunk: (chunk: string) => {
-                                               hasReceivedContent = true;
-                                               appendedContent += chunk;
-                                               const fullContent = originalContent + appendedContent;
-                                               this.setChatStreaming(msg.convId, fullContent, msg.id);
-                                               conversationsStore.updateMessageAtIndex(idx, { content: fullContent });
-                                       },
-
-                                       onReasoningChunk: (reasoningChunk: string) => {
-                                               hasReceivedContent = true;
-                                               appendedThinking += reasoningChunk;
-                                               conversationsStore.updateMessageAtIndex(idx, {
-                                                       thinking: originalThinking + appendedThinking
-                                               });
-                                       },
-
-                                       onTimings: (timings: ChatMessageTimings, promptProgress?: ChatMessagePromptProgress) => {
-                                               const tokensPerSecond =
-                                                       timings?.predicted_ms && timings?.predicted_n
-                                                               ? (timings.predicted_n / timings.predicted_ms) * 1000
-                                                               : 0;
-                                               this.updateProcessingStateFromTimings(
-                                                       {
-                                                               prompt_n: timings?.prompt_n || 0,
-                                                               prompt_ms: timings?.prompt_ms,
-                                                               predicted_n: timings?.predicted_n || 0,
-                                                               predicted_per_second: tokensPerSecond,
-                                                               cache_n: timings?.cache_n || 0,
-                                                               prompt_progress: promptProgress
-                                                       },
-                                                       msg.convId
-                                               );
-                                       },
-
-                                       onComplete: async (
-                                               finalContent?: string,
-                                               reasoningContent?: string,
-                                               timings?: ChatMessageTimings
-                                       ) => {
-                                               const fullContent = originalContent + (finalContent || appendedContent);
-                                               const fullThinking = originalThinking + (reasoningContent || appendedThinking);
-                                               await DatabaseService.updateMessage(msg.id, {
-                                                       content: fullContent,
-                                                       thinking: fullThinking,
-                                                       timestamp: Date.now(),
-                                                       timings
-                                               });
-                                               conversationsStore.updateMessageAtIndex(idx, {
-                                                       content: fullContent,
-                                                       thinking: fullThinking,
-                                                       timestamp: Date.now(),
-                                                       timings
-                                               });
-                                               conversationsStore.updateConversationTimestamp();
-                                               this.setChatLoading(msg.convId, false);
-                                               this.clearChatStreaming(msg.convId);
-                                               this.clearProcessingState(msg.convId);
-                                       },
+       getAddFilesHandler(): ((files: File[]) => void) | null {
+               return this.addFilesHandler;
+       }
 
-                                       onError: async (error: Error) => {
-                                               if (this.isAbortError(error)) {
-                                                       if (hasReceivedContent && appendedContent) {
-                                                               await DatabaseService.updateMessage(msg.id, {
-                                                                       content: originalContent + appendedContent,
-                                                                       thinking: originalThinking + appendedThinking,
-                                                                       timestamp: Date.now()
-                                                               });
-                                                               conversationsStore.updateMessageAtIndex(idx, {
-                                                                       content: originalContent + appendedContent,
-                                                                       thinking: originalThinking + appendedThinking,
-                                                                       timestamp: Date.now()
-                                                               });
-                                                       }
-                                                       this.setChatLoading(msg.convId, false);
-                                                       this.clearChatStreaming(msg.convId);
-                                                       this.clearProcessingState(msg.convId);
-                                                       return;
-                                               }
-                                               console.error('Continue generation error:', error);
-                                               conversationsStore.updateMessageAtIndex(idx, {
-                                                       content: originalContent,
-                                                       thinking: originalThinking
-                                               });
-                                               await DatabaseService.updateMessage(msg.id, {
-                                                       content: originalContent,
-                                                       thinking: originalThinking
-                                               });
-                                               this.setChatLoading(msg.convId, false);
-                                               this.clearChatStreaming(msg.convId);
-                                               this.clearProcessingState(msg.convId);
-                                               this.showErrorDialog(
-                                                       error.name === 'TimeoutError' ? 'timeout' : 'server',
-                                                       error.message
-                                               );
-                                       }
-                               },
-                               msg.convId,
-                               abortController.signal
-                       );
-               } catch (error) {
-                       if (!this.isAbortError(error)) console.error('Failed to continue message:', error);
-                       if (activeConv) this.setChatLoading(activeConv.id, false);
-               }
+       public getAllLoadingChats(): string[] {
+               return Array.from(this.chatLoadingStates.keys());
        }
 
-       public isChatLoadingPublic(convId: string): boolean {
-               return this.isChatLoading(convId);
+       public getAllStreamingChats(): string[] {
+               return Array.from(this.chatStreamingStates.keys());
        }
+
        public getChatStreamingPublic(
                convId: string
        ): { response: string; messageId: string } | undefined {
                return this.getChatStreaming(convId);
        }
-       public getAllLoadingChats(): string[] {
-               return Array.from(this.chatLoadingStates.keys());
+
+       public isChatLoadingPublic(convId: string): boolean {
+               return this.isChatLoading(convId);
        }
-       public getAllStreamingChats(): string[] {
-               return Array.from(this.chatStreamingStates.keys());
+
+       isEditing(): boolean {
+               return this.isEditModeActive;
+       }
+
+       setEditModeActive(handler: (files: File[]) => void): void {
+               this.isEditModeActive = true;
+               this.addFilesHandler = handler;
        }
 
        // ─────────────────────────────────────────────────────────────────────────────
@@ -1416,13 +1462,17 @@ class ChatStore {
 
 export const chatStore = new ChatStore();
 
-export const isLoading = () => chatStore.isLoading;
+export const activeProcessingState = () => chatStore.activeProcessingState;
+export const clearEditMode = () => chatStore.clearEditMode();
 export const currentResponse = () => chatStore.currentResponse;
 export const errorDialog = () => chatStore.errorDialogState;
-export const activeProcessingState = () => chatStore.activeProcessingState;
-export const isChatStreaming = () => chatStore.isStreaming();
-
-export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
-export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
+export const getAddFilesHandler = () => chatStore.getAddFilesHandler();
 export const getAllLoadingChats = () => chatStore.getAllLoadingChats();
 export const getAllStreamingChats = () => chatStore.getAllStreamingChats();
+export const getChatStreaming = (convId: string) => chatStore.getChatStreamingPublic(convId);
+export const isChatLoading = (convId: string) => chatStore.isChatLoadingPublic(convId);
+export const isChatStreaming = () => chatStore.isStreaming();
+export const isEditing = () => chatStore.isEditing();
+export const isLoading = () => chatStore.isLoading;
+export const setEditModeActive = (handler: (files: File[]) => void) =>
+       chatStore.setEditModeActive(handler);