ui : add temporary new-chat tabs

New-chat tabs are unsaved conversations carrying a temporary id used
directly as the route (#/chat/<id>). They live in memory and are only
persisted to the database - keeping the same id so the route and tab
stay stable - when the first message is sent. Deleting one drops it
without confirmation, and deleting conversations now closes their tabs.
This commit is contained in:
Aleksander Grygier
2026-08-17 14:01:22 +02:00
committed by GitHub
parent 0bc2d8d0c8
commit 25f7bafa24
5 changed files with 207 additions and 28 deletions
@@ -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 @@
<ServerLoadingSplash />
{:else}
<div
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-full px-4 md:py-0 pt-12 pb-48 md:pb-4"
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-1rem-var(--chat-tabs-height,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
style:--chat-form-bottom-position={chatFormBottomPosition}
ondragenter={dragAndDrop.dragHandlers.dragenter}
ondragleave={dragAndDrop.dragHandlers.dragleave}
@@ -146,6 +146,23 @@ export class DatabaseService {
return conversation;
}
/**
* Persist a conversation with a caller-supplied id. Used to save an
* unsaved new-chat tab (which already carries a temporary id) on its
* first message.
*/
static async createConversationWithId(conversation: DatabaseConversation): Promise<void> {
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.
@@ -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;
@@ -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/<id>`); it is persisted to the database - and moved to
* `conversations` - only when the first message is sent.
*/
temporaryConversations = $state<DatabaseConversation[]>([]);
/** 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<void> {
// 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<void> {
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<boolean> {
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<string> {
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<void> {
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<DatabaseConversation>): 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;
@@ -46,6 +46,8 @@ export interface ConversationsPreferencesHost {
activeConversation: DatabaseConversation | null;
conversations: DatabaseConversation[];
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
isTemporaryConversation(id: string): boolean;
updateTemporaryConversation(id: string, updates: Partial<DatabaseConversation>): 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
});
}