Compare commits

...
Author SHA1 Message Date
Aleksander GrygierandGitHub 7cf1e7f5f5 ui : add sampling & penalties submenu to model selector
Assisted-by: pi
2026-08-21 15:38:49 +02:00
Aleksander GrygierandGitHub 4fa82dc623 ui : use merged sampling & penalties section in model selector
Assisted-by: pi
2026-08-21 15:38:49 +02:00
Aleksander GrygierandGitHub 9c476f0112 ui : fix eslint issues in model selector settings submenu 2026-08-21 15:38:49 +02:00
Aleksander GrygierandGitHub 5a17ef4cc2 ui : fix formatting in model selector settings submenu 2026-08-21 15:38:49 +02:00
Aleksander GrygierandGitHub a5e63c3069 ui : add sampling and penalties submenus to model selector
Add reusable ModelsSelectorSettingsSubmenu that renders a settings section's
fields as inline editable inputs bound to settingsStore. Wire it into the model
selector dropdown for the Sampling and Penalties sections, showing server
defaults as placeholders and disabling the submenus until a model is loaded.
2026-08-21 15:38:49 +02:00
Aleksander GrygierandGitHub 90a84847f6 ui : fix eslint issues in chat form and model selector 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub 10ac600337 ui : fix formatting in chat form and model selector 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub eedf494f04 feat: Enable microphone input as default for audio models 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub 535e737a87 ui : add raw model id tooltip to model selector options 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub 11294657fc ui : make model option hover and focus highlight override the active state 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub f46e6cce9c ui : move model list into a submenu within the model selector 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub be7321a4cc ui : add show-org-name-in-trigger display setting 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub f11a90d113 ui : keep reasoning submenu visible regardless of model state 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub a43c1c0cde ui : show reasoning and modality icons on model options and search by modality 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub 06f8d7ae96 ui : strip trailing container-format segments from parsed model names 2026-08-21 15:38:48 +02:00
Aleksander GrygierandGitHub 0806ae2bf0 chat : account for the tab bar offset in the assistant min-height
Subtract the tab bar offset when it is shown so the last assistant message
does not overflow the available viewport space.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub b1667513bd chat : adjust tab bar width and use a shared offset variable
Widen the tab bar for the expanded sidebar and rename the tab bar height
variable to --chat-tabs-offset with a smaller value so the chat screen
min-height accounts for the overlay without overshooting.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 903841b24e chat : make the whole tab item act as a link
The full tab is now a link instead of only the inner label button, while
the stop and close buttons stay interactive by swallowing their clicks.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub e13011bbbd chat : add keyboard shortcut to jump between conversation tabs
Shift+Cmd/Ctrl+Left/Right cycles the open tabs, mirroring the existing
Shift+Cmd/Ctrl+Up/Down conversation navigation.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub bbd11affe9 refactor: Naming 2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub e6d1b63385 ui : improve chat tabs carousel UX
Scroll newly added tabs into view, fade overflowing tabs at the edges, and hide the New chat button while a new-chat tab is open.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 3d4fa69038 ui : migrate carousels to ScrollCarousel
Switch the settings mobile header, attachments list, thumbnail strip, and MCP resources to the unified component, and drop HorizontalScrollCarousel.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 0d574e77c0 ui : add unified ScrollCarousel component
Single carousel component with top/center variants, gap and scroll options, and hover-revealed chevrons. Rename the HorizontalScrollCarousel accessibility story accordingly.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 2e875fa3d5 ui : fix carousel scrollability detection
Observe the content wrapper as well as the container, since adding overflowing items does not change the container's own box size. Also expose an onScrollableChange callback.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 9bde74c881 refactor: Componentize 2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub c5fd4f9f95 feat: Add stop button to tabs 2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub e36d711267 chat : soften tab bar fade and dim inactive tabs
Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 79434c514b chat : add close-tab keyboard shortcut
Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 1723443567 chat : restyle the scroll-to-bottom button to match tab styling
Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 8bcd025902 chat : turn tab bar into a horizontally scrollable carousel
Make the tab bar a horizontally scrollable carousel with edge scroll buttons
and active-tab centering, and align its styling with the sidebar.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 1144652072 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
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub f8386b0368 chat : add opt-in conversation tabs setting
Add a Display setting that turns browser-style conversation tabs on or off,
enabled by default.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub b59d8bfe9d ui : track sidebar expanded state in a shared ui store
Move the desktop sidebar expanded/collapsed state out of deviceStore into a
dedicated uiStore so the chat tab bar can react to it.

