refactor: Centralize chat-wide actions in ChatMessages.svelte

This commit is contained in:
Aleksander Grygier 2026-01-27 12:57:41 +01:00
parent 6b6ebd6bca
commit cbcd7956c8
2 changed files with 78 additions and 183 deletions

View File

@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { base } from '$app/paths'; import { base } from '$app/paths';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte'; import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte';
import { DatabaseService } from '$lib/services'; import { DatabaseService } from '$lib/services';
import { config } from '$lib/stores/settings.svelte';
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui'; import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants/ui';
import { MessageRole, AttachmentType } from '$lib/enums'; import { MessageRole, AttachmentType } from '$lib/enums';
import { copyToClipboard, isIMEComposing, formatMessageForClipboard } from '$lib/utils';
import ChatMessageAssistant from './ChatMessageAssistant.svelte'; import ChatMessageAssistant from './ChatMessageAssistant.svelte';
import ChatMessageUser from './ChatMessageUser.svelte'; import ChatMessageUser from './ChatMessageUser.svelte';
import ChatMessageSystem from './ChatMessageSystem.svelte'; import ChatMessageSystem from './ChatMessageSystem.svelte';
@ -18,42 +17,12 @@
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
onCopy?: (message: DatabaseMessage) => void;
onContinueAssistantMessage?: (message: DatabaseMessage) => void;
onDelete?: (message: DatabaseMessage) => void;
onEditWithBranching?: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
onEditWithReplacement?: (
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) => void;
onEditUserMessagePreserveResponses?: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
onNavigateToSibling?: (siblingId: string) => void;
onRegenerateWithBranching?: (message: DatabaseMessage, modelOverride?: string) => void;
siblingInfo?: ChatMessageSiblingInfo | null; siblingInfo?: ChatMessageSiblingInfo | null;
} }
let { let { class: className = '', message, siblingInfo = null }: Props = $props();
class: className = '',
message, const chatActions = getChatActionsContext();
onCopy,
onContinueAssistantMessage,
onDelete,
onEditWithBranching,
onEditWithReplacement,
onEditUserMessagePreserveResponses,
onNavigateToSibling,
onRegenerateWithBranching,
siblingInfo = null
}: Props = $props();
let deletionInfo = $state<{ let deletionInfo = $state<{
totalCount: number; totalCount: number;
@ -83,7 +52,6 @@
return null; return null;
}); });
// Auto-start edit mode if this message is the pending edit target
$effect(() => { $effect(() => {
const pendingId = pendingEditMessageId(); const pendingId = pendingEditMessageId();
@ -112,23 +80,12 @@
editedUploadedFiles = []; editedUploadedFiles = [];
} }
function handleEditedExtrasChange(extras: DatabaseMessageExtra[]) { function handleCopy() {
editedExtras = extras; chatActions.copy(message);
}
function handleEditedUploadedFilesChange(files: ChatUploadedFile[]) {
editedUploadedFiles = files;
}
async function handleCopy() {
const asPlainText = Boolean(config().copyTextAttachmentsAsPlainText);
const clipboardContent = formatMessageForClipboard(message.content, message.extra, asPlainText);
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
onCopy?.(message);
} }
function handleConfirmDelete() { function handleConfirmDelete() {
onDelete?.(message); chatActions.delete(message);
showDeleteDialog = false; showDeleteDialog = false;
} }
@ -159,28 +116,16 @@
}, 0); }, 0);
} }
function handleEditedContentChange(content: string) {
editedContent = content;
}
function handleEditKeydown(event: KeyboardEvent) {
// Check for IME composition using isComposing property and keyCode 229 (specifically for IME composition on Safari)
// This prevents saving edit when confirming IME word selection (e.g., Japanese/Chinese input)
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
handleSaveEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
handleCancelEdit();
}
}
function handleRegenerate(modelOverride?: string) { function handleRegenerate(modelOverride?: string) {
onRegenerateWithBranching?.(message, modelOverride); chatActions.regenerateWithBranching(message, modelOverride);
} }
function handleContinue() { function handleContinue() {
onContinueAssistantMessage?.(message); chatActions.continueAssistantMessage(message);
}
function handleNavigateToSibling(siblingId: string) {
chatActions.navigateToSibling(siblingId);
} }
async function handleSaveEdit() { async function handleSaveEdit() {
@ -205,11 +150,11 @@
} }
} else if (message.role === MessageRole.USER) { } else if (message.role === MessageRole.USER) {
const finalExtras = await getMergedExtras(); const finalExtras = await getMergedExtras();
onEditWithBranching?.(message, editedContent.trim(), finalExtras); chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
} else { } else {
// For assistant messages, preserve exact content including trailing whitespace // For assistant messages, preserve exact content including trailing whitespace
// This is important for the Continue feature to work properly // This is important for the Continue feature to work properly
onEditWithReplacement?.(message, editedContent, shouldBranchAfterEdit); chatActions.editWithReplacement(message, editedContent, shouldBranchAfterEdit);
} }
isEditing = false; isEditing = false;
@ -250,18 +195,12 @@
bind:textareaElement bind:textareaElement
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent}
{isEditing}
{message} {message}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditKeydown={handleEditKeydown} onNavigateToSibling={handleNavigateToSibling}
onEditedContentChange={handleEditedContentChange}
{onNavigateToSibling}
onSaveEdit={handleSaveEdit}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
@ -270,23 +209,13 @@
<ChatMessageMcpPrompt <ChatMessageMcpPrompt
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent}
{editedExtras}
{editedUploadedFiles}
{isEditing}
{message} {message}
mcpPrompt={mcpPromptExtra} mcpPrompt={mcpPromptExtra}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditedContentChange={handleEditedContentChange} onNavigateToSibling={handleNavigateToSibling}
onEditedExtrasChange={handleEditedExtrasChange}
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
{onNavigateToSibling}
onSaveEdit={handleSaveEdit}
onSaveEditOnly={handleSaveEditOnly}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
@ -295,22 +224,12 @@
<ChatMessageUser <ChatMessageUser
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent}
{editedExtras}
{editedUploadedFiles}
{isEditing}
{message} {message}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditedContentChange={handleEditedContentChange} onNavigateToSibling={handleNavigateToSibling}
onEditedExtrasChange={handleEditedExtrasChange}
onEditedUploadedFilesChange={handleEditedUploadedFilesChange}
{onNavigateToSibling}
onSaveEdit={handleSaveEdit}
onSaveEditOnly={handleSaveEditOnly}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
@ -320,24 +239,16 @@
bind:textareaElement bind:textareaElement
class={className} class={className}
{deletionInfo} {deletionInfo}
{editedContent}
{isEditing}
{message} {message}
messageContent={message.content} messageContent={message.content}
onCancelEdit={handleCancelEdit}
onConfirmDelete={handleConfirmDelete} onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue} onContinue={handleContinue}
onCopy={handleCopy} onCopy={handleCopy}
onDelete={handleDelete} onDelete={handleDelete}
onEdit={handleEdit} onEdit={handleEdit}
onEditKeydown={handleEditKeydown} onNavigateToSibling={handleNavigateToSibling}
onEditedContentChange={handleEditedContentChange}
{onNavigateToSibling}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
onSaveEdit={handleSaveEdit}
onShowDeleteDialogChange={handleShowDeleteDialogChange} onShowDeleteDialogChange={handleShowDeleteDialogChange}
{shouldBranchAfterEdit}
onShouldBranchAfterEditChange={(value) => (shouldBranchAfterEdit = value)}
{showDeleteDialog} {showDeleteDialog}
{siblingInfo} {siblingInfo}
/> />

