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 11a98cae59..bb18bae50f 100644 --- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -78,12 +78,7 @@ 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; - } + showDeleteDialog = true; } } }); diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte index 9e433026a5..d37d88f924 100644 --- a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte @@ -3,20 +3,24 @@ import { page } from '$app/state'; import * as Tooltip from '$lib/components/ui/tooltip'; import { cn } from '$lib/components/ui/utils'; - import { chatStore, conversationsStore, tabsStore } from '$lib/stores'; + import { chatStore, conversationsStore, NEW_CHAT_TAB_ID, tabsStore } from '$lib/stores'; - let activeId = $derived(page.params.id); + let activeId = $derived(page.params.id ?? NEW_CHAT_TAB_ID); let tabs = $derived( tabsStore.openTabs.map((id) => ({ id, - isNewChat: conversationsStore.isTemporaryConversation(id), + isNewChat: id === NEW_CHAT_TAB_ID, name: - conversationsStore.conversations.find((c) => c.id === id)?.name ?? - (conversationsStore.isTemporaryConversation(id) ? 'New chat' : 'Chat') + id === NEW_CHAT_TAB_ID + ? 'New chat' + : (conversationsStore.conversations.find((c) => c.id === id)?.name ?? 'Chat') })) ); + // hide the New chat button when the new-chat tab is the only one open + let showNewChatButton = $derived(tabsStore.openTabs.some((id) => id !== NEW_CHAT_TAB_ID)); + let loadingIds = $derived(new Set(chatStore.getAllLoadingChats())); function handleClose(id: string) { @@ -87,23 +91,25 @@ {/each} - - - {#snippet child({ props })} - - {/snippet} - + {#if showNewChatButton} + + + {#snippet child({ props })} + + {/snippet} + - -

New chat

-
-
+ +

New chat

