]> git.djapps.eu Git - pkg/ggml/sources/llama.cpp/commitdiff
ui: IndexedDB and Conversations data fixes (#26278)
authorAleksander Grygier <redacted>
Thu, 30 Jul 2026 08:10:37 +0000 (10:10 +0200)
committerGitHub <redacted>
Thu, 30 Jul 2026 08:10:37 +0000 (10:10 +0200)
* fix: single-flight conversations store init

* refactor: remove unused legacy-migration util

* fix: make createSystemMessage transactional

* fix: delete message branches cascading on edit/regenerate

* fix: stop stamping lastModified on conversation metadata updates

* fix: count cascaded forks in bulk delete toast, bulkify deleteAll

* refactor: drop redundant conversation list respreads

* refactor: create conversation in a single write

* fix: use table constant in toggleConversationPin

* fix: keep the system message placeholder out of the edit form

* fix: keep focus in the system message editor after opening it

* fix: focus the main chat form after submitting a system message

* fix: update timestamp of the correct conversation on stream completion

tools/ui/src/lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddDropdown.svelte
tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte
tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenForm.svelte
tools/ui/src/lib/services/database.service.ts
tools/ui/src/lib/stores/chat.svelte.ts
tools/ui/src/lib/stores/conversations.svelte.ts
tools/ui/src/lib/utils/legacy-migration.ts [deleted file]

index 905c2fe6f05b9bc7938308043c66a54b84ba380d..f81dcf09c06a7eec73aac26854dddb11157a44e3 100644 (file)
@@ -48,6 +48,9 @@
        }: Props = $props();
 
        let dropdownOpen = $state(false);
+       // The system message action moves focus to the message editor, so the menu
+       // must not restore focus to the trigger on close
+       let suppressCloseAutoFocus = false;
 
        function handleMcpSettingsClick() {
                dropdownOpen = false;
                        </Tooltip.Content>
                </Tooltip.Root>
 
-               <DropdownMenu.Content align="start" class="w-52">
+               <DropdownMenu.Content
+                       align="start"
+                       class="w-52"
+                       onCloseAutoFocus={(e) => {
+                               if (suppressCloseAutoFocus) {
+                                       suppressCloseAutoFocus = false;
+                                       e.preventDefault();
+                               }
+                       }}
+               >
                        <ChatFormActionAddReasoningSubmenu />
 
                        <DropdownMenu.Separator />
 
                        <DropdownMenu.Item
                                class="flex cursor-pointer items-center gap-2"
-                               onclick={onSystemPromptClick}
+                               onclick={() => {
+                                       suppressCloseAutoFocus = true;
+                                       onSystemPromptClick?.();
+                               }}
                        >
                                <MessageSquare class={ICON_CLASS_DEFAULT} />
 
index 8e8a14ac31bc0739d28c42224adcf62a1d062f98..b8068f790734b37992a87f55cee88a6fc0d04af2 100644 (file)
@@ -2,6 +2,7 @@
        import { goto } from '$app/navigation';
        import { getChatActionsContext, setMessageEditContext } from '$lib/contexts';
        import { chatStore, pendingEditMessageId } from '$lib/stores/chat.svelte';
+       import { isMobile } from '$lib/stores/viewport.svelte';
        import { conversationsStore } from '$lib/stores/conversations.svelte';
        import { DatabaseService } from '$lib/services/database.service';
        import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
                assistantMessages: number;
                messageTypes: string[];
        } | null>(null);
