]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
webui: Conversation forking + branching improvements (#21021)
authorAleksander Grygier <redacted>
Sat, 28 Mar 2026 12:38:15 +0000 (13:38 +0100)
committerGitHub <redacted>
Sat, 28 Mar 2026 12:38:15 +0000 (13:38 +0100)
* refactor: Make `DialogConfirmation` extensible with children slot

* feat: Add conversation forking logic

* feat: Conversation forking UI

* feat: Update delete/edit dialogs and logic for forks

* refactor: Improve Chat Sidebar UX and add MCP Servers entry

* refactor: Cleanup

* feat: Update message in place when editing leaf nodes

* chore: Cleanup

* chore: Cleanup

* chore: Cleanup

* chore: Cleanup

* chore: Cleanup

* chore: Cleanup

* refactor: Post-review improvements

* chore: update webui build output

* test: Update Storybook test

* chore: update webui build output

* chore: update webui build output

25 files changed:
tools/server/public/index.html.gz
tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessage.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageUser.svelte
tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreenHeader.svelte
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebar.svelte
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarActions.svelte
tools/server/webui/src/lib/components/app/chat/ChatSidebar/ChatSidebarConversationItem.svelte
tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte
tools/server/webui/src/lib/constants/context-keys.ts [new file with mode: 0644]
tools/server/webui/src/lib/constants/index.ts
tools/server/webui/src/lib/constants/ui.ts
tools/server/webui/src/lib/contexts/chat-actions.context.ts
tools/server/webui/src/lib/contexts/chat-settings-dialog.context.ts [new file with mode: 0644]
tools/server/webui/src/lib/contexts/index.ts
tools/server/webui/src/lib/contexts/message-edit.context.ts
tools/server/webui/src/lib/services/database.service.ts
tools/server/webui/src/lib/stores/chat.svelte.ts
tools/server/webui/src/lib/stores/conversations.svelte.ts
tools/server/webui/src/lib/types/database.d.ts
tools/server/webui/src/routes/+layout.svelte
tools/server/webui/tests/stories/ChatSidebar.stories.svelte

index f68e50ef300b5987484cb5910b133d7c7b44fa26..5e7326a5844aa61f5860d9ce7b6be7b197ff280e 100644 (file)
Binary files a/tools/server/public/index.html.gz and b/tools/server/public/index.html.gz differ
index b51dd682e053d283c6954437edc9d9b274b09722..95c3c5da1ff27d350cb0f64a5a13ac8f748f543f 100644 (file)
@@ -10,9 +10,9 @@
                ModelsSelector,
                ModelsSelectorSheet
        } from '$lib/components/app';
-       import { DialogChatSettings } from '$lib/components/app/dialogs';
        import { SETTINGS_SECTION_TITLES } from '$lib/constants';
        import { mcpStore } from '$lib/stores/mcp.svelte';
+       import { getChatSettingsDialogContext } from '$lib/contexts';
        import { FileTypeCategory } from '$lib/enums';
        import { getFileTypeCategory } from '$lib/utils';
        import { config } from '$lib/stores/settings.svelte';
                selectorModelRef?.open();
        }
 
