mirror of
https://github.com/LostRuins/koboldcpp.git
synced 2026-09-20 01:31:42 +02:00
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:
+5
-4
@@ -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)}
|
||||
|
||||
+4
-3
@@ -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}
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
*
|
||||
|
||||
+25
-24
@@ -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"
|
||||
|
||||
+30
-18
@@ -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';
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -79,6 +79,8 @@ export {
|
||||
ColorMode,
|
||||
HtmlInputType,
|
||||
McpPromptVariant,
|
||||
ScrollCarouselVariant,
|
||||
SidebarAction,
|
||||
TooltipSide,
|
||||
UrlProtocol
|
||||
} from './ui.enums';
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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?.()}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user