Merge branch 'upstream' into concedo_experimental

# Conflicts:
#	.github/actions/ccache-clear/action.yml
#	.github/workflows/build-apple.yml
#	.github/workflows/build-cpu.yml
#	.github/workflows/build-cuda-ubuntu.yml
#	.github/workflows/build-opencl.yml
#	.github/workflows/build-openvino.yml
#	.github/workflows/build-sycl.yml
#	.github/workflows/build-vulkan.yml
#	.github/workflows/build-wasm.yml
#	.github/workflows/build-webgpu.yml
#	.github/workflows/hip-quality-check.yml
#	.github/workflows/server.yml
#	CONTRIBUTING.md
#	README.md
#	ci/run.sh
#	common/CMakeLists.txt
#	common/chat.cpp
#	docs/autoparser.md
#	ggml/src/ggml-webgpu/wgsl-shaders/flash_attn.wgsl
#	ggml/src/ggml-webgpu/wgsl-shaders/flash_attn_tile.wgsl
#	ggml/src/ggml-webgpu/wgsl-shaders/flash_attn_vec_split.wgsl
#	scripts/sync_vendor.py
#	tests/CMakeLists.txt
#	tests/peg-parser/test-json-serialization.cpp
#	tests/peg-parser/tests.h
#	tests/test-chat-auto-parser.cpp
#	tests/test-chat-peg-parser.cpp
#	tests/test-chat-template.cpp
#	tests/test-chat.cpp
#	tests/test-grammar-integration.cpp
#	tests/test-jinja.cpp
#	tests/test-json-schema-to-grammar.cpp
#	tests/test-llama-archs.cpp
#	tests/test-model-resolution.cpp
#	tests/test-recurrent-state-rollback.cpp
#	tools/CMakeLists.txt
This commit is contained in:
Concedo
2026-08-25 20:44:34 +08:00
124 changed files with 3564 additions and 1659 deletions
@@ -3,8 +3,9 @@
ChatAttachmentsListItem,
DialogChatAttachmentsPreview,
DialogMcpResourcePreview,
HorizontalScrollCarousel
ScrollCarousel
} from '$lib/components/app';
import { ScrollCarouselVariant } from '$lib/enums';
import type { DatabaseMessageExtraMcpResource } from '$lib/types';
import { getAttachmentDisplayItems, isMcpPrompt, isMcpResource } from '$lib/utils';
@@ -42,7 +43,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 +92,11 @@
{#if displayItems.length > 0}
<div class={className} {style}>
{#if limitToSingleRow}
<HorizontalScrollCarousel bind:this={carouselRef}>
<ScrollCarousel bind:this={carouselRef} variant={ScrollCarouselVariant.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,7 +1,8 @@
<script lang="ts">
import { FileText, Music, Video } from '@lucide/svelte';
import { HorizontalScrollCarousel } from '$lib/components/app/misc';
import { ScrollCarousel } from '$lib/components/app';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { ScrollCarouselVariant } from '$lib/enums';
interface PreviewItem {
id: string;
@@ -33,7 +34,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={ScrollCarouselVariant.CENTER}>
{#each items as item, index (item.id)}
<button
{...{ [UI_DATA_ATTRS.THUMBNAIL_INDEX]: index }}
@@ -64,6 +65,6 @@
{/if}
</button>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
{/if}
@@ -625,7 +625,7 @@
}
if (rootElement && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) {
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey;
const isWordJump = (event.altKey || event.ctrlKey) && !event.metaKey && !event.shiftKey;
const isPlainLeft =
event.key === 'ArrowLeft' && !event.altKey && !event.ctrlKey && !event.metaKey;
@@ -1,8 +1,6 @@
<script lang="ts">
import {
ChatAttachmentsListItemMcpResource,
HorizontalScrollCarousel
} from '$lib/components/app';
import { ChatAttachmentsListItemMcpResource, ScrollCarousel } from '$lib/components/app';
import { ScrollCarouselVariant } from '$lib/enums';
import { mcpStore } from '$lib/stores';
interface Props {
@@ -26,7 +24,7 @@
{#if hasAttachments}
<div class={className}>
<HorizontalScrollCarousel gapSize="2">
<ScrollCarousel gapSize="2" variant={ScrollCarouselVariant.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)
);
}
}
@@ -41,10 +41,7 @@
let showDeleteDialog = $state(false);
let showEmptyFileDialog = $state(false);
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);
@@ -297,7 +294,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/20 shadow-sm text-accent-foreground absolute bottom-4"
/>
</div>
@@ -0,0 +1,136 @@
<script lang="ts">
import ChatTabsItem from './ChatTabsItem.svelte';
import ChatTabsNewChatButton from './ChatTabsNewChatButton.svelte';
import { page } from '$app/state';
import { ScrollCarousel } from '$lib/components/app';
import {
CHAT_TABS_MAX_WIDTH,
NEW_CHAT_LABEL,
NEW_CHAT_TAB_ID,
UI_DATA_ATTRS,
UNNAMED_CHAT_LABEL
} from '$lib/constants';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
import { chatStore, conversationsStore, 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_LABEL
: (conversationsStore.conversations.find((c) => c.id === id)?.name ?? UNNAMED_CHAT_LABEL)
}))
);
// 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);
}
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 hasAddedTab = tabs.some((t) => !previousTabIds.has(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 (!hasAddedTab && !activeChanged) return;
// wait for the new tab to be laid out before scrolling to it
void tick().then(() => {
const el = carousel.scrollContainer?.querySelector<HTMLElement>(
`[${UI_DATA_ATTRS.ACTIVE_TAB}]`
);
if (el) {
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
? CHAT_TABS_MAX_WIDTH.EXPANDED_SIDEBAR
: CHAT_TABS_MAX_WIDTH.COLLAPSED_SIDEBAR}"
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}
<ChatTabsNewChatButton onclick={() => void conversationsStore.openNewChat()} />
{/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,156 @@
<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 { ICON_CLASS_SM, ICON_CLASS_XS, ROUTES, UI_DATA_ATTRS } 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) {
// let cmd/ctrl/middle-click fall through so the browser keeps its own
// behavior (open in a new window); route the plain click ourselves so the
// new-chat sentinel and history behave exactly like programmatic nav
if (event.metaKey || event.ctrlKey || event.button === 1) return;
event.preventDefault();
onActivate?.(tab.id);
}
// stop/close sit on top of the tab link; swallow their clicks so they do
// not also navigate
function handleActionClick(event: MouseEvent, action: () => void) {
event.preventDefault();
event.stopPropagation();
action();
}
</script>
<!-- the tab link covers the whole item; stop/close sit on top as siblings so
interactive elements are never nested inside the anchor -->
<div
{...{ [UI_DATA_ATTRS.ACTIVE_TAB]: isActive ? 'true' : undefined }}
class={cn(
'relative flex h-8 max-w-52 min-w-0 shrink-0 items-center gap-1 rounded-lg pr-1 text-sm whitespace-nowrap 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'
)}
>
<a
{href}
class="absolute inset-0 z-0 rounded-lg"
onclick={handleActivate}
onauxclick={(e) => onAuxClick?.(tab.id, e)}
aria-current={isActive ? 'page' : undefined}
aria-label={tab.name}
></a>
{#if isLoading}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class="stop-button relative z-10 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 {ICON_CLASS_SM} animate-spin transition-opacity duration-300 {contentOpacity}"
/>
<Square
class="stop-icon hidden {ICON_CLASS_XS} 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="pointer-events-none {ICON_CLASS_SM} shrink-0 transition-opacity {contentOpacity}"
/>
{/if}
<span class="pointer-events-none truncate transition-opacity {contentOpacity}">{tab.name}</span>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
class={cn(
'relative z-10 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={ICON_CLASS_SM} />
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Close tab</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<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>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Plus } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
interface Props {
onclick?: () => void;
}
let { onclick }: Props = $props();
</script>
<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}
aria-label="New chat"
>
<Plus class="{ICON_CLASS_DEFAULT} opacity-40 transition-opacity group-hover:opacity-100" />
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>New chat</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -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>
@@ -0,0 +1,131 @@
<script lang="ts">
import { ChevronLeft, ChevronRight } from '@lucide/svelte';
import { cn } from '$lib/components/ui/utils';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { ScrollCarouselVariant } from '$lib/enums';
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;
/** Show the arrows whenever the content overflows, even without hover. */
alwaysShowArrows?: boolean;
/** Arrow placement and styling. */
variant?: ScrollCarouselVariant;
}
let {
alwaysShowArrows = false,
carousel: externalCarousel,
children,
class: className = '',
containerClass = '',
gapSize = '3',
innerClass = '',
variant = ScrollCarouselVariant.TOP
}: Props = $props();
const internalCarousel = useScrollCarousel();
const carousel = $derived(externalCarousel ?? internalCarousel);
const isCenter = $derived(variant === ScrollCarouselVariant.CENTER);
function scrollLeft(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
const container = carousel.scrollContainer;
if (!container) return;
container.scrollBy({ behavior: 'smooth', left: -(container.clientWidth * 0.67) });
}
function scrollRight(event?: MouseEvent) {
event?.stopPropagation();
event?.preventDefault();
const container = carousel.scrollContainer;
if (!container) return;
container.scrollBy({ behavior: 'smooth', left: 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
? alwaysShowArrows
? 'opacity-100'
: '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', isCenter && 'items-start', `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
? alwaysShowArrows
? 'opacity-100'
: '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>
@@ -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
*
@@ -44,6 +37,13 @@ export { default as TruncatedText } from './TruncatedText.svelte';
*/
export { default as KeyboardShortcutInfo } from './KeyboardShortcutInfo.svelte';
/**
* **ScrollCarousel** - Feature/carousel with center-aligned overflow controls
*
* Horizontal scrollable container with arrows that center the focused item.
*/
export { default as ScrollCarousel } from './ScrollCarousel.svelte';
/**
* **CodeBlockActions** - Actions bar for code blocks (copy, preview)
*
@@ -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'}
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"
@@ -11,8 +11,8 @@
ROUTES,
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { deviceStore } from '$lib/stores';
import { SidebarAction, TooltipSide } from '$lib/enums';
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 === SidebarAction.NEW_CHAT
? () => {
onNewChat?.();
void 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 === SidebarAction.NEW_CHAT
? () => {
onNewChat?.();
void 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/app';
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} alwaysShowArrows containerClass="py-2" 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,18 @@
/** Sentinel tab id for the bare `#/` new-chat screen */
export const NEW_CHAT_TAB_ID = 'new-chat';
/** Label shown for the new-chat sentinel tab. */
export const NEW_CHAT_LABEL = 'New chat';
/** Fallback label for conversations without an auto-generated title. */
export const UNNAMED_CHAT_LABEL = 'Chat';
/**
* Tab bar max width so it stays clear of the sidebar strip. The expanded strip
* is `md:w-72` and the collapsed one `md:w-12`; these hold the fully tuned
* `max-w-[calc(100vw-?rem)]` classes so the offset has a single source.
*/
export const CHAT_TABS_MAX_WIDTH = {
COLLAPSED_SIDEBAR: 'max-w-[calc(100vw-5rem)]',
EXPANDED_SIDEBAR: 'max-w-[calc(100vw-20rem)]'
} as const;
@@ -26,5 +26,11 @@ export const CHAT_INPUT_FOCUS_SELECTOR =
/** Default Tailwind size class for inline icon components (lucide, etc.). */
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
/** Small Tailwind size class for inline icons. */
export const ICON_CLASS_SM = 'h-3.5 w-3.5';
/** Extra-small Tailwind size class for inline icons. */
export const ICON_CLASS_XS = 'h-3 w-3';
/** Icon size + spinning animation; used for live-streaming tool indicators. */
export const ICON_CLASS_SPIN = 'h-4 w-4 animate-spin';
+1
View File
@@ -4,6 +4,7 @@
export * from './agentic.constants';
export * from './api-endpoints.constants';
export * from './app.constants';
export * from './chat-tabs.constants';
export * from './database.constants';
export * from './reasoning-effort.constants';
export * from './recommended-mcp-servers.constants';
@@ -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',
@@ -255,6 +255,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.',
@@ -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 CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
export const USER_OVERRIDES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.userOverrides`;
export const DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.dismissedRecommendedMcpServers`;
+8 -2
View File
@@ -1,7 +1,7 @@
import { ROUTES } from './routes.constants';
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import { ToolSource } from '$lib/enums/tools.enums';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
export const FORK_TREE_DEPTH_PADDING = 8;
@@ -10,6 +10,7 @@ export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
/** Data attributes for app-level DOM contracts. */
export const UI_DATA_ATTRS = {
ACTIVE: 'data-active',
ACTIVE_TAB: 'data-active-tab',
CONVERSATION_ROW: 'data-conversation-row',
HIGHLIGHT_THEME_PREVIEW: 'data-highlight-theme-preview',
PICKER_INDEX: 'data-picker-index',
@@ -55,7 +56,12 @@ 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: SidebarAction.NEW_CHAT,
icon: SquarePen,
keys: ['shift', 'cmd', 'o'],
tooltip: 'New chat'
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
activeRouteId: '/mcp-servers',
+2
View File
@@ -79,6 +79,8 @@ export {
ColorMode,
HtmlInputType,
McpPromptVariant,
ScrollCarouselVariant,
SidebarAction,
TooltipSide,
UrlProtocol
} from './ui.enums';
+5 -1
View File
@@ -7,6 +7,8 @@ export enum KeyboardKey {
ARROW_RIGHT = 'ArrowRight',
ARROW_UP = 'ArrowUp',
B_LOWER = 'b',
BRACKET_LEFT = 'BracketLeft',
BRACKET_RIGHT = 'BracketRight',
D_LOWER = 'd',
D_UPPER = 'D',
E_UPPER = 'E',
@@ -16,5 +18,7 @@ export enum KeyboardKey {
O_LOWER = 'o',
O_UPPER = 'O',
SPACE = ' ',
TAB = 'Tab'
TAB = 'Tab',
X_LOWER = 'x',
X_UPPER = 'X'
}
+15
View File
@@ -11,6 +11,21 @@ export enum TooltipSide {
TOP = 'top'
}
/**
* ScrollCarousel arrow placement.
*/
export enum ScrollCarouselVariant {
CENTER = 'center',
TOP = 'top'
}
/**
* Sidebar icon strip actions handled directly by the sidebar.
*/
export enum SidebarAction {
NEW_CHAT = 'new-chat'
}
/**
* MCP prompt display variant
*/
@@ -1,6 +1,7 @@
import { goto } from '$app/navigation';
import { ROUTES } from '$lib/constants';
import { page } from '$app/state';
import { NEW_CHAT_TAB_ID } from '$lib/constants';
import { KeyboardKey } from '$lib/enums';
import { conversationsStore, settingsStore, tabsStore } from '$lib/stores';
interface KeyboardShortcutsCallbacks {
activateSearchMode?: () => void;
@@ -9,6 +10,8 @@ interface KeyboardShortcutsCallbacks {
deleteActiveConversation?: () => void;
navigateToPrevConversation?: () => void;
navigateToNextConversation?: () => void;
navigateToPrevTab?: () => void;
navigateToNextTab?: () => void;
toggleSidebar?: () => void;
}
@@ -34,7 +37,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
) {
event.preventDefault();
goto(ROUTES.NEW_CHAT);
void conversationsStore.openNewChat();
}
if (event.shiftKey && isCmdOrCtrl && event.key === KeyboardKey.E_UPPER) {
@@ -42,6 +45,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;
// close-tab only makes sense with conversation tabs enabled
if (!settingsStore.config.conversationTabs) return;
event.preventDefault();
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 +85,16 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
event.preventDefault();
callbacks.navigateToNextConversation?.();
}
if (isCmdOrCtrl && event.altKey && event.shiftKey && event.code === KeyboardKey.BRACKET_LEFT) {
event.preventDefault();
callbacks.navigateToPrevTab?.();
}
if (isCmdOrCtrl && event.altKey && event.shiftKey && event.code === KeyboardKey.BRACKET_RIGHT) {
event.preventDefault();
callbacks.navigateToNextTab?.();
}
}
return { handleKeydown };
@@ -2,6 +2,7 @@ export function useScrollCarousel() {
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;
@@ -15,18 +16,6 @@ export function useScrollCarousel() {
scrollContainer.scrollBy({ behavior: 'smooth', left: scrollOffset });
}
function scrollLeft() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ behavior: 'smooth', left: -250 });
}
function scrollRight() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ behavior: 'smooth', left: 250 });
}
function updateScrollButtons() {
if (!scrollContainer) return;
@@ -36,10 +25,21 @@ export function useScrollCarousel() {
canScrollRight = sl < scrollWidth - clientWidth - 1;
}
// 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,14 +49,18 @@ export function useScrollCarousel() {
get canScrollRight() {
return canScrollRight;
},
get contentContainer() {
return contentContainer;
},
set contentContainer(el: HTMLDivElement | undefined) {
contentContainer = el;
},
get scrollContainer() {
return scrollContainer;
},
set scrollContainer(el: HTMLDivElement | undefined) {
scrollContainer = el;
},
scrollLeft,
scrollRight,
scrollToCenter,
updateScrollButtons
};
+1 -1
View File
@@ -294,7 +294,7 @@ export { SandboxService } from './sandbox.service';
* **RouterService** — Dynamic route URL construction utility
*
* Stateless utility for building dynamic route URLs from ROUTES base paths.
* Static routes (START, NEW_CHAT, MCP_SERVERS) live in ROUTES constants;
* Static routes (START, MCP_SERVERS) live in ROUTES constants;
* dynamic routes (CHAT, SETTINGS) are constructed here by appending parameters.
*
* **Architecture & Relationships:**
@@ -21,6 +21,7 @@ import {
type ConversationsPreferencesHost
} from '$lib/stores/conversations/preferences.svelte';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import { tabsStore } from '$lib/stores/tabs.svelte';
import { filterByLeafNodeId, findLeafNode, generateConversationTitle } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
@@ -105,8 +106,8 @@ class ConversationsStore implements ConversationsPreferencesHost {
/**
* Deletes multiple conversations in sequence.
* Mirrors deleteConversation() per-id; navigates to NEW_CHAT only if the
* currently-open chat was among the deleted ones.
* Mirrors deleteConversation() per-id; navigates to the new-chat screen only
* if the currently-open chat was among the deleted ones.
* @param convIds - Conversation IDs to delete
*/
async bulkDeleteConversations(convIds: string[]): Promise<void> {
@@ -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,15 @@ 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<void> {
this.clearActiveConversation();
await goto(ROUTES.START);
}
/**
* Refreshes active messages based on currNode after branch navigation.
*/
@@ -676,14 +699,6 @@ class ConversationsStore implements ConversationsPreferencesHost {
);
}
/**
*
*
* Import & Export
*
*
*/
/**
* Updates the current node of the active conversation
* @param nodeId - The new current node ID
@@ -717,6 +732,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 { 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';
+7 -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,12 @@ export function initStores(): Promise<void> {
permissionsStore.initialize();
toolsStore.initialize();
void versionStore.initialize();
void conversationsStore.initialize();
// the full conversation list loads in the background; once it is back,
// prune persisted tabs against the conversations that still exist
void conversationsStore.initialize().then(() => {
tabsStore.init(conversationsStore.conversations.map((c) => c.id));
});
})();
return startup;
+154
View File
@@ -0,0 +1,154 @@
/**
* 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 kept across reloads.
*/
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { CONVERSATION_TABS_LOCALSTORAGE_KEY, NEW_CHAT_TAB_ID, ROUTES } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
import { untrack } from 'svelte';
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) {
// tab not tracked (e.g. Conversation tabs are off); still fall back to
// the new-chat screen when closing the active conversation
if (id === activeTabId) {
await goto(ROUTES.START);
}
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;
// the new-chat sentinel is a pseudo-tab, not a conversation, but it is
// still kept so a reload on `#/` does not drop the tab the user is on
const isLive = (id: string) => validIds.includes(id) || id === NEW_CHAT_TAB_ID;
const persisted = this.load().filter(isLive);
// tabs already in openTabs come from the live route, so they stay as they
// are: `validIds` is a snapshot and a conversation created while the list
// was loading is not in it
const extras = this.openTabs.filter((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(CONVERSATION_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(CONVERSATION_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();
+3
View File
@@ -1,3 +1,4 @@
import type { SidebarAction } from '$lib/enums';
import type { Component } from 'svelte';
/**
@@ -7,6 +8,8 @@ export interface DesktopIconStripItem {
icon: Component;
tooltip: string;
route?: string;
/** Custom action handled by the sidebar, e.g. opening a new-chat tab */
action?: SidebarAction;
activeRouteId?: string;
activeRoutePrefix?: string;
activeUrlIncludes?: string;
+24 -2
View File
@@ -1,12 +1,34 @@
<script lang="ts">
import { page } from '$app/state';
import { ChatScreen } from '$lib/components/app';
import { ChatScreen, ChatTabs } from '$lib/components/app';
import { NEW_CHAT_TAB_ID } from '$lib/constants';
import { 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);
let showTabs = $derived(
Boolean(settingsStore.config.conversationTabs) &&
(page.params.id || tabsStore.openTabs.some((id) => id !== NEW_CHAT_TAB_ID))
);
$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?.()}
+7 -11
View File
@@ -8,24 +8,19 @@
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
*/
// Clear params after handling the deep link so a refresh does not replay them
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(), {});
}
@@ -40,8 +35,8 @@
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
// with ?load=true in router mode, start loading right away so the
// model is ready sooner; not awaited so the UI stays usable
if (
loadParam === 'true' &&
serverStore.isRouterMode &&
@@ -66,11 +61,12 @@
}
}
// Handle ?q= parameter - create new conversation and send message
// ?q= creates the conversation, the chat route sends the prompt once the
// conversation id is in the URL
if (qParam !== null) {
await conversationsStore.createConversation();
clearUrlParams();
} else if (modelParam || newChatParam === 'true') {
} else if (modelParam) {
clearUrlParams();
}
}
@@ -85,7 +81,7 @@
await modelsStore.fetch();
if (qParam !== null || modelParam !== null || newChatParam === 'true') {
if (qParam !== null || modelParam !== null) {
await handleUrlParams();
}
+41 -2
View File
@@ -11,6 +11,7 @@
FAVICON_PATHS,
FAVICON_SELECTORS,
HEADERS,
NEW_CHAT_TAB_ID,
ROUTES,
SETTINGS_KEYS,
TOOLTIP_DELAY_DURATION
@@ -26,6 +27,7 @@
modelsStore,
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 = untrack(() => 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 -3
View File
@@ -21,10 +21,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 without a history entry.
$effect(() => {
if (browser && !deviceStore.isMobile) {
goto(ROUTES.NEW_CHAT, { replaceState: true });
goto(ROUTES.START, { replaceState: true });
}
});
@@ -66,7 +66,7 @@
if (history.length > 1) {
history.back();
} else {
goto(ROUTES.NEW_CHAT);
conversationsStore.openNewChat();
}
}
</script>