-       let showChatSettingsDialogWithMcpSection = $state(false);
+       const chatSettingsDialog = getChatSettingsDialogContext();
 
        let hasMcpPromptsSupport = $derived.by(() => {
                const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
                                {onSystemPromptClick}
                                {onMcpPromptClick}
                                {onMcpResourcesClick}
-                               onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
+                               onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
                        />
                {:else}
                        <ChatFormActionAttachmentsDropdown
                                {onSystemPromptClick}
                                {onMcpPromptClick}
                                {onMcpResourcesClick}
-                               onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
+                               onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
                        />
                {/if}
 
                <McpServersSelector
                        {disabled}
-                       onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
+                       onSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
                />
        </div>
 
                />
        {/if}
 </div>
-
-<DialogChatSettings
-       open={showChatSettingsDialogWithMcpSection}
-       onOpenChange={(open) => (showChatSettingsDialogWithMcpSection = open)}
-       initialSection={SETTINGS_SECTION_TITLES.MCP}
-/>
index 52a9355104a60dc1b34d2e3281b9a403b17ffbf4..b48c71aec1675e293ff9fb967ce858467b3740c7 100644 (file)
                chatActions.continueAssistantMessage(message);
        }
 
+       function handleForkConversation(options: { name: string; includeAttachments: boolean }) {
+               chatActions.forkConversation(message, options);
+       }
+
        function handleNavigateToSibling(siblingId: string) {
                chatActions.navigateToSibling(siblingId);
        }
                onCopy={handleCopy}
                onDelete={handleDelete}
                onEdit={handleEdit}
+               onForkConversation={handleForkConversation}
                onNavigateToSibling={handleNavigateToSibling}
                onShowDeleteDialogChange={handleShowDeleteDialogChange}
                {showDeleteDialog}
                onCopy={handleCopy}
                onDelete={handleDelete}
                onEdit={handleEdit}
+               onForkConversation={handleForkConversation}
                onNavigateToSibling={handleNavigateToSibling}
                onRegenerate={handleRegenerate}
                onShowDeleteDialogChange={handleShowDeleteDialogChange}
index 97b34e92cc97e9926a629fd32e3c3cc3e553e232..fec1a506eb8f77229b762a59dcde1306f94294b9 100644 (file)
@@ -1,12 +1,16 @@
 <script lang="ts">
-       import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
+       import { Edit, Copy, RefreshCw, Trash2, ArrowRight, GitBranch } from '@lucide/svelte';
        import {
                ActionIcon,
                ChatMessageBranchingControls,
                DialogConfirmation
        } from '$lib/components/app';
        import { Switch } from '$lib/components/ui/switch';
+       import { Checkbox } from '$lib/components/ui/checkbox';
+       import Input from '$lib/components/ui/input/input.svelte';
+       import Label from '$lib/components/ui/label/label.svelte';
        import { MessageRole } from '$lib/enums';
+       import { activeConversation } from '$lib/stores/conversations.svelte';
 
        interface Props {
                role: MessageRole.USER | MessageRole.ASSISTANT;
@@ -24,6 +28,7 @@
                onEdit?: () => void;
                onRegenerate?: () => void;
                onContinue?: () => void;
+               onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
                onDelete: () => void;
                onConfirmDelete: () => void;
                onNavigateToSibling?: (siblingId: string) => void;
@@ -42,6 +47,7 @@
                onConfirmDelete,
                onContinue,
                onDelete,
+               onForkConversation,
                onNavigateToSibling,
                onShowDeleteDialogChange,
                onRegenerate,
                onRawOutputToggle
        }: Props = $props();
 
+       let showForkDialog = $state(false);
+       let forkName = $state('');
+       let forkIncludeAttachments = $state(true);
+
        function handleConfirmDelete() {
                onConfirmDelete();
                onShowDeleteDialogChange(false);
        }
+
+       function handleOpenForkDialog() {
+               const conv = activeConversation();
+
+               forkName = `Fork of ${conv?.name ?? 'Conversation'}`;
+               forkIncludeAttachments = true;
+               showForkDialog = true;
+       }
+
+       function handleConfirmFork() {
+               onForkConversation?.({ name: forkName.trim(), includeAttachments: forkIncludeAttachments });
+               showForkDialog = false;
+       }
 </script>
 
 <div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-between">
                                <ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
                        {/if}
 
+                       {#if onForkConversation}
+                               <ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} />
+                       {/if}
+
                        <ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
                </div>
        </div>
        onConfirm={handleConfirmDelete}
        onCancel={() => onShowDeleteDialogChange(false)}
 />
+
+<DialogConfirmation
+       bind:open={showForkDialog}
+       title="Fork Conversation"
+       description="Create a new conversation branching from this message."
+       confirmText="Fork"
+       cancelText="Cancel"
+       icon={GitBranch}
+       onConfirm={handleConfirmFork}
+       onCancel={() => (showForkDialog = false)}
+>
+       <div class="flex flex-col gap-4 py-2">
+               <div class="flex flex-col gap-2">
+                       <Label for="fork-name">Title</Label>
+
+                       <Input
+                               id="fork-name"
+                               class="text-foreground"
+                               placeholder="Enter fork name"
+                               type="text"
+                               bind:value={forkName}
+                       />
+               </div>
+
+               <div class="flex items-center gap-2">
+                       <Checkbox
+                               id="fork-attachments"
+                               checked={forkIncludeAttachments}
+                               onCheckedChange={(checked) => {
+                                       forkIncludeAttachments = checked === true;
+                               }}
+                       />
+
+                       <Label for="fork-attachments" class="cursor-pointer text-sm font-normal">
+                               Include all attachments
+                       </Label>
+               </div>
+       </div>
+</DialogConfirmation>
index 8b07b385fb56b2c49af0b3d2b416354ed64e5644..97aee0300812034a316dd74b5bec02380bafd121 100644 (file)
@@ -39,6 +39,7 @@
                onContinue?: () => void;
                onDelete: () => void;
                onEdit?: () => void;
+               onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
                onNavigateToSibling?: (siblingId: string) => void;
                onRegenerate: (modelOverride?: string) => void;
                onShowDeleteDialogChange: (show: boolean) => void;
@@ -58,6 +59,7 @@
                onCopy,
                onDelete,
                onEdit,
+               onForkConversation,
                onNavigateToSibling,
                onRegenerate,
                onShowDeleteDialogChange,
                        onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
                                ? onContinue
                                : undefined}
+                       {onForkConversation}
                        {onDelete}
                        {onConfirmDelete}
                        {onNavigateToSibling}
index 9b9d9a3e993bba3ddbfcc9b2b9db208cf2780288..f2f6288d68d4eee53f71226dac81984fab4c975f 100644 (file)
@@ -21,6 +21,7 @@
                onEdit: () => void;
                onDelete: () => void;
                onConfirmDelete: () => void;
+               onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
                onShowDeleteDialogChange: (show: boolean) => void;
                onNavigateToSibling?: (siblingId: string) => void;
                onCopy: () => void;
@@ -35,6 +36,7 @@
                onEdit,
                onDelete,
                onConfirmDelete,
+               onForkConversation,
                onShowDeleteDialogChange,
                onNavigateToSibling,
                onCopy
                                        {onCopy}
                                        {onDelete}
                                        {onEdit}
+                                       {onForkConversation}
                                        {onNavigateToSibling}
                                        {onShowDeleteDialogChange}
                                        {siblingInfo}
index 439e8adb38117cc7ef266a6d18f6f1841b1b5b0f..5ff53644cac5746847ee4b326db827e230ff9c89 100644 (file)
                        onUserAction?.();
                        await chatStore.continueAssistantMessage(message.id);
                        refreshAllMessages();
+               },
+
+               forkConversation: async (
+                       message: DatabaseMessage,
+                       options: { name: string; includeAttachments: boolean }
+               ) => {
+                       await conversationsStore.forkConversation(message.id, options);
                }
        });
 
index 2730d5e738d7fe118dcb1e90ef0c29b6c9070954..0a920be6fa20e3d7b8084492fb2821dff506dc92 100644 (file)
@@ -1,16 +1,11 @@
 <script lang="ts">
        import { Settings } from '@lucide/svelte';
-       import { DialogChatSettings } from '$lib/components/app';
        import { Button } from '$lib/components/ui/button';
        import { useSidebar } from '$lib/components/ui/sidebar';
+       import { getChatSettingsDialogContext } from '$lib/contexts';
 
        const sidebar = useSidebar();
