diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index d78de1f85f..11a98cae59 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -40,11 +40,10 @@ let initialMessage = $state(''); let showDeleteDialog = $state(false); let showEmptyFileDialog = $state(false); + // a new-chat tab is an unsaved (temporary) conversation, so the empty + // greeting shows whenever the route is a new-chat tab with no messages let isEmpty = $derived( - showCenteredEmpty && - !conversationsStore.activeConversation && - conversationsStore.activeMessages.length === 0 && - !chatStore.isLoading + showCenteredEmpty && conversationsStore.activeMessages.length === 0 && !chatStore.isLoading ); let activeErrorDialog = $derived(chatStore.errorDialogState); let isServerLoading = $derived(serverStore.loading); @@ -76,8 +75,15 @@ }); const { handleKeydown } = useKeyboardShortcuts({ deleteActiveConversation: () => { - if (conversationsStore.activeConversation) { - showDeleteDialog = true; + const conversation = conversationsStore.activeConversation; + + if (conversation) { + // an empty new-chat tab is unsaved, so drop it without confirmation + if (conversationsStore.isTemporaryConversation(conversation.id)) { + conversationsStore.deleteConversation(conversation.id); + } else { + showDeleteDialog = true; + } } } }); @@ -297,7 +303,7 @@ {:else}
{ + await db[IDXDB_TABLES.conversations].add(conversation); + } + + /** + * + * + * Messages + * + * + */ + /** * Creates a new message branch by adding a message and updating parent/child relationships. * Also updates the conversation's currNode to point to the new message. diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts index aab824fd71..e138d555c0 100644 --- a/tools/ui/src/lib/stores/chat/index.svelte.ts +++ b/tools/ui/src/lib/stores/chat/index.svelte.ts @@ -167,6 +167,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { if (!activeConv) { await conversationsStore.createConversation(); activeConv = conversationsStore.activeConversation; + } else if (conversationsStore.isTemporaryConversation(activeConv.id)) { + await conversationsStore.persistTemporaryConversation(activeConv.id); } if (!activeConv) return; @@ -644,6 +646,9 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost { if (!activeConv) { await conversationsStore.createConversation(); isNewConversation = true; + } else if (conversationsStore.isTemporaryConversation(activeConv.id)) { + await conversationsStore.persistTemporaryConversation(activeConv.id); + isNewConversation = true; } const currentConv = conversationsStore.activeConversation; diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts index 7d6dc326c4..7ee4776574 100644 --- a/tools/ui/src/lib/stores/conversations/index.svelte.ts +++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts @@ -21,7 +21,8 @@ import { type ConversationsPreferencesHost } from '$lib/stores/conversations/preferences.svelte'; import { settingsStore } from '$lib/stores/settings/index.svelte'; -import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; +import { tabsStore } from '$lib/stores/tabs.svelte'; +import { filterByLeafNodeId, findLeafNode, generateConversationTitle, uuid } from '$lib/utils'; import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; @@ -38,6 +39,13 @@ class ConversationsStore implements ConversationsPreferencesHost { /** Whether the store has been initialized */ isInitialized = $state(false); + /** + * Unsaved new-chat tabs. Each carries a temporary id used directly as the + * route (`#/chat/`); it is persisted to the database - and moved to + * `conversations` - only when the first message is sent. + */ + temporaryConversations = $state([]); + /** Per-chat options (MCP overrides, reasoning effort, cwd), composed here. */ private _preferences = new ConversationPreferences(this); @@ -138,8 +146,13 @@ class ConversationsStore implements ConversationsPreferencesHost { this.notifyConversationsDeleted([...idsToRemove]); if (activeWasDeleted) { + const activeId = this.activeConversation!.id; + + tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId)); this.clearActiveConversation(); - await goto(ROUTES.NEW_CHAT); + await tabsStore.close(activeId, activeId); + } else { + tabsStore.removeTabs([...idsToRemove]); } toast.success( @@ -276,11 +289,13 @@ class ConversationsStore implements ConversationsPreferencesHost { this.clearActiveConversation(); this.conversations = []; + this.temporaryConversations = []; + tabsStore.clear(); this.notifyConversationsDeleted(allIds); toast.success('All conversations deleted'); - await goto(ROUTES.NEW_CHAT); + await goto(ROUTES.START); } catch (error) { console.error('Failed to delete all conversations:', error); toast.error('Failed to delete conversations'); @@ -292,6 +307,20 @@ class ConversationsStore implements ConversationsPreferencesHost { * @param convId - The conversation ID to delete */ async deleteConversation(convId: string, options?: { deleteWithForks?: boolean }): Promise { + // an unsaved new-chat tab is not in the DB; just drop it and close its tab + if (this.isTemporaryConversation(convId)) { + this.temporaryConversations = this.temporaryConversations.filter((c) => c.id !== convId); + + if (this.activeConversation?.id === convId) { + this.clearActiveConversation(); + await tabsStore.close(convId, convId); + } else { + tabsStore.removeTabs([convId]); + } + + return; + } + try { await DatabaseService.deleteConversation(convId, options); @@ -313,8 +342,13 @@ class ConversationsStore implements ConversationsPreferencesHost { this.conversations = this.conversations.filter((c) => !idsToRemove.has(c.id)); if (this.activeConversation && idsToRemove.has(this.activeConversation.id)) { + const activeId = this.activeConversation.id; + + tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId)); this.clearActiveConversation(); - await goto(ROUTES.NEW_CHAT); + await tabsStore.close(activeId, activeId); + } else { + tabsStore.removeTabs([...idsToRemove]); } this.notifyConversationsDeleted([...idsToRemove]); @@ -333,7 +367,9 @@ class ConversationsStore implements ConversationsPreferencesHost { if (this.activeConversation?.id === convId) { this.clearActiveConversation(); - await goto(ROUTES.NEW_CHAT); + await tabsStore.close(convId, convId); + } else { + tabsStore.removeTabs([convId]); } this.notifyConversationsDeleted([convId]); @@ -360,6 +396,17 @@ class ConversationsStore implements ConversationsPreferencesHost { ConversationTransferService.downloadConversationFile({ conv: conversation, messages }); } + /** + * Persist the active conversation if it is an unsaved new-chat tab. + */ + async ensureActiveConversationPersisted(): Promise { + const active = this.activeConversation; + + if (active && this.isTemporaryConversation(active.id)) { + await this.persistTemporaryConversation(active.id); + } + } + /** * Finds the index of a message in active messages. * @@ -475,6 +522,11 @@ class ConversationsStore implements ConversationsPreferencesHost { return this.initPromise; } + /** True if the id refers to an unsaved new-chat tab */ + isTemporaryConversation(id: string): boolean { + return this.temporaryConversations.some((c) => c.id === id); + } + /** * Loads a specific conversation and its messages * @param convId - The conversation ID to load @@ -482,6 +534,17 @@ class ConversationsStore implements ConversationsPreferencesHost { */ async loadConversation(convId: string): Promise { try { + // unsaved new-chat tab: present in memory only + const temp = this.temporaryConversations.find((c) => c.id === convId); + + if (temp) { + this.preferences.pendingCwd = null; + this.activeConversation = temp; + this.activeMessages = []; + + return true; + } + const conversation = await DatabaseService.getConversation(convId); if (!conversation) { @@ -571,6 +634,39 @@ class ConversationsStore implements ConversationsPreferencesHost { return () => this.conversationDeletionListeners.delete(listener); } + /** + * Open a fresh new-chat tab (an unsaved conversation) and navigate to it. + * Used by the "New chat" actions (sidebar, keyboard, tab bar, search) and + * prompt/model deep-links. + */ + async openNewChatTab(): Promise { + const conversation = this.createTemporaryConversation(); + + await goto(RouterService.chat(conversation.id)); + + return conversation.id; + } + + /** + * Persist an unsaved new-chat tab to the database, keeping its id so the + * route and tab stay stable. Called on the first message of a new chat. + * @param convId - The temporary conversation id to persist + */ + async persistTemporaryConversation(convId: string): Promise { + const temp = this.temporaryConversations.find((c) => c.id === convId); + + if (!temp) return; + + // clone out of the $state proxy so IndexedDB serializes plain data + const conversation = { ...temp }; + + await DatabaseService.createConversationWithId(conversation); + + this.temporaryConversations = this.temporaryConversations.filter((c) => c.id !== convId); + this.conversations = [conversation, ...this.conversations]; + this.activeConversation = conversation; + } + /** * Refreshes active messages based on currNode after branch navigation. */ @@ -676,14 +772,6 @@ class ConversationsStore implements ConversationsPreferencesHost { ); } - /** - * - * - * Import & Export - * - * - */ - /** * Updates the current node of the active conversation * @param nodeId - The new current node ID @@ -717,6 +805,38 @@ class ConversationsStore implements ConversationsPreferencesHost { } } + /** + * + * + * Import & Export + * + * + */ + + /** Update an unsaved new-chat tab in place (not persisted to the DB) */ + updateTemporaryConversation(id: string, updates: Partial): void { + this.temporaryConversations = this.temporaryConversations.map((c) => + c.id === id ? { ...c, ...updates } : c + ); + } + + /** Build an unsaved new-chat conversation, baking in the pending cwd/effort */ + private createTemporaryConversation(): DatabaseConversation { + const conversation: DatabaseConversation = { + currNode: '', + cwd: this.preferences.pendingCwd ?? undefined, + id: uuid(), + lastModified: Date.now(), + name: 'New chat', + reasoningEffort: this.preferences.pendingReasoningEffort + }; + + this.preferences.pendingCwd = null; + this.temporaryConversations = [...this.temporaryConversations, conversation]; + + return conversation; + } + private notifyConversationsDeleted(convIds: string[]): void { if (convIds.length === 0) return; diff --git a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts index 65a02344b6..3ff28773be 100644 --- a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts +++ b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts @@ -46,6 +46,8 @@ export interface ConversationsPreferencesHost { activeConversation: DatabaseConversation | null; conversations: DatabaseConversation[]; applyConversationUpdate(id: string, updates: Partial): void; + isTemporaryConversation(id: string): boolean; + updateTemporaryConversation(id: string, updates: Partial): void; } export class ConversationPreferences { @@ -152,11 +154,21 @@ export class ConversationPreferences { return; } - this.host.applyConversationUpdate(this.host.activeConversation.id, { + const id = this.host.activeConversation.id; + + this.host.applyConversationUpdate(id, { cwd: trimmed }); - await DatabaseService.updateConversation(this.host.activeConversation.id, { + // unsaved new-chat tab: keep the pick in memory until it is persisted + if (this.host.isTemporaryConversation(id)) { + this.host.updateTemporaryConversation(id, { cwd: trimmed }); + this.pendingCwd = null; + + return; + } + + await DatabaseService.updateConversation(id, { cwd: trimmed }); @@ -202,12 +214,22 @@ export class ConversationPreferences { } } - await DatabaseService.updateConversation(this.host.activeConversation.id, { - mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + const overrides = newOverrides.length > 0 ? newOverrides : undefined; + const id = this.host.activeConversation.id; + + this.host.applyConversationUpdate(id, { + mcpServerOverrides: overrides }); - this.host.applyConversationUpdate(this.host.activeConversation.id, { - mcpServerOverrides: newOverrides.length > 0 ? newOverrides : undefined + // unsaved new-chat tab: keep the override in memory until it is persisted + if (this.host.isTemporaryConversation(id)) { + this.host.updateTemporaryConversation(id, { mcpServerOverrides: overrides }); + + return; + } + + await DatabaseService.updateConversation(id, { + mcpServerOverrides: overrides }); } @@ -224,11 +246,20 @@ export class ConversationPreferences { return; } - this.host.applyConversationUpdate(this.host.activeConversation.id, { + const id = this.host.activeConversation.id; + + this.host.applyConversationUpdate(id, { reasoningEffort: effort }); - await DatabaseService.updateConversation(this.host.activeConversation.id, { + // unsaved new-chat tab: keep the effort in memory until it is persisted + if (this.host.isTemporaryConversation(id)) { + this.host.updateTemporaryConversation(id, { reasoningEffort: effort }); + + return; + } + + await DatabaseService.updateConversation(id, { reasoningEffort: effort }); }