webui: Conversation forking + branching improvements (#21021)
* 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
This commit is contained in:
parent
b0f0dd3e51
commit
51a84efc53
Binary file not shown.
|
|
@ -10,9 +10,9 @@
|
||||||
ModelsSelector,
|
ModelsSelector,
|
||||||
ModelsSelectorSheet
|
ModelsSelectorSheet
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { DialogChatSettings } from '$lib/components/app/dialogs';
|
|
||||||
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
|
import { SETTINGS_SECTION_TITLES } from '$lib/constants';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
|
import { getChatSettingsDialogContext } from '$lib/contexts';
|
||||||
import { FileTypeCategory } from '$lib/enums';
|
import { FileTypeCategory } from '$lib/enums';
|
||||||
import { getFileTypeCategory } from '$lib/utils';
|
import { getFileTypeCategory } from '$lib/utils';
|
||||||
import { config } from '$lib/stores/settings.svelte';
|
import { config } from '$lib/stores/settings.svelte';
|
||||||
|
|
@ -169,7 +169,7 @@
|
||||||
selectorModelRef?.open();
|
selectorModelRef?.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
let showChatSettingsDialogWithMcpSection = $state(false);
|
const chatSettingsDialog = getChatSettingsDialogContext();
|
||||||
|
|
||||||
let hasMcpPromptsSupport = $derived.by(() => {
|
let hasMcpPromptsSupport = $derived.by(() => {
|
||||||
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
|
||||||
|
|
@ -197,7 +197,7 @@
|
||||||
{onSystemPromptClick}
|
{onSystemPromptClick}
|
||||||
{onMcpPromptClick}
|
{onMcpPromptClick}
|
||||||
{onMcpResourcesClick}
|
{onMcpResourcesClick}
|
||||||
onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
|
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<ChatFormActionAttachmentsDropdown
|
<ChatFormActionAttachmentsDropdown
|
||||||
|
|
@ -210,13 +210,13 @@
|
||||||
{onSystemPromptClick}
|
{onSystemPromptClick}
|
||||||
{onMcpPromptClick}
|
{onMcpPromptClick}
|
||||||
{onMcpResourcesClick}
|
{onMcpResourcesClick}
|
||||||
onMcpSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
|
onMcpSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<McpServersSelector
|
<McpServersSelector
|
||||||
{disabled}
|
{disabled}
|
||||||
onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
|
onSettingsClick={() => chatSettingsDialog.open(SETTINGS_SECTION_TITLES.MCP)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -265,9 +265,3 @@
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogChatSettings
|
|
||||||
open={showChatSettingsDialogWithMcpSection}
|
|
||||||
onOpenChange={(open) => (showChatSettingsDialogWithMcpSection = open)}
|
|
||||||
initialSection={SETTINGS_SECTION_TITLES.MCP}
|
|
||||||
/>
|
|
||||||
|
|
|
||||||
|
|
@ -180,6 +180,10 @@
|
||||||
chatActions.continueAssistantMessage(message);
|
chatActions.continueAssistantMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleForkConversation(options: { name: string; includeAttachments: boolean }) {
|
||||||
|
chatActions.forkConversation(message, options);
|
||||||
|
}
|
||||||
|
|
||||||
function handleNavigateToSibling(siblingId: string) {
|
function handleNavigateToSibling(siblingId: string) {
|
||||||
chatActions.navigateToSibling(siblingId);
|
chatActions.navigateToSibling(siblingId);
|
||||||
}
|
}
|
||||||
|
|
@ -285,6 +289,7 @@
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
onForkConversation={handleForkConversation}
|
||||||
onNavigateToSibling={handleNavigateToSibling}
|
onNavigateToSibling={handleNavigateToSibling}
|
||||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||||
{showDeleteDialog}
|
{showDeleteDialog}
|
||||||
|
|
@ -303,6 +308,7 @@
|
||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
|
onForkConversation={handleForkConversation}
|
||||||
onNavigateToSibling={handleNavigateToSibling}
|
onNavigateToSibling={handleNavigateToSibling}
|
||||||
onRegenerate={handleRegenerate}
|
onRegenerate={handleRegenerate}
|
||||||
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
onShowDeleteDialogChange={handleShowDeleteDialogChange}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Edit, Copy, RefreshCw, Trash2, ArrowRight } from '@lucide/svelte';
|
import { Edit, Copy, RefreshCw, Trash2, ArrowRight, GitBranch } from '@lucide/svelte';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
ChatMessageBranchingControls,
|
ChatMessageBranchingControls,
|
||||||
DialogConfirmation
|
DialogConfirmation
|
||||||
} from '$lib/components/app';
|
} from '$lib/components/app';
|
||||||
import { Switch } from '$lib/components/ui/switch';
|
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 { MessageRole } from '$lib/enums';
|
||||||
|
import { activeConversation } from '$lib/stores/conversations.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
role: MessageRole.USER | MessageRole.ASSISTANT;
|
role: MessageRole.USER | MessageRole.ASSISTANT;
|
||||||
|
|
@ -24,6 +28,7 @@
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
onRegenerate?: () => void;
|
onRegenerate?: () => void;
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
|
onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
onNavigateToSibling?: (siblingId: string) => void;
|
onNavigateToSibling?: (siblingId: string) => void;
|
||||||
|
|
@ -42,6 +47,7 @@
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
onContinue,
|
onContinue,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onForkConversation,
|
||||||
onNavigateToSibling,
|
onNavigateToSibling,
|
||||||
onShowDeleteDialogChange,
|
onShowDeleteDialogChange,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
|
|
@ -53,10 +59,27 @@
|
||||||
onRawOutputToggle
|
onRawOutputToggle
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
let showForkDialog = $state(false);
|
||||||
|
let forkName = $state('');
|
||||||
|
let forkIncludeAttachments = $state(true);
|
||||||
|
|
||||||
function handleConfirmDelete() {
|
function handleConfirmDelete() {
|
||||||
onConfirmDelete();
|
onConfirmDelete();
|
||||||
onShowDeleteDialogChange(false);
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-between">
|
<div class="relative {justify === 'start' ? 'mt-2' : ''} flex h-6 items-center justify-between">
|
||||||
|
|
@ -86,6 +109,10 @@
|
||||||
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
<ActionIcon icon={ArrowRight} tooltip="Continue" onclick={onContinue} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if onForkConversation}
|
||||||
|
<ActionIcon icon={GitBranch} tooltip="Fork conversation" onclick={handleOpenForkDialog} />
|
||||||
|
{/if}
|
||||||
|
|
||||||
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
<ActionIcon icon={Trash2} tooltip="Delete" onclick={onDelete} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -116,3 +143,42 @@
|
||||||
onConfirm={handleConfirmDelete}
|
onConfirm={handleConfirmDelete}
|
||||||
onCancel={() => onShowDeleteDialogChange(false)}
|
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>
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
onContinue?: () => void;
|
onContinue?: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit?: () => void;
|
onEdit?: () => void;
|
||||||
|
onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
|
||||||
onNavigateToSibling?: (siblingId: string) => void;
|
onNavigateToSibling?: (siblingId: string) => void;
|
||||||
onRegenerate: (modelOverride?: string) => void;
|
onRegenerate: (modelOverride?: string) => void;
|
||||||
onShowDeleteDialogChange: (show: boolean) => void;
|
onShowDeleteDialogChange: (show: boolean) => void;
|
||||||
|
|
@ -58,6 +59,7 @@
|
||||||
onCopy,
|
onCopy,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
onForkConversation,
|
||||||
onNavigateToSibling,
|
onNavigateToSibling,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onShowDeleteDialogChange,
|
onShowDeleteDialogChange,
|
||||||
|
|
@ -345,6 +347,7 @@
|
||||||
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
|
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
|
||||||
? onContinue
|
? onContinue
|
||||||
: undefined}
|
: undefined}
|
||||||
|
{onForkConversation}
|
||||||
{onDelete}
|
{onDelete}
|
||||||
{onConfirmDelete}
|
{onConfirmDelete}
|
||||||
{onNavigateToSibling}
|
{onNavigateToSibling}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onConfirmDelete: () => void;
|
onConfirmDelete: () => void;
|
||||||
|
onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
|
||||||
onShowDeleteDialogChange: (show: boolean) => void;
|
onShowDeleteDialogChange: (show: boolean) => void;
|
||||||
onNavigateToSibling?: (siblingId: string) => void;
|
onNavigateToSibling?: (siblingId: string) => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
|
|
@ -35,6 +36,7 @@
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onConfirmDelete,
|
onConfirmDelete,
|
||||||
|
onForkConversation,
|
||||||
onShowDeleteDialogChange,
|
onShowDeleteDialogChange,
|
||||||
onNavigateToSibling,
|
onNavigateToSibling,
|
||||||
onCopy
|
onCopy
|
||||||
|
|
@ -114,6 +116,7 @@
|
||||||
{onCopy}
|
{onCopy}
|
||||||
{onDelete}
|
{onDelete}
|
||||||
{onEdit}
|
{onEdit}
|
||||||
|
{onForkConversation}
|
||||||
{onNavigateToSibling}
|
{onNavigateToSibling}
|
||||||
{onShowDeleteDialogChange}
|
{onShowDeleteDialogChange}
|
||||||
{siblingInfo}
|
{siblingInfo}
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,13 @@
|
||||||
onUserAction?.();
|
onUserAction?.();
|
||||||
await chatStore.continueAssistantMessage(message.id);
|
await chatStore.continueAssistantMessage(message.id);
|
||||||
refreshAllMessages();
|
refreshAllMessages();
|
||||||
|
},
|
||||||
|
|
||||||
|
forkConversation: async (
|
||||||
|
message: DatabaseMessage,
|
||||||
|
options: { name: string; includeAttachments: boolean }
|
||||||
|
) => {
|
||||||
|
await conversationsStore.forkConversation(message.id, options);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings } from '@lucide/svelte';
|
import { Settings } from '@lucide/svelte';
|
||||||
import { DialogChatSettings } from '$lib/components/app';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { useSidebar } from '$lib/components/ui/sidebar';
|
import { useSidebar } from '$lib/components/ui/sidebar';
|
||||||
|
import { getChatSettingsDialogContext } from '$lib/contexts';
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
|
const chatSettingsDialog = getChatSettingsDialogContext();
|
||||||
let settingsOpen = $state(false);
|
|
||||||
|
|
||||||
function toggleSettings() {
|
|
||||||
settingsOpen = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
|
|
@ -22,12 +17,10 @@
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-lg"
|
size="icon-lg"
|
||||||
onclick={toggleSettings}
|
onclick={() => chatSettingsDialog.open()}
|
||||||
class="rounded-full backdrop-blur-lg"
|
class="rounded-full backdrop-blur-lg"
|
||||||
>
|
>
|
||||||
<Settings class="h-4 w-4" />
|
<Settings class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<DialogChatSettings open={settingsOpen} onOpenChange={(open) => (settingsOpen = open)} />
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Trash2 } from '@lucide/svelte';
|
import { Trash2, Pencil } from '@lucide/svelte';
|
||||||
import { ChatSidebarConversationItem, DialogConfirmation } from '$lib/components/app';
|
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 ScrollArea from '$lib/components/ui/scroll-area/scroll-area.svelte';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar';
|
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 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 { chatStore } from '$lib/stores/chat.svelte';
|
||||||
import { getPreviewText } from '$lib/utils';
|
import { getPreviewText } from '$lib/utils';
|
||||||
import ChatSidebarActions from './ChatSidebarActions.svelte';
|
import ChatSidebarActions from './ChatSidebarActions.svelte';
|
||||||
|
|
@ -18,6 +23,7 @@
|
||||||
let isSearchModeActive = $state(false);
|
let isSearchModeActive = $state(false);
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let showDeleteDialog = $state(false);
|
let showDeleteDialog = $state(false);
|
||||||
|
let deleteWithForks = $state(false);
|
||||||
let showEditDialog = $state(false);
|
let showEditDialog = $state(false);
|
||||||
let selectedConversation = $state<DatabaseConversation | null>(null);
|
let selectedConversation = $state<DatabaseConversation | null>(null);
|
||||||
let editedName = $state('');
|
let editedName = $state('');
|
||||||
|
|
@ -35,10 +41,30 @@
|
||||||
return conversations();
|
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) {
|
async function handleDeleteConversation(id: string) {
|
||||||
const conversation = conversations().find((conv) => conv.id === id);
|
const conversation = conversations().find((conv) => conv.id === id);
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
selectedConversation = conversation;
|
selectedConversation = conversation;
|
||||||
|
deleteWithForks = false;
|
||||||
showDeleteDialog = true;
|
showDeleteDialog = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -54,11 +80,14 @@
|
||||||
|
|
||||||
function handleConfirmDelete() {
|
function handleConfirmDelete() {
|
||||||
if (selectedConversation) {
|
if (selectedConversation) {
|
||||||
|
const convId = selectedConversation.id;
|
||||||
|
const withForks = deleteWithForks;
|
||||||
showDeleteDialog = false;
|
showDeleteDialog = false;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
conversationsStore.deleteConversation(selectedConversation.id);
|
conversationsStore.deleteConversation(convId, {
|
||||||
selectedConversation = null;
|
deleteWithForks: withForks
|
||||||
|
});
|
||||||
}, 100); // Wait for animation to finish
|
}, 100); // Wait for animation to finish
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -110,7 +139,7 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ScrollArea class="h-[100vh]">
|
<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}>
|
<a href="#/" onclick={handleMobileSidebarItemClick}>
|
||||||
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
|
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -118,7 +147,7 @@
|
||||||
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
|
<ChatSidebarActions {handleMobileSidebarItemClick} bind:isSearchModeActive bind:searchQuery />
|
||||||
</Sidebar.Header>
|
</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}
|
{#if (filteredConversations.length > 0 && isSearchModeActive) || !isSearchModeActive}
|
||||||
<Sidebar.GroupLabel>
|
<Sidebar.GroupLabel>
|
||||||
{isSearchModeActive ? 'Search results' : 'Conversations'}
|
{isSearchModeActive ? 'Search results' : 'Conversations'}
|
||||||
|
|
@ -127,15 +156,17 @@
|
||||||
|
|
||||||
<Sidebar.GroupContent>
|
<Sidebar.GroupContent>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
{#each filteredConversations as conversation (conversation.id)}
|
{#each conversationTree as { conversation, depth } (conversation.id)}
|
||||||
<Sidebar.MenuItem class="mb-1">
|
<Sidebar.MenuItem class="mb-1 p-0">
|
||||||
<ChatSidebarConversationItem
|
<ChatSidebarConversationItem
|
||||||
conversation={{
|
conversation={{
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
name: conversation.name,
|
name: conversation.name,
|
||||||
lastModified: conversation.lastModified,
|
lastModified: conversation.lastModified,
|
||||||
currNode: conversation.currNode
|
currNode: conversation.currNode,
|
||||||
|
forkedFromConversationId: conversation.forkedFromConversationId
|
||||||
}}
|
}}
|
||||||
|
{depth}
|
||||||
{handleMobileSidebarItemClick}
|
{handleMobileSidebarItemClick}
|
||||||
isActive={currentChatId === conversation.id}
|
isActive={currentChatId === conversation.id}
|
||||||
onSelect={selectConversation}
|
onSelect={selectConversation}
|
||||||
|
|
@ -146,7 +177,7 @@
|
||||||
</Sidebar.MenuItem>
|
</Sidebar.MenuItem>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
{#if filteredConversations.length === 0}
|
{#if conversationTree.length === 0}
|
||||||
<div class="px-2 py-4 text-center">
|
<div class="px-2 py-4 text-center">
|
||||||
<p class="mb-4 p-4 text-sm text-muted-foreground">
|
<p class="mb-4 p-4 text-sm text-muted-foreground">
|
||||||
{searchQuery.length > 0
|
{searchQuery.length > 0
|
||||||
|
|
@ -177,35 +208,40 @@
|
||||||
showDeleteDialog = false;
|
showDeleteDialog = false;
|
||||||
selectedConversation = null;
|
selectedConversation = null;
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{#if selectedConversationHasDescendants}
|
||||||
|
<div class="flex items-center gap-2 py-2">
|
||||||
|
<Checkbox id="delete-with-forks" bind:checked={deleteWithForks} />
|
||||||
|
|
||||||
<AlertDialog.Root bind:open={showEditDialog}>
|
<Label for="delete-with-forks" class="text-sm">Also delete all forked conversations</Label>
|
||||||
<AlertDialog.Content>
|
</div>
|
||||||
<AlertDialog.Header>
|
{/if}
|
||||||
<AlertDialog.Title>Edit Conversation Name</AlertDialog.Title>
|
</DialogConfirmation>
|
||||||
<AlertDialog.Description>
|
|
||||||
<Input
|
<DialogConfirmation
|
||||||
class="mt-4 text-foreground"
|
bind:open={showEditDialog}
|
||||||
onkeydown={(e) => {
|
title="Edit Conversation Name"
|
||||||
if (e.key === 'Enter') {
|
description=""
|
||||||
e.preventDefault();
|
confirmText="Save"
|
||||||
handleConfirmEdit();
|
cancelText="Cancel"
|
||||||
}
|
icon={Pencil}
|
||||||
}}
|
onConfirm={handleConfirmEdit}
|
||||||
placeholder="Enter a new name"
|
onCancel={() => {
|
||||||
type="text"
|
showEditDialog = false;
|
||||||
bind:value={editedName}
|
selectedConversation = null;
|
||||||
/>
|
}}
|
||||||
</AlertDialog.Description>
|
onKeydown={(e) => {
|
||||||
</AlertDialog.Header>
|
if (e.key === 'Enter') {
|
||||||
<AlertDialog.Footer>
|
e.preventDefault();
|
||||||
<AlertDialog.Cancel
|
e.stopImmediatePropagation();
|
||||||
onclick={() => {
|
handleConfirmEdit();
|
||||||
showEditDialog = false;
|
}
|
||||||
selectedConversation = null;
|
}}
|
||||||
}}>Cancel</AlertDialog.Cancel
|
>
|
||||||
>
|
<Input
|
||||||
<AlertDialog.Action onclick={handleConfirmEdit}>Save</AlertDialog.Action>
|
class="text-foreground"
|
||||||
</AlertDialog.Footer>
|
placeholder="Enter a new name"
|
||||||
</AlertDialog.Content>
|
type="text"
|
||||||
</AlertDialog.Root>
|
bind:value={editedName}
|
||||||
|
/>
|
||||||
|
</DialogConfirmation>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
import { KeyboardShortcutInfo } from '$lib/components/app';
|
import { KeyboardShortcutInfo } from '$lib/components/app';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
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 {
|
interface Props {
|
||||||
handleMobileSidebarItemClick: () => void;
|
handleMobileSidebarItemClick: () => void;
|
||||||
|
|
@ -18,6 +21,8 @@
|
||||||
|
|
||||||
let searchInput: HTMLInputElement | null = $state(null);
|
let searchInput: HTMLInputElement | null = $state(null);
|
||||||
|
|
||||||
|
const chatSettingsDialog = getChatSettingsDialogContext();
|
||||||
|
|
||||||
function handleSearchModeDeactivate() {
|
function handleSearchModeDeactivate() {
|
||||||
isSearchModeActive = false;
|
isSearchModeActive = false;
|
||||||
searchQuery = '';
|
searchQuery = '';
|
||||||
|
|
@ -30,7 +35,7 @@
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-0.5">
|
<div class="my-1 space-y-1">
|
||||||
{#if isSearchModeActive}
|
{#if isSearchModeActive}
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
<Search class="absolute top-2.5 left-2 h-4 w-4 text-muted-foreground" />
|
||||||
|
|
@ -50,13 +55,14 @@
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<Button
|
<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#/"
|
href="?new_chat=true#/"
|
||||||
onclick={handleMobileSidebarItemClick}
|
onclick={handleMobileSidebarItemClick}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<SquarePen class="h-4 w-4" />
|
<SquarePen class="h-4 w-4" />
|
||||||
|
|
||||||
New chat
|
New chat
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -64,7 +70,7 @@
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
class="w-full justify-between hover:[&>kbd]:opacity-100"
|
class="w-full justify-between backdrop-blur-none! hover:[&>kbd]:opacity-100"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
isSearchModeActive = true;
|
isSearchModeActive = true;
|
||||||
}}
|
}}
|
||||||
|
|
@ -72,10 +78,25 @@
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Search class="h-4 w-4" />
|
<Search class="h-4 w-4" />
|
||||||
Search conversations
|
|
||||||
|
Search
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<KeyboardShortcutInfo keys={['cmd', 'k']} />
|
<KeyboardShortcutInfo keys={['cmd', 'k']} />
|
||||||
</Button>
|
</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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,23 @@
|
||||||
<script lang="ts">
|
<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 { DropdownMenuActions } from '$lib/components/app';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import { FORK_TREE_DEPTH_PADDING } from '$lib/constants';
|
||||||
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
import { getAllLoadingChats } from '$lib/stores/chat.svelte';
|
||||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
depth?: number;
|
||||||
conversation: DatabaseConversation;
|
conversation: DatabaseConversation;
|
||||||
handleMobileSidebarItemClick?: () => void;
|
handleMobileSidebarItemClick?: () => void;
|
||||||
onDelete?: (id: string) => void;
|
onDelete?: (id: string) => void;
|
||||||
|
|
@ -23,7 +33,8 @@
|
||||||
onEdit,
|
onEdit,
|
||||||
onSelect,
|
onSelect,
|
||||||
onStop,
|
onStop,
|
||||||
isActive = false
|
isActive = false,
|
||||||
|
depth = 0
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let renderActionsDropdown = $state(false);
|
let renderActionsDropdown = $state(false);
|
||||||
|
|
@ -88,14 +99,34 @@
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
<!-- svelte-ignore a11y_mouse_events_have_key_events -->
|
||||||
<button
|
<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'
|
? 'bg-foreground/5 text-accent-foreground'
|
||||||
: ''}"
|
: ''} px-3"
|
||||||
onclick={handleSelect}
|
onclick={handleSelect}
|
||||||
onmouseover={handleMouseOver}
|
onmouseover={handleMouseOver}
|
||||||
onmouseleave={handleMouseLeave}
|
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}
|
{#if isLoading}
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger>
|
<Tooltip.Trigger>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
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';
|
import { KeyboardKey } from '$lib/enums';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onKeydown?: (event: KeyboardEvent) => void;
|
onKeydown?: (event: KeyboardEvent) => void;
|
||||||
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -26,7 +27,8 @@
|
||||||
icon,
|
icon,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
onKeydown
|
onKeydown,
|
||||||
|
children
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
|
@ -60,6 +62,10 @@
|
||||||
</AlertDialog.Description>
|
</AlertDialog.Description>
|
||||||
</AlertDialog.Header>
|
</AlertDialog.Header>
|
||||||
|
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<AlertDialog.Footer>
|
<AlertDialog.Footer>
|
||||||
<AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
|
<AlertDialog.Cancel onclick={onCancel}>{cancelText}</AlertDialog.Cancel>
|
||||||
<AlertDialog.Action
|
<AlertDialog.Action
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -10,6 +10,7 @@ export * from './cache';
|
||||||
export * from './chat-form';
|
export * from './chat-form';
|
||||||
export * from './code-blocks';
|
export * from './code-blocks';
|
||||||
export * from './code';
|
export * from './code';
|
||||||
|
export * from './context-keys';
|
||||||
export * from './css-classes';
|
export * from './css-classes';
|
||||||
export * from './favicon';
|
export * from './favicon';
|
||||||
export * from './floating-ui-constraints';
|
export * from './floating-ui-constraints';
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
|
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
|
import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants';
|
||||||
|
|
||||||
export interface ChatActionsContext {
|
export interface ChatActionsContext {
|
||||||
copy: (message: DatabaseMessage) => void;
|
copy: (message: DatabaseMessage) => void;
|
||||||
|
|
@ -21,9 +22,13 @@ export interface ChatActionsContext {
|
||||||
) => void;
|
) => void;
|
||||||
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
|
regenerateWithBranching: (message: DatabaseMessage, modelOverride?: string) => void;
|
||||||
continueAssistantMessage: (message: DatabaseMessage) => 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 {
|
export function setChatActionsContext(ctx: ChatActionsContext): ChatActionsContext {
|
||||||
return setContext(CHAT_ACTIONS_KEY, ctx);
|
return setContext(CHAT_ACTIONS_KEY, ctx);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -11,3 +11,9 @@ export {
|
||||||
setChatActionsContext,
|
setChatActionsContext,
|
||||||
type ChatActionsContext
|
type ChatActionsContext
|
||||||
} from './chat-actions.context';
|
} from './chat-actions.context';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getChatSettingsDialogContext,
|
||||||
|
setChatSettingsDialogContext,
|
||||||
|
type ChatSettingsDialogContext
|
||||||
|
} from './chat-settings-dialog.context';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
|
import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants';
|
||||||
|
|
||||||
export interface MessageEditState {
|
export interface MessageEditState {
|
||||||
readonly isEditing: boolean;
|
readonly isEditing: boolean;
|
||||||
|
|
@ -22,7 +23,7 @@ export interface MessageEditActions {
|
||||||
|
|
||||||
export type MessageEditContext = MessageEditState & 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).
|
* Sets the message edit context. Call this in the parent component (ChatMessage.svelte).
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import Dexie, { type EntityTable } from 'dexie';
|
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 {
|
class LlamacppDatabase extends Dexie {
|
||||||
conversations!: EntityTable<DatabaseConversation, string>;
|
conversations!: EntityTable<DatabaseConversation, string>;
|
||||||
|
|
@ -173,8 +174,47 @@ export class DatabaseService {
|
||||||
*
|
*
|
||||||
* @param id - Conversation ID
|
* @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 () => {
|
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.conversations.delete(id);
|
||||||
await db.messages.where('convId').equals(id).delete();
|
await db.messages.where('convId').equals(id).delete();
|
||||||
});
|
});
|
||||||
|
|
@ -364,4 +404,88 @@ export class DatabaseService {
|
||||||
return { imported: importedCount, skipped: skippedCount };
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1265,35 +1265,53 @@ class ChatStore {
|
||||||
let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
|
let result = this.getMessageByIdWithRole(messageId, MessageRole.USER);
|
||||||
if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
|
if (!result) result = this.getMessageByIdWithRole(messageId, MessageRole.SYSTEM);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
const { message: msg } = result;
|
const { message: msg, index: idx } = result;
|
||||||
try {
|
try {
|
||||||
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
|
const allMessages = await conversationsStore.getConversationMessages(activeConv.id);
|
||||||
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
const rootMessage = allMessages.find((m) => m.type === 'root' && m.parent === null);
|
||||||
const isFirstUserMessage =
|
const isFirstUserMessage =
|
||||||
msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
|
msg.role === MessageRole.USER && rootMessage && msg.parent === rootMessage.id;
|
||||||
const parentId = msg.parent || rootMessage?.id;
|
|
||||||
if (!parentId) return;
|
|
||||||
const extrasToUse =
|
const extrasToUse =
|
||||||
newExtras !== undefined
|
newExtras !== undefined
|
||||||
? JSON.parse(JSON.stringify(newExtras))
|
? JSON.parse(JSON.stringify(newExtras))
|
||||||
: msg.extra
|
: msg.extra
|
||||||
? JSON.parse(JSON.stringify(msg.extra))
|
? JSON.parse(JSON.stringify(msg.extra))
|
||||||
: undefined;
|
: undefined;
|
||||||
const newMessage = await DatabaseService.createMessageBranch(
|
|
||||||
{
|
let messageIdForResponse: string;
|
||||||
convId: msg.convId,
|
|
||||||
type: msg.type,
|
if (msg.children.length === 0) {
|
||||||
timestamp: Date.now(),
|
// No responses after this message — update in place instead of branching
|
||||||
role: msg.role,
|
const updates: Partial<DatabaseMessage> = {
|
||||||
content: newContent,
|
content: newContent,
|
||||||
toolCalls: msg.toolCalls || '',
|
timestamp: Date.now(),
|
||||||
children: [],
|
extra: extrasToUse
|
||||||
extra: extrasToUse,
|
};
|
||||||
model: msg.model
|
await DatabaseService.updateMessage(msg.id, updates);
|
||||||
},
|
conversationsStore.updateMessageAtIndex(idx, updates);
|
||||||
parentId
|
messageIdForResponse = msg.id;
|
||||||
);
|
} else {
|
||||||
await conversationsStore.updateCurrentNode(newMessage.id);
|
// 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();
|
conversationsStore.updateConversationTimestamp();
|
||||||
if (isFirstUserMessage && newContent.trim())
|
if (isFirstUserMessage && newContent.trim())
|
||||||
await conversationsStore.updateConversationTitleWithConfirmation(
|
await conversationsStore.updateConversationTitleWithConfirmation(
|
||||||
|
|
@ -1301,7 +1319,8 @@ class ChatStore {
|
||||||
newContent.trim()
|
newContent.trim()
|
||||||
);
|
);
|
||||||
await conversationsStore.refreshActiveMessages();
|
await conversationsStore.refreshActiveMessages();
|
||||||
if (msg.role === MessageRole.USER) await this.generateResponseForMessage(newMessage.id);
|
if (msg.role === MessageRole.USER)
|
||||||
|
await this.generateResponseForMessage(messageIdForResponse);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to edit message with branching:', error);
|
console.error('Failed to edit message with branching:', error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,12 @@ import {
|
||||||
MULTIPLE_UNDERSCORE_REGEX,
|
MULTIPLE_UNDERSCORE_REGEX,
|
||||||
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY
|
MCP_DEFAULT_ENABLED_LOCALSTORAGE_KEY
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
|
|
||||||
|
export interface ConversationTreeItem {
|
||||||
|
conversation: DatabaseConversation;
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
class ConversationsStore {
|
class ConversationsStore {
|
||||||
/**
|
/**
|
||||||
|
|
@ -300,15 +306,45 @@ class ConversationsStore {
|
||||||
* Deletes a conversation and all its messages
|
* Deletes a conversation and all its messages
|
||||||
* @param convId - The conversation ID to delete
|
* @param convId - The conversation ID to delete
|
||||||
*/
|
*/
|
||||||
async deleteConversation(convId: string): Promise<void> {
|
async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await DatabaseService.deleteConversation(convId);
|
await DatabaseService.deleteConversation(convId, options);
|
||||||
|
|
||||||
this.conversations = this.conversations.filter((c) => c.id !== convId);
|
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) {
|
if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) {
|
||||||
this.clearActiveConversation();
|
this.clearActiveConversation();
|
||||||
await goto(`?new_chat=true#/`);
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to delete conversation:', error);
|
console.error('Failed to delete conversation:', error);
|
||||||
|
|
@ -658,6 +694,42 @@ class ConversationsStore {
|
||||||
this.saveMcpDefaults();
|
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 activeConversation = () => conversationsStore.activeConversation;
|
||||||
export const activeMessages = () => conversationsStore.activeMessages;
|
export const activeMessages = () => conversationsStore.activeMessages;
|
||||||
export const isConversationsInitialized = () => conversationsStore.isInitialized;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export interface DatabaseConversation {
|
||||||
lastModified: number;
|
lastModified: number;
|
||||||
name: string;
|
name: string;
|
||||||
mcpServerOverrides?: McpServerOverride[];
|
mcpServerOverrides?: McpServerOverride[];
|
||||||
|
forkedFromConversationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseMessageExtraAudioFile {
|
export interface DatabaseMessageExtraAudioFile {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,11 @@
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { untrack } from 'svelte';
|
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 { isLoading } from '$lib/stores/chat.svelte';
|
||||||
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
|
|
@ -17,8 +21,10 @@
|
||||||
import { modelsStore } from '$lib/stores/models.svelte';
|
import { modelsStore } from '$lib/stores/models.svelte';
|
||||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||||
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
import { TOOLTIP_DELAY_DURATION } from '$lib/constants';
|
||||||
|
import type { SettingsSectionTitle } from '$lib/constants';
|
||||||
import { KeyboardKey } from '$lib/enums';
|
import { KeyboardKey } from '$lib/enums';
|
||||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||||
|
import { setChatSettingsDialogContext } from '$lib/contexts';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
|
@ -42,6 +48,16 @@
|
||||||
let titleUpdateNewTitle = $state('');
|
let titleUpdateNewTitle = $state('');
|
||||||
let titleUpdateResolve: ((value: boolean) => void) | null = null;
|
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
|
// Global keyboard shortcuts
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
const isCtrlOrCmd = event.ctrlKey || event.metaKey;
|
||||||
|
|
@ -213,6 +229,12 @@
|
||||||
|
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
|
|
||||||
|
<DialogChatSettings
|
||||||
|
open={chatSettingsDialogOpen}
|
||||||
|
onOpenChange={(open) => (chatSettingsDialogOpen = open)}
|
||||||
|
initialSection={chatSettingsDialogInitialSection}
|
||||||
|
/>
|
||||||
|
|
||||||
<DialogConversationTitleUpdate
|
<DialogConversationTitleUpdate
|
||||||
bind:open={titleUpdateDialogOpen}
|
bind:open={titleUpdateDialogOpen}
|
||||||
currentTitle={titleUpdateCurrentTitle}
|
currentTitle={titleUpdateCurrentTitle}
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@
|
||||||
conversationsStore.conversations = mockConversations;
|
conversationsStore.conversations = mockConversations;
|
||||||
}, 0));
|
}, 0));
|
||||||
|
|
||||||
const searchTrigger = screen.getByText('Search conversations');
|
const searchTrigger = screen.getByText('Search');
|
||||||
userEvent.click(searchTrigger);
|
userEvent.click(searchTrigger);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue