ui : render conversation tab bar in chat layout

Desktop-only tab bar above the chat screen, one tab per open
conversation or new-chat tab. The active tab follows the route id;
clicking navigates, middle-click or the close button closes (switching
to the left neighbor), and a trailing + starts a new chat. Tabs appear
only on chat-id routes; the bare #/ new-chat view has none. The bare
route stays put unless a prompt/model deep-link routes it to a new-chat
tab.
This commit is contained in:
Aleksander Grygier
2026-08-17 14:01:31 +02:00
committed by GitHub
parent 25f7bafa24
commit d37f8a5c4d
4 changed files with 151 additions and 80 deletions
@@ -0,0 +1,109 @@
<script lang="ts">
import { Loader2, Plus, SquarePen, X } from '@lucide/svelte';
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';
let activeId = $derived(page.params.id);
let tabs = $derived(
tabsStore.openTabs.map((id) => ({
id,
isNewChat: conversationsStore.isTemporaryConversation(id),
name:
conversationsStore.conversations.find((c) => c.id === id)?.name ??
(conversationsStore.isTemporaryConversation(id) ? 'New chat' : 'Chat')
}))
);
let loadingIds = $derived(new Set(chatStore.getAllLoadingChats()));
function handleClose(id: string) {
void tabsStore.close(id, activeId ?? null);
}
function handleAuxClick(id: string, event: MouseEvent) {
// middle-click closes, like browser tabs
if (event.button === 1) {
event.preventDefault();
handleClose(id);
}
}
</script>
<nav
class="sticky pl-1 top-0 pt-3.5 z-10 hidden md:block transition-colors ease-in-out"
aria-label="Open conversations"
>
<div class="flex h-10 items-center gap-1.25 overflow-x-auto px-2">
{#each tabs as tab (tab.id)}
{@const isActive = tab.id === activeId}
{@const isLoading = loadingIds.has(tab.id)}
<div
class={cn(
'group flex h-8 max-w-52 min-w-0 shrink-0 items-center gap-1 rounded-lg pr-1 pl-3 text-sm transition-colors backdrop-blur-xl',
isActive
? 'bg-foreground/8 text-foreground'
: 'text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
)}
>
<button
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2"
onclick={() => tabsStore.activate(tab.id)}
onauxclick={(e) => handleAuxClick(tab.id, e)}
aria-current={isActive ? 'page' : undefined}
>
{#if isLoading}
<Loader2 class="h-3.5 w-3.5 shrink-0 animate-spin" />
{:else if tab.isNewChat}
<SquarePen class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="truncate">{tab.name}</span>
</button>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:bg-foreground/10 hover:text-foreground'
)}
onclick={() => handleClose(tab.id)}
aria-label="Close tab"
>
<X class="h-3.5 w-3.5" />
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Close tab</p>
</Tooltip.Content>
</Tooltip.Root>
</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>
<Tooltip.Content>
<p>New chat</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
</nav>
@@ -686,6 +686,18 @@ export { default as ChatMessageSystem } from './ChatMessages/ChatMessage/ChatMes
*/
export { default as ChatScreen } from './ChatScreen/ChatScreen.svelte';
/**
* **ChatTabs** - Browser-style tab bar for open conversations
*
* Horizontal strip of tabs rendered above ChatScreen in the chat layout,
* one per conversation tracked by tabsStore. The active tab follows the
* route's conversation id; clicking a tab navigates to it, middle-click or
* the close button closes it (switching to the left neighbor when closing
* the active tab), and a trailing "+" button starts a new chat. Shows a
* spinner on tabs with a running generation. Desktop-only.
*/
export { default as ChatTabs } from './ChatTabs/ChatTabs.svelte';
/**
* Visual overlay displayed when user drags files over the chat screen.
* Shows drop zone indicator to guide users where to release files.
+25 -3
View File
@@ -1,12 +1,34 @@
<script lang="ts">
import { page } from '$app/state';
import { ChatScreen } from '$lib/components/app';
import { ChatScreen, ChatTabs } from '$lib/components/app';
import { conversationsStore, tabsStore } from '$lib/stores';
let { children } = $props();
let showCenteredEmpty = $derived(!page.params.id);
// 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
);
// 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
$effect(() => {
const id = page.params.id;
if (id) {
tabsStore.syncWithRoute(id);
}
});
</script>
<ChatScreen {showCenteredEmpty} />
<div class={showTabs ? 'md:[--chat-tabs-height:2.5rem]' : ''}>
{#if showTabs}
<ChatTabs />
{/if}
<ChatScreen {showCenteredEmpty} />
</div>
{@render children?.()}
+5 -77
View File
@@ -1,79 +1,11 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import { DialogModelNotAvailable } from '$lib/components/app';
import { APP_NAME, URL_PARAMS } from '$lib/constants';
import { chatStore, conversationsStore, modelsStore, serverStore } from '$lib/stores';
import { chatStore, conversationsStore, modelsStore } from '$lib/stores';
import { onMount } from 'svelte';
let qParam = $derived(page.url.searchParams.get(URL_PARAMS.QUERY));
let modelParam = $derived(page.url.searchParams.get(URL_PARAMS.MODEL));
let newChatParam = $derived(page.url.searchParams.get(URL_PARAMS.NEW_CHAT));
let loadParam = $derived(page.url.searchParams.get(URL_PARAMS.LOAD));
// Dialog state for model not available error
let showModelNotAvailable = $state(false);
let requestedModelName = $state('');
let availableModelNames = $derived(modelsStore.models.map((m) => m.model));
/**
* Clear URL params after message is sent to prevent re-sending on refresh
*/
function clearUrlParams() {
const url = new URL(page.url);
url.searchParams.delete(URL_PARAMS.QUERY);
url.searchParams.delete(URL_PARAMS.MODEL);
url.searchParams.delete(URL_PARAMS.LOAD);
url.searchParams.delete(URL_PARAMS.NEW_CHAT);
replaceState(url.toString(), {});
}
async function handleUrlParams() {
await modelsStore.fetch();
if (modelParam) {
const model = modelsStore.findModelByName(modelParam);
if (model) {
try {
await modelsStore.selectModelById(model.id);
// with ?load=true, start loading right away so the model is ready sooner;
// not awaited, so the UI stays usable during the load
if (
loadParam === 'true' &&
serverStore.isRouterMode &&
!modelsStore.isModelLoaded(model.id)
) {
modelsStore.status
.load(model.id)
.catch((error) => console.error('Failed to load model:', error));
}
} catch (error) {
console.error('Failed to select model:', error);
requestedModelName = modelParam;
showModelNotAvailable = true;
return;
}
} else {
requestedModelName = modelParam;
showModelNotAvailable = true;
return;
}
}
// Handle ?q= parameter - create new conversation and send message
if (qParam !== null) {
await conversationsStore.createConversation();
clearUrlParams();
} else if (modelParam || newChatParam === 'true') {
clearUrlParams();
}
}
onMount(async () => {
if (!conversationsStore.isInitialized) {
@@ -85,8 +17,10 @@
await modelsStore.fetch();
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
await handleUrlParams();
// a prompt/model deep-link routes to a new-chat tab, which sends it;
// otherwise `#/` is a plain new-chat view with no tabs
if (qParam !== null || modelParam !== null) {
await conversationsStore.openNewChatTab();
}
await modelsStore.ensureFirstModelSelected();
@@ -96,9 +30,3 @@
<svelte:head>
<title>{APP_NAME}</title>
</svelte:head>
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
modelName={requestedModelName}
availableModels={availableModelNames}
/>