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}
-/>
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}
<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;
onEdit?: () => void;
onRegenerate?: () => void;
onContinue?: () => void;
+ onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
onDelete: () => void;
onConfirmDelete: () => void;
onNavigateToSibling?: (siblingId: string) => void;
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>
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;
onCopy,
onDelete,
onEdit,
+ onForkConversation,
onNavigateToSibling,
onRegenerate,
onShowDeleteDialogChange,
onContinue={currentConfig.enableContinueGeneration && !hasReasoningMarkers
? onContinue
: undefined}
+ {onForkConversation}
{onDelete}
{onConfirmDelete}
{onNavigateToSibling}
onEdit: () => void;
onDelete: () => void;
onConfirmDelete: () => void;
+ onForkConversation?: (options: { name: string; includeAttachments: boolean }) => void;
onShowDeleteDialogChange: (show: boolean) => void;
onNavigateToSibling?: (siblingId: string) => void;
onCopy: () => void;
onEdit,
onDelete,
onConfirmDelete,
+ onForkConversation,
onShowDeleteDialogChange,
onNavigateToSibling,
onCopy
{onCopy}
{onDelete}
{onEdit}
+ {onForkConversation}
{onNavigateToSibling}
{onShowDeleteDialogChange}
{siblingInfo}
onUserAction?.();
await chatStore.continueAssistantMessage(message.id);
refreshAllMessages();
+ },
+
+ forkConversation: async (
+ message: DatabaseMessage,
+ options: { name: string; includeAttachments: boolean }
+ ) => {
+ await conversationsStore.forkConversation(message.id, options);
}
});
<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)} />
<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';
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>
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;
let searchInput: HTMLInputElement | null = $state(null);
+ const chatSettingsDialog = getChatSettingsDialogContext();
+
function handleSearchModeDeactivate() {
isSearchModeActive = false;
searchQuery = '';
});
</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>
</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>
<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;
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>
<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 {
onConfirm: () => void;
onCancel: () => void;
onKeydown?: (event: KeyboardEvent) => void;
+ children?: Snippet;
}
let {
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
--- /dev/null
+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';
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';
+export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
import { getContext, setContext } from 'svelte';
+import { CONTEXT_KEY_CHAT_ACTIONS } from '$lib/constants';
export interface ChatActionsContext {
copy: (message: DatabaseMessage) => void;
) => 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);
--- /dev/null
+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);
+}
setChatActionsContext,
type ChatActionsContext
} from './chat-actions.context';
+
+export {
+ getChatSettingsDialogContext,
+ setChatSettingsDialogContext,
+ type ChatSettingsDialogContext
+} from './chat-settings-dialog.context';
import { getContext, setContext } from 'svelte';
+import { CONTEXT_KEY_MESSAGE_EDIT } from '$lib/constants';
export interface MessageEditState {
readonly isEditing: boolean;
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).
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>;
*
* @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();
});
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;
+ });
+ }
}
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(
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);
}
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 {
/**
* 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);
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;
+ }
+ }
+
/**
*
*
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;
+}
lastModified: number;
name: string;
mcpServerOverrides?: McpServerOverride[];
+ forkedFromConversationId?: string;
}
export interface DatabaseMessageExtraAudioFile {
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}
conversationsStore.conversations = mockConversations;
}, 0));
- const searchTrigger = screen.getByText('Search conversations');
+ const searchTrigger = screen.getByText('Search');
userEvent.click(searchTrigger);
}}
>