mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-20 01:31:31 +02:00
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:
committed by
GitHub
parent
25f7bafa24
commit
d37f8a5c4d
@@ -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.
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user