-       let editedContent = $derived(message.content);
+       // The system message placeholder must never surface as editable content; keeping
+       // it in the derived (not just in handleEdit) guards against prop invalidation
+       // reverting the override while editing
+       let editedContent = $derived(
+               message.role === MessageRole.SYSTEM && message.content === SYSTEM_MESSAGE_PLACEHOLDER
+                       ? ''
+                       : message.content
+       );
 
        let rawEditContent = $derived.by(() => {
                if (message.role !== MessageRole.ASSISTANT) return undefined;
                chatActions.navigateToSibling(siblingId);
        }
 
+       // After the system message flow ends, hand focus to the main chat form
+       function focusMainChatForm() {
+               if (isMobile.current) return;
+               document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
+       }
+
        async function handleSaveEdit() {
                if (message.role === MessageRole.SYSTEM) {
                        // System messages: update in place without branching
                                isEditing = false;
                                if (conversationDeleted) {
                                        goto(ROUTES.START);
+                               } else {
+                                       focusMainChatForm();
                                }
                                return;
                        }
                        if (index !== -1) {
                                conversationsStore.updateMessageAtIndex(index, { content: newContent });
                        }
+                       focusMainChatForm();
                } else if (message.role === MessageRole.USER) {
                        const finalExtras = await getMergedExtras();
                        chatActions.editWithBranching(message, editedContent.trim(), finalExtras);
index 600180742a42dadf90e6ce9c32f8daad7d38b9fd..8eb17eeae485c412fab2c8d1cbbfd0707b6cd6d6 100644 (file)
                onFileRemove?.(fileId);
        }
 
+       // Auto-focus must not steal focus already claimed elsewhere (e.g. the system
+       // message editor opened just before a navigation)
+       function focusFormUnlessCaptured() {
+               const active = document.activeElement;
+               if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) return;
+               chatFormRef?.focus();
+       }
+
        onMount(() => {
                if (!isMobile.current) {
-                       setTimeout(() => chatFormRef?.focus(), 100);
+                       setTimeout(focusFormUnlessCaptured, 100);
                }
        });
 
        afterNavigate((navigation) => {
                if (navigation?.from != null && !isMobile.current) {
-                       setTimeout(() => chatFormRef?.focus(), 100);
+                       setTimeout(focusFormUnlessCaptured, 100);
                }
        });
 
 
        $effect(() => {
                if (previousIsLoading && !isLoading) {
-                       setTimeout(() => chatFormRef?.focus(), 10);
+                       setTimeout(focusFormUnlessCaptured, 10);
                }
 
                previousIsLoading = isLoading;
index bc65caaca3865c4435f441bbb1b4d0a7ef6f0c5f..0a7c59b9c8c5d221f3753c63f8c4f2dd6686f7ba 100644 (file)
@@ -31,14 +31,19 @@ export class DatabaseService {
         * Creates a new conversation.
         *
         * @param name - Name of the conversation
+        * @param fields - Optional extra fields (e.g. reasoningEffort)
         * @returns The created conversation
         */
-       static async createConversation(name: string): Promise<DatabaseConversation> {
+       static async createConversation(
+               name: string,
+               fields?: Partial<Omit<DatabaseConversation, 'id' | 'name' | 'lastModified'>>
+       ): Promise<DatabaseConversation> {
                const conversation: DatabaseConversation = {
                        id: uuid(),
                        name,
                        lastModified: Date.now(),
-                       currNode: ''
+                       currNode: '',
+                       ...fields
                };
 
                await db[IDXDB_TABLES.conversations].add(conversation);
@@ -137,7 +142,7 @@ export class DatabaseService {
         * @param systemPrompt - The system prompt content (must be non-empty)
         * @param parentId - Parent message ID (typically the root message)
         * @returns The created system message
-        * @throws Error if systemPrompt is empty
+        * @throws Error if systemPrompt is empty or the parent message does not exist
         */
        static async createSystemMessage(
                convId: string,
@@ -149,27 +154,30 @@ export class DatabaseService {
                        throw new Error('Cannot create system message with empty content');
                }
 
-               const systemMessage: DatabaseMessage = {
-                       id: uuid(),
-                       convId,
-                       type: MessageRole.SYSTEM,
-                       timestamp: Date.now(),
-                       role: MessageRole.SYSTEM,
-                       content: trimmedPrompt,
-                       parent: parentId,
-                       children: []
-               };
-
-               await db[IDXDB_TABLES.messages].add(systemMessage);
+               return await db.transaction('rw', db[IDXDB_TABLES.messages], async () => {
+                       const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
+                       if (!parentMessage) {
+                               throw new Error(`Parent message ${parentId} not found`);
+                       }
 
-               const parentMessage = await db[IDXDB_TABLES.messages].get(parentId);
-               if (parentMessage) {
+                       const systemMessage: DatabaseMessage = {
+                               id: uuid(),
+                               convId,
+                               type: MessageRole.SYSTEM,
+                               timestamp: Date.now(),
+                               role: MessageRole.SYSTEM,
+                               content: trimmedPrompt,
+                               parent: parentId,
+                               children: []
+                       };
+
+                       await db[IDXDB_TABLES.messages].add(systemMessage);
                        await db[IDXDB_TABLES.messages].update(parentId, {
                                children: [...parentMessage.children, systemMessage.id]
                        });
-               }
 
-               return systemMessage;
+                       return systemMessage;
+               });
        }
 
        /**
@@ -442,7 +450,8 @@ export class DatabaseService {
        }
 
        /**
-        * Updates a conversation.
+        * Updates a conversation. `lastModified` is never stamped implicitly;
+        * pass it in `updates` to bump the conversation in recency ordering.
         *
         * @param id - Conversation ID
         * @param updates - Partial updates to apply
@@ -452,10 +461,7 @@ export class DatabaseService {
                id: string,
                updates: Partial<Omit<DatabaseConversation, 'id'>>
        ): Promise<void> {
-               await db[IDXDB_TABLES.conversations].update(id, {
-                       ...updates,
-                       lastModified: Date.now()
-               });
+               await db[IDXDB_TABLES.conversations].update(id, updates);
        }
 
        /**
@@ -473,7 +479,7 @@ export class DatabaseService {
         * @returns The new pinned status
         */
        static async toggleConversationPin(id: string): Promise<boolean> {
-               const conversation = await db.conversations.get(id);
+               const conversation = await db[IDXDB_TABLES.conversations].get(id);
                if (!conversation) {
                        throw new Error(`Conversation ${id} not found`);
                }
@@ -497,7 +503,6 @@ export class DatabaseService {
                const result = new Map<string, boolean>();
                if (cleanIds.length === 0) return result;
 
-               const now = Date.now();
                await db.transaction('rw', db[IDXDB_TABLES.conversations], async () => {
                        const convs = await db[IDXDB_TABLES.conversations].bulkGet(cleanIds);
                        const updates: DatabaseConversation[] = [];
@@ -505,7 +510,7 @@ export class DatabaseService {
                                const conv = convs[i];
                                if (!conv) continue;
                                const newPinned = !conv.pinned;
-                               updates.push({ ...conv, pinned: newPinned, lastModified: now });
+                               updates.push({ ...conv, pinned: newPinned });
                                result.set(cleanIds[i], newPinned);
                        }
                        if (updates.length === 0) return;
index 222723ab108d8ddef907d41ff7a8bb303b2843af..935ed8e165da695645c045dde281b36328f49f7f 100644 (file)
@@ -1658,7 +1658,8 @@ class ChatStore {
                                        generateConversationTitle(newContent, Boolean(config().titleGenerationUseFirstLine))
                                );
                        const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex + 1);
-                       for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
+                       if (messagesToRemove.length > 0)
+                               await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
                        conversationsStore.sliceActiveMessages(messageIndex + 1);
                        conversationsStore.updateConversationTimestamp();
                        this.setChatLoading(activeConv.id, true);
@@ -1690,7 +1691,7 @@ class ChatStore {
                const { index: messageIndex } = result;
                try {
                        const messagesToRemove = conversationsStore.activeMessages.slice(messageIndex);
-                       for (const message of messagesToRemove) await DatabaseService.deleteMessage(message.id);
+                       await DatabaseService.deleteMessageCascading(activeConv.id, messagesToRemove[0].id);
                        conversationsStore.sliceActiveMessages(messageIndex);
                        conversationsStore.updateConversationTimestamp();
                        this.setChatLoading(activeConv.id, true);
@@ -2037,7 +2038,7 @@ class ChatStore {
                                                        timings
                                                });
 
-                                               conversationsStore.updateConversationTimestamp();
+                                               conversationsStore.updateConversationTimestamp(msg.convId);
 
                                                this.setChatLoading(msg.convId, false);
                                                this.clearChatStreaming(msg.convId);
index e467c8fad973d31cb8775b5ffb373ff591e1dff6..1a1157c72c2225b047f99bc48b8f9c5eefd9257a 100644 (file)
@@ -111,6 +111,9 @@ class ConversationsStore {
                | ((messageId: string, updates: Partial<DatabaseMessage>) => void)
                | null = null;
 
+       /** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
+       private initPromise: Promise<void> | null = null;
+
        /**
         *
         *
@@ -121,19 +124,25 @@ class ConversationsStore {
 
        /**
         * Initialize the store by loading conversations from database.
-        * Must be called once after app startup.
+        * Safe to call multiple times: concurrent callers share a single run,
+        * and a failed run can be retried by calling again.
         */
-       async init(): Promise<void> {
-               if (!browser) return;
-               if (this.isInitialized) return;
+       init(): Promise<void> {
+               if (!browser) return Promise.resolve();
+               if (this.initPromise) return this.initPromise;
+
+               this.initPromise = (async () => {
+                       try {
+                               await MigrationService.runAllMigrations();
+                               await this.loadConversations();
+                               this.isInitialized = true;
+                       } catch (error) {
+                               console.error('Failed to initialize conversations:', error);
+                               this.initPromise = null;
+                       }
+               })();
 
-               try {
-                       await MigrationService.runAllMigrations();
-                       await this.loadConversations();
-                       this.isInitialized = true;
-               } catch (error) {
-                       console.error('Failed to initialize conversations:', error);
-               }
+               return this.initPromise;
        }
 
        /**
@@ -237,15 +246,11 @@ class ConversationsStore {
         */
        async createConversation(name?: string): Promise<string> {
                const conversationName = name || `Chat ${new Date().toLocaleString()}`;
-               const conversation = await DatabaseService.createConversation(conversationName);
 
                // No MCP override list is seeded: getAllMcpServerOverrides resolves
                // servers without a per-conversation override to `mcpServers[i].enabled`,
                // and only explicit toggles are stored on the conversation.
-
-               // Inherit the global reasoning default into the new conversation
-               conversation.reasoningEffort = this.pendingReasoningEffort;
-               await DatabaseService.updateConversation(conversation.id, {
+               const conversation = await DatabaseService.createConversation(conversationName, {
                        reasoningEffort: this.pendingReasoningEffort
                });
 
@@ -358,10 +363,7 @@ class ConversationsStore {
        async deleteAll(): Promise<void> {
                try {
                        const allConversations = await DatabaseService.getAllConversations();
-
-                       for (const conv of allConversations) {
-                               await DatabaseService.deleteConversation(conv.id);
-                       }
+                       await DatabaseService.bulkDeleteConversations(allConversations.map((c) => c.id));
 
                        this.clearActiveConversation();
                        this.conversations = [];
@@ -412,7 +414,9 @@ class ConversationsStore {
                        }
 
                        toast.success(
-                               convIds.length === 1 ? 'Conversation deleted' : `${convIds.length} conversations deleted`
+                               idsToRemove.size === 1
+                                       ? 'Conversation deleted'
+                                       : `${idsToRemove.size} conversations deleted`
                        );
                } catch (error) {
                        console.error('Failed to bulk delete conversations:', error);
@@ -443,7 +447,6 @@ class ConversationsStore {
                                const newPinned = updates.get(this.conversations[i].id);
                                if (newPinned !== undefined) this.conversations[i].pinned = newPinned;
                        }
-                       this.conversations = [...this.conversations];
 
                        toast.success(
                                convIds.length === 1
@@ -552,7 +555,6 @@ class ConversationsStore {
 
                        if (convIndex !== -1) {
                                this.conversations[convIndex].name = name;
-                               this.conversations = [...this.conversations];
                        }
 
                        if (this.activeConversation?.id === convId) {
@@ -576,7 +578,6 @@ class ConversationsStore {
 
                        if (convIndex !== -1) {
                                this.conversations[convIndex].pinned = newPinnedState;
-                               this.conversations = [...this.conversations];
                        }
 
                        if (this.activeConversation?.id === convId) {
@@ -591,18 +592,33 @@ class ConversationsStore {
        }
 
        /**
-        * Updates conversation lastModified timestamp and moves it to top of list
+        * Marks a conversation as recently active: stamps lastModified (persisted)
+        * and moves it to the top of the list. Only message-activity flows call
+        * this; metadata updates (rename, pin, settings) do not.
+        *
+        * @param convId - Conversation that produced the activity, defaults to the active one
         */
-       updateConversationTimestamp(): void {
-               if (!this.activeConversation) return;
+       updateConversationTimestamp(convId?: string): void {
+               const targetId = convId ?? this.activeConversation?.id;
+               if (!targetId) return;
 
-               const chatIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
+               const now = Date.now();
+
+               const chatIndex = this.conversations.findIndex((c) => c.id === targetId);
 
                if (chatIndex !== -1) {
-                       this.conversations[chatIndex].lastModified = Date.now();
+                       this.conversations[chatIndex].lastModified = now;
                        const updatedConv = this.conversations.splice(chatIndex, 1)[0];
                        this.conversations = [updatedConv, ...this.conversations];
                }
+
+               if (this.activeConversation?.id === targetId) {
+                       this.activeConversation = { ...this.activeConversation, lastModified: now };
+               }
+
+               DatabaseService.updateConversation(targetId, { lastModified: now }).catch((error) =>
+                       console.error('Failed to update conversation timestamp:', error)
+               );
        }
 
        /**
@@ -773,7 +789,6 @@ class ConversationsStore {
                if (convIndex !== -1) {
                        this.conversations[convIndex].mcpServerOverrides =
                                newOverrides.length > 0 ? newOverrides : undefined;
-                       this.conversations = [...this.conversations];
                }
        }
 
@@ -837,7 +852,6 @@ class ConversationsStore {
                const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
                if (convIndex !== -1) {
                        this.conversations[convIndex].reasoningEffort = effort;
-                       this.conversations = [...this.conversations];
                }
        }
 
diff --git a/tools/ui/src/lib/utils/legacy-migration.ts b/tools/ui/src/lib/utils/legacy-migration.ts
deleted file mode 100644 (file)
index 6b0890a..0000000
+++ /dev/null
@@ -1,364 +0,0 @@
-/**
- * @deprecated Legacy migration utility — remove at some point in the future once all users have migrated to the new structured agentic message format.
- *
- * Converts old marker-based agentic messages to the new structured format
- * with separate messages per turn.
- *
- * Old format: Single assistant message with markers in content:
- *   <<<reasoning_content_start>>>...<<<reasoning_content_end>>>
- *   <<<AGENTIC_TOOL_CALL_START>>>...<<<AGENTIC_TOOL_CALL_END>>>
- *
- * New format: Separate messages per turn:
- *   - assistant (content + reasoningContent + toolCalls)
- *   - tool (toolCallId + content)
- *   - assistant (next turn)
- *   - ...
- */
-
-import { LEGACY_AGENTIC_REGEX, LEGACY_REASONING_TAGS } from '$lib/constants';
-import { DatabaseService } from '$lib/services/database.service';
-import { MessageRole, MessageType } from '$lib/enums';
-import type { DatabaseMessage } from '$lib/types/database';
-
-const MIGRATION_DONE_KEY = 'llama-ui-migration-v2-done';
-/** @deprecated Use {@link MIGRATION_DONE_KEY} instead */
-const DEPRECATED_MIGRATION_DONE_KEY = 'llama-webui-migration-v2-done';
-
-/**
- * @deprecated Part of legacy migration — remove with the migration module.
- * Check if migration has been performed.
- */
-export function isMigrationNeeded(): boolean {
-       try {
-               // Check new key first, fall back to deprecated old key
-               if (localStorage.getItem(MIGRATION_DONE_KEY)) return false;
-               if (localStorage.getItem(DEPRECATED_MIGRATION_DONE_KEY)) {
-                       // Migrate to new key
-                       try {
-                               localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
-                               localStorage.removeItem(DEPRECATED_MIGRATION_DONE_KEY);
-                       } catch {
-                               // Ignore storage errors
-                       }
-                       return false;
-               }
-               return true;
-       } catch {
-               return false;
-       }
-}
-
-/**
- * Mark migration as done.
- */
-function markMigrationDone(): void {
-       try {
-               localStorage.setItem(MIGRATION_DONE_KEY, String(Date.now()));
-       } catch {
-               // Ignore localStorage errors
-       }
-}
-
-/**
- * Check if a message has legacy markers in its content.
- */
-function hasLegacyMarkers(message: DatabaseMessage): boolean {
-       if (!message.content) return false;
-       return LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test(message.content);
-}
-
-/**
- * Extract reasoning content from legacy marker format.
- */
-function extractLegacyReasoning(content: string): { reasoning: string; cleanContent: string } {
-       let reasoning = '';
-       let cleanContent = content;
-
-       // Extract all reasoning blocks
-       const re = new RegExp(LEGACY_AGENTIC_REGEX.REASONING_EXTRACT.source, 'g');
-       let match;
-       while ((match = re.exec(content)) !== null) {
-               reasoning += match[1];
-       }
-
-       // Remove reasoning tags from content
-       cleanContent = cleanContent
-               .replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
-               .replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '');
-
-       return { reasoning, cleanContent };
-}
-
-/**
- * Parse legacy content with tool call markers into structured turns.
- */
-interface ParsedTurn {
-       textBefore: string;
-       toolCalls: Array<{
-               name: string;
-               args: string;
-               result: string;
-       }>;
-}
-
-function parseLegacyToolCalls(content: string): ParsedTurn[] {
-       const turns: ParsedTurn[] = [];
-       const regex = new RegExp(LEGACY_AGENTIC_REGEX.COMPLETED_TOOL_CALL.source, 'g');
-
-       let lastIndex = 0;
-       let currentTurn: ParsedTurn = { textBefore: '', toolCalls: [] };
-       let match;
-
-       while ((match = regex.exec(content)) !== null) {
-               const textBefore = content.slice(lastIndex, match.index).trim();
-
-               // If there's text between tool calls and we already have tool calls,
-               // that means a new turn started (text after tool results = new LLM turn)
-               if (textBefore && currentTurn.toolCalls.length > 0) {
-                       turns.push(currentTurn);
-                       currentTurn = { textBefore, toolCalls: [] };
-               } else if (textBefore && currentTurn.toolCalls.length === 0) {
-                       currentTurn.textBefore = textBefore;
-               }
-
-               currentTurn.toolCalls.push({
-                       name: match[1],
-                       args: match[2],
-                       result: match[3].replace(/^\n+|\n+$/g, '')
-               });
-
-               lastIndex = match.index + match[0].length;
-       }
-
-       // Any remaining text after the last tool call
-       const remainingText = content.slice(lastIndex).trim();
-
-       if (currentTurn.toolCalls.length > 0) {
-               turns.push(currentTurn);
-       }
-
-       // If there's text after all tool calls, it's the final assistant response
-       if (remainingText) {
-               // Remove any partial/open markers
-               const cleanRemaining = remainingText
-                       .replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '')
-                       .trim();
-               if (cleanRemaining) {
-                       turns.push({ textBefore: cleanRemaining, toolCalls: [] });
-               }
-       }
-
-       // If no tool calls found at all, return the original content as a single turn
-       if (turns.length === 0) {
-               turns.push({ textBefore: content.trim(), toolCalls: [] });
-       }
-
-       return turns;
-}
-
-/**
- * Migrate a single conversation's messages from legacy format to new format.
- */
-async function migrateConversation(convId: string): Promise<number> {
-       const allMessages = await DatabaseService.getConversationMessages(convId);
-       let migratedCount = 0;
-
-       for (const message of allMessages) {
-               if (message.role !== MessageRole.ASSISTANT) continue;
-               if (!hasLegacyMarkers(message)) {
-                       // Still check for reasoning-only markers (no tool calls)
-                       if (message.content?.includes(LEGACY_REASONING_TAGS.START)) {
-                               const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
-                               await DatabaseService.updateMessage(message.id, {
-                                       content: cleanContent.trim(),
-                                       reasoningContent: reasoning || undefined
-                               });
-                               migratedCount++;
-                       }
-                       continue;
-               }
-
-               // Has agentic markers - full migration needed
-               const { reasoning, cleanContent } = extractLegacyReasoning(message.content);
-               const turns = parseLegacyToolCalls(cleanContent);
-
-               // Parse existing toolCalls JSON to try to match IDs
-               let existingToolCalls: Array<{
-                       id: string;
-                       function?: { name: string; arguments: string };
-               }> = [];
-               if (message.toolCalls) {
-                       try {
-                               existingToolCalls = JSON.parse(message.toolCalls);
-                       } catch {
-                               // Ignore
-                       }
-               }
-
-               // First turn uses the existing message
-               const firstTurn = turns[0];
-               if (!firstTurn) continue;
-
-               // Match tool calls from the first turn to existing IDs
-               const firstTurnToolCalls = firstTurn.toolCalls.map((tc, i) => {
-                       const existing =
-                               existingToolCalls.find((e) => e.function?.name === tc.name) || existingToolCalls[i];
-                       return {
-                               id: existing?.id || `legacy_tool_${i}`,
-                               type: 'function' as const,
-                               function: { name: tc.name, arguments: tc.args }
-                       };
-               });
-
-               // Update the existing message for the first turn
-               await DatabaseService.updateMessage(message.id, {
-                       content: firstTurn.textBefore,
-                       reasoningContent: reasoning || undefined,
-                       toolCalls: firstTurnToolCalls.length > 0 ? JSON.stringify(firstTurnToolCalls) : ''
-               });
-
-               let currentParentId = message.id;
-               let toolCallIdCounter = existingToolCalls.length;
-
-               // Create tool result messages for the first turn
-               for (let i = 0; i < firstTurn.toolCalls.length; i++) {
-                       const tc = firstTurn.toolCalls[i];
-                       const toolCallId = firstTurnToolCalls[i]?.id || `legacy_tool_${i}`;
-
-                       const toolMsg = await DatabaseService.createMessageBranch(
-                               {
-                                       convId,
-                                       type: MessageType.TEXT,
-                                       role: MessageRole.TOOL,
-                                       content: tc.result,
-                                       toolCallId,
-                                       timestamp: message.timestamp + i + 1,
-                                       toolCalls: '',
-                                       children: []
-                               },
-                               currentParentId
-                       );
-                       currentParentId = toolMsg.id;
-               }
-
-               // Create messages for subsequent turns
-               for (let turnIdx = 1; turnIdx < turns.length; turnIdx++) {
-                       const turn = turns[turnIdx];
-
-                       const turnToolCalls = turn.toolCalls.map((tc, i) => {
-                               const idx = toolCallIdCounter + i;
-                               const existing = existingToolCalls[idx];
-                               return {
-                                       id: existing?.id || `legacy_tool_${idx}`,
-                                       type: 'function' as const,
-                                       function: { name: tc.name, arguments: tc.args }
-                               };
-                       });
-                       toolCallIdCounter += turn.toolCalls.length;
-
-                       // Create assistant message for this turn
-                       const assistantMsg = await DatabaseService.createMessageBranch(
-                               {
-                                       convId,
-                                       type: MessageType.TEXT,
-                                       role: MessageRole.ASSISTANT,
-                                       content: turn.textBefore,
-                                       timestamp: message.timestamp + turnIdx * 100,
-                                       toolCalls: turnToolCalls.length > 0 ? JSON.stringify(turnToolCalls) : '',
-                                       children: [],
-                                       model: message.model
-                               },
-                               currentParentId
-                       );
-                       currentParentId = assistantMsg.id;
-
-                       // Create tool result messages for this turn
-                       for (let i = 0; i < turn.toolCalls.length; i++) {
-                               const tc = turn.toolCalls[i];
-                               const toolCallId = turnToolCalls[i]?.id || `legacy_tool_${toolCallIdCounter + i}`;
-
-                               const toolMsg = await DatabaseService.createMessageBranch(
-                                       {
-                                               convId,
-                                               type: MessageType.TEXT,
-                                               role: MessageRole.TOOL,
-                                               content: tc.result,
-                                               toolCallId,
-                                               timestamp: message.timestamp + turnIdx * 100 + i + 1,
-                                               toolCalls: '',
-                                               children: []
-                                       },
-                                       currentParentId
-                               );
-                               currentParentId = toolMsg.id;
-                       }
-               }
-
-               // Re-parent any children of the original message to the last created message
-               // (the original message's children list was the next user message or similar)
-               if (message.children.length > 0 && currentParentId !== message.id) {
-                       for (const childId of message.children) {
-                               // Skip children we just created (they were already properly parented)
-                               const child = allMessages.find((m) => m.id === childId);
-                               if (!child) continue;
-                               // Only re-parent non-tool messages that were original children
-                               if (child.role !== MessageRole.TOOL) {
-                                       await DatabaseService.updateMessage(childId, { parent: currentParentId });
-                                       // Add to new parent's children
-                                       const newParent = await DatabaseService.getConversationMessages(convId).then((msgs) =>
-                                               msgs.find((m) => m.id === currentParentId)
-                                       );
-                                       if (newParent && !newParent.children.includes(childId)) {
-                                               await DatabaseService.updateMessage(currentParentId, {
-                                                       children: [...newParent.children, childId]
-                                               });
-                                       }
-                               }
-                       }
-                       // Clear re-parented children from the original message
-                       await DatabaseService.updateMessage(message.id, { children: [] });
-               }
-
-               migratedCount++;
-       }
-
-       return migratedCount;
-}
-
-/**
- * @deprecated Part of legacy migration — remove with the migration module.
- * Run the full migration across all conversations.
- * This should be called once at app startup if migration is needed.
- */
-export async function runLegacyMigration(): Promise<void> {
-       if (!isMigrationNeeded()) return;
-
-       if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
-               console.log('[Migration] Starting legacy message format migration...');
-
-       try {
-               const conversations = await DatabaseService.getAllConversations();
-               let totalMigrated = 0;
-
-               for (const conv of conversations) {
-                       const count = await migrateConversation(conv.id);
-                       totalMigrated += count;
-               }
-
-               if (import.meta.env.DEV && import.meta.env.VITE_DEBUG) {
-                       if (totalMigrated > 0) {
-                               console.log(
-                                       `[Migration] Migrated ${totalMigrated} messages across ${conversations.length} conversations`
-                               );
-                       } else {
-                               console.log('[Migration] No legacy messages found, marking as done');
-                       }
-               }
-
-               markMigrationDone();
-       } catch (error) {
-               console.error('[Migration] Failed to migrate legacy messages:', error);
-               // Still mark as done to avoid infinite retry loops
-               markMigrationDone();
-       }
-}