+
+
+ {/if} diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte index bf662d9ba5..b18b08549f 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationActions.svelte @@ -113,7 +113,7 @@ item.action === 'new-chat' ? () => { onNewChat?.(); - conversationsStore.openNewChatTab(); + conversationsStore.openNewChat(); } : item.route ? () => { @@ -167,7 +167,7 @@ item.action === 'new-chat' ? () => { onNewChat?.(); - conversationsStore.openNewChatTab(); + conversationsStore.openNewChat(); } : item.route ? () => { diff --git a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts index fe22f89465..4a2fd0af8c 100644 --- a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts +++ b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts @@ -33,7 +33,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) { ) { event.preventDefault(); - conversationsStore.openNewChatTab(); + conversationsStore.openNewChat(); } if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) { diff --git a/tools/ui/src/lib/stores/chat/index.svelte.ts b/tools/ui/src/lib/stores/chat/index.svelte.ts index e138d555c0..aab824fd71 100644 --- a/tools/ui/src/lib/stores/chat/index.svelte.ts +++ b/tools/ui/src/lib/stores/chat/index.svelte.ts @@ -167,8 +167,6 @@ 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; @@ -646,9 +644,6 @@ 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 7ee4776574..7ff35a1806 100644 --- a/tools/ui/src/lib/stores/conversations/index.svelte.ts +++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts @@ -21,8 +21,8 @@ import { type ConversationsPreferencesHost } from '$lib/stores/conversations/preferences.svelte'; import { settingsStore } from '$lib/stores/settings/index.svelte'; -import { tabsStore } from '$lib/stores/tabs.svelte'; -import { filterByLeafNodeId, findLeafNode, generateConversationTitle, uuid } from '$lib/utils'; +import { NEW_CHAT_TAB_ID, tabsStore } from '$lib/stores/tabs.svelte'; +import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils'; import { SvelteSet } from 'svelte/reactivity'; import { toast } from 'svelte-sonner'; @@ -39,13 +39,6 @@ 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); @@ -289,7 +282,6 @@ class ConversationsStore implements ConversationsPreferencesHost { this.clearActiveConversation(); this.conversations = []; - this.temporaryConversations = []; tabsStore.clear(); this.notifyConversationsDeleted(allIds); @@ -307,20 +299,6 @@ 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); @@ -396,17 +374,6 @@ 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. * @@ -522,11 +489,6 @@ 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 @@ -534,17 +496,6 @@ 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) { @@ -635,36 +586,14 @@ class ConversationsStore implements ConversationsPreferencesHost { } /** - * 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. + * Start a fresh chat by navigating to the bare `#/` new-chat screen. The + * chat layout opens a new-chat tab for it when Conversation tabs are on. */ - async openNewChatTab(): Promise { - const conversation = this.createTemporaryConversation(); + async openNewChat(): Promise { + this.clearActiveConversation(); + await goto(ROUTES.START); - 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; + return NEW_CHAT_TAB_ID; } /** @@ -813,30 +742,6 @@ class ConversationsStore implements ConversationsPreferencesHost { * */ - /** 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 3ff28773be..fea9286033 100644 --- a/tools/ui/src/lib/stores/conversations/preferences.svelte.ts +++ b/tools/ui/src/lib/stores/conversations/preferences.svelte.ts @@ -46,8 +46,6 @@ 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 { @@ -160,14 +158,6 @@ export class ConversationPreferences { cwd: trimmed }); - // 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 }); @@ -221,13 +211,6 @@ export class ConversationPreferences { mcpServerOverrides: overrides }); - // 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 }); @@ -252,13 +235,6 @@ export class ConversationPreferences { reasoningEffort: effort }); - // 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 }); diff --git a/tools/ui/src/lib/stores/index.ts b/tools/ui/src/lib/stores/index.ts index 40ddd10bfd..2bf5039d5c 100644 --- a/tools/ui/src/lib/stores/index.ts +++ b/tools/ui/src/lib/stores/index.ts @@ -23,7 +23,7 @@ export { chatStore } from './chat/index.svelte'; export { draftMessagesStore } from './chat/drafts.svelte'; // CONVERSATION TABS -export { tabsStore } from './tabs.svelte'; +export { NEW_CHAT_TAB_ID, tabsStore } from './tabs.svelte'; // CONTEXT STATS (active conversation context window usage) export { contextStatsStore } from './chat/context-stats.svelte'; diff --git a/tools/ui/src/lib/stores/tabs.svelte.ts b/tools/ui/src/lib/stores/tabs.svelte.ts index f9fc83ecae..6c37b37b1e 100644 --- a/tools/ui/src/lib/stores/tabs.svelte.ts +++ b/tools/ui/src/lib/stores/tabs.svelte.ts @@ -1,11 +1,10 @@ /** * tabsStore - Reactive State Store for Browser-Style Conversation Tabs * - * Tracks which conversations and new-chat screens are open as tabs in the - * chat layout, in order. Every tab - real conversation or unsaved new-chat - * tab - is a `#/chat/` route; new-chat tabs simply carry a temporary - * conversation id that is only persisted to the database once a message is - * sent (see conversationsStore.temporaryConversations). + * Tracks which conversations and the new-chat screen are open as tabs in + * the chat layout, in order. Real conversation tabs are `#/chat/` + * routes; the new-chat tab is the bare `#/` route, represented here by the + * `NEW_CHAT_TAB_ID` sentinel (see {@link NEW_CHAT_TAB_ID}). * * **Architecture & Relationships:** * - **conversationsStore**: owns conversation data; calls `removeTabs()` / @@ -13,8 +12,8 @@ * so there is no circular dependency - tab names are resolved by the * ChatTabs component from conversationsStore. * - Tab order persists to localStorage and is pruned against the loaded - * conversation list on init. Unsaved new-chat tabs are dropped on reload - * (they are not in the database), which matches browser behavior. + * conversation list on init. The new-chat tab is dropped on reload (it is + * not a conversation), which matches browser behavior. */ import { browser } from '$app/environment'; @@ -23,16 +22,19 @@ import { CHAT_TABS_LOCALSTORAGE_KEY, ROUTES } from '$lib/constants'; import { RouterService } from '$lib/services/router.service'; import { untrack } from 'svelte'; +/** Sentinel tab id for the bare `#/` new-chat screen */ +export const NEW_CHAT_TAB_ID = 'new-chat'; + class TabsStore { - /** Ordered tab ids: conversation ids and temporary new-chat ids */ + /** Ordered tab ids: conversation ids and the `NEW_CHAT_TAB_ID` sentinel */ openTabs = $state([]); /** False until init() has read the persisted tabs; save() is a no-op before that */ private initialized = false; - /** Navigate to a tab */ + /** Navigate to a tab (the new-chat sentinel maps to the bare `#/` route) */ async activate(id: string): Promise { - await goto(RouterService.chat(id)); + await goto(id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id)); } /** Remove all tabs (e.g. after deleting all conversations) */ @@ -61,7 +63,7 @@ class TabsStore { const target = (idx > 0 ? this.openTabs[idx - 1] : this.openTabs[0]) ?? null; if (target) { - await goto(RouterService.chat(target)); + await goto(target === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(target)); } else { await goto(ROUTES.START); } diff --git a/tools/ui/src/routes/(chat)/+layout.svelte b/tools/ui/src/routes/(chat)/+layout.svelte index 33769bc911..9f943626a9 100644 --- a/tools/ui/src/routes/(chat)/+layout.svelte +++ b/tools/ui/src/routes/(chat)/+layout.svelte @@ -1,23 +1,25 @@