C_Screen["ChatScreen"]
C_Form["ChatForm"]
C_Messages["ChatMessages"]
+ C_Message["ChatMessage"]
+ C_MessageEditForm["ChatMessageEditForm"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
end
%% 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
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
C_Form["ChatForm"]
C_Messages["ChatMessages"]
C_Message["ChatMessage"]
+ C_MessageUser["ChatMessageUser"]
+ C_MessageEditForm["ChatMessageEditForm"]
C_Attach["ChatAttachments"]
C_ModelsSelector["ModelsSelector"]
C_Settings["ChatSettings"]
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"]
RE7["getChatStreaming()"]
RE8["getAllLoadingChats()"]
RE9["getAllStreamingChats()"]
+ RE9a["isEditModeActive()"]
+ RE9b["getAddFilesHandler()"]
+ RE9c["setEditModeActive()"]
+ RE9d["clearEditMode()"]
end
subgraph ConvExports["conversationsStore"]
RE10["conversations()"]
%% 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
%% Components use Hooks
C_Form --> H1
C_Message --> H1 & H2
+ C_MessageEditForm --> H1
C_Screen --> H2
%% Hooks use Stores
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
"@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": {
"@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",
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';
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);
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;
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}
--- /dev/null
+<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)}
+/>
<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: {
onSaveEditOnly?: () => void;
onEditKeydown: (event: KeyboardEvent) => void;
onEditedContentChange: (content: string) => void;
+ onEditedExtrasChange?: (extras: DatabaseMessageExtra[]) => void;
+ onEditedUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
message,
isEditing,
editedContent,
+ editedExtras = [],
+ editedUploadedFiles = [],
siblingInfo = null,
showDeleteDialog,
deletionInfo,
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%]">
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();
}
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}
--- /dev/null
+import Root from './switch.svelte';
+
+export {
+ Root,
+ //
+ Root as Switch
+};
--- /dev/null
+<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>
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
// 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,
);
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();
}
}
- 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;
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);
}
}
- 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;
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,
thinking: msg.thinking || '',
toolCalls: msg.toolCalls || '',
children: [],
- extra: msg.extra ? JSON.parse(JSON.stringify(msg.extra)) : undefined,
+ extra: extrasToUse,
model: msg.model
},
parentId
}
}
- 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;
}
// ─────────────────────────────────────────────────────────────────────────────
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);