mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-09-04 02:37:27 +02:00
ui: Chat Conversation Tabbed navigation (#27263)
* ui : add browser-style conversation tabs store Track open conversation tabs in order, persisted to localStorage and pruned against the loaded conversation list on init. The chat layout syncs the route's tab on every navigation, so any way of reaching a conversation opens a tab for it. * ui : add temporary new-chat tabs New-chat tabs are unsaved conversations carrying a temporary id used directly as the route (#/chat/<id>). They live in memory and are only persisted to the database - keeping the same id so the route and tab stay stable - when the first message is sent. Deleting one drops it without confirmation, and deleting conversations now closes their tabs. * ui : render conversation tab bar in chat layout Desktop-only tab bar above the chat screen, one tab per open conversation or new-chat tab. The active tab follows the route id; clicking navigates, middle-click or the close button closes (switching to the left neighbor), and a trailing + starts a new chat. Tabs appear only on chat-id routes; the bare #/ new-chat view has none. The bare route stays put unless a prompt/model deep-link routes it to a new-chat tab. * ui : route new-chat entry points through tabs The sidebar New chat item, Cmd+Shift+O, the search page and the arrow-key fallback now open a new-chat tab instead of navigating to the ?new_chat URL, which is removed. New chat is no longer a special route but a tab like any other conversation. * ui : track sidebar expanded state in a shared ui store Move the desktop sidebar expanded/collapsed state out of deviceStore into a dedicated uiStore so the chat tab bar can react to it. Assisted-by: pi * chat : add opt-in conversation tabs setting Add a Display setting that turns browser-style conversation tabs on or off, enabled by default. Assisted-by: pi * chat : add browser-style conversation tabs with a new-chat screen Track open conversations as tabs above the chat, one per open chat, plus a single New chat tab for the bare `#/` route. New chat is just the `#/` screen - no temporary conversations - and its tab is dropped when navigating away. Sending the first message creates a real conversation and opens a tab for it. Assisted-by: pi * chat : turn tab bar into a horizontally scrollable carousel Make the tab bar a horizontally scrollable carousel with edge scroll buttons and active-tab centering, and align its styling with the sidebar. Assisted-by: pi * chat : restyle the scroll-to-bottom button to match tab styling Assisted-by: pi * chat : add close-tab keyboard shortcut Assisted-by: pi * chat : soften tab bar fade and dim inactive tabs Assisted-by: pi * feat: Add stop button to tabs * refactor: Componentize * ui : fix carousel scrollability detection Observe the content wrapper as well as the container, since adding overflowing items does not change the container's own box size. Also expose an onScrollableChange callback. Assisted-by: pi * ui : add unified ScrollCarousel component Single carousel component with top/center variants, gap and scroll options, and hover-revealed chevrons. Rename the HorizontalScrollCarousel accessibility story accordingly. Assisted-by: pi * ui : migrate carousels to ScrollCarousel Switch the settings mobile header, attachments list, thumbnail strip, and MCP resources to the unified component, and drop HorizontalScrollCarousel. Assisted-by: pi * ui : improve chat tabs carousel UX Scroll newly added tabs into view, fade overflowing tabs at the edges, and hide the New chat button while a new-chat tab is open. Assisted-by: pi * refactor: Naming * chat : add keyboard shortcut to jump between conversation tabs Shift+Cmd/Ctrl+Left/Right cycles the open tabs, mirroring the existing Shift+Cmd/Ctrl+Up/Down conversation navigation. Assisted-by: pi * chat : make the whole tab item act as a link The full tab is now a link instead of only the inner label button, while the stop and close buttons stay interactive by swallowing their clicks. Assisted-by: pi * chat : adjust tab bar width and use a shared offset variable Widen the tab bar for the expanded sidebar and rename the tab bar height variable to --chat-tabs-offset with a smaller value so the chat screen min-height accounts for the overlay without overshooting. Assisted-by: pi * chat : account for the tab bar offset in the assistant min-height Subtract the tab bar offset when it is shown so the last assistant message does not overflow the available viewport space. Assisted-by: pi * refactor: Post-review fixes * ui : restore deep links on the chat start page - handle ?model selection, with ?load=true eager router loading - ?q now creates a conversation, sends the prompt, and clears the params - show the not-available-model dialog for unknown models - never block mount on the conversation list Assisted-by: pi * ui : fix tab item link nesting and centralize tab constants - the tab anchor covers the whole item while stop/close stay siblings, so interactive elements are never nested inside the anchor - cmd/ctrl/middle clicks are left to the browser (new window) - extract the tab labels, the active-tab data attribute, and the sidebar-offset max widths into constants Assisted-by: pi * ui : tidy scroll carousel hook and keep mobile header arrows on - drop the dead scrollLeft/scrollRight helpers and the unused onScrollableChange/scrollBy props - init the carousel once instead of inside a derived - restore items-start on the center variant - always show the settings header arrows on touch Assisted-by: pi * ui : keep the new-chat tab across reloads and fall back on close - the new-chat sentinel is no longer pruned on init, so reloading on the bare new-chat route keeps the tab the user is on - closing the active conversation falls back to the new-chat screen when Conversation tabs are off Assisted-by: pi * ui : don't block startup on the conversation list - prune persisted tabs after the list loads in the background instead of awaiting it during init - openNewChat now returns void; its return value was never read Assisted-by: pi * ui: fix routing nits * chore: Update doc comments * refactor: Mark fire-and-forget openNewChat calls as `void` * chat: fix the deep-linked prompt, the tab width and the tab shortcuts The chat start page creates the conversation and hands the prompt over to the chat route, which still sees it in the query string. Sending it on both sides queues the second copy as a pending message, which shows up as a stray user bubble once the answer lands and vanishes on reload since it never reaches the database. The tab bar takes the max width of the collapsed sidebar while it is expanded, and the other way round. The tab list is pruned against a snapshot of the loaded conversations, so a conversation created while that list is still loading loses its tab even though the route just opened it. The active tab then falls out of the list and the cycling shortcut jumps to an edge on every keypress instead of moving one tab over. Tabs synced from the route are kept as they are, only the persisted ones are pruned. The rich chat input claims ctrl or alt with shift and an arrow for its badge-aware word jump, which now belongs to the tab cycling shortcut. Holding shift hands the key combination over, the plain word jump is unchanged. The close-tab shortcut consumes the event before checking whether the setting is on, and the logo background loses its importance flag. --------- Co-authored-by: Pascal <[email protected]>
This commit is contained in:
co-authored by
Pascal
parent
6657ded4fa
commit
8144f3192e
+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';
|
||||
|
||||
@@ -16,5 +16,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.shiftKey && event.key === KeyboardKey.ARROW_LEFT) {
|
||||
event.preventDefault();
|
||||
callbacks.navigateToPrevTab?.();
|
||||
}
|
||||
|
||||
if (isCmdOrCtrl && event.shiftKey && event.key === KeyboardKey.ARROW_RIGHT) {
|
||||
event.preventDefault();
|
||||
callbacks.navigateToNextTab?.();
|
||||
}
|
||||
}
|
||||
|
||||
return { handleKeydown };
|
||||
|
||||
@@ -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>
|
||||
|
||||
+8
-7
@@ -1,15 +1,16 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import HorizontalScrollCarousel from '$lib/components/app/misc/HorizontalScrollCarousel.svelte';
|
||||
import { ScrollCarousel } from '$lib/components/app';
|
||||
import { ScrollCarouselVariant } from '$lib/enums';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: HorizontalScrollCarousel,
|
||||
component: ScrollCarousel,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/HorizontalScrollCarousel/Accessibility'
|
||||
title: 'Components/ScrollCarousel/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -33,10 +34,10 @@
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<HorizontalScrollCarousel class="w-96">
|
||||
<ScrollCarousel class="w-96" variant={ScrollCarouselVariant.CENTER}>
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
<button type="button">after</button>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -60,10 +61,10 @@
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<HorizontalScrollCarousel class="w-48">
|
||||
<ScrollCarousel class="w-48" variant={ScrollCarouselVariant.CENTER}>
|
||||
{#each [...Array(20).keys()] as i (i)}
|
||||
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
</Story>
|
||||
Reference in New Issue
Block a user