-
-       let settingsOpen = $state(false);
-
-       function toggleSettings() {
-               settingsOpen = true;
-       }
+       const chatSettingsDialog = getChatSettingsDialogContext();
 </script>
 
 <header
                <Button
                        variant="ghost"
                        size="icon-lg"
-                       onclick={toggleSettings}
+                       onclick={() => chatSettingsDialog.open()}
                        class="rounded-full backdrop-blur-lg"
                >
                        <Settings class="h-4 w-4" />
                </Button>
        </div>
 </header>
-
-<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
index 970394baa4a4779e772c23b4b40f71aee36f3f58..751406b3e39f6e86ffb29d28698a7b52ed90b52a 100644 (file)
@@ -1,13 +1,18 @@
 <script lang="ts">
        import { goto } from '$app/navigation';
        import { page } from '$app/state';
-       import { Trash2 } from '@lucide/svelte';
+       import { Trash2, Pencil } from '@lucide/svelte';
        import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
+       import { Checkbox } from '$lib/components/ui/checkbox';
+       import Label from '$lib/components/ui/label/label.svelte';
        import ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
        import * as Sidebar from '$lib/components/ui/sidebar';
-       import * as AlertDialog from '$lib/components/ui/alert-dialog';
        import Input from '$lib/components/ui/input/input.svelte';
-       import { conversationsStore, conversations } from '$lib/stores/conversations.svelte';
+       import {
+               conversationsStore,
+               conversations,
+               buildConversationTree
+       } from '$lib/stores/conversations.svelte';
        import { chatStore } from '$lib/stores/chat.svelte';
        import { getPreviewText } from '$lib/utils';
        import ChatSidebarActions from './ChatSidebarActions.svelte';
@@ -18,6 +23,7 @@
        let isSearchModeActive = $state(false);
        let searchQuery = $state('');
        let showDeleteDialog = $state(false);
+       let deleteWithForks = $state(false);
        let showEditDialog = $state(false);
        let selectedConversation = $state<DatabaseConversation | null>(null);
        let editedName = $state('');
                return conversations();
        });
 
+       let conversationTree = $derived(buildConversationTree(filteredConversations));
+
+       let selectedConversationHasDescendants = $derived.by(() => {
+               if (!selectedConversation) return false;
+
+               const allConvs = conversations();
+               const queue = [selectedConversation.id];
+
+               while (queue.length > 0) {
+                       const parentId = queue.pop()!;
+
+                       for (const c of allConvs) {
+                               if (c.forkedFromConversationId === parentId) return true;
+                       }
+               }
+
+               return false;
+       });
+
        async function handleDeleteConversation(id: string) {
                const conversation = conversations().find((conv) => conv.id === id);
                if (conversation) {
                        selectedConversation = conversation;
+                       deleteWithForks = false;
                        showDeleteDialog = true;
                }
        }
 
        function handleConfirmDelete() {
                if (selectedConversation) {
+                       const convId = selectedConversation.id;
+                       const withForks = deleteWithForks;
                        showDeleteDialog = false;
 
                        setTimeout(() => {
-                               conversationsStore.deleteConversation(selectedConversation.id);
-                               selectedConversation = null;
+                               conversationsStore.deleteConversation(convId, {
+                                       deleteWithForks: withForks
+                               });
                        }, 100); // Wait for animation to finish
                }
        }
 </script>
 
 <ScrollArea class="h-[100vh]">
-       <Sidebar.Header class=" top-0 z-10 gap-6 bg-sidebar/50 px-4 py-4 pb-2 backdrop-blur-lg md:sticky">
+       <Sidebar.Header class=" top-0 z-10 gap-4 bg-sidebar/50 p-4 pb-2 backdrop-blur-lg md:sticky">
                <a href="#/" onclick={handleMobileSidebarItemClick}>
                        <h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
                </a>
                <ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
        </Sidebar.Header>
 
