ui: Clean up contexts, remove prop drilling from Chat Form Actions (#26951)

* refactor: Remove dead context for Chat Settings and create a new one for Chat Messages Actions

* refactor: Contexts & types
This commit is contained in:
Aleksander Grygier
2026-08-13 08:21:15 +02:00
committed by GitHub
parent f2efd64141
commit d86c7d62df
32 changed files with 378 additions and 577 deletions
+1 -1
View File
@@ -32,8 +32,8 @@ import type {
ApiRouterModelsStatusResponse, ApiRouterModelsStatusResponse,
ApiRouterModelsUnloadRequest, ApiRouterModelsUnloadRequest,
ApiRouterModelsUnloadResponse, ApiRouterModelsUnloadResponse,
// Chat types
ChatAttachmentDisplayItem, ChatAttachmentDisplayItem,
// Chat types
ChatMessagePromptProgress, ChatMessagePromptProgress,
ChatMessageSiblingInfo, ChatMessageSiblingInfo,
ChatMessageTimings, ChatMessageTimings,
@@ -15,37 +15,16 @@
ICON_CLASS_DEFAULT, ICON_CLASS_DEFAULT,
TOOLTIP_DELAY_DURATION TOOLTIP_DELAY_DURATION
} from '$lib/constants'; } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props { interface Props {
class?: string; class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpSettingsClick?: () => void;
onMcpResourcesClick?: () => void;
} }
let { let { class: className = '' }: Props = $props();
class: className = '',
disabled = false, const chatFormActions = getChatFormActionsContext();
hasAudioModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onSystemPromptClick
}: Props = $props();
let dropdownOpen = $state(false); let dropdownOpen = $state(false);
// The system message action moves focus to the message editor, so the menu // The system message action moves focus to the message editor, so the menu
@@ -54,18 +33,23 @@
function handleMcpSettingsClick() { function handleMcpSettingsClick() {
dropdownOpen = false; dropdownOpen = false;
onMcpSettingsClick?.(); chatFormActions.onMcpSettingsClick?.();
} }
const attachmentMenu = useAttachmentMenu( const attachmentMenu = useAttachmentMenu(
() => ({ () => ({
hasAudioModality, hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport, hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport, hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality, hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}), }),
() => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => { () => {
dropdownOpen = false; dropdownOpen = false;
} }
@@ -85,7 +69,7 @@
buttonVariants({ variant: 'secondary' }), buttonVariants({ variant: 'secondary' }),
'file-upload-button h-8 w-8 cursor-pointer rounded-full p-0' 'file-upload-button h-8 w-8 cursor-pointer rounded-full p-0'
)} )}
{disabled} disabled={chatFormActions.disabled}
> >
<span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span> <span class="sr-only">{ATTACHMENT_TOOLTIP_TEXT}</span>
@@ -162,7 +146,7 @@
class="flex cursor-pointer items-center gap-2" class="flex cursor-pointer items-center gap-2"
onclick={() => { onclick={() => {
suppressCloseAutoFocus = true; suppressCloseAutoFocus = true;
onSystemPromptClick?.(); chatFormActions.onSystemPromptClick?.();
}} }}
> >
<MessageSquare class={ICON_CLASS_DEFAULT} /> <MessageSquare class={ICON_CLASS_DEFAULT} />
@@ -174,12 +158,12 @@
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} /> <ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#if hasMcpPromptsSupport} {#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
class="flex cursor-pointer items-center gap-2" class="flex cursor-pointer items-center gap-2"
onclick={onMcpPromptClick} onclick={chatFormActions.onMcpPromptClick}
> >
<Zap class={ICON_CLASS_DEFAULT} /> <Zap class={ICON_CLASS_DEFAULT} />
@@ -187,10 +171,10 @@
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
{#if hasMcpResourcesSupport} {#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item <DropdownMenu.Item
class="flex cursor-pointer items-center gap-2" class="flex cursor-pointer items-center gap-2"
onclick={onMcpResourcesClick} onclick={chatFormActions.onMcpResourcesClick}
> >
<FolderOpen class={ICON_CLASS_DEFAULT} /> <FolderOpen class={ICON_CLASS_DEFAULT} />
@@ -19,6 +19,7 @@
ICON_CLASS_DEFAULT, ICON_CLASS_DEFAULT,
TOOLTIP_DELAY_DURATION TOOLTIP_DELAY_DURATION
} from '$lib/constants'; } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { HealthCheckStatus } from '$lib/enums'; import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums'; import { AttachmentAction } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
@@ -29,33 +30,12 @@
interface Props { interface Props {
class?: string; class?: string;
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasVisionModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>; trigger: Snippet<[{ disabled: boolean; onclick?: () => void }]>;
} }
let { let { class: className = '', trigger }: Props = $props();
class: className = '',
disabled = false, const chatFormActions = getChatFormActionsContext();
hasAudioModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onSystemPromptClick,
trigger
}: Props = $props();
let sheetOpen = $state(false); let sheetOpen = $state(false);
let reasoningExpanded = $state(false); let reasoningExpanded = $state(false);
@@ -65,13 +45,18 @@
const attachmentMenu = useAttachmentMenu( const attachmentMenu = useAttachmentMenu(
() => ({ () => ({
hasAudioModality, hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport, hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport, hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality, hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}), }),
() => ({ onFileUpload, onMcpPromptClick, onMcpResourcesClick, onSystemPromptClick }),
() => { () => {
sheetOpen = false; sheetOpen = false;
} }
@@ -91,7 +76,7 @@
<div class="flex items-center gap-1 {className}"> <div class="flex items-center gap-1 {className}">
<Sheet.Root bind:open={sheetOpen}> <Sheet.Root bind:open={sheetOpen}>
{@render trigger({ disabled, onclick: () => (sheetOpen = true) })} {@render trigger({ disabled: chatFormActions.disabled, onclick: () => (sheetOpen = true) })}
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto"> <Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header> <Sheet.Header>
@@ -350,7 +335,7 @@
<span>System Message</span> <span>System Message</span>
</button> </button>
{#if hasMcpPromptsSupport} {#if chatFormActions.hasMcpPromptsSupport}
<button <button
type="button" type="button"
class={sheetItemClass} class={sheetItemClass}
@@ -362,7 +347,7 @@
</button> </button>
{/if} {/if}
{#if hasMcpResourcesSupport} {#if chatFormActions.hasMcpResourcesSupport}
<button <button
type="button" type="button"
class={sheetItemClass} class={sheetItemClass}
@@ -3,65 +3,14 @@
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte'; import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte'; import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import { isMobile } from '$lib/stores'; import { isMobile } from '$lib/stores';
interface Props {
disabled?: boolean;
hasAudioModality?: boolean;
hasVideoModality?: boolean;
hasMcpPromptsSupport?: boolean;
hasMcpResourcesSupport?: boolean;
hasVisionModality?: boolean;
onFileUpload?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
onSystemPromptClick?: () => void;
}
let {
disabled = false,
hasAudioModality = false,
hasMcpPromptsSupport = false,
hasMcpResourcesSupport = false,
hasVideoModality = false,
hasVisionModality = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onSystemPromptClick
}: Props = $props();
</script> </script>
{#if isMobile.current} {#if isMobile.current}
<ChatFormActionAddSheet <ChatFormActionAddSheet>
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
>
{#snippet trigger({ disabled, onclick })} {#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} /> <ChatFormActionAddButton {disabled} {onclick} />
{/snippet} {/snippet}
</ChatFormActionAddSheet> </ChatFormActionAddSheet>
{:else} {:else}
<ChatFormActionAddDropdown <ChatFormActionAddDropdown />
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onMcpPromptClick}
{onMcpResourcesClick}
{onMcpSettingsClick}
{onSystemPromptClick}
/>
{/if} {/if}
@@ -11,6 +11,7 @@
} from '$lib/components/app'; } from '$lib/components/app';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants'; import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums'; import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services'; import { ChatService } from '$lib/services';
import { chatStore, conversationsStore, mcpStore, settingsStore } from '$lib/stores'; import { chatStore, conversationsStore, mcpStore, settingsStore } from '$lib/stores';
@@ -132,6 +133,42 @@
return livePromptTokens > 0 || liveOutputTokens > 0; return livePromptTokens > 0 || liveOutputTokens > 0;
}); });
setChatFormActionsContext({
get disabled() {
return disabled;
},
get hasAudioModality() {
return hasAudioModality;
},
get hasMcpPromptsSupport() {
return hasMcpPromptsSupport;
},
get hasMcpResourcesSupport() {
return hasMcpResourcesSupport;
},
get hasVideoModality() {
return hasVideoModality;
},
get hasVisionModality() {
return hasVisionModality;
},
get onFileUpload() {
return onFileUpload;
},
get onMcpPromptClick() {
return onMcpPromptClick;
},
get onMcpResourcesClick() {
return onMcpResourcesClick;
},
get onMcpSettingsClick() {
return () => goto(ROUTES.MCP_SERVERS);
},
get onSystemPromptClick() {
return onSystemPromptClick;
}
});
</script> </script>
<div <div
@@ -140,19 +177,7 @@
> >
{#if showAddButton} {#if showAddButton}
<div class="mr-auto flex items-center gap-2"> <div class="mr-auto flex items-center gap-2">
<ChatFormActionsAdd <ChatFormActionsAdd />
{disabled}
{hasAudioModality}
{hasVideoModality}
{hasVisionModality}
{hasMcpPromptsSupport}
{hasMcpResourcesSupport}
{onFileUpload}
{onSystemPromptClick}
{onMcpPromptClick}
{onMcpResourcesClick}
onMcpSettingsClick={() => goto(ROUTES.MCP_SERVERS)}
/>
</div> </div>
{/if} {/if}
@@ -8,16 +8,21 @@
ChatMessageUser ChatMessageUser
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import { REASONING_TAGS, ROUTES, SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants'; import { REASONING_TAGS, ROUTES, SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { getChatActionsContext, setMessageEditContext } from '$lib/contexts'; import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums'; import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service'; import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores'; import { chatStore, conversationsStore, isMobile } from '$lib/stores';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; import type {
ChatMessageActions,
ChatMessageDeletionInfo,
DatabaseMessageExtraMcpPrompt
} from '$lib/types';
import { deriveAgenticSections } from '$lib/utils'; import { deriveAgenticSections } from '$lib/utils';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only'; import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
interface Props { interface Props {
class?: string; class?: string;
chatActions: ChatMessageActions;
message: DatabaseMessage; message: DatabaseMessage;
toolMessages?: DatabaseMessage[]; toolMessages?: DatabaseMessage[];
isLastAssistantMessage?: boolean; isLastAssistantMessage?: boolean;
@@ -27,6 +32,7 @@
} }
let { let {
chatActions,
class: className = '', class: className = '',
isLastAssistantMessage = false, isLastAssistantMessage = false,
isLastUserMessage = false, isLastUserMessage = false,
@@ -36,14 +42,7 @@
toolMessages = [] toolMessages = []
}: Props = $props(); }: Props = $props();
const chatActions = getChatActionsContext(); let deletionInfo = $state<ChatMessageDeletionInfo | null>(null);
let deletionInfo = $state<{
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null>(null);
// The system message placeholder must never surface as editable content; keeping // The system message placeholder must never surface as editable content; keeping
// it in the derived (not just in handleEdit) guards against prop invalidation // it in the derived (not just in handleEdit) guards against prop invalidation
// reverting the override while editing // reverting the override while editing
@@ -112,7 +111,7 @@
let showSaveOnlyOption = $derived(message.role === MessageRole.USER); let showSaveOnlyOption = $derived(message.role === MessageRole.USER);
let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT); let showBranchAfterEditOption = $derived(message.role === MessageRole.ASSISTANT);
setMessageEditContext({ setChatMessageEditContext({
cancel: handleCancelEdit, cancel: handleCancelEdit,
get editedContent() { get editedContent() {
return editedContent; return editedContent;
@@ -166,6 +165,30 @@
startEdit: handleEdit startEdit: handleEdit
}); });
setChatMessageActionsContext({
confirmDelete: handleConfirmDelete,
copy: handleCopy,
get deletionInfo() {
return deletionInfo;
},
get forkConversation() {
const isForkableUser = message.role === MessageRole.USER && !mcpPromptExtra;
return isForkableUser || message.role === MessageRole.ASSISTANT
? handleForkConversation
: undefined;
},
navigateToSibling: handleNavigateToSibling,
requestDelete: handleDelete,
setShowDeleteDialog: handleShowDeleteDialogChange,
get showDeleteDialog() {
return showDeleteDialog;
},
get siblingInfo() {
return siblingInfo;
}
});
let mcpPromptExtra = $derived.by(() => { let mcpPromptExtra = $derived.by(() => {
if (message.role !== MessageRole.USER) return null; if (message.role !== MessageRole.USER) return null;
@@ -360,73 +383,22 @@
<div class="chat-message" class:chat-message--synthetic={isSynthetic}> <div class="chat-message" class:chat-message--synthetic={isSynthetic}>
{#if message.role === MessageRole.SYSTEM} {#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem <ChatMessageSystem bind:textareaElement class={className} {message} />
bind:textareaElement
class={className}
{deletionInfo}
{message}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if mcpPromptExtra} {:else if mcpPromptExtra}
<ChatMessageMcpPrompt <ChatMessageMcpPrompt class={className} {message} mcpPrompt={mcpPromptExtra} />
class={className}
{deletionInfo}
{message}
mcpPrompt={mcpPromptExtra}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else if isSynthetic} {:else if isSynthetic}
<ChatMessageSynthetic {message} class={className} /> <ChatMessageSynthetic {message} class={className} />
{:else if message.role === MessageRole.USER} {:else if message.role === MessageRole.USER}
<ChatMessageUser <ChatMessageUser class={className} {isLastUserMessage} {message} {nextAssistantMessage} />
class={className}
{deletionInfo}
{isLastUserMessage}
{message}
{nextAssistantMessage}
onConfirmDelete={handleConfirmDelete}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onForkConversation={handleForkConversation}
onNavigateToSibling={handleNavigateToSibling}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/>
{:else} {:else}
<ChatMessageAssistant <ChatMessageAssistant
bind:textareaElement bind:textareaElement
class={className} class={className}
{deletionInfo}
{isLastAssistantMessage} {isLastAssistantMessage}
{message} {message}
{toolMessages} {toolMessages}
onConfirmDelete={handleConfirmDelete}
onContinue={handleContinue} onContinue={handleContinue}
onCopy={handleCopy}
onDelete={handleDelete}
onEdit={handleEdit}
onForkConversation={handleForkConversation}
onNavigateToSibling={handleNavigateToSibling}
onRegenerate={handleRegenerate} onRegenerate={handleRegenerate}
onShowDeleteDialogChange={handleShowDeleteDialogChange}
{showDeleteDialog}
{siblingInfo}
/> />
{/if} {/if}
</div> </div>
@@ -8,7 +8,7 @@
ChatMessageAssistantStatistics, ChatMessageAssistantStatistics,
ChatMessageEditForm ChatMessageEditForm
} from '$lib/components/app'; } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts'; import { getChatMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, modelsStore, serverStore, settingsStore } from '$lib/stores'; import { chatStore, modelsStore, serverStore, settingsStore } from '$lib/stores';
@@ -17,51 +17,26 @@
interface Props { interface Props {
class?: string; class?: string;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
isLastAssistantMessage?: boolean; isLastAssistantMessage?: boolean;
message: DatabaseMessage; message: DatabaseMessage;
toolMessages?: DatabaseMessage[]; toolMessages?: DatabaseMessage[];
onCopy: () => void;
onConfirmDelete: () => void;
onContinue?: () => void; onContinue?: () => void;
onDelete: () => void;
onEdit?: () => void;
onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
onNavigateToSibling?: (siblingId: string) => void;
onRegenerate: (modelOverride?: string) => void; onRegenerate: (modelOverride?: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
showDeleteDialog: boolean;
siblingInfo?: ChatMessageSiblingInfo | null;
textareaElement?: HTMLTextAreaElement; textareaElement?: HTMLTextAreaElement;
} }
let { let {
class: className = '', class: className = '',
deletionInfo,
isLastAssistantMessage = false, isLastAssistantMessage = false,
message, message,
onConfirmDelete,
onContinue, onContinue,
onCopy,
onDelete,
onEdit,
onForkConversation,
onNavigateToSibling,
onRegenerate, onRegenerate,
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null,
textareaElement = $bindable(), textareaElement = $bindable(),
toolMessages = [] toolMessages = []
}: Props = $props(); }: Props = $props();
// Get edit context // Get edit context
const editCtx = getMessageEditContext(); const editCtx = getChatMessageEditContext();
const isAgentic = $derived(hasAgenticContent(message, toolMessages)); const isAgentic = $derived(hasAgenticContent(message, toolMessages));
const processingState = useProcessingState(); const processingState = useProcessingState();
@@ -207,18 +182,8 @@
role={MessageRole.ASSISTANT} role={MessageRole.ASSISTANT}
justify="start" justify="start"
actionsPosition="left" actionsPosition="left"
{siblingInfo}
{showDeleteDialog}
{deletionInfo}
{onCopy}
{onEdit}
{onRegenerate} {onRegenerate}
onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined} onContinue={currentConfig.enableContinueGeneration ? onContinue : undefined}
{onForkConversation}
{onDelete}
{onConfirmDelete}
{onNavigateToSibling}
{onShowDeleteDialogChange}
showRawOutputSwitch={currentConfig.showRawOutputSwitch} showRawOutputSwitch={currentConfig.showRawOutputSwitch}
rawOutputEnabled={showRawOutput} rawOutputEnabled={showRawOutput}
onRawOutputToggle={(enabled) => (showRawOutput = enabled)} onRawOutputToggle={(enabled) => (showRawOutput = enabled)}
@@ -4,7 +4,7 @@
ChatMessageEditForm, ChatMessageEditForm,
ChatMessageMcpPromptContent ChatMessageMcpPromptContent
} from '$lib/components/app'; } from '$lib/components/app';
import { getMessageEditContext } from '$lib/contexts'; import { getChatMessageEditContext } from '$lib/contexts';
import { McpPromptVariant, MessageRole } from '$lib/enums'; import { McpPromptVariant, MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types'; import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
@@ -12,39 +12,12 @@
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
mcpPrompt: DatabaseMessageExtraMcpPrompt; mcpPrompt: DatabaseMessageExtraMcpPrompt;
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
} }
let { let { class: className = '', mcpPrompt, message }: Props = $props();
class: className = '',
deletionInfo,
mcpPrompt,
message,
onConfirmDelete,
onCopy,
onDelete,
onEdit,
onNavigateToSibling,
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null
}: Props = $props();
// Get edit context // Get edit context
const editCtx = getMessageEditContext(); const editCtx = getChatMessageEditContext();
</script> </script>
<div <div
@@ -63,20 +36,7 @@
{#if message.timestamp} {#if message.timestamp}
<div class="max-w-[80%]"> <div class="max-w-[80%]">
<ChatMessageActionIcons <ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role={MessageRole.USER}
/>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -4,7 +4,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Card } from '$lib/components/ui/card'; import { Card } from '$lib/components/ui/card';
import { INPUT_CLASSES } from '$lib/constants'; import { INPUT_CLASSES } from '$lib/constants';
import { getMessageEditContext } from '$lib/contexts'; import { getChatMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums'; import { KeyboardKey, MessageRole } from '$lib/enums';
import { settingsStore } from '$lib/stores'; import { settingsStore } from '$lib/stores';
import { autoResizeTextarea, isIMEComposing } from '$lib/utils'; import { autoResizeTextarea, isIMEComposing } from '$lib/utils';
@@ -12,39 +12,12 @@
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
onCopy: () => void;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
textareaElement?: HTMLTextAreaElement; textareaElement?: HTMLTextAreaElement;
} }
let { let { class: className = '', message, textareaElement = $bindable() }: Props = $props();
class: className = '',
deletionInfo,
message,
onConfirmDelete,
onCopy,
onDelete,
onEdit,
onNavigateToSibling,
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null,
textareaElement = $bindable()
}: Props = $props();
const editCtx = getMessageEditContext(); const editCtx = getChatMessageEditContext();
function handleEditKeydown(event: KeyboardEvent) { function handleEditKeydown(event: KeyboardEvent) {
if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) {
@@ -218,20 +191,7 @@
{#if message.timestamp} {#if message.timestamp}
<div class="max-w-[80%]"> <div class="max-w-[80%]">
<ChatMessageActionIcons <ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role={MessageRole.USER}
/>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -12,8 +12,7 @@
import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte'; import ChatMessageToolCallBlockSearchResults from './ChatMessageToolCallBlockSearchResults.svelte';
import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte'; import ChatMessageToolCallBlockWriteFile from './ChatMessageToolCallBlockWriteFile.svelte';
import { BuiltInTool } from '$lib/enums'; import { BuiltInTool } from '$lib/enums';
import type { AgenticSection } from '$lib/types'; import type { AgenticSection, DatabaseMessageExtra } from '$lib/types';
import type { DatabaseMessageExtra } from '$lib/types';
import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils'; import { extractSearchQuery, extractSearchResults, isWebSearchToolName } from '$lib/utils';
interface Props { interface Props {
@@ -13,8 +13,7 @@
import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants'; import { SETTINGS_KEYS, TOOL_RUNTIME_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AttachmentType } from '$lib/enums'; import { AttachmentType } from '$lib/enums';
import { settingsStore, toolsStore } from '$lib/stores'; import { settingsStore, toolsStore } from '$lib/stores';
import type { AgenticSection, ToolResultLine } from '$lib/types'; import type { AgenticSection, DatabaseMessageExtra, ToolResultLine } from '$lib/types';
import type { DatabaseMessageExtra } from '$lib/types';
import { import {
abbreviateHome, abbreviateHome,
type ExecShellExitStatus, type ExecShellExitStatus,
@@ -5,7 +5,7 @@
ChatMessageStatistics, ChatMessageStatistics,
ChatMessageUserBubble ChatMessageUserBubble
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import { getMessageEditContext } from '$lib/contexts'; import { getChatMessageEditContext } from '$lib/contexts';
import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums'; import { ChatMessageStatisticsMode, MessageRole } from '$lib/enums';
import { useProcessingState } from '$lib/hooks/use-processing-state.svelte'; import { useProcessingState } from '$lib/hooks/use-processing-state.svelte';
import { chatStore, settingsStore } from '$lib/stores'; import { chatStore, settingsStore } from '$lib/stores';
@@ -13,44 +13,19 @@
interface Props { interface Props {
class?: string; class?: string;
message: DatabaseMessage; message: DatabaseMessage;
siblingInfo?: ChatMessageSiblingInfo | null;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
isLastUserMessage?: boolean; isLastUserMessage?: boolean;
nextAssistantMessage?: DatabaseMessage | null; nextAssistantMessage?: DatabaseMessage | null;
showDeleteDialog: boolean;
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
onShowDeleteDialogChange: (show: boolean) => void;
onNavigateToSibling?: (siblingId: string) => void;
onCopy: () => void;
} }
let { let {
class: className = '', class: className = '',
deletionInfo,
isLastUserMessage = false, isLastUserMessage = false,
message, message,
nextAssistantMessage = null, nextAssistantMessage = null
onConfirmDelete,
onCopy,
onDelete,
onEdit,
onForkConversation,
onNavigateToSibling,
onShowDeleteDialogChange,
showDeleteDialog,
siblingInfo = null
}: Props = $props(); }: Props = $props();
// Get contexts // Get contexts
const editCtx = getMessageEditContext(); const editCtx = getChatMessageEditContext();
const processingState = useProcessingState(); const processingState = useProcessingState();
const currentConfig = $derived(settingsStore.config); const currentConfig = $derived(settingsStore.config);
@@ -132,21 +107,7 @@
{#if message.timestamp} {#if message.timestamp}
<div class="max-w-[80%]"> <div class="max-w-[80%]">
<ChatMessageActionIcons <ChatMessageActionIcons actionsPosition="right" justify="end" role={MessageRole.USER} />
actionsPosition="right"
{deletionInfo}
justify="end"
{onConfirmDelete}
{onCopy}
{onDelete}
{onEdit}
{onForkConversation}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
{showDeleteDialog}
role={MessageRole.USER}
/>
</div> </div>
{/if} {/if}
{/if} {/if}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte'; import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app'; import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte'; import { useChatMessageEditContext } from '$lib/hooks/use-chat-message-edit-context.svelte';
interface Props { interface Props {
class?: string; class?: string;
@@ -21,7 +21,7 @@
onSendImmediately onSendImmediately
}: Props = $props(); }: Props = $props();
const editCtx = useMessageEditContext({ const editCtx = useChatMessageEditContext({
getContent: () => content, getContent: () => content,
getExtras: () => extras, getExtras: () => extras,
onSave: (content, extras) => onEdit(content, extras) onSave: (content, extras) => onEdit(content, extras)
@@ -9,6 +9,7 @@
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { getChatMessageActionsContext, getChatMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { conversationsStore } from '$lib/stores'; import { conversationsStore } from '$lib/stores';
@@ -16,23 +17,8 @@
role: MessageRole.USER | MessageRole.ASSISTANT; role: MessageRole.USER | MessageRole.ASSISTANT;
justify: 'start' | 'end'; justify: 'start' | 'end';
actionsPosition: 'left' | 'right'; actionsPosition: 'left' | 'right';
siblingInfo?: ChatMessageSiblingInfo | null;
showDeleteDialog: boolean;
deletionInfo: {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
} | null;
onCopy: () => void;
onEdit?: () => void;
onRegenerate?: () => void; onRegenerate?: () => void;
onContinue?: () => void; onContinue?: () => void;
onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
onShowDeleteDialogChange: (show: boolean) => void;
showRawOutputSwitch?: boolean; showRawOutputSwitch?: boolean;
rawOutputEnabled?: boolean; rawOutputEnabled?: boolean;
onRawOutputToggle?: (enabled: boolean) => void; onRawOutputToggle?: (enabled: boolean) => void;
@@ -40,32 +26,25 @@
let { let {
actionsPosition, actionsPosition,
deletionInfo,
justify, justify,
onConfirmDelete,
onContinue, onContinue,
onCopy,
onDelete,
onEdit,
onForkConversation,
onNavigateToSibling,
onRawOutputToggle, onRawOutputToggle,
onRegenerate, onRegenerate,
onShowDeleteDialogChange,
rawOutputEnabled = false, rawOutputEnabled = false,
role, role,
showDeleteDialog, showRawOutputSwitch = false
showRawOutputSwitch = false,
siblingInfo = null
}: Props = $props(); }: Props = $props();
const messageActions = getChatMessageActionsContext();
const editCtx = getChatMessageEditContext();
let showForkDialog = $state(false); let showForkDialog = $state(false);
let forkName = $state(''); let forkName = $state('');
let forkIncludeAttachments = $state(true); let forkIncludeAttachments = $state(true);
function handleConfirmDelete() { function handleConfirmDelete() {
onConfirmDelete(); messageActions.confirmDelete();
onShowDeleteDialogChange(false); messageActions.setShowDeleteDialog(false);
} }
function handleOpenForkDialog() { function handleOpenForkDialog() {
@@ -77,7 +56,10 @@
} }
function handleConfirmFork() { function handleConfirmFork() {
onForkConversation?.({ includeAttachments: forkIncludeAttachments, name: forkName.trim() }); messageActions.forkConversation?.({
includeAttachments: forkIncludeAttachments,
name: forkName.trim()
});
showForkDialog = false; showForkDialog = false;
} }
</script> </script>
@@ -88,18 +70,16 @@
? 'left-0' ? 'left-0'
: 'right-0'} flex items-center gap-2 opacity-100 transition-opacity" : 'right-0'} flex items-center gap-2 opacity-100 transition-opacity"
> >
{#if siblingInfo && siblingInfo.totalSiblings > 1} {#if messageActions.siblingInfo && messageActions.siblingInfo.totalSiblings > 1}
<ChatMessageActionIconsBranchingControls {siblingInfo} {onNavigateToSibling} /> <ChatMessageActionIconsBranchingControls />
{/if} {/if}
<div <div
class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150" class="pointer-events-auto inset-0 flex items-center gap-1 opacity-100 transition-all duration-150"
> >
<ActionIcon icon={Copy} tooltip="Copy" onclick={onCopy} /> <ActionIcon icon={Copy} tooltip="Copy" onclick={messageActions.copy} />
{#if onEdit} <ActionIcon icon={Edit} tooltip="Edit" onclick={editCtx.startEdit} />
<ActionIcon icon={Edit} tooltip="Edit" onclick={onEdit} />
{/if}
{#if role === MessageRole.ASSISTANT && onRegenerate} {#if role === MessageRole.ASSISTANT && onRegenerate}
<ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} /> <ActionIcon icon={RefreshCw} tooltip="Regenerate" onclick={() => onRegenerate()} />
@@ -109,11 +89,11 @@
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} /> <ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
{/if} {/if}
{#if onForkConversation} {#if messageActions.forkConversation}
<ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} /> <ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} />
{/if} {/if}
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} /> <ActionIcon icon={Trash2} tooltip="Delete" onclick={messageActions.requestDelete} />
</div> </div>
</div> </div>
@@ -129,19 +109,19 @@
</div> </div>
<DialogConfirmation <DialogConfirmation
bind:open={showDeleteDialog} open={messageActions.showDeleteDialog}
title="Delete Message" title="Delete Message"
description={deletionInfo && deletionInfo.totalCount > 1 description={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `This will delete ${deletionInfo.totalCount} messages including: ${deletionInfo.userMessages} user message${deletionInfo.userMessages > 1 ? 's' : ''} and ${deletionInfo.assistantMessages} assistant response${deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.` ? `This will delete ${messageActions.deletionInfo.totalCount} messages including: ${messageActions.deletionInfo.userMessages} user message${messageActions.deletionInfo.userMessages > 1 ? 's' : ''} and ${messageActions.deletionInfo.assistantMessages} assistant response${messageActions.deletionInfo.assistantMessages > 1 ? 's' : ''}. All messages in this branch and their responses will be permanently removed. This action cannot be undone.`
: 'Are you sure you want to delete this message? This action cannot be undone.'} : 'Are you sure you want to delete this message? This action cannot be undone.'}
confirmText={deletionInfo && deletionInfo.totalCount > 1 confirmText={messageActions.deletionInfo && messageActions.deletionInfo.totalCount > 1
? `Delete ${deletionInfo.totalCount} Messages` ? `Delete ${messageActions.deletionInfo.totalCount} Messages`
: 'Delete'} : 'Delete'}
cancelText="Cancel" cancelText="Cancel"
variant="destructive" variant="destructive"
icon={Trash2} icon={Trash2}
onConfirm={handleConfirmDelete} onConfirm={handleConfirmDelete}
onCancel={() => onShowDeleteDialogChange(false)} onCancel={() => messageActions.setShowDeleteDialog(false)}
/> />
<DialogConfirmation <DialogConfirmation
@@ -1,14 +1,17 @@
<script lang="ts"> <script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte'; import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { ActionIcon } from '$lib/components/app'; import { ActionIcon } from '$lib/components/app';
import { getChatMessageActionsContext } from '$lib/contexts';
interface Props { interface Props {
class?: string; class?: string;
siblingInfo: ChatMessageSiblingInfo | null;
onNavigateToSibling?: (siblingId: string) => void;
} }
let { class: className = '', onNavigateToSibling, siblingInfo }: Props = $props(); let { class: className = '' }: Props = $props();
const messageActions = getChatMessageActionsContext();
let siblingInfo = $derived(messageActions.siblingInfo);
let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0); let hasPrevious = $derived(siblingInfo && siblingInfo.currentIndex > 0);
let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1); let hasNext = $derived(siblingInfo && siblingInfo.currentIndex < siblingInfo.totalSiblings - 1);
@@ -31,7 +34,7 @@
tooltip="Previous version" tooltip="Previous version"
disabled={!hasPrevious} disabled={!hasPrevious}
class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}" class="h-5 w-5 p-0 {!hasPrevious ? '!cursor-not-allowed opacity-30' : ''}"
onclick={() => onNavigateToSibling?.(previousSiblingId!)} onclick={() => messageActions.navigateToSibling(previousSiblingId!)}
/> />
<span class="px-1 font-mono text-xs"> <span class="px-1 font-mono text-xs">
@@ -43,7 +46,7 @@
tooltip="Next version" tooltip="Next version"
disabled={!hasNext} disabled={!hasNext}
class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}" class="h-5 w-5 p-0 {!hasNext ? 'opacity-30' : ''}"
onclick={() => onNavigateToSibling?.(nextSiblingId!)} onclick={() => messageActions.navigateToSibling(nextSiblingId!)}
/> />
</div> </div>
{/if} {/if}
@@ -9,8 +9,8 @@
} from '$lib/components/app'; } from '$lib/components/app';
import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums'; import { AgenticSectionType, ChatMessageStatsView, ToolPermissionDecision } from '$lib/enums';
import { agenticStore, settingsStore } from '$lib/stores'; import { agenticStore, settingsStore } from '$lib/stores';
import type { AgenticSection } from '$lib/types';
import type { import type {
AgenticSection,
ChatMessageAgenticTimings, ChatMessageAgenticTimings,
ChatMessageAgenticTurnStats, ChatMessageAgenticTurnStats,
DatabaseMessage DatabaseMessage
@@ -3,12 +3,12 @@
import { ChatForm, DialogConfirmation } from '$lib/components/app'; import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { getMessageEditContext } from '$lib/contexts'; import { getChatMessageEditContext } from '$lib/contexts';
import { KeyboardKey, MessageRole } from '$lib/enums'; import { KeyboardKey, MessageRole } from '$lib/enums';
import { chatStore } from '$lib/stores'; import { chatStore } from '$lib/stores';
import { processFilesToChatUploaded } from '$lib/utils/browser-only'; import { processFilesToChatUploaded } from '$lib/utils/browser-only';
const editCtx = getMessageEditContext(); const editCtx = getChatMessageEditContext();
let saveWithoutRegenerate = $state(false); let saveWithoutRegenerate = $state(false);
let showDiscardDialog = $state(false); let showDiscardDialog = $state(false);
@@ -4,8 +4,7 @@
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants'; import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants';
import { AgenticSectionType } from '$lib/enums'; import { AgenticSectionType } from '$lib/enums';
import { settingsStore } from '$lib/stores'; import { settingsStore } from '$lib/stores';
import type { DatabaseMessageExtra } from '$lib/types'; import type { AgenticSection, DatabaseMessageExtra } from '$lib/types';
import type { AgenticSection } from '$lib/types';
interface Props { interface Props {
section: AgenticSection; section: AgenticSection;
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app'; import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores'; import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores';
import type { ChatMessageActions } from '$lib/types';
import { import {
buildSiblingInfoMap, buildSiblingInfoMap,
copyToClipboard, copyToClipboard,
@@ -22,7 +22,7 @@
const currentConfig = settingsStore.config; const currentConfig = settingsStore.config;
setChatActionsContext({ const chatActions: ChatMessageActions = {
continueAssistantMessage: async (message: DatabaseMessage) => { continueAssistantMessage: async (message: DatabaseMessage) => {
onUserAction?.(); onUserAction?.();
await chatStore.continueAssistantMessage(message.id); await chatStore.continueAssistantMessage(message.id);
@@ -91,7 +91,7 @@
await chatStore.regenerateMessageWithBranching(message.id, modelOverride); await chatStore.regenerateMessageWithBranching(message.id, modelOverride);
refreshAllMessages(); refreshAllMessages();
} }
}); };
function refreshAllMessages() { function refreshAllMessages() {
const conversation = conversationsStore.activeConversation; const conversation = conversationsStore.activeConversation;
@@ -228,6 +228,7 @@
{#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)} {#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
<ChatMessage <ChatMessage
class="mx-auto mt-12 w-full max-w-3xl" class="mx-auto mt-12 w-full max-w-3xl"
{chatActions}
{message} {message}
{toolMessages} {toolMessages}
{isLastAssistantMessage} {isLastAssistantMessage}
@@ -17,7 +17,6 @@
SETTINGS_CHAT_SECTIONS, SETTINGS_CHAT_SECTIONS,
SETTINGS_SECTION_TITLES SETTINGS_SECTION_TITLES
} from '$lib/constants'; } from '$lib/constants';
import { setChatSettingsConfigContext } from '$lib/contexts';
import { ColorMode } from '$lib/enums/ui.enums'; import { ColorMode } from '$lib/enums/ui.enums';
import { RouterService } from '$lib/services/router.service'; import { RouterService } from '$lib/services/router.service';
import { modelsStore, serverStore, settingsReferrer, settingsStore } from '$lib/stores'; import { modelsStore, serverStore, settingsReferrer, settingsStore } from '$lib/stores';
@@ -122,14 +121,6 @@
export function reset() { export function reset() {
localConfig = { ...settingsStore.config }; localConfig = { ...settingsStore.config };
} }
setChatSettingsConfigContext({
handleConfigChange,
handleThemeChange,
get localConfig() {
return localConfig;
}
});
</script> </script>
<div class="mx-auto flex h-full w-full flex-col md:pl-8" in:fade={{ duration: 150 }}> <div class="mx-auto flex h-full w-full flex-col md:pl-8" in:fade={{ duration: 150 }}>
@@ -1,3 +1,3 @@
export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit'; export const CONTEXT_KEY_CHAT_MESSAGE_EDIT = 'chat-message-edit';
export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions'; export const CONTEXT_KEY_CHAT_MESSAGE_ACTIONS = 'chat-message-actions';
export const CONTEXT_KEY_CHAT_SETTINGS_CONFIG = 'chat-settings-config'; export const CONTEXT_KEY_CHAT_FORM_ACTIONS = 'chat-form-actions';
@@ -1,39 +0,0 @@
import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants';
import { getContext, setContext } from 'svelte';
export interface ChatActionsContext {
copy: (message: DatabaseMessage) => void;
delete: (message: DatabaseMessage) => void;
navigateToSibling: (siblingId: string) => void;
editWithBranching: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
editWithReplacement: (
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) => void;
editUserMessagePreserveResponses: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
continueAssistantMessage: (message: DatabaseMessage) => void;
forkConversation: (
message: DatabaseMessage,
options: { name: string; includeAttachments: boolean }
) => void;
}
const CHAT_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_ACTIONS);
export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext {
return setContext(CHAT_ACTIONS_KEY, ctx);
}
export function getChatActionsContext(): ChatActionsContext {
return getContext(CHAT_ACTIONS_KEY);
}
@@ -0,0 +1,19 @@
import { CONTEXT_KEY_CHAT_FORM_ACTIONS } from '$lib/constants';
import type { ChatFormActionsContext } from '$lib/types';
import { getContext, setContext } from 'svelte';
const CHAT_FORM_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_FORM_ACTIONS);
/**
* Sets the chat form actions context. Call in the parent component (ChatFormActions.svelte).
*/
export function setChatFormActionsContext(ctx: ChatFormActionsContext): ChatFormActionsContext {
return setContext(CHAT_FORM_ACTIONS_KEY, ctx);
}
/**
* Gets the chat form actions context. Call in child components.
*/
export function getChatFormActionsContext(): ChatFormActionsContext {
return getContext(CHAT_FORM_ACTIONS_KEY);
}
@@ -0,0 +1,21 @@
import { CONTEXT_KEY_CHAT_MESSAGE_ACTIONS } from '$lib/constants';
import type { ChatMessageActionsContext } from '$lib/types';
import { getContext, setContext } from 'svelte';
const CHAT_MESSAGE_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_MESSAGE_ACTIONS);
/**
* Sets the per-message actions context. Call this in the parent component (ChatMessage.svelte).
*/
export function setChatMessageActionsContext(
ctx: ChatMessageActionsContext
): ChatMessageActionsContext {
return setContext(CHAT_MESSAGE_ACTIONS_KEY, ctx);
}
/**
* Gets the per-message actions context. Call this in child components.
*/
export function getChatMessageActionsContext(): ChatMessageActionsContext {
return getContext(CHAT_MESSAGE_ACTIONS_KEY);
}
@@ -0,0 +1,19 @@
import { CONTEXT_KEY_CHAT_MESSAGE_EDIT } from '$lib/constants';
import type { ChatMessageEditContext } from '$lib/types';
import { getContext, setContext } from 'svelte';
const CHAT_MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_CHAT_MESSAGE_EDIT);
/**
* Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
*/
export function setChatMessageEditContext(ctx: ChatMessageEditContext): ChatMessageEditContext {
return setContext(CHAT_MESSAGE_EDIT_KEY, ctx);
}
/**
* Gets the message edit context. Call this in child components.
*/
export function getChatMessageEditContext(): ChatMessageEditContext {
return getContext(CHAT_MESSAGE_EDIT_KEY);
}
@@ -1,20 +0,0 @@
import { CONTEXT_KEY_CHAT_SETTINGS_CONFIG } from '$lib/constants';
import { getContext, setContext } from 'svelte';
export interface ChatSettingsConfigContext {
readonly localConfig: SettingsConfigType;
handleConfigChange: (key: string, value: string | boolean) => void;
handleThemeChange: (theme: string) => void;
}
const CHAT_SETTINGS_CONFIG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_CONFIG);
export function setChatSettingsConfigContext(
ctx: ChatSettingsConfigContext
): ChatSettingsConfigContext {
return setContext(CHAT_SETTINGS_CONFIG_KEY, ctx);
}
export function getChatSettingsConfigContext(): ChatSettingsConfigContext {
return getContext(CHAT_SETTINGS_CONFIG_KEY);
}
+5 -16
View File
@@ -1,19 +1,8 @@
export { export { getChatMessageEditContext, setChatMessageEditContext } from './chat-message-edit.context';
getMessageEditContext,
setMessageEditContext,
type MessageEditContext,
type MessageEditState,
type MessageEditActions
} from './message-edit.context';
export { export {
getChatActionsContext, getChatMessageActionsContext,
setChatActionsContext, setChatMessageActionsContext
type ChatActionsContext } from './chat-message-actions.context';
} from './chat-actions.context';
export { export { getChatFormActionsContext, setChatFormActionsContext } from './chat-form-actions.context';
getChatSettingsConfigContext,
setChatSettingsConfigContext,
type ChatSettingsConfigContext
} from './chat-settings-config.context';
@@ -1,51 +0,0 @@
import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import { getContext, setContext } from 'svelte';
export interface MessageEditState {
readonly isEditing: boolean;
readonly editedContent: string;
readonly editedExtras: DatabaseMessageExtra[];
readonly editedUploadedFiles: ChatUploadedFile[];
readonly originalContent: string;
readonly originalExtras: DatabaseMessageExtra[];
readonly showSaveOnlyOption: boolean;
readonly showBranchAfterEditOption: boolean;
readonly shouldBranchAfterEdit: boolean;
readonly messageRole: MessageRole;
readonly rawEditContent?: string;
}
export interface MessageEditActions {
setContent: (content: string) => void;
setExtras: (extras: DatabaseMessageExtra[]) => void;
setUploadedFiles: (files: ChatUploadedFile[]) => void;
save: () => void;
saveOnly: () => void;
cancel: () => void;
startEdit: () => void;
}
export interface AssistantEditActions {
setShouldBranchAfterEdit: (value: boolean) => void;
}
export type MessageEditContext = MessageEditState &
MessageEditActions &
Partial<AssistantEditActions>;
const MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_MESSAGE_EDIT);
/**
* Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
*/
export function setMessageEditContext(ctx: MessageEditContext): MessageEditContext {
return setContext(MESSAGE_EDIT_KEY, ctx);
}
/**
* Gets the message edit context. Call this in child components.
*/
export function getMessageEditContext(): MessageEditContext {
return getContext(MESSAGE_EDIT_KEY);
}
@@ -1,15 +1,15 @@
import { setMessageEditContext } from '$lib/contexts'; import { setChatMessageEditContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums'; import { MessageRole } from '$lib/enums';
import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra'; import { parseFilesToMessageExtras } from '$lib/utils/convert-files-to-extra';
interface UseMessageEditContextOptions { interface UseChatMessageEditContextOptions {
getContent: () => string; getContent: () => string;
getExtras: () => DatabaseMessageExtra[]; getExtras: () => DatabaseMessageExtra[];
showSaveOnlyOption?: boolean; showSaveOnlyOption?: boolean;
onSave: (content: string, extras?: DatabaseMessageExtra[]) => void; onSave: (content: string, extras?: DatabaseMessageExtra[]) => void;
} }
export function useMessageEditContext(options: UseMessageEditContextOptions) { export function useChatMessageEditContext(options: UseChatMessageEditContextOptions) {
let isEditing = $state(false); let isEditing = $state(false);
let editedContent = $state(''); let editedContent = $state('');
let editedExtras = $state<DatabaseMessageExtra[]>([]); let editedExtras = $state<DatabaseMessageExtra[]>([]);
@@ -45,7 +45,7 @@ export function useMessageEditContext(options: UseMessageEditContextOptions) {
isEditing = false; isEditing = false;
} }
setMessageEditContext({ setChatMessageEditContext({
cancel: handleCancelEdit, cancel: handleCancelEdit,
get editedContent() { get editedContent() {
return editedContent; return editedContent;
+110 -1
View File
@@ -7,7 +7,8 @@ import type {
AttachmentMenuItemId, AttachmentMenuItemId,
ChatFormCommandAction, ChatFormCommandAction,
ErrorDialogType, ErrorDialogType,
FileMentionEntryType FileMentionEntryType,
MessageRole
} from '$lib/enums'; } from '$lib/enums';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
@@ -235,3 +236,111 @@ export interface ChatFormCommand {
action: ChatFormCommandAction; action: ChatFormCommandAction;
disabled: boolean; disabled: boolean;
} }
/**
* Data shown in the message delete confirmation dialog.
*/
export interface ChatMessageDeletionInfo {
totalCount: number;
userMessages: number;
assistantMessages: number;
messageTypes: string[];
}
/**
* Conversation-level message operations owned by ChatMessages (store calls + list
* refresh + user-action notification), passed to each ChatMessage as a prop.
*/
export interface ChatMessageActions {
copy: (message: DatabaseMessage) => void;
delete: (message: DatabaseMessage) => void;
navigateToSibling: (siblingId: string) => void;
editWithBranching: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
editWithReplacement: (
message: DatabaseMessage,
newContent: string,
shouldBranch: boolean
) => void;
editUserMessagePreserveResponses: (
message: DatabaseMessage,
newContent: string,
newExtras?: DatabaseMessageExtra[]
) => void;
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
continueAssistantMessage: (message: DatabaseMessage) => void;
forkConversation: (
message: DatabaseMessage,
options: { name: string; includeAttachments: boolean }
) => void;
}
/**
* Per-message actions and state. Set once per message in ChatMessage.svelte and
* consumed by its descendants (action icons, branching controls).
*/
export interface ChatMessageActionsContext {
readonly siblingInfo: ChatMessageSiblingInfo | null;
readonly deletionInfo: ChatMessageDeletionInfo | null;
readonly showDeleteDialog: boolean;
copy: () => void;
requestDelete: () => void;
confirmDelete: () => void;
setShowDeleteDialog: (show: boolean) => void;
navigateToSibling: (siblingId: string) => void;
forkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
}
export interface ChatMessageEditState {
readonly isEditing: boolean;
readonly editedContent: string;
readonly editedExtras: DatabaseMessageExtra[];
readonly editedUploadedFiles: ChatUploadedFile[];
readonly originalContent: string;
readonly originalExtras: DatabaseMessageExtra[];
readonly showSaveOnlyOption: boolean;
readonly showBranchAfterEditOption: boolean;
readonly shouldBranchAfterEdit: boolean;
readonly messageRole: MessageRole;
readonly rawEditContent?: string;
}
export interface ChatMessageEditActions {
setContent: (content: string) => void;
setExtras: (extras: DatabaseMessageExtra[]) => void;
setUploadedFiles: (files: ChatUploadedFile[]) => void;
save: () => void;
saveOnly: () => void;
cancel: () => void;
startEdit: () => void;
}
export interface ChatMessageAssistantEditActions {
setShouldBranchAfterEdit: (value: boolean) => void;
}
export type ChatMessageEditContext = ChatMessageEditState &
ChatMessageEditActions &
Partial<ChatMessageAssistantEditActions>;
/**
* Actions and capability flags for the ChatForm add-menu. Set once in
* ChatFormActions.svelte and consumed by its deep descendants (the add sheet,
* dropdown and MCP servers submenu) to avoid relaying them through props.
*/
export interface ChatFormActionsContext {
readonly disabled: boolean;
readonly hasAudioModality: boolean;
readonly hasVideoModality: boolean;
readonly hasVisionModality: boolean;
readonly hasMcpPromptsSupport: boolean;
readonly hasMcpResourcesSupport: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
+8
View File
@@ -44,6 +44,14 @@ export type {
ChatUploadedFile, ChatUploadedFile,
ChatAttachmentDisplayItem, ChatAttachmentDisplayItem,
ChatMessageSiblingInfo, ChatMessageSiblingInfo,
ChatMessageActions,
ChatMessageActionsContext,
ChatMessageDeletionInfo,
ChatMessageEditContext,
ChatMessageEditState,
ChatMessageEditActions,
ChatMessageAssistantEditActions,
ChatFormActionsContext,
ChatMessagePromptProgress, ChatMessagePromptProgress,
ChatMessageTimings, ChatMessageTimings,
ChatMessageAgenticTimings, ChatMessageAgenticTimings,
@@ -1,6 +1,7 @@
<script module lang="ts"> <script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf'; import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte'; import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte';
import type { ChatMessageActions } from '$lib/types';
const { Story } = defineMeta({ const { Story } = defineMeta({
component: ChatMessage, component: ChatMessage,
@@ -10,6 +11,18 @@
title: 'Components/ChatScreen/ChatMessage' title: 'Components/ChatScreen/ChatMessage'
}); });
const chatActions: ChatMessageActions = {
continueAssistantMessage: () => {},
copy: () => {},
delete: () => {},
editUserMessagePreserveResponses: () => {},
editWithBranching: () => {},
editWithReplacement: () => {},
forkConversation: () => {},
navigateToSibling: () => {},
regenerateWithBranching: () => {}
};
// Mock messages for different scenarios // Mock messages for different scenarios
const userMessage: DatabaseMessage = { const userMessage: DatabaseMessage = {
children: [], children: [],
@@ -190,7 +203,7 @@
}} }}
> >
<div class="w-[56rem]"> <div class="w-[56rem]">
<ChatMessage message={streamingMessage} /> <ChatMessage message={streamingMessage} {chatActions} />
</div> </div>
</Story> </Story>