}: 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} />
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);
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;
* 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);
* @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,
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;
+ });
}
/**
}
/**
- * 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
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);
}
/**
* @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`);
}
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[] = [];
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;
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);
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);
timings
});
- conversationsStore.updateConversationTimestamp();
+ conversationsStore.updateConversationTimestamp(msg.convId);
this.setChatLoading(msg.convId, false);
this.clearChatStreaming(msg.convId);
| ((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;
+
/**
*
*
/**
* 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;
}
/**
*/
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
});
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 = [];
}
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);
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
if (convIndex !== -1) {
this.conversations[convIndex].name = name;
- this.conversations = [...this.conversations];
}
if (this.activeConversation?.id === convId) {
if (convIndex !== -1) {
this.conversations[convIndex].pinned = newPinnedState;
- this.conversations = [...this.conversations];
}
if (this.activeConversation?.id === convId) {
}
/**
- * 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)
+ );
}
/**
if (convIndex !== -1) {
this.conversations[convIndex].mcpServerOverrides =
newOverrides.length > 0 ? newOverrides : undefined;
- this.conversations = [...this.conversations];
}
}
const convIndex = this.conversations.findIndex((c) => c.id === this.activeConversation!.id);
if (convIndex !== -1) {
this.conversations[convIndex].reasoningEffort = effort;
- this.conversations = [...this.conversations];
}
}
+++ /dev/null
-/**
- * @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();
- }
-}