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
});
}