Assisted-by: pi
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 1ae4576fe3 ui : route new-chat entry points through tabs
The sidebar New chat item, Cmd+Shift+O, the search page and the
arrow-key fallback now open a new-chat tab instead of navigating to the
?new_chat URL, which is removed. New chat is no longer a special route
but a tab like any other conversation.
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub d37f8a5c4d 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.
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 25f7bafa24 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.
2026-08-21 15:38:46 +02:00
Aleksander GrygierandGitHub 0bc2d8d0c8 ui : add browser-style conversation tabs store
Track open conversation tabs in order, persisted to localStorage and
pruned against the loaded conversation list on init. The chat layout
syncs the route's tab on every navigation, so any way of reaching a
conversation opens a tab for it.
2026-08-21 15:38:46 +02:00
52 changed files with 1493 additions and 555 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { Eye, Mic, Video } from '@lucide/svelte';
import { Image, Mic, Video } from '@lucide/svelte';
import { ModelModality } from '$lib/enums';
interface Props {
@@ -19,7 +19,7 @@
]}
>
{#if modality === ModelModality.VISION}
<Eye class="h-3 w-3" />
<Image class="h-3 w-3" />
Vision (Image)
{:else if modality === ModelModality.VIDEO}
@@ -2,9 +2,9 @@
import {
ChatAttachmentsListItem,
DialogChatAttachmentsPreview,
DialogMcpResourcePreview,
HorizontalScrollCarousel
DialogMcpResourcePreview
} from '$lib/components/app';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from '$lib/utils';
@@ -42,7 +42,7 @@
uploadedFiles = $bindable([])
}: Props = $props();
let carouselRef: HorizontalScrollCarousel | undefined = $state();
let carouselRef: ScrollCarousel | undefined = $state();
let mcpResourcePreviewOpen = $state(false);
let mcpResourcePreviewExtra = $state<DatabaseMessageExtraMcpResource | null>(null);
let previewFocusIndex = $state(0);
@@ -91,11 +91,11 @@
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
<HorizontalScrollCarousel bind:this={carouselRef}>
<ScrollCarousel bind:this={carouselRef} variant="center">
{#each displayItems as item (item.id)}
{@render attachmentitem(item)}
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
{:else}
<div class="flex flex-wrap items-start justify-end gap-3">
{#each displayItems as item (item.id)}
@@ -1,6 +1,6 @@
<script lang="ts">
import { FileText, Music, Video } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
interface PreviewItem {
@@ -33,7 +33,7 @@
{#if items.length > 1}
<div class="sticky bottom-0 z-10 mt-4 flex-shrink-0">
<HorizontalScrollCarousel class="max-w-full">
<ScrollCarousel class="max-w-full" variant="center">
{#each items as item, index (item.id)}
<button
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
@@ -64,6 +64,6 @@
{/if}
</button>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
{/if}
@@ -2,7 +2,6 @@
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
import {
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu,
ChatFormActionAddToolsSubmenu
} from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
@@ -93,10 +92,6 @@
}
}}
>
<ChatFormActionAddReasoningSubmenu />
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class={ICON_CLASS_DEFAULT} />
@@ -156,11 +151,11 @@
<ChatFormActionAddToolsSubmenu />
<DropdownMenu.Separator />
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
@@ -8,69 +8,67 @@
const reasoning = useReasoningMenu();
</script>
{#if reasoning.modelSupportsThinking}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.thinkingEnabled}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
? 'text-muted-foreground'
: ''}"
>
Reasoning
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
<span
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
? 'text-muted-foreground'
: ''}"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
Reasoning
<span class="flex-1">{level.label}</span>
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
<span class="flex-1">{level.label}</span>
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -100,7 +100,7 @@
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
{#if reasoning.thinkingEnabled}
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
@@ -194,6 +194,8 @@
</Collapsible.Content>
</Collapsible.Root>
<div class="h-px bg-border"></div>
<Collapsible.Root open={mcpExpanded} onOpenChange={(open) => (mcpExpanded = open)}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
@@ -1,8 +1,6 @@
<script lang="ts">
import {
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
import { ChatAttachmentsListItemMcpResource } from '$lib/components/app';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import { mcpStore } from '$lib/stores';
interface Props {
@@ -26,7 +24,7 @@
{#if hasAttachments}
<div class={className}>
<HorizontalScrollCarousel gapSize="2">
<ScrollCarousel gapSize="2" variant="center">
{#each attachments as attachment, i (attachment.id)}
<ChatAttachmentsListItemMcpResource
class={i === 0 ? 'ml-3' : ''}
@@ -35,6 +33,6 @@
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
{/if}
@@ -196,7 +196,7 @@
--assistant-min-height-offset: calc(
var(--last-user-message-height, 19rem) + var(--chat-form-height, 6rem) +
var(--chat-form-bottom-position, 0.5rem) + var(--chat-form-padding-top, 6rem) +
var(--assistant-margin-top, 3rem)
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
);
min-height: calc(100dvh - var(--assistant-min-height-offset));
@@ -204,7 +204,7 @@
--assistant-min-height-offset: calc(
var(--last-user-message-height, 18rem) + var(--chat-form-height, 6rem) +
var(--chat-form-bottom-position, 1rem) + var(--chat-form-padding-top, 6rem) +
var(--assistant-margin-top, 3rem)
var(--assistant-margin-top, 3rem) + var(--chat-tabs-offset, 0px)
);
}
}
@@ -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,7 +75,9 @@
});
const { handleKeydown } = useKeyboardShortcuts({
deleteActiveConversation: () => {
if (conversationsStore.activeConversation) {
const conversation = conversationsStore.activeConversation;
if (conversation) {
showDeleteDialog = true;
}
}
@@ -297,7 +298,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-offset,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}
@@ -14,6 +14,6 @@
tooltip="Scroll to bottom"
size="lg"
iconSize={ICON_CLASS_DEFAULT}
class="h-9 w-9 rounded-full bg-accent text-accent-foreground absolute bottom-4 shadow-md"
class="h-9 w-9 rounded-full bg-muted/60 border border-border/50 shadow-sm text-accent-foreground absolute bottom-4"
/>
</div>
@@ -0,0 +1,145 @@
<script lang="ts">
import ChatTabsItem from './ChatTabsItem.svelte';
import { Plus } from '@lucide/svelte';
import { page } from '$app/state';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import * as Tooltip from '$lib/components/ui/tooltip';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
import { chatStore, conversationsStore, NEW_CHAT_TAB_ID, tabsStore, uiStore } from '$lib/stores';
import { tick } from 'svelte';
const carousel = useScrollCarousel();
let activeId = $derived(page.params.id ?? NEW_CHAT_TAB_ID);
let tabs = $derived(
tabsStore.openTabs.map((id) => ({
id,
isNewChat: id === NEW_CHAT_TAB_ID,
name:
id === NEW_CHAT_TAB_ID
? 'New chat'
: (conversationsStore.conversations.find((c) => c.id === id)?.name ?? 'Chat')
}))
);
// hide the New chat button while a new-chat tab is already open
let showNewChatButton = $derived(!tabsStore.openTabs.includes(NEW_CHAT_TAB_ID));
let loadingIds = $derived(new Set(chatStore.getAllLoadingChats()));
function handleClose(id: string) {
void tabsStore.close(id, activeId ?? null);
}
function handleStop(id: string, event: MouseEvent) {
event.stopPropagation();
void chatStore.stopGenerationForChat(id);
}
function handleAuxClick(id: string, event: MouseEvent) {
// middle-click closes, like browser tabs
if (event.button === 1) {
event.preventDefault();
handleClose(id);
}
}
let previousTabIds = new Set<string>();
let previousActiveId: string | null = null;
$effect(() => {
const currentIds = new Set(tabs.map((t) => t.id));
const addedIds = tabs.filter((t) => !previousTabIds.has(t.id)).map((t) => t.id);
previousTabIds = currentIds;
const activeChanged = activeId !== previousActiveId;
previousActiveId = activeId;
// scroll when the active tab changes (a click) or when a new tab is added
if (addedIds.length === 0 && !activeChanged) return;
// wait for the new tab to be laid out before scrolling to it
void tick().then(() => {
const el = carousel.scrollContainer?.querySelector<HTMLElement>('[data-active-tab]');
if (el && activeId) {
carousel.scrollToCenter(el);
}
});
});
</script>
<nav
class="group sticky pl-1 top-0 z-10 hidden md:block chat-tabs-fade transition-[padding] duration-200 ease-in-out pt-3.25 {uiStore.isSidebarExpanded
? 'max-w-[calc(100vw-20rem)]'
: 'max-w-[calc(100vw-5rem)]'}"
aria-label="Open conversations"
>
<div class="relative">
<ScrollCarousel
class="h-10"
containerClass="flex h-10 min-w-0 items-center"
innerClass="items-center gap-1.25"
{carousel}
>
{#each tabs as tab (tab.id)}
<ChatTabsItem
{tab}
isActive={tab.id === activeId}
isLoading={loadingIds.has(tab.id)}
onActivate={(id) => tabsStore.activate(id)}
onClose={handleClose}
onStop={handleStop}
onAuxClick={handleAuxClick}
/>
{/each}
{#if showNewChatButton}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class="backdrop-blur-lg flex h-8 w-8 mr-4 shrink-0 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-foreground/5"
onclick={() => conversationsStore.openNewChat()}
aria-label="New chat"
>
<Plus class="h-4 w-4 opacity-40 transition-opacity group-hover:opacity-100" />
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>New chat</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</ScrollCarousel>
<div
class="pointer-events-none absolute inset-y-0 left-0 z-[5] w-8 bg-gradient-to-r from-background to-transparent transition-opacity {carousel.canScrollLeft
? 'opacity-100'
: 'opacity-0'}"
></div>
<div
class="pointer-events-none absolute inset-y-0 right-0 z-[5] w-8 bg-gradient-to-l from-background to-transparent transition-opacity {carousel.canScrollRight
? 'opacity-100'
: 'opacity-0'}"
></div>
</div>
</nav>
<style>
.chat-tabs-fade {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--background) 100%, transparent) 25%,
color-mix(in srgb, var(--background) 80%, transparent) 50%,
color-mix(in srgb, var(--background) 40%, transparent) 75%,
transparent 100%
);
}
</style>
@@ -0,0 +1,144 @@
<script lang="ts">
import { Loader2, Square, SquarePen, X } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { cn } from '$lib/components/ui/utils';
import { ROUTES } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
interface Tab {
id: string;
isNewChat: boolean;
name: string;
}
interface Props {
tab: Tab;
isActive?: boolean;
isLoading?: boolean;
onActivate?: (id: string) => void;
onClose?: (id: string) => void;
onStop?: (id: string, event: MouseEvent) => void;
onAuxClick?: (id: string, event: MouseEvent) => void;
}
let {
isActive = false,
isLoading = false,
onActivate,
onAuxClick,
onClose,
onStop,
tab
}: Props = $props();
let contentOpacity = $derived(isActive ? '' : 'opacity-45 group-hover:opacity-75');
let href = $derived(tab.isNewChat ? ROUTES.START : RouterService.chat(tab.id));
function handleActivate(event: MouseEvent) {
// keep routing through tabsStore.activate() so the new-chat sentinel
// and history behave exactly like the programmatic navigation
event.preventDefault();
onActivate?.(tab.id);
}
// stop/close sit inside the tab link; swallow the click so the tab does
// not also navigate
function handleActionClick(event: MouseEvent, action: () => void) {
event.preventDefault();
event.stopPropagation();
action();
}
</script>
<a
data-active-tab={isActive ? 'true' : undefined}
{href}
class={cn(
'flex h-8 max-w-52 min-w-0 shrink-0 cursor-pointer items-center gap-1 rounded-lg pr-1 text-sm whitespace-nowrap no-underline transition-[background-color,border-color,box-shadow] hover:bg-foreground/10 border backdrop-blur-xl first:ml-2',
isLoading ? 'pl-1' : 'pl-3',
isActive
? 'bg-muted/60 border-border/10 shadow-sm text-accent-foreground hover:bg-primary/15'
: 'border-transparent hover:bg-primary/10 hover:border-border/10 hover:shadow-sm'
)}
onclick={handleActivate}
onauxclick={(e) => onAuxClick?.(tab.id, e)}
aria-current={isActive ? 'page' : undefined}
>
{#if isLoading}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class="stop-button flex h-5 w-5 shrink-0 cursor-pointer items-center justify-center rounded-sm text-muted-foreground transition-colors hover:text-foreground"
onclick={(e) => handleActionClick(e, () => onStop?.(tab.id, e))}
aria-label="Stop generation"
>
<Loader2
class="loading-icon h-3.5 w-3.5 animate-spin transition-opacity duration-300 {contentOpacity}"
/>
<Square
class="stop-icon hidden h-3 w-3 fill-current text-destructive transition-opacity {contentOpacity}"
/>
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Stop generation</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if tab.isNewChat}
<SquarePen class="h-3.5 w-3.5 shrink-0 transition-opacity {contentOpacity}" />
{/if}
<span class="truncate transition-opacity {contentOpacity}">{tab.name}</span>
<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',
contentOpacity
)}
onclick={(e) => handleActionClick(e, () => onClose?.(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>
</a>
<style>
.stop-button {
:global(.stop-icon) {
display: none;
}
:global(.loading-icon) {
display: block;
}
&:is(:hover) {
:global(.stop-icon) {
display: block;
}
:global(.loading-icon) {
display: none;
}
}
}
</style>
@@ -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,96 +0,0 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children?: Snippet;
gapSize?: string;
onScrollableChange?: (isScrollable: boolean) => void;
}
let { children, class: className = '', gapSize = '3', onScrollableChange }: Props = $props();
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let scrollContainer: HTMLDivElement | undefined = $state();
function scrollLeft(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (!scrollContainer) return;
scrollContainer.scrollBy({ behavior: 'smooth', left: scrollContainer.clientWidth * -0.67 });
}
function scrollRight(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
if (!scrollContainer) return;
scrollContainer.scrollBy({ behavior: 'smooth', left: scrollContainer.clientWidth * 0.67 });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { clientWidth, scrollLeft, scrollWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1;
const isScrollable = scrollWidth > clientWidth;
onScrollableChange?.(isScrollable);
}
export function resetScroll() {
if (scrollContainer) {
scrollContainer.scrollLeft = 0;
setTimeout(() => {
updateScrollButtons();
}, 0);
}
}
$effect(() => {
if (!scrollContainer) return;
const observer = new ResizeObserver(() => updateScrollButtons());
observer.observe(scrollContainer);
return () => observer.disconnect();
});
</script>
<div class="relative {className}">
<button
class="absolute top-1/2 left-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0"
onclick={scrollLeft}
disabled={!canScrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class={ICON_CLASS_DEFAULT} />
</button>
<div
class="scrollbar-hide flex items-start gap-{gapSize} overflow-x-auto"
bind:this={scrollContainer}
onscroll={updateScrollButtons}
>
{@render children?.()}
</div>
<button
class="absolute top-1/2 right-4 z-10 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-background/25 shadow-md backdrop-blur-xs transition-opacity hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0"
onclick={scrollRight}
disabled={!canScrollRight}
aria-label="Scroll right"
>
<ChevronRight class={ICON_CLASS_DEFAULT} />
</button>
</div>
@@ -21,13 +21,6 @@
*/
export { default as ConversationSelection } from './ConversationSelection.svelte';
/**
* Horizontal scrollable carousel with navigation arrows.
* Used for displaying items in a horizontally scrollable container
* with left/right navigation buttons that appear on hover.
*/
export { default as HorizontalScrollCarousel } from './HorizontalScrollCarousel.svelte';
/**
* **TruncatedText** - Text with ellipsis and tooltip
*
@@ -1,16 +1,22 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelModalities } from '$lib/types/models';
interface Props {
modelId: string;
hideOrgName?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
modalities?: ModelModalities;
supportsThinking?: boolean;
class?: string;
}
@@ -20,8 +26,11 @@
hideOrgName = false,
hideQuantization,
hideTags,
modalities,
modelId,
showRaw = undefined,
showRawTooltip = false,
supportsThinking = false,
tags,
...rest
}: Props = $props();
@@ -42,6 +51,9 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
let hasModalityIcons = $derived(
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
);
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -50,37 +62,103 @@
{#if resolvedShowRaw}
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
<span class="inline-flex items-center gap-1">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{#if hasModalityIcons}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#if supportsThinking}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
</span>
{/if}
@@ -0,0 +1,27 @@
<script lang="ts">
import ModelsSelectorSettingsFields from './ModelsSelectorSettingsFields.svelte';
import { Funnel } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import type { SettingsSection } from '$lib/types';
interface Props {
section: SettingsSection;
disabled?: boolean;
}
let { disabled = false, section }: Props = $props();
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2" {disabled}>
<Funnel class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="text-sm text-muted-foreground">Sampling & Penalties</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="max-h-96 w-72 overflow-y-auto bg-popover p-3 pt-2 text-popover-foreground shadow-md outline-none"
>
<ModelsSelectorSettingsFields {section} />
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -1,20 +1,28 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
ModelsSelectorAdvancedSubmenu,
ModelsSelectorList,
ModelsSelectorOption
} from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import {
MODEL_SELECTOR_ICON,
SETTINGS_CHAT_SECTIONS,
SETTINGS_KEYS,
SETTINGS_SECTION_SLUGS
} from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore } from '$lib/stores';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
@@ -37,6 +45,9 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
// The model submenu opens together with the menu so the list and its search
// box are immediately available, as before the submenu was introduced
let modelSubOpen = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -44,24 +55,49 @@
onOpenChange: (open) => {
isOpen = open;
highlightedId = null;
if (open) {
// Defer submenu open so the Sub component is mounted first;
// setting bind:open synchronously can be lost if the Sub hasn't
// rendered yet.
queueMicrotask(() => {
if (isOpen) modelSubOpen = true;
});
} else {
modelSubOpen = false;
}
},
useGlobalSelection: () => useGlobalSelection
});
const reasoning = useReasoningMenu();
// Sampling/penalties need a loaded model to show server defaults, so
// disable the submenu until one is loaded.
const hasModelLoaded = $derived(modelsStore.loadedModelIds.length > 0);
const samplingPenaltiesSection = $derived(
SETTINGS_CHAT_SECTIONS.find((s) => s.slug === SETTINGS_SECTION_SLUGS.SAMPLING_PENALTIES)
);
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
$effect(() => {
void ms.searchTerm;
highlightedId = null;
});
// Focus the dropdown's search box without scrolling the page. bits-ui
// Focus the model submenu's search box without scrolling the page. bits-ui
// auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here.
$effect(() => {
if (!isOpen) return;
if (!isOpen || !modelSubOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input'
'[data-slot="dropdown-menu-sub-content"] input'
);
search?.focus({ preventScroll: true });
@@ -188,7 +224,7 @@
<DropdownMenu.Trigger
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
@@ -203,16 +239,22 @@
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
<span class="flex min-w-0 items-center gap-1">
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideQuantization
hideOrgName={!showOrgNameInTrigger}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
</span>
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -236,73 +278,101 @@
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
searchValue={ms.searchTerm}
onSearchChange={(v) => ms.setSearchTerm(v)}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
disabled
>
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
<DropdownMenu.Sub bind:open={modelSubOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<MODEL_SELECTOR_ICON class="h-4 w-4" />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{option}
{isSelected}
{isHighlighted}
{isFav}
{hideOrgName}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
onMouseEnter={() => (highlightedId = option.id)}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
{#if selectedOption}
<ModelId
modelId={selectedOption.model}
class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
/>
{/snippet}
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<ModelsSelectorList
groups={ms.groupedFilteredOptions}
{currentModel}
activeId={ms.activeId}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
renderOption={modelOption}
/>
</div>
</DropdownMenuSearchable>
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
<DropdownMenuSearchable
searchValue={ms.searchTerm}
onSearchChange={(v) => ms.setSearchTerm(v)}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
disabled
>
<ModelId modelId={currentModel} class="flex-1" hideQuantization />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{option}
{isSelected}
{isHighlighted}
{isFav}
{hideOrgName}
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
onMouseEnter={() => (highlightedId = option.id)}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
/>
{/snippet}
<ModelsSelectorList
groups={ms.groupedFilteredOptions}
{currentModel}
activeId={ms.activeId}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
onSelect={ms.handleSelect}
onInfoClick={ms.handleInfoClick}
renderOption={modelOption}
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
{#if samplingPenaltiesSection}
<ModelsSelectorAdvancedSubmenu
section={samplingPenaltiesSection}
disabled={!hasModelLoaded}
/>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -333,11 +403,15 @@
<ModelId
modelId={selectedOption.model}
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
/>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
@@ -58,15 +58,19 @@
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
</script>
<div
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer',
isSelected && 'bg-accent/50 text-accent-foreground',
isSelected && !isHighlighted && 'bg-accent/50',
isHighlighted && 'bg-accent',
!isSelected && !isHighlighted && 'hover:bg-muted',
(isSelected || isHighlighted) && 'text-accent-foreground',
'hover:bg-accent',
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
role="option"
@@ -82,6 +86,9 @@
{hideOrgName}
aliases={option.aliases}
tags={option.tags}
{modalities}
{supportsThinking}
showRawTooltip
class="flex-1"
/>
@@ -0,0 +1,78 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import { SettingsFieldType } from '$lib/enums/settings.enums';
import { modelsStore, serverStore, settingsStore } from '$lib/stores';
import type { SettingsSection } from '$lib/types';
import { normalizeFloatingPoint } from '$lib/utils/precision';
interface Props {
section: SettingsSection;
}
let { section }: Props = $props();
let currentModelParams = $derived.by(() => {
void modelsStore.props.cacheVersion;
if (serverStore.isRouterMode) {
const currentModelName = modelsStore.selectedModelName;
if (currentModelName) {
const currentModelProps = modelsStore.props.getModelProps(currentModelName);
return (currentModelProps?.default_generation_settings?.params ?? {}) as Record<
string,
unknown
>;
}
}
return (serverStore.defaultParams ?? {}) as Record<string, unknown>;
});
function currentValue(key: string): string {
const value = settingsStore.config[key];
return value == null ? '' : String(value);
}
function handleInput(key: string, value: string) {
settingsStore.updateConfig(key, value);
}
function placeholder(key: string): string {
const serverDefault = currentModelParams[key];
return serverDefault != null ? `Default: ${normalizeFloatingPoint(serverDefault)}` : '';
}
</script>
<div class="grid gap-1">
{#each section.fields ?? [] as field (field.key)}
{#if field.type === SettingsFieldType.INPUT}
<label class="mb-2 flex flex-col gap-1.5">
<span class="text-xs font-medium text-muted-foreground">{field.label}</span>
<Input
type="text"
value={currentValue(field.key)}
placeholder={placeholder(field.key)}
oninput={(event) => handleInput(field.key, event.currentTarget.value)}
class="h-8"
/>
</label>
{:else if field.type === SettingsFieldType.CHECKBOX}
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-1 py-1.5 text-sm hover:bg-accent"
>
<Checkbox
checked={Boolean(settingsStore.config[field.key])}
onCheckedChange={(checked) => settingsStore.updateConfig(field.key, Boolean(checked))}
/>
<span>{field.label}</span>
</label>
{/if}
{/each}
</div>
@@ -0,0 +1,26 @@
<script lang="ts">
import ModelsSelectorSettingsFields from './ModelsSelectorSettingsFields.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import type { SettingsSection } from '$lib/types';
interface Props {
section: SettingsSection;
disabled?: boolean;
}
let { disabled = false, section }: Props = $props();
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2" {disabled}>
<section.icon class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="text-sm text-muted-foreground">{section.title}</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="max-h-96 w-72 overflow-y-auto bg-popover p-2 text-popover-foreground shadow-md outline-none"
>
<ModelsSelectorSettingsFields {section} />
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -65,6 +65,10 @@ export { default as ModelsSelectorList } from './ModelsSelectorList.svelte';
*/
export { default as ModelsSelectorOption } from './ModelsSelectorOption.svelte';
export { default as ModelsSelectorAdvancedSubmenu } from './ModelsSelectorAdvancedSubmenu.svelte';
export { default as ModelsSelectorSettingsSubmenu } from './ModelsSelectorSettingsSubmenu.svelte';
/**
* **ModelsSelectorSheet** - Mobile model selection sheet
*
@@ -1,3 +1,4 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
available: OrgGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
const modalities = option.modalities;
if (!modalities) return false;
switch (term) {
case ModelModality.VISION.toLowerCase():
return modalities.vision;
case ModelModality.AUDIO.toLowerCase():
return modalities.audio;
case ModelModality.VIDEO.toLowerCase():
return modalities.video;
default:
return false;
}
}
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase();
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
matchesModality(option, term)
);
}
@@ -14,7 +14,7 @@
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, deviceStore, settingsStore } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore, settingsStore, uiStore } from '$lib/stores';
import { buildConversationTree } from '$lib/utils';
import { circIn } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
@@ -31,30 +31,29 @@
toggleSidebar: () => toggleExpandedMode()
});
let isExpandedMode = $state(false);
let hoveredTooltip = $state<string | null>(null);
let logoHovered = $state(false);
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
const isStripExpanded = $derived(uiStore.isSidebarExpanded || hoveredTooltip !== null);
const isOnMobile = $derived(deviceStore.isMobile);
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
$effect(() => {
if (alwaysShowOnDesktop && !isOnMobile) {
isExpandedMode = true;
uiStore.isSidebarExpanded = true;
}
});
function toggleExpandedMode() {
isExpandedMode = !isExpandedMode;
uiStore.isSidebarExpanded = !uiStore.isSidebarExpanded;
if (!isExpandedMode) {
if (!uiStore.isSidebarExpanded) {
hoveredTooltip = null;
}
}
$effect(() => {
if (!isExpandedMode) {
if (!uiStore.isSidebarExpanded) {
isSearchModeActive = false;
searchQuery = '';
@@ -66,7 +65,7 @@
$effect(() => {
if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
isExpandedMode = false;
uiStore.isSidebarExpanded = false;
}
});
@@ -294,7 +293,7 @@
}
pendingCollapse = setTimeout(() => {
isExpandedMode = false;
uiStore.isSidebarExpanded = false;
pendingCollapse = null;
}, 100);
}
@@ -314,7 +313,7 @@
class={[
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
'md:h-[calc(100dvh-1.125rem)]',
isExpandedMode &&
uiStore.isSidebarExpanded &&
(deviceStore.isStandalone
? 'h-[calc(100dvh-2rem)]'
: deviceStore.isIOSDevice
@@ -323,9 +322,9 @@
'rounded-3xl md:rounded-2xl',
'flex flex-col justify-between',
'md:transition-[width,padding] duration-200 ease-out',
isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl border-border shadow-md',
isStripExpanded && 'md:w-72 md:bg-muted/60 md:backdrop-blur-xl shadow-md',
!isStripExpanded && 'md:w-12',
isExpandedMode && 'is-expanded'
uiStore.isSidebarExpanded && 'is-expanded'
]}
>
<div class="px-2 flex items-center justify-between">
@@ -337,24 +336,26 @@
onmouseleave={() => (logoHovered = false)}
>
<ActionIcon
icon={!isExpandedMode && logoHovered && innerWidth > 768 ? PanelLeftOpen : Logo}
icon={!uiStore.isSidebarExpanded && logoHovered && innerWidth > 768
? PanelLeftOpen
: Logo}
size="lg"
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
class="{isExpandedMode
class="{uiStore.isSidebarExpanded
? 'bg-muted! md:bg-foreground/5!'
: 'bg-transparent!'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
href={isExpandedMode ? ROUTES.START : undefined}
onclick={isExpandedMode ? undefined : toggleExpandedMode}
tooltip={isExpandedMode ? undefined : 'Open Sidebar'}
: 'bg-transparent'} md:h-9 md:w-9 h-10 w-10 rounded-full md:hover:bg-foreground/10! pointer-events-auto"
href={uiStore.isSidebarExpanded ? ROUTES.START : undefined}
onclick={uiStore.isSidebarExpanded ? undefined : toggleExpandedMode}
tooltip={uiStore.isSidebarExpanded ? undefined : 'Open Sidebar'}
tooltipSide={TooltipSide.RIGHT}
ariaLabel={isExpandedMode ? 'Go to start' : 'Expand navigation'}
ariaLabel={uiStore.isSidebarExpanded ? 'Go to start' : 'Expand navigation'}
/>
</div>
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
{#if isOnMobile || (uiStore.isSidebarExpanded && !alwaysShowOnDesktop)}
<div
class="flex items-center transition-all duration-150 ease-out {deviceStore.isMobile &&
!isExpandedMode
!uiStore.isSidebarExpanded
? 'opacity-0 h-0!'
: ''}"
in:fade={{ delay: 50, duration: 150, easing: circIn }}
@@ -377,12 +378,12 @@
<div
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {deviceStore.isMobile
? 'transition-[opacity,height] duration-200 ease-out'
: ''} {deviceStore.isMobile && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
: ''} {deviceStore.isMobile && !uiStore.isSidebarExpanded ? 'opacity-0 !h-0' : ''}"
in:fade={{ duration: 200 }}
out:fade={{ duration: 200 }}
>
<SidebarNavigationActions
isExpandedMode={innerWidth > 768 ? isExpandedMode : true}
isExpandedMode={innerWidth > 768 ? uiStore.isSidebarExpanded : true}
class="px-2"
bind:isSearchModeActive
bind:searchQuery
@@ -391,7 +392,7 @@
searchQuery = '';
}}
onSearchClick={() => {
isExpandedMode = true;
uiStore.isSidebarExpanded = true;
isSearchModeActive = true;
}}
onNewChat={() => {
@@ -401,7 +402,7 @@
}}
/>
{#if isExpandedMode || isOnMobile}
{#if uiStore.isSidebarExpanded || isOnMobile}
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
<SidebarNavigationConversationList
class="px-2"
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { deviceStore } from '$lib/stores';
import { conversationsStore, deviceStore } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -109,14 +109,20 @@
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
{@const itemOnClick = item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemOnClick =
item.action === 'new-chat'
? () => {
onNewChat?.();
conversationsStore.openNewChat();
}
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -157,14 +163,20 @@
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemOnClick =
item.action === 'new-chat'
? () => {
onNewChat?.();
conversationsStore.openNewChat();
}
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -1,5 +1,6 @@
<script lang="ts">
import { ChevronLeft, ChevronRight, Settings } from '@lucide/svelte';
import { Settings } from '@lucide/svelte';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString } from '$lib/enums';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
@@ -44,70 +45,42 @@
</div>
<div class="border-b border-border/30 py-2">
<div class="relative flex items-center" style="scroll-padding: 1rem;">
<button
class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {carousel.canScrollLeft
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={carousel.scrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class={ICON_CLASS_DEFAULT} />
</button>
<div
class="scrollbar-hide overflow-x-auto py-2"
bind:this={carousel.scrollContainer}
onscroll={carousel.updateScrollButtons}
>
<div class="flex min-w-max gap-2">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
href={getHref(section)}
onclick={(e: MouseEvent) => {
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</a>
{:else}
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</button>
{/if}
{/each}
</div>
</div>
<button
class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {carousel.canScrollRight
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={carousel.scrollRight}
aria-label="Scroll right"
>
<ChevronRight class={ICON_CLASS_DEFAULT} />
</button>
</div>
<ScrollCarousel {carousel} innerClass="gap-2">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
href={getHref(section)}
onclick={(e: MouseEvent) => {
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</a>
{:else}
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</button>
{/if}
{/each}
</ScrollCarousel>
</div>
</div>
@@ -0,0 +1,3 @@
import ScrollCarousel from './scroll-carousel.svelte';
export { ScrollCarousel };
@@ -0,0 +1,129 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
/** External carousel hook for callers that need to drive it (e.g. scrollToCenter). */
carousel?: ReturnType<typeof useScrollCarousel>;
/** Classes for the outer relative wrapper. */
class?: string;
/** Classes for the scrollable overflow container. */
containerClass?: string;
/** Classes for the min-w-max content wrapper. */
innerClass?: string;
/** Tailwind gap class applied to the content wrapper. */
gapSize?: string;
/** Arrow placement and styling. */
variant?: 'top' | 'center';
/** Called with whether the content overflows the container. */
onScrollableChange?: (isScrollable: boolean) => void;
/** Pixels scrolled per arrow click; defaults to ~2/3 of the container width. */
scrollBy?: number;
}
let {
carousel: externalCarousel,
children,
class: className = '',
containerClass = '',
gapSize = '3',
innerClass = '',
onScrollableChange,
scrollBy,
variant = 'top'
}: Props = $props();
const internalCarousel = $derived(useScrollCarousel(onScrollableChange));
const carousel = $derived(externalCarousel ?? internalCarousel);
const isCenter = $derived(variant === 'center');
function scrollLeft(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
const container = carousel.scrollContainer;
if (!container) return;
container.scrollBy({ behavior: 'smooth', left: -(scrollBy ?? container.clientWidth * 0.67) });
}
function scrollRight(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
const container = carousel.scrollContainer;
if (!container) return;
container.scrollBy({ behavior: 'smooth', left: scrollBy ?? container.clientWidth * 0.67 });
}
export function resetScroll() {
const container = carousel.scrollContainer;
if (!container) return;
container.scrollLeft = 0;
setTimeout(() => carousel.updateScrollButtons(), 0);
}
</script>
<div
class={cn('group relative', !isCenter && 'flex items-center', className)}
style={!isCenter ? 'scroll-padding: 1rem;' : undefined}
>
<button
class={cn(
'absolute z-10 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-opacity',
isCenter
? 'top-1/2 left-4 -translate-y-1/2 bg-background/25 backdrop-blur-xs hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0'
: 'left-2 bg-muted backdrop-blur-sm hover:bg-accent',
!isCenter &&
(carousel.canScrollLeft
? 'opacity-0 group-hover:opacity-100'
: 'pointer-events-none opacity-0')
)}
{...isCenter ? { disabled: !carousel.canScrollLeft } : {}}
onclick={scrollLeft}
aria-label="Scroll left"
>
<ChevronLeft class={ICON_CLASS_DEFAULT} />
</button>
<div
class={cn('scrollbar-hide overflow-x-auto', containerClass)}
bind:this={carousel.scrollContainer}
onscroll={carousel.updateScrollButtons}
>
<div
class={cn('flex min-w-max', `gap-${gapSize}`, innerClass)}
bind:this={carousel.contentContainer}
>
{@render children?.()}
</div>
</div>
<button
class={cn(
'absolute z-10 flex h-6 w-6 items-center justify-center rounded-full shadow-md transition-opacity',
isCenter
? 'top-1/2 right-4 -translate-y-1/2 bg-background/25 backdrop-blur-xs hover:bg-background/45 disabled:pointer-events-none disabled:opacity-0'
: 'right-2 bg-muted backdrop-blur-sm hover:bg-accent',
!isCenter &&
(carousel.canScrollRight
? 'opacity-0 group-hover:opacity-100'
: 'pointer-events-none opacity-0')
)}
{...isCenter ? { disabled: !carousel.canScrollRight } : {}}
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class={ICON_CLASS_DEFAULT} />
</button>
</div>
@@ -4,8 +4,6 @@ export const URL_PARAMS = {
LOAD: 'load',
/** Model to select. */
MODEL: 'model',
/** Start a new chat. */
NEW_CHAT: 'new_chat',
/** Prompt to send on arrival. */
QUERY: 'q'
} as const;
@@ -15,8 +13,6 @@ export const ROUTES = {
CHAT: '#/chat',
/** MCP servers. */
MCP_SERVERS: '#/mcp-servers',
/** New chat — root with new chat query param. */
NEW_CHAT: `?${URL_PARAMS.NEW_CHAT}=true#/`,
/** Search — mobile-only full-page conversation search. */
SEARCH: '#/search',
/** Settings base — for dynamic settings URLs use RouterService. */
@@ -11,6 +11,7 @@ export const SETTINGS_KEYS = {
API_KEY: 'apiKey',
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
BACKEND_SAMPLING: 'backend_sampling',
CONVERSATION_TABS: 'conversationTabs',
COPY_TEXT_ATTACHMENTS_AS_PLAIN_TEXT: 'copyTextAttachmentsAsPlainText',
CUSTOM_CSS: 'customCss',
// PY_INTERPRETER_ENABLED: 'pyInterpreterEnabled',
@@ -53,6 +54,7 @@ export const SETTINGS_KEYS = {
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
@@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
defaultValue: true,
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
isExperimental: true,
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX
@@ -255,6 +254,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Always show sidebar on desktop',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: true,
help: 'Show open chats as browser-style tabs above the conversation, one per open chat. When disabled, only one chat is shown at a time.',
key: SETTINGS_KEYS.CONVERSATION_TABS,
label: 'Conversation tabs',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display full raw model identifiers (e.g. "ggml-org/GLM-4.7-Flash-GGUF:Q8_0") instead of parsed names with badges.',
@@ -276,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show model tags',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the organization name in the model selector trigger button.',
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
label: 'Show organization name in model selector trigger',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the current build version in the bottom-right corner of the interface.',
@@ -22,6 +22,7 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CHAT_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.chatTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
+1 -1
View File
@@ -55,7 +55,7 @@ export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
export const MAX_HEIGHT_CODE_BLOCK = '22rem';
export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{ icon: SquarePen, keys: ['shift', 'cmd', 'o'], route: ROUTES.NEW_CHAT, tooltip: 'New chat' },
{ action: 'new-chat', icon: SquarePen, keys: ['shift', 'cmd', 'o'], tooltip: 'New chat' },
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
activeRouteId: '/mcp-servers',
+3 -1
View File
@@ -16,5 +16,7 @@ export enum KeyboardKey {
O_LOWER = 'o',
O_UPPER = 'O',
SPACE = ' ',
TAB = 'Tab'
TAB = 'Tab',
X_LOWER = 'x',
X_UPPER = 'X'
}
@@ -1,6 +1,6 @@
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants';
import { page } from '$app/state';
import { KeyboardKey } from '$lib/enums';
import { conversationsStore, NEW_CHAT_TAB_ID, settingsStore, tabsStore } from '$lib/stores';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
@@ -9,6 +9,8 @@ interface KeyboardShortcutsCallbacks {
deleteActiveConversation?: () => void;
navigateToPrevConversation?: () => void;
navigateToNextConversation?: () => void;
navigateToPrevTab?: () => void;
navigateToNextTab?: () => void;
toggleSidebar?: () => void;
}
@@ -34,7 +36,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
) {
event.preventDefault();
goto(ROUTES.NEW_CHAT);
conversationsStore.openNewChat();
}
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
@@ -42,6 +44,28 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
callbacks.editActiveConversation?.();
}
if (
event.shiftKey &&
isCmdOrCtrl &&
(event.key === KeyboardKey.X_LOWER || event.key === KeyboardKey.X_UPPER)
) {
// several components register this shortcut; only let the first handler
// act so the synchronous navigation does not cascade-close every tab
if (event.defaultPrevented) return;
event.preventDefault();
// close-tab only makes sense with conversation tabs enabled
if (!settingsStore.config.conversationTabs) return;
const activeId =
page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
if (activeId) {
void tabsStore.close(activeId, activeId);
}
}
if (
isCmdOrCtrl &&
event.shiftKey &&
@@ -60,6 +84,16 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
event.preventDefault();
callbacks.navigateToNextConversation?.();
}
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_LEFT) {
event.preventDefault();
callbacks.navigateToPrevTab?.();
}
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_RIGHT) {
event.preventDefault();
callbacks.navigateToNextTab?.();
}
}
return { handleKeydown };
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
readonly thinkingEnabled: boolean;
readonly isReasoningActive: boolean;
readonly isOff: boolean;
readonly currentEffort: ReasoningEffort;
readonly levels: ReasoningEffortLevel[];
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
// Thinking is effectively on (lightbulb lit) either when an explicit effort
// is selected, or when the effort is left at "Default" and the model
// supports thinking.
const isReasoningActive = $derived(
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
);
return {
get currentEffort() {
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
get isOff() {
return currentEffort === ReasoningEffort.OFF;
},
get isReasoningActive() {
return isReasoningActive;
},
isSelected(level: ReasoningEffortLevel): boolean {
return currentEffort === level.value;
},
@@ -1,7 +1,8 @@
export function useScrollCarousel() {
export function useScrollCarousel(onScrollableChange?: (isScrollable: boolean) => void) {
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let scrollContainer = $state<HTMLDivElement | undefined>();
let contentContainer = $state<HTMLDivElement | undefined>();
function scrollToCenter(element: HTMLElement) {
if (!scrollContainer) return;
@@ -34,12 +35,25 @@ export function useScrollCarousel() {
canScrollLeft = sl > 0;
canScrollRight = sl < scrollWidth - clientWidth - 1;
onScrollableChange?.(scrollWidth > clientWidth);
}
// Re-evaluate arrow visibility whenever the container or its content resizes,
// otherwise the arrows may not appear when overflowing items are added (e.g. new
// tabs/attachments) and the user has not scrolled yet.
$effect(() => {
if (scrollContainer) {
updateScrollButtons();
}
if (!scrollContainer) return;
updateScrollButtons();
const observer = new ResizeObserver(() => updateScrollButtons());
observer.observe(scrollContainer);
if (contentContainer) observer.observe(contentContainer);
return () => observer.disconnect();
});
return {
@@ -49,6 +63,12 @@ export function useScrollCarousel() {
get canScrollRight() {
return canScrollRight;
},
get contentContainer() {
return contentContainer;
},
set contentContainer(el: HTMLDivElement | undefined) {
contentContainer = el;
},
get scrollContainer() {
return scrollContainer;
},
@@ -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.
+10 -1
View File
@@ -187,8 +187,17 @@ export class ModelsService {
// 6. Model name = segments before params; tags = remaining segments after params
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
const modelSegments = segments.slice(0, pivotIdx);
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
// strip trailing container-format segments (e.g. GGUF) from the model name
while (
modelSegments.length > 0 &&
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
) {
modelSegments.pop();
}
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
@@ -21,6 +21,7 @@ import {
type ConversationsPreferencesHost
} from '$lib/stores/conversations/preferences.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
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';
@@ -138,8 +139,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 +282,12 @@ class ConversationsStore implements ConversationsPreferencesHost {
this.clearActiveConversation();
this.conversations = [];
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');
@@ -313,8 +320,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 +345,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]);
@@ -571,6 +585,17 @@ class ConversationsStore implements ConversationsPreferencesHost {
return () => this.conversationDeletionListeners.delete(listener);
}
/**
* 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 openNewChat(): Promise<string> {
this.clearActiveConversation();
await goto(ROUTES.START);
return NEW_CHAT_TAB_ID;
}
/**
* Refreshes active messages based on currNode after branch navigation.
*/
@@ -676,14 +701,6 @@ class ConversationsStore implements ConversationsPreferencesHost {
);
}
/**
*
*
* Import & Export
*
*
*/
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
@@ -717,6 +734,14 @@ class ConversationsStore implements ConversationsPreferencesHost {
}
}
/**
*
*
* Import & Export
*
*
*/
private notifyConversationsDeleted(convIds: string[]): void {
if (convIds.length === 0) return;
@@ -152,11 +152,13 @@ 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, {
await DatabaseService.updateConversation(id, {
cwd: trimmed
});
@@ -202,12 +204,15 @@ 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
await DatabaseService.updateConversation(id, {
mcpServerOverrides: overrides
});
}
@@ -224,11 +229,13 @@ 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, {
await DatabaseService.updateConversation(id, {
reasoningEffort: effort
});
}
+6
View File
@@ -22,6 +22,9 @@ export { chatStore } from './chat/index.svelte';
export { draftMessagesStore } from './chat/drafts.svelte';
// CONVERSATION TABS
export { NEW_CHAT_TAB_ID, tabsStore } from './tabs.svelte';
// CONTEXT STATS (active conversation context window usage)
export { contextStatsStore } from './chat/context-stats.svelte';
@@ -40,6 +43,9 @@ export { modelsStore } from './models/index.svelte';
// SERVER
export { serverStore } from './server.svelte';
// UI / LAYOUT
export { uiStore } from './ui.svelte';
// SETTINGS / UI PREFERENCES
export { settingsStore } from './settings/index.svelte';
+5 -15
View File
@@ -1,21 +1,8 @@
/**
* Explicit store initialization, run once and shared by every caller.
*
* Order matters: migrations run first because they rename and rewrite
* localStorage keys, so every store that reads localStorage initializes
* only after they complete. Constructors and module-level side effects
* stay empty so import order can no longer change startup behavior.
*
* The returned promise resolves once the persisted state is in memory, which
* route loads await before reading settings: they run ahead of the root layout
* script. The conversation list loads in the background, awaited by the chat
* page that renders it.
*/
// direct imports, not via the barrel, to avoid circular deps
import { conversationsStore } from './conversations/index.svelte';
import { permissionsStore } from './permissions.svelte';
import { settingsStore } from './settings/index.svelte';
import { tabsStore } from './tabs.svelte';
import { toolsStore } from './tools.svelte';
import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
@@ -33,7 +20,10 @@ export function initStores(): Promise<void> {
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
void conversationsStore.initialize();
await conversationsStore.initialize();
// prune persisted tabs against the loaded conversation list
tabsStore.init(conversationsStore.conversations.map((c) => c.id));
})();
return startup;
+145
View File
@@ -0,0 +1,145 @@
/**
* tabsStore - Reactive State Store for Browser-Style Conversation Tabs
*
* 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()` /
* `close()` when conversations are deleted. This store never imports it,
* 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. The new-chat tab is dropped on reload (it is
* not a conversation), which matches browser behavior.
*/
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
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 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 (the new-chat sentinel maps to the bare `#/` route) */
async activate(id: string): Promise<void> {
await goto(id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id));
}
/** Remove all tabs (e.g. after deleting all conversations) */
clear(): void {
this.openTabs = [];
this.save();
}
/**
* Close a tab. When it belongs to the active route, navigate to the left
* neighbor (or the right one when the closed tab was leftmost), falling
* back to the new-chat screen when no tabs remain.
* @param id - Tab id to close
* @param activeTabId - Tab id of the current route, if any
*/
async close(id: string, activeTabId: string | null): Promise<void> {
const idx = this.openTabs.indexOf(id);
if (idx === -1) return;
this.openTabs = this.openTabs.filter((tabId) => tabId !== id);
this.save();
if (id !== activeTabId) return;
const target = (idx > 0 ? this.openTabs[idx - 1] : this.openTabs[0]) ?? null;
if (target) {
await goto(target === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(target));
} else {
await goto(ROUTES.START);
}
}
/**
* Load persisted tabs, dropping conversation ids that no longer exist.
* Called once from initStores() after conversations are loaded.
* Merges with (rather than replaces) current openTabs: the chat layout
* syncs the route's tab before this async init completes, and replacing
* here would drop it.
* @param validIds - Ids of conversations present in the database
*/
init(validIds: string[]): void {
if (!browser) return;
const valid = new Set(validIds);
const persisted = this.load().filter((id) => valid.has(id));
const extras = this.openTabs.filter((id) => valid.has(id) && !persisted.includes(id));
this.openTabs = [...persisted, ...extras];
this.initialized = true;
this.save();
}
/**
* Remove tabs without navigating. Used when conversations are deleted
* while some other conversation stays open.
* @param ids - Tab ids to drop
*/
removeTabs(ids: string[]): void {
const removed = new Set(ids);
const next = this.openTabs.filter((id) => !removed.has(id));
if (next.length !== this.openTabs.length) {
this.openTabs = next;
this.save();
}
}
/**
* Sync the tab strip with the route. Called from the chat layout on every
* navigation, so any way of reaching a conversation or new-chat tab opens
* a tab for it.
* @param id - The conversation (or temporary new-chat) id of the route
*/
syncWithRoute(id: string): void {
// untrack: callers invoke this from an effect keyed on the route, and
// reading openTabs here would subscribe that effect to openTabs too -
// closing the active tab would then re-run the effect and re-add the tab
untrack(() => {
if (!this.openTabs.includes(id)) {
this.openTabs = [...this.openTabs, id];
this.save();
}
});
}
private load(): string[] {
try {
const raw = localStorage.getItem(CHAT_TABS_LOCALSTORAGE_KEY);
const parsed: unknown = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
} catch {
return [];
}
}
private save(): void {
// never write before init has read the persisted tabs, or an early
// route sync (layout effect runs before async init) would clobber them
if (!browser || !this.initialized) return;
localStorage.setItem(CHAT_TABS_LOCALSTORAGE_KEY, JSON.stringify(this.openTabs));
}
}
export const tabsStore = new TabsStore();
+14
View File
@@ -0,0 +1,14 @@
/**
* uiStore - Shared UI/layout state
*
* Holds cross-component UI state that does not belong to a single component
* (e.g. the desktop sidebar's expanded/collapsed state, which the sidebar
* controls and the chat tab bar reacts to).
*/
class UiStore {
/** Whether the desktop sidebar is expanded (open). */
isSidebarExpanded = $state(false);
}
export const uiStore = new UiStore();
+2
View File
@@ -7,6 +7,8 @@ export interface DesktopIconStripItem {
icon: Component;
tooltip: string;
route?: string;
/** Custom action handled by the sidebar, e.g. opening a new-chat tab */
action?: 'new-chat';
activeRouteId?: string;
activeRoutePrefix?: string;
activeUrlIncludes?: string;
+26 -2
View File
@@ -1,12 +1,36 @@
<script lang="ts">
import { page } from '$app/state';
import { ChatScreen } from '$lib/components/app';
import { ChatScreen, ChatTabs } from '$lib/components/app';
import { NEW_CHAT_TAB_ID, settingsStore, tabsStore } from '$lib/stores';
let { children } = $props();
// 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))
);
// any navigation to a conversation or the new-chat screen opens a tab for it
$effect(() => {
const id = page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
if (id && settingsStore.config.conversationTabs) {
tabsStore.syncWithRoute(id);
}
});
</script>
<ChatScreen {showCenteredEmpty} />
<div class={showTabs ? 'md:[--chat-tabs-offset:1.25rem]' : ''}>
{#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 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.openNewChat();
}
await modelsStore.ensureFirstModelSelected();
@@ -96,9 +30,3 @@
<svelte:head>
<title>{APP_NAME}</title>
</svelte:head>
<DialogModelNotAvailable
bind:open={showModelNotAvailable}
modelName={requestedModelName}
availableModels={availableModelNames}
/>
+41 -2
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';
@@ -74,6 +76,27 @@
}
}
function navigateToTab(direction: -1 | 1) {
// only makes sense with conversation tabs enabled
if (!settingsStore.config.conversationTabs) return;
const openTabs = tabsStore.openTabs;
if (openTabs.length === 0) return;
const activeId = page.params.id ?? NEW_CHAT_TAB_ID;
const idx = openTabs.indexOf(activeId);
// active tab not in list (e.g. a non-chat route): start from an edge
const targetIdx =
idx === -1
? direction === 1
? 0
: openTabs.length - 1
: (idx + direction + openTabs.length) % openTabs.length;
void tabsStore.activate(openTabs[targetIdx]);
}
function navigateToConversation(direction: -1 | 1) {
const allConvs = conversationsStore.conversations;
@@ -96,15 +119,31 @@
if (targetIdx >= 0 && targetIdx < allConvs.length) {
goto(RouterService.chat(allConvs[targetIdx].id));
} else {
goto(ROUTES.NEW_CHAT);
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?.(),
navigateToNextConversation: () => navigateToConversation(1),
navigateToPrevConversation: () => navigateToConversation(-1)
navigateToNextTab: () => navigateToTab(1),
navigateToPrevConversation: () => navigateToConversation(-1),
navigateToPrevTab: () => navigateToTab(-1)
});
function checkApiKey() {
+3 -4
View File
@@ -3,7 +3,6 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
import { ROUTES } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
@@ -21,10 +20,10 @@
});
// Search page is intended for mobile; on desktop the sidebar already exposes
// in-place search, so bounce back to a chat.
// in-place search, so bounce back to a new-chat tab.
$effect(() => {
if (browser && !deviceStore.isMobile) {
goto(ROUTES.NEW_CHAT, { replaceState: true });
conversationsStore.openNewChat();
}
});
@@ -66,7 +65,7 @@
if (history.length > 1) {
history.back();
} else {
goto(ROUTES.NEW_CHAT);
conversationsStore.openNewChat();
}
}
</script>
@@ -1,15 +1,15 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import HorizontalScrollCarousel from '$lib/components/app/misc/HorizontalScrollCarousel.svelte';
import { ScrollCarousel } from '$lib/components/ui/scroll-carousel';
import { expect, waitFor } from 'storybook/test';
const { Story } = defineMeta({
component: HorizontalScrollCarousel,
component: ScrollCarousel,
parameters: {
layout: 'centered'
},
tags: ['!dev'],
title: 'Components/HorizontalScrollCarousel/Accessibility'
title: 'Components/ScrollCarousel/Accessibility'
});
</script>
@@ -33,10 +33,10 @@
>
<div>
<button type="button">before</button>
<HorizontalScrollCarousel class="w-96">
<ScrollCarousel class="w-96" variant="center">
<div class="h-12 w-12 shrink-0 bg-muted"></div>
<div class="h-12 w-12 shrink-0 bg-muted"></div>
</HorizontalScrollCarousel>
</ScrollCarousel>
<button type="button">after</button>
</div>
</Story>
@@ -60,10 +60,10 @@
>
<div>
<button type="button">before</button>
<HorizontalScrollCarousel class="w-48">
<ScrollCarousel class="w-48" variant="center">
{#each [...Array(20).keys()] as i (i)}
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
</Story>
@@ -97,6 +97,38 @@ describe('parseModelId', () => {
});
});
it('strips trailing container format segments from model names', () => {
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
activatedParams: null,
modelName: 'DeepSeek-V4-Flash-0731',
orgName: 'unsloth',
params: null,
quantization: 'Q2_K_XL',
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
tags: []
});
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
activatedParams: null,
modelName: 'Laguna-S-2.1',
orgName: 'unsloth',
params: null,
quantization: 'Q4_K_XL',
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
tags: []
});
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
activatedParams: null,
modelName: 'Model-Name',
orgName: 'org',
params: null,
quantization: null,
raw: 'org/Model-Name-GGUF',
tags: []
});
});
it('handles real-world examples correctly', () => {
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
activatedParams: null,