View File

@ -1,10 +1,11 @@
<script lang="ts"> <script lang="ts">
import { ChatMessage } from '$lib/components/app'; import { ChatMessage } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores/chat.svelte'; import { chatStore } from '$lib/stores/chat.svelte';
import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte'; import { conversationsStore, activeConversation } from '$lib/stores/conversations.svelte';
import { config } from '$lib/stores/settings.svelte'; import { config } from '$lib/stores/settings.svelte';
import { getMessageSiblings } from '$lib/utils'; import { copyToClipboard, formatMessageForClipboard, getMessageSiblings } from '$lib/utils';
interface Props { interface Props {
class?: string; class?: string;
@ -17,6 +18,62 @@
let allConversationMessages = $state<DatabaseMessage[]>([]); let allConversationMessages = $state<DatabaseMessage[]>([]);
const currentConfig = config(); const currentConfig = config();
setChatActionsContext({
copy: async (message: DatabaseMessage) => {
const asPlainText = Boolean(currentConfig.copyTextAttachmentsAsPlainText);
const clipboardContent = formatMessageForClipboard(
message.content,
message.extra,
asPlainText
);
await copyToClipboard(clipboardContent, 'Message copied to clipboard');
},
delete: async (message: DatabaseMessage) => {
await chatStore.deleteMessage(message.id);
refreshAllMessages();
},
navigateToSibling: async (siblingId: string) => {
await conversationsStore.navigateToSibling(siblingId);
},
editWithBranching: async (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
onUserAction?.();
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
},
editWithReplacement: async (
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) => {
onUserAction?.();
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
refreshAllMessages();
},
editUserMessagePreserveResponses: async (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => {
onUserAction?.();
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
},
regenerateWithBranching: async (message: DatabaseMessage, modelOverride?: string) => {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
},
continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
}
});
function refreshAllMessages() { function refreshAllMessages() {
const conversation = activeConversation(); const conversation = activeConversation();
@ -62,83 +119,10 @@
}; };
}); });
}); });
async function handleNavigateToSibling(siblingId: string) {
await conversationsStore.navigateToSibling(siblingId);
}
async function handleEditWithBranching(
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) {
onUserAction?.();
await chatStore.editMessageWithBranching(message.id, newContent, newExtras);
refreshAllMessages();
}
async function handleEditWithReplacement(
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) {
onUserAction?.();
await chatStore.editAssistantMessage(message.id, newContent, shouldBranch);
refreshAllMessages();
}
async function handleRegenerateWithBranching(message: DatabaseMessage, modelOverride?: string) {
onUserAction?.();
await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages();
}
async function handleContinueAssistantMessage(message: DatabaseMessage) {
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
}
async function handleEditUserMessagePreserveResponses(
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) {
onUserAction?.();
await chatStore.editUserMessagePreserveResponses(message.id, newContent, newExtras);
refreshAllMessages();
}
async function handleDeleteMessage(message: DatabaseMessage) {
await chatStore.deleteMessage(message.id);
refreshAllMessages();
}
</script> </script>
<div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; "> <div class="flex h-full flex-col space-y-10 pt-16 md:pt-24 {className}" style="height: auto; ">
{#each displayMessages as { message, siblingInfo } (message.id)} {#each displayMessages as { message, siblingInfo } (message.id)}
<ChatMessage <ChatMessage class="mx-auto w-full max-w-[48rem]" {message} {siblingInfo} />
class="mx-auto w-full max-w-[48rem]"
{message}
{siblingInfo}
onDelete={handleDeleteMessage}
onNavigateToSibling={handleNavigateToSibling}
onEditWithBranching={handleEditWithBranching}
onEditWithReplacement={handleEditWithReplacement}
onEditUserMessagePreserveResponses={handleEditUserMessagePreserveResponses}
onRegenerateWithBranching={handleRegenerateWithBranching}
onContinueAssistantMessage={handleContinueAssistantMessage}
/>
{/each} {/each}
</div> </div>