mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-19 17:24:57 +02:00
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:
committed by
GitHub
parent
0bc2d8d0c8
commit
25f7bafa24
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user