-       <Sidebar.Group class="mt-4 space-y-2 p-0 px-4">
+       <Sidebar.Group class="mt-2 space-y-2 p-0 px-4">
                {#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
                        <Sidebar.GroupLabel>
                                {isSearchModeActive ? 'Search results' : 'Conversations'}
 
                <Sidebar.GroupContent>
                        <Sidebar.Menu>
-                               {#each filteredConversations as conversation (conversation.id)}
-                                       <Sidebar.MenuItem class="mb-1">
+                               {#each conversationTree as { conversation, depth } (conversation.id)}
+                                       <Sidebar.MenuItem class="mb-1 p-0">
                                                <ChatSidebarConversationItem
                                                        conversation={{
                                                                id: conversation.id,
                                                                name: conversation.name,
                                                                lastModified: conversation.lastModified,
-                                                               currNode: conversation.currNode
+                                                               currNode: conversation.currNode,
+                                                               forkedFromConversationId: conversation.forkedFromConversationId
                                                        }}
+                                                       {depth}
                                                        {handleMobileSidebarItemClick}
                                                        isActive={currentChatId === conversation.id}
                                                        onSelect={selectConversation}
                                        </Sidebar.MenuItem>
                                {/each}
 
-                               {#if filteredConversations.length === 0}
+                               {#if conversationTree.length === 0}
                                        <div class="px-2 py-4 text-center">
                                                <p class="mb-4 p-4 text-sm text-muted-foreground">
                                                        {searchQuery.length > 0
                showDeleteDialog = false;
                selectedConversation = null;
        }}
-/>
-
-<AlertDialog.Root bind:open={showEditDialog}>
-       <AlertDialog.Content>
-               <AlertDialog.Header>
-                       <AlertDialog.Title>Edit Conversation Name</AlertDialog.Title>
-                       <AlertDialog.Description>
-                               <Input
-                                       class="mt-4 text-foreground"
-                                       onkeydown={(e) => {
-                                               if (e.key === 'Enter') {
-                                                       e.preventDefault();
-                                                       handleConfirmEdit();
-                                               }
-                                       }}
-                                       placeholder="Enter a new name"
-                                       type="text"
-                                       bind:value={editedName}
-                               />
-                       </AlertDialog.Description>
-               </AlertDialog.Header>
-               <AlertDialog.Footer>
-                       <AlertDialog.Cancel
-                               onclick={() => {
-                                       showEditDialog = false;
-                                       selectedConversation = null;
-                               }}>Cancel</AlertDialog.Cancel
-                       >
-                       <AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action>
-               </AlertDialog.Footer>
-       </AlertDialog.Content>
-</AlertDialog.Root>
+>
+       {#if selectedConversationHasDescendants}
+               <div class="flex items-center gap-2 py-2">
+                       <Checkbox id="delete-with-forks" bind:checked={deleteWithForks} />
+
+                       <Label for="delete-with-forks" class="text-sm">Also delete all forked conversations</Label>
+               </div>
+       {/if}
+</DialogConfirmation>
+
+<DialogConfirmation
+       bind:open={showEditDialog}
+       title="Edit Conversation Name"
+       description=""
+       confirmText="Save"
+       cancelText="Cancel"
+       icon={Pencil}
+       onConfirm={handleConfirmEdit}
+       onCancel={() => {
+               showEditDialog = false;
+               selectedConversation = null;
+       }}
+       onKeydown={(e) => {
+               if (e.key === 'Enter') {
+                       e.preventDefault();
+                       e.stopImmediatePropagation();
+                       handleConfirmEdit();
+               }
+       }}
+>
+       <Input
+               class="text-foreground"
+               placeholder="Enter a new name"
+               type="text"
+               bind:value={editedName}
+       />
+</DialogConfirmation>
index 30d1f9d4b7e98a3f8cf846d8d73bb3e25660e9ae..e0d379f6df314fe5c7e7576215adc6d5b15c4f58 100644 (file)
@@ -3,6 +3,9 @@
        import { KeyboardShortcutInfo } from '$lib/components/app';
        import { Button } from '$lib/components/ui/button';
        import { Input } from '$lib/components/ui/input';
+       import { McpLogo } from '$lib/components/app';
+       import { SETTINGS_SECTION_TITLES } from '$lib/constants';
+       import { getChatSettingsDialogContext } from '$lib/contexts';
 
        interface Props {
                handleMobileSidebarItemClick: () => void;
@@ -18,6 +21,8 @@
 
        let searchInput: HTMLInputElement | null = $state(null);
 
+       const chatSettingsDialog = getChatSettingsDialogContext();
+
        function handleSearchModeDeactivate() {
                isSearchModeActive = false;
                searchQuery = '';
@@ -30,7 +35,7 @@
        });
 </script>
 
-<div class="space-y-0.5">
+<div class="my-1 space-y-1">
        {#if isSearchModeActive}
                <div class="relative">
                        <Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
                </div>
        {:else}
                <Button
-                       class="w-full justify-between hover:[&>kbd]:opacity-100"
+                       class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
                        href="?new_chat=true#/"
                        onclick={handleMobileSidebarItemClick}
                        variant="ghost"
                >
                        <div class="flex items-center gap-2">
                                <SquarePen class="h-4 w-4" />
+
                                New chat
                        </div>
 
@@ -64,7 +70,7 @@
                </Button>
 
                <Button
-                       class="w-full justify-between hover:[&>kbd]:opacity-100"
+                       class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
                        onclick={() => {
                                isSearchModeActive = true;
                        }}
                >
                        <div class="flex items-center gap-2">
                                <Search class="h-4 w-4" />
-                               Search conversations
+
+                               Search
                        </div>
 
                        <KeyboardShortcutInfo keys={['cmd', 'k']} />
                </Button>
+
+               <Button
+                       class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
+                       onclick={() => {
+                               chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP);
+                       }}
+                       variant="ghost"
+               >
+                       <div class="flex items-center gap-2">
+                               <McpLogo class="h-4 w-4" />
+
+                               MCP Servers
+                       </div>
+               </Button>
        {/if}
 </div>
index 5c48909cd81bc73192f2021be9e9f2b1ede9b05b..e22cb4f829e49cec045cce71c2ba02c697a8569d 100644 (file)
@@ -1,13 +1,23 @@
 <script lang="ts">
-       import { Trash2, Pencil, MoreHorizontal, Download, Loader2, Square } from '@lucide/svelte';
+       import {
+               Trash2,
+               Pencil,
+               MoreHorizontal,
+               Download,
+               Loader2,
+               Square,
+               GitBranch
+       } from '@lucide/svelte';
        import { DropdownMenuActions } from '$lib/components/app';
        import * as Tooltip from '$lib/components/ui/tooltip';
+       import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
        import { getAllLoadingChats } from '$lib/stores/chat.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        import { onMount } from 'svelte';
 
        interface Props {
                isActive?: boolean;
+               depth?: number;
                conversation: DatabaseConversation;
                handleMobileSidebarItemClick?: () => void;
                onDelete?: (id: string) => void;
@@ -23,7 +33,8 @@
                onEdit,
                onSelect,
                onStop,
-               isActive = false
+               isActive = false,
+               depth = 0
        }: Props = $props();
 
        let renderActionsDropdown = $state(false);
 
 <!-- svelte-ignore a11y_mouse_events_have_key_events -->
 <button
-       class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
+       class="group flex min-h-9 w-full cursor-pointer items-center justify-between space-x-3 rounded-lg py-1.5 text-left transition-colors hover:bg-foreground/10 {isActive
                ? 'bg-foreground/5 text-accent-foreground'
-               : ''}"
+               : ''} px-3"
        onclick={handleSelect}
        onmouseover={handleMouseOver}
        onmouseleave={handleMouseLeave}
 >
-       <div class="flex min-w-0 flex-1 items-center gap-2">
+       <div
+               class="flex min-w-0 flex-1 items-center gap-2"
+               style:padding-left="{depth * FORK_TREE_DEPTH_PADDING}px"
+       >
+               {#if depth > 0}
+                       <Tooltip.Root>
+                               <Tooltip.Trigger>
+                                       <a
+                                               href="#/chat/{conversation.forkedFromConversationId}"
+                                               class="flex shrink-0 items-center text-muted-foreground transition-colors hover:text-foreground"
+                                       >
+                                               <GitBranch class="h-3.5 w-3.5" />
+                                       </a>
+                               </Tooltip.Trigger>
+
+                               <Tooltip.Content>
+                                       <p>See parent conversation</p>
+                               </Tooltip.Content>
+                       </Tooltip.Root>
+               {/if}
+
                {#if isLoading}
                        <Tooltip.Root>
                                <Tooltip.Trigger>
index d8aa66f3e81a7fd677f3425df222a6cdf731f437..41fdf7d410cd84e0b57e88d5d64380a9e738a044 100644 (file)
@@ -1,6 +1,6 @@
 <script lang="ts">
        import * as AlertDialog from '$lib/components/ui/alert-dialog';
-       import type { Component } from 'svelte';
+       import type { Component, Snippet } from 'svelte';
        import { KeyboardKey } from '$lib/enums';
 
        interface Props {
@@ -14,6 +14,7 @@
                onConfirm: () => void;
                onCancel: () => void;
                onKeydown?: (event: KeyboardEvent) => void;
+               children?: Snippet;
        }
 
        let {
@@ -26,7 +27,8 @@
                icon,
                onConfirm,
                onCancel,
-               onKeydown
+               onKeydown,
+               children
        }: Props = $props();
 
        function handleKeydown(event: KeyboardEvent) {
                        </AlertDialog.Description>
                </AlertDialog.Header>
 
+               {#if children}
+                       {@render children()}
+               {/if}
+
                <AlertDialog.Footer>
                        <AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
                        <AlertDialog.Action
diff --git a/tools/server/webui/src/lib/constants/context-keys.ts b/tools/server/webui/src/lib/constants/context-keys.ts
new file mode 100644 (file)
index 0000000..ba61d25
--- /dev/null
@@ -0,0 +1,3 @@
+export const CONTEXT_KEY_MESSAGE_EDIT = 'chat-message-edit';
+export const CONTEXT_KEY_CHAT_ACTIONS = 'chat-actions';
+export const CONTEXT_KEY_CHAT_SETTINGS_DIALOG = 'chat-settings-dialog';
index f3593c03b1291646ef1c48201bd688f21c03db7c..70bed50a699943e472cb015cae7f27859fd51321 100644 (file)
@@ -10,6 +10,7 @@ export * from './cache';
 export * from './chat-form';
 export * from './code-blocks';
 export * from './code';
+export * from './context-keys';
 export * from './css-classes';
 export * from './favicon';
 export * from './floating-ui-constraints';
index a75b30f2f8a42d2d177dfa6381ddcacb8e13b102..be9c50d0768833f63081b50e78402e1dfd953a63 100644 (file)
@@ -1 +1,2 @@
+export const FORK_TREE_DEPTH_PADDING = 8;
 export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
index eba0fec027e360d2e85ecb672a9ba3f99cc1f915..e9050fa27fb3ddd236ffa5815279cd13e1cbc156 100644 (file)
@@ -1,4 +1,5 @@
 import { getContext, setContext } from 'svelte';
+import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants';
 
 export interface ChatActionsContext {
        copy: (message: DatabaseMessage) => void;
@@ -21,9 +22,13 @@ export interface ChatActionsContext {
        ) => 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('chat-actions');
+const CHAT_ACTIONS_KEY = Symbol.for(CONTEXT_KEY_CHAT_ACTIONS);
 
 export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext {
        return setContext(CHAT_ACTIONS_KEY, ctx);
diff --git a/tools/server/webui/src/lib/contexts/chat-settings-dialog.context.ts b/tools/server/webui/src/lib/contexts/chat-settings-dialog.context.ts
new file mode 100644 (file)
index 0000000..3f98c3c
--- /dev/null
@@ -0,0 +1,19 @@
+import { getContext, setContext } from 'svelte';
+import type { SettingsSectionTitle } from '$lib/constants';
+import { CONTEXT_KEY_CHAT_SETTINGS_DIALOG } from '$lib/constants';
+
+export interface ChatSettingsDialogContext {
+       open: (initialSection?: SettingsSectionTitle) => void;
+}
+
+const CHAT_SETTINGS_DIALOG_KEY = Symbol.for(CONTEXT_KEY_CHAT_SETTINGS_DIALOG);
+
+export function setChatSettingsDialogContext(
+       ctx: ChatSettingsDialogContext
+): ChatSettingsDialogContext {
+       return setContext(CHAT_SETTINGS_DIALOG_KEY, ctx);
+}
+
+export function getChatSettingsDialogContext(): ChatSettingsDialogContext {
+       return getContext(CHAT_SETTINGS_DIALOG_KEY);
+}
index 73ff6f96fa93747be766253ca4be2742264a1004..6c69070d8195b60ed102de706b5bf00f1845992d 100644 (file)
@@ -11,3 +11,9 @@ export {
        setChatActionsContext,
        type ChatActionsContext
 } from './chat-actions.context';
+
+export {
+       getChatSettingsDialogContext,
+       setChatSettingsDialogContext,
+       type ChatSettingsDialogContext
+} from './chat-settings-dialog.context';
index 7af116daa5a4659c6ab0e7dbb7f1c8c3182e23e6..2d344cca50ff1ccf0ef339c087aea72e7a8defe2 100644 (file)
@@ -1,4 +1,5 @@
 import { getContext, setContext } from 'svelte';
+import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants';
 
 export interface MessageEditState {
        readonly isEditing: boolean;
@@ -22,7 +23,7 @@ export interface MessageEditActions {
 
 export type MessageEditContext = MessageEditState & MessageEditActions;
 
-const MESSAGE_EDIT_KEY = Symbol.for('chat-message-edit');
+const MESSAGE_EDIT_KEY = Symbol.for(CONTEXT_KEY_MESSAGE_EDIT);
 
 /**
  * Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
index 53ec80c27ed76f0ff35f5edf67b1d29b24c53489..8f7b81fe97dcaf5d6975af4141d01e28084cd867 100644 (file)
@@ -1,5 +1,6 @@
 import Dexie, { type EntityTable } from 'dexie';
-import { findDescendantMessages, uuid } from '$lib/utils';
+import { findDescendantMessages, uuid, filterByLeafNodeId } from '$lib/utils';
+import type { McpServerOverride } from '$lib/types/database';
 
 class LlamacppDatabase extends Dexie {
        conversations!: EntityTable<DatabaseConversation, string>;
@@ -173,8 +174,47 @@ export class DatabaseService {
         *
         * @param id - Conversation ID
         */
-       static async deleteConversation(id: string): Promise<void> {
+       static async deleteConversation(
+               id: string,
+               options?: { deleteWithForks?: boolean }
+       ): Promise<void> {
                await db.transaction('rw', [db.conversations, db.messages], async () => {
+                       if (options?.deleteWithForks) {
+                               // Recursively collect all descendant IDs
+                               const idsToDelete: string[] = [];
+                               const queue = [id];
+
+                               while (queue.length > 0) {
+                                       const parentId = queue.pop()!;
+                                       const children = await db.conversations
+                                               .filter((c) => c.forkedFromConversationId === parentId)
+                                               .toArray();
+
+                                       for (const child of children) {
+                                               idsToDelete.push(child.id);
+                                               queue.push(child.id);
+                                       }
+                               }
+
+                               for (const forkId of idsToDelete) {
+                                       await db.conversations.delete(forkId);
+                                       await db.messages.where('convId').equals(forkId).delete();
+                               }
+                       } else {
+                               // Reparent direct children to deleted conv's parent
+                               const conv = await db.conversations.get(id);
+                               const newParent = conv?.forkedFromConversationId;
+                               const directChildren = await db.conversations
+                                       .filter((c) => c.forkedFromConversationId === id)
+                                       .toArray();
+
+                               for (const child of directChildren) {
+                                       await db.conversations.update(child.id, {
+                                               forkedFromConversationId: newParent ?? undefined
+                                       });
+                               }
+                       }
+
                        await db.conversations.delete(id);
                        await db.messages.where('convId').equals(id).delete();
                });
@@ -364,4 +404,88 @@ export class DatabaseService {
                        return { imported: importedCount, skipped: skippedCount };
                });
        }
+
+       /**
+        *
+        *
+        * Forking
+        *
+        *
+        */
+
+       /**
+        * Forks a conversation at a specific message, creating a new conversation
+        * containing all messages from the root up to (and including) the target message.
+        *
+        * @param sourceConvId - The source conversation ID
+        * @param atMessageId - The message ID to fork at (the new conversation ends here)
+        * @param options - Fork options (name and whether to include attachments)
+        * @returns The newly created conversation
+        */
+       static async forkConversation(
+               sourceConvId: string,
+               atMessageId: string,
+               options: { name: string; includeAttachments: boolean }
+       ): Promise<DatabaseConversation> {
+               return await db.transaction('rw', [db.conversations, db.messages], async () => {
+                       const sourceConv = await db.conversations.get(sourceConvId);
+                       if (!sourceConv) {
+                               throw new Error(`Source conversation ${sourceConvId} not found`);
+                       }
+
+                       const allMessages = await db.messages.where('convId').equals(sourceConvId).toArray();
+
+                       const pathMessages = filterByLeafNodeId(allMessages, atMessageId, true) as DatabaseMessage[];
+                       if (pathMessages.length === 0) {
+                               throw new Error(`Could not resolve message path to ${atMessageId}`);
+                       }
+
+                       const idMap = new Map<string, string>();
+
+                       for (const msg of pathMessages) {
+                               idMap.set(msg.id, uuid());
+                       }
+
+                       const newConvId = uuid();
+                       const clonedMessages: DatabaseMessage[] = pathMessages.map((msg) => {
+                               const newId = idMap.get(msg.id)!;
+                               const newParent = msg.parent ? (idMap.get(msg.parent) ?? null) : null;
+                               const newChildren = msg.children
+                                       .filter((childId: string) => idMap.has(childId))
+                                       .map((childId: string) => idMap.get(childId)!);
+
+                               return {
+                                       ...msg,
+                                       id: newId,
+                                       convId: newConvId,
+                                       parent: newParent,
+                                       children: newChildren,
+                                       extra: options.includeAttachments ? msg.extra : undefined
+                               };
+                       });
+
+                       const lastClonedMessage = clonedMessages[clonedMessages.length - 1];
+                       const newConv: DatabaseConversation = {
+                               id: newConvId,
+                               name: options.name,
+                               lastModified: Date.now(),
+                               currNode: lastClonedMessage.id,
+                               forkedFromConversationId: sourceConvId,
+                               mcpServerOverrides: sourceConv.mcpServerOverrides
+                                       ? sourceConv.mcpServerOverrides.map((o: McpServerOverride) => ({
+                                                       serverId: o.serverId,
+                                                       enabled: o.enabled
+                                               }))
+                                       : undefined
+                       };
+
+                       await db.conversations.add(newConv);
+
+                       for (const msg of clonedMessages) {
+                               await db.messages.add(msg);
+                       }
+
+                       return newConv;
+               });
+       }
 }
index e07f12b36c58a39a326aa1748567e5808428a7ed..3af91be8367459ddf0660ab73a72ea212e021e4b 100644 (file)
@@ -1265,35 +1265,53 @@ class ChatStore {
                let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
                if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
                if (!result) return;
-               const { message: msg } = result;
+               const { message: msg, index: idx } = result;
                try {
                        const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
                        const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
                        const isFirstUserMessage =
                                msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
-                       const parentId = msg.parent || rootMessage?.id;
-                       if (!parentId) return;
                        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,
-                                       type: msg.type,
-                                       timestamp: Date.now(),
-                                       role: msg.role,
+
+                       let messageIdForResponse: string;
+
+                       if (msg.children.length === 0) {
+                               // No responses after this message — update in place instead of branching
+                               const updates: Partial<DatabaseMessage> = {
                                        content: newContent,
-                                       toolCalls: msg.toolCalls || '',
-                                       children: [],
-                                       extra: extrasToUse,
-                                       model: msg.model
-                               },
-                               parentId
-                       );
-                       await conversationsStore.updateCurrentNode(newMessage.id);
+                                       timestamp: Date.now(),
+                                       extra: extrasToUse
+                               };
+                               await DatabaseService.updateMessage(msg.id, updates);
+                               conversationsStore.updateMessageAtIndex(idx, updates);
+                               messageIdForResponse = msg.id;
+                       } else {
+                               // Has children — create a new branch as sibling
+                               const parentId = msg.parent || rootMessage?.id;
+                               if (!parentId) return;
+                               const newMessage = await DatabaseService.createMessageBranch(
+                                       {
+                                               convId: msg.convId,
+                                               type: msg.type,
+                                               timestamp: Date.now(),
+                                               role: msg.role,
+                                               content: newContent,
+                                               toolCalls: msg.toolCalls || '',
+                                               children: [],
+                                               extra: extrasToUse,
+                                               model: msg.model
+                                       },
+                                       parentId
+                               );
+                               await conversationsStore.updateCurrentNode(newMessage.id);
+                               messageIdForResponse = newMessage.id;
+                       }
+
                        conversationsStore.updateConversationTimestamp();
                        if (isFirstUserMessage && newContent.trim())
                                await conversationsStore.updateConversationTitleWithConfirmation(
@@ -1301,7 +1319,8 @@ class ChatStore {
                                        newContent.trim()
                                );
                        await conversationsStore.refreshActiveMessages();
-                       if (msg.role === MessageRole.USER) await this.generateResponseForMessage(newMessage.id);
+                       if (msg.role === MessageRole.USER)
+                               await this.generateResponseForMessage(messageIdForResponse);
                } catch (error) {
                        console.error('Failed to edit message with branching:', error);
                }
index 3cfbd3d1c9e89e2aa69e0359875f4eccba4b91af..5769ee98fdee54751019bc5a5d4a76d2f8beaa24 100644 (file)
@@ -39,6 +39,12 @@ import {
        MULTIPLE_UNDERSCORE_REGEX,
        MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY
 } from '$lib/constants';
+import { SvelteMap, SvelteSet } from 'svelte/reactivity';
+
+export interface ConversationTreeItem {
+       conversation: DatabaseConversation;
+       depth: number;
+}
 
 class ConversationsStore {
        /**
@@ -300,15 +306,45 @@ class ConversationsStore {
         * Deletes a conversation and all its messages
         * @param convId - The conversation ID to delete
         */
-       async deleteConversation(convId: string): Promise<void> {
+       async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise<void> {
                try {
-                       await DatabaseService.deleteConversation(convId);
-
-                       this.conversations = this.conversations.filter((c) => c.id !== convId);
+                       await DatabaseService.deleteConversation(convId, options);
+
+                       if (options?.deleteWithForks) {
+                               // Collect all descendants recursively
+                               const idsToRemove = new SvelteSet([convId]);
+                               const queue = [convId];
+                               while (queue.length > 0) {
+                                       const parentId = queue.pop()!;
+                                       for (const c of this.conversations) {
+                                               if (c.forkedFromConversationId === parentId && !idsToRemove.has(c.id)) {
+                                                       idsToRemove.add(c.id);
+                                                       queue.push(c.id);
+                                               }
+                                       }
+                               }
+                               this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id));
 
-                       if (this.activeConversation?.id === convId) {
-                               this.clearActiveConversation();
-                               await goto(`?new_chat=true#/`);
+                               if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
+                                       this.clearActiveConversation();
+                                       await goto(`?new_chat=true#/`);
+                               }
+                       } else {
+                               // Reparent direct children to deleted conv's parent (or promote to top-level)
+                               const deletedConv = this.conversations.find((c) => c.id === convId);
+                               const newParent = deletedConv?.forkedFromConversationId;
+                               this.conversations = this.conversations
+                                       .filter((c) => c.id !== convId)
+                                       .map((c) =>
+                                               c.forkedFromConversationId === convId
+                                                       ? { ...c, forkedFromConversationId: newParent }
+                                                       : c
+                                       );
+
+                               if (this.activeConversation?.id === convId) {
+                                       this.clearActiveConversation();
+                                       await goto(`?new_chat=true#/`);
+                               }
                        }
                } catch (error) {
                        console.error('Failed to delete conversation:', error);
@@ -658,6 +694,42 @@ class ConversationsStore {
                this.saveMcpDefaults();
        }
 
+       /**
+        * Forks a conversation at a specific message, creating a new conversation
+        * containing messages from root up to the target message, then navigates to it.
+        *
+        * @param messageId - The message ID to fork at
+        * @param options - Fork options (name and whether to include attachments)
+        * @returns The new conversation ID, or null if fork failed
+        */
+       async forkConversation(
+               messageId: string,
+               options: { name: string; includeAttachments: boolean }
+       ): Promise<string | null> {
+               if (!this.activeConversation) return null;
+
+               try {
+                       const newConv = await DatabaseService.forkConversation(
+                               this.activeConversation.id,
+                               messageId,
+                               options
+                       );
+
+                       this.conversations = [newConv, ...this.conversations];
+
+                       await goto(`#/chat/${newConv.id}`);
+
+                       toast.success('Conversation forked');
+
+                       return newConv.id;
+               } catch (error) {
+                       console.error('Failed to fork conversation:', error);
+                       toast.error('Failed to fork conversation');
+
+                       return null;
+               }
+       }
+
        /**
         *
         *
@@ -830,3 +902,53 @@ export const conversations = () => conversationsStore.conversations;
 export const activeConversation = () => conversationsStore.activeConversation;
 export const activeMessages = () => conversationsStore.activeMessages;
 export const isConversationsInitialized = () => conversationsStore.isInitialized;
+
+/**
+ * Builds a flat tree of conversations with depth levels for nested forks.
+ * Accepts a pre-filtered list so search filtering stays in the component.
+ */
+export function buildConversationTree(convs: DatabaseConversation[]): ConversationTreeItem[] {
+       const childrenByParent = new SvelteMap<string, DatabaseConversation[]>();
+       const forkIds = new SvelteSet<string>();
+
+       for (const conv of convs) {
+               if (conv.forkedFromConversationId) {
+                       forkIds.add(conv.id);
+
+                       const siblings = childrenByParent.get(conv.forkedFromConversationId) || [];
+
+                       siblings.push(conv);
+                       childrenByParent.set(conv.forkedFromConversationId, siblings);
+               }
+       }
+
+       const result: ConversationTreeItem[] = [];
+       const visited = new SvelteSet<string>();
+
+       function walk(conv: DatabaseConversation, depth: number) {
+               visited.add(conv.id);
+               result.push({ conversation: conv, depth });
+
+               const children = childrenByParent.get(conv.id);
+               if (children) {
+                       children.sort((a, b) => b.lastModified - a.lastModified);
+
+                       for (const child of children) {
+                               walk(child, depth + 1);
+                       }
+               }
+       }
+
+       const roots = convs.filter((c) => !forkIds.has(c.id));
+       for (const root of roots) {
+               walk(root, 0);
+       }
+
+       for (const conv of convs) {
+               if (!visited.has(conv.id)) {
+                       walk(conv, 1);
+               }
+       }
+
+       return result;
+}
index 50f51ecf5dc5a62b61fb3828a3d70877f0a6662b..95ff7377c6e6a1b18d8db6438131ab2f18985457 100644 (file)
@@ -12,6 +12,7 @@ export interface DatabaseConversation {
        lastModified: number;
        name: string;
        mcpServerOverrides?: McpServerOverride[];
+       forkedFromConversationId?: string;
 }
 
 export interface DatabaseMessageExtraAudioFile {
index 9093fc2197c763bdca7a83d128b20f3b340d9cbe..ccc207f8519bca71e8e01b88b32f5247d225334f 100644 (file)
@@ -4,7 +4,11 @@
        import { browser } from '$app/environment';
        import { page } from '$app/state';
        import { untrack } from 'svelte';
-       import { ChatSidebar, DialogConversationTitleUpdate } from '$lib/components/app';
+       import {
+               ChatSidebar,
+               DialogConversationTitleUpdate,
+               DialogChatSettings
+       } from '$lib/components/app';
        import { isLoading } from '$lib/stores/chat.svelte';
        import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
        import * as Sidebar from '$lib/components/ui/sidebar/index.js';
        import { modelsStore } from '$lib/stores/models.svelte';
        import { mcpStore } from '$lib/stores/mcp.svelte';
        import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
+       import type { SettingsSectionTitle } from '$lib/constants';
        import { KeyboardKey } from '$lib/enums';
        import { IsMobile } from '$lib/hooks/is-mobile.svelte';
+       import { setChatSettingsDialogContext } from '$lib/contexts';
 
        let { children } = $props();
 
        let titleUpdateNewTitle = $state('');
        let titleUpdateResolve: ((value: boolean) => void) | null = null;
 
+       let chatSettingsDialogOpen = $state(false);
+       let chatSettingsDialogInitialSection = $state<SettingsSectionTitle | undefined>(undefined);
+
+       setChatSettingsDialogContext({
+               open: (initialSection?: SettingsSectionTitle) => {
+                       chatSettingsDialogInitialSection = initialSection;
+                       chatSettingsDialogOpen = true;
+               }
+       });
+
        // Global keyboard shortcuts
        function handleKeydown(event: KeyboardEvent) {
                const isCtrlOrCmd = event.ctrlKey || event.metaKey;
 
        <Toaster richColors />
 
+       <DialogChatSettings
+               open={chatSettingsDialogOpen}
+               onOpenChange={(open) => (chatSettingsDialogOpen = open)}
+               initialSection={chatSettingsDialogInitialSection}
+       />
+
        <DialogConversationTitleUpdate
                bind:open={titleUpdateDialogOpen}
                currentTitle={titleUpdateCurrentTitle}
index 42cea8783cd022d135bea01f268341ac6f800f22..7ff9538a3e82d79148f0ef0b2ae82bc1dbda810b 100644 (file)
@@ -73,7 +73,7 @@
                        conversationsStore.conversations = mockConversations;
                }, 0));
                
-               const searchTrigger = screen.getByText('Search conversations');
+               const searchTrigger = screen.getByText('Search');
                userEvent.click(searchTrigger);
        }}
 >