chat : add browser-style conversation tabs with a new-chat screen

Track open conversations as tabs above the chat, one per open chat, plus a
single New chat tab for the bare `#/` route. New chat is just the `#/`
screen - no temporary conversations - and its tab is dropped when navigating
away. Sending the first message creates a real conversation and opens a tab
for it.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-08-20 18:13:44 +02:00
committed by GitHub
parent f8386b0368
commit 1144652072
13 changed files with 88 additions and 191 deletions
@@ -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;
}
}
});
@@ -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 @@
</div>
{/each}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class="backdrop-blur-lg flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
onclick={() => conversationsStore.openNewChatTab()}
aria-label="New chat"
>
<Plus class="h-4 w-4" />
</button>
{/snippet}
</Tooltip.Trigger>
{#if showNewChatButton}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class="backdrop-blur-lg flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-foreground/5 hover:text-foreground"
onclick={() => conversationsStore.openNewChat()}
aria-label="New chat"
>
<Plus class="h-4 w-4" />
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>New chat</p>
</Tooltip.Content>
</Tooltip.Root>
<Tooltip.Content>
<p>New chat</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
</nav>
@@ -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
? () => {
@@ -33,7 +33,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
) {
event.preventDefault();
conversationsStore.openNewChatTab();
conversationsStore.openNewChat();
}
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
@@ -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;
@@ -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/<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);
@@ -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<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);
@@ -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<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.
*
@@ -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<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) {
@@ -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<string> {
const conversation = this.createTemporaryConversation();
async openNewChat(): Promise<string> {
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<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;
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<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,8 +46,6 @@ 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 {
@@ -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
});
+1 -1
View File
@@ -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';
+13 -11
View File
@@ -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/<id>` 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/<id>`
* 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<string[]>([]);
/** 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<void> {
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);
}
+12 -10
View File
@@ -1,23 +1,25 @@
<script lang="ts">
import { page } from '$app/state';
import { ChatScreen, ChatTabs } from '$lib/components/app';
import { conversationsStore, tabsStore } from '$lib/stores';
import { NEW_CHAT_TAB_ID, settingsStore, tabsStore } from '$lib/stores';
let { children } = $props();
// the new-chat screen is a temporary conversation (unsaved) or the bare `#/` route
let showCenteredEmpty = $derived(
page.params.id ? conversationsStore.isTemporaryConversation(page.params.id) : true
// the new-chat screen is the bare `#/` route (no conversation id)
let showCenteredEmpty = $derived(!page.params.id);
// tabs show whenever the Conversation tabs setting is enabled, except on the
// bare `#/` new-chat view when no real conversation tab is open yet
let showTabs = $derived(
Boolean(settingsStore.config.conversationTabs) &&
(page.params.id || tabsStore.openTabs.some((id) => id !== NEW_CHAT_TAB_ID))
);
// tabs appear only on chat-id routes; the bare `#/` new-chat view has none
let showTabs = $derived(!!page.params.id);
// any navigation to a conversation or new-chat tab opens a tab for it
// any navigation to a conversation or the new-chat screen opens a tab for it
$effect(() => {
const id = page.params.id;
const id = page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
if (id) {
if (id && settingsStore.config.conversationTabs) {
tabsStore.syncWithRoute(id);
}
});
+3 -3
View File
@@ -17,10 +17,10 @@
await modelsStore.fetch();
// a prompt/model deep-link routes to a new-chat tab, which sends it;
// otherwise `#/` is a plain new-chat view with no tabs
// a prompt/model deep-link opens a new chat (tab or plain view, per the
// Conversation tabs setting); otherwise `#/` is a plain new-chat view
if (qParam !== null || modelParam !== null) {
await conversationsStore.openNewChatTab();
await conversationsStore.openNewChat();
}
await modelsStore.ensureFirstModelSelected();
+17 -1
View File
@@ -24,8 +24,10 @@
deviceStore,
mcpStore,
modelsStore,
NEW_CHAT_TAB_ID,
serverStore,
settingsStore,
tabsStore,
versionStore
} from '$lib/stores';
import { initStores } from '$lib/stores/init';
@@ -96,10 +98,24 @@
if (targetIdx >= 0 && targetIdx < allConvs.length) {
goto(RouterService.chat(allConvs[targetIdx].id));
} else {
conversationsStore.openNewChatTab();
conversationsStore.openNewChat();
}
}
// navigating away from the new-chat screen drops its tab, so it does not
// linger once the user moves to a real conversation or another route
let previousChatId = $state<string | undefined>(undefined);
$effect(() => {
const id = page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
const prev = previousChatId;
previousChatId = id;
if (id !== prev && prev && settingsStore.config.conversationTabs && prev === NEW_CHAT_TAB_ID) {
untrack(() => tabsStore.removeTabs([NEW_CHAT_TAB_ID]));
}
});
// Global keyboard shortcuts
const { handleKeydown } = useKeyboardShortcuts({
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
+2 -2
View File
@@ -23,7 +23,7 @@
// in-place search, so bounce back to a new-chat tab.
$effect(() => {
if (browser && !deviceStore.isMobile) {
conversationsStore.openNewChatTab();
conversationsStore.openNewChat();
}
});
@@ -65,7 +65,7 @@
if (history.length > 1) {
history.back();
} else {
conversationsStore.openNewChatTab();
conversationsStore.openNewChat();
}
}
</script>