From 8144f3192e5a3131cd043f284525e6ceebf82d0f Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Sun, 23 Aug 2026 10:46:49 +0200 Subject: [PATCH] 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/). 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 --- .../ChatAttachmentsList.svelte | 9 +- ...hatAttachmentsPreviewThumbnailStrip.svelte | 7 +- .../ChatFormInput/ChatFormInputRich.svelte | 2 +- .../ChatForm/ChatFormMcpResourcesList.svelte | 10 +- .../ChatMessageAssistant.svelte | 4 +- .../app/chat/ChatScreen/ChatScreen.svelte | 7 +- .../ChatScreenActionScrollDown.svelte | 2 +- .../app/chat/ChatTabs/ChatTabs.svelte | 136 +++++++++++++++ .../app/chat/ChatTabs/ChatTabsItem.svelte | 156 ++++++++++++++++++ .../ChatTabs/ChatTabsNewChatButton.svelte | 30 ++++ tools/ui/src/lib/components/app/chat/index.ts | 12 ++ .../app/misc/HorizontalScrollCarousel.svelte | 96 ----------- .../components/app/misc/ScrollCarousel.svelte | 131 +++++++++++++++ tools/ui/src/lib/components/app/misc/index.ts | 14 +- .../SidebarNavigation.svelte | 49 +++--- .../SidebarNavigationActions.svelte | 48 ++++-- .../settings/SettingsChatMobileHeader.svelte | 105 +++++------- .../src/lib/constants/chat-tabs.constants.ts | 18 ++ .../lib/constants/css-classes.constants.ts | 6 + tools/ui/src/lib/constants/index.ts | 1 + .../ui/src/lib/constants/routes.constants.ts | 4 - .../lib/constants/settings-keys.constants.ts | 1 + .../src/lib/constants/settings.constants.ts | 7 + .../ui/src/lib/constants/storage.constants.ts | 1 + tools/ui/src/lib/constants/ui.constants.ts | 10 +- tools/ui/src/lib/enums/index.ts | 2 + tools/ui/src/lib/enums/keyboard.enums.ts | 4 +- tools/ui/src/lib/enums/ui.enums.ts | 15 ++ .../hooks/use-keyboard-shortcuts.svelte.ts | 41 ++++- .../lib/hooks/use-scroll-carousel.svelte.ts | 38 +++-- tools/ui/src/lib/services/index.ts | 2 +- .../lib/stores/conversations/index.svelte.ts | 51 ++++-- .../conversations/preferences.svelte.ts | 23 ++- tools/ui/src/lib/stores/index.ts | 6 + tools/ui/src/lib/stores/init.ts | 22 +-- tools/ui/src/lib/stores/tabs.svelte.ts | 154 +++++++++++++++++ tools/ui/src/lib/stores/ui.svelte.ts | 14 ++ tools/ui/src/lib/types/navigation.d.ts | 3 + tools/ui/src/routes/(chat)/+layout.svelte | 26 ++- tools/ui/src/routes/(chat)/+page.svelte | 18 +- tools/ui/src/routes/+layout.svelte | 43 ++++- tools/ui/src/routes/search/+page.svelte | 6 +- ...lte => ScrollCarousel.a11y.stories.svelte} | 15 +- 43 files changed, 1026 insertions(+), 323 deletions(-) create mode 100644 tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte create mode 100644 tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte delete mode 100644 tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte create mode 100644 tools/ui/src/lib/components/app/misc/ScrollCarousel.svelte create mode 100644 tools/ui/src/lib/constants/chat-tabs.constants.ts create mode 100644 tools/ui/src/lib/stores/tabs.svelte.ts create mode 100644 tools/ui/src/lib/stores/ui.svelte.ts rename tools/ui/tests/stories/a11y/{HorizontalScrollCarousel.a11y.stories.svelte => ScrollCarousel.a11y.stories.svelte} (80%) diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte index 36895c8e79..2de9460aa7 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsList/ChatAttachmentsList.svelte @@ -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(null); let previewFocusIndex = $state(0); @@ -91,11 +92,11 @@ {#if displayItems.length > 0}
{#if limitToSingleRow} - + {#each displayItems as item (item.id)} {@render attachmentitem(item)} {/each} - + {:else}
{#each displayItems as item (item.id)} diff --git a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte index 366c8372b9..f0ea9675fb 100644 --- a/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatAttachments/ChatAttachmentsPreview/ChatAttachmentsPreviewThumbnailStrip.svelte @@ -1,7 +1,8 @@ + + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte new file mode 100644 index 0000000000..b76a88efe0 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte @@ -0,0 +1,156 @@ + + + +
+ onAuxClick?.(tab.id, e)} + aria-current={isActive ? 'page' : undefined} + aria-label={tab.name} + > + + {#if isLoading} + + + {#snippet child({ props })} + + {/snippet} + + + +

Stop generation

+
+
+ {/if} + + {#if tab.isNewChat} + + {/if} + + {tab.name} + + + + {#snippet child({ props })} + + {/snippet} + + + +

Close tab

+
+
+
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte new file mode 100644 index 0000000000..d62e551f78 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsNewChatButton.svelte @@ -0,0 +1,30 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + +

New chat

+
+
diff --git a/tools/ui/src/lib/components/app/chat/index.ts b/tools/ui/src/lib/components/app/chat/index.ts index 34571d53ba..a96ae37891 100644 --- a/tools/ui/src/lib/components/app/chat/index.ts +++ b/tools/ui/src/lib/components/app/chat/index.ts @@ -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. diff --git a/tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte b/tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte deleted file mode 100644 index e2edb4d025..0000000000 --- a/tools/ui/src/lib/components/app/misc/HorizontalScrollCarousel.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -
- - -
- {@render children?.()} -
- - -
diff --git a/tools/ui/src/lib/components/app/misc/ScrollCarousel.svelte b/tools/ui/src/lib/components/app/misc/ScrollCarousel.svelte new file mode 100644 index 0000000000..a0a914f5ec --- /dev/null +++ b/tools/ui/src/lib/components/app/misc/ScrollCarousel.svelte @@ -0,0 +1,131 @@ + + +
+ + +
+
+ {@render children?.()} +
+
+ + +
diff --git a/tools/ui/src/lib/components/app/misc/index.ts b/tools/ui/src/lib/components/app/misc/index.ts index b550ae66a5..a10410ef92 100644 --- a/tools/ui/src/lib/components/app/misc/index.ts +++ b/tools/ui/src/lib/components/app/misc/index.ts @@ -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) * diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte index aa63c2915f..424f2feca9 100644 --- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte +++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte @@ -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(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' ]} >
@@ -337,24 +336,26 @@ onmouseleave={() => (logoHovered = false)} > 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'} />
- {#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)} + {#if isOnMobile || (uiStore.isSidebarExpanded && !alwaysShowOnDesktop)}
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}
{ - 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, diff --git a/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte b/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte index 58617956a0..1e8c76f8e3 100644 --- a/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte +++ b/tools/ui/src/lib/components/app/settings/SettingsChatMobileHeader.svelte @@ -1,5 +1,6 @@ - +
+ {#if showTabs} + + {/if} + + +
{@render children?.()} diff --git a/tools/ui/src/routes/(chat)/+page.svelte b/tools/ui/src/routes/(chat)/+page.svelte index de8574e352..95a3e19470 100644 --- a/tools/ui/src/routes/(chat)/+page.svelte +++ b/tools/ui/src/routes/(chat)/+page.svelte @@ -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(); } diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte index f87bbe26a2..16adfe3327 100644 --- a/tools/ui/src/routes/+layout.svelte +++ b/tools/ui/src/routes/+layout.svelte @@ -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(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() { diff --git a/tools/ui/src/routes/search/+page.svelte b/tools/ui/src/routes/search/+page.svelte index a882e55529..548b9911a6 100644 --- a/tools/ui/src/routes/search/+page.svelte +++ b/tools/ui/src/routes/search/+page.svelte @@ -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(); } } diff --git a/tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte b/tools/ui/tests/stories/a11y/ScrollCarousel.a11y.stories.svelte similarity index 80% rename from tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte rename to tools/ui/tests/stories/a11y/ScrollCarousel.a11y.stories.svelte index ef5abeafaa..b9bf5afbc6 100644 --- a/tools/ui/tests/stories/a11y/HorizontalScrollCarousel.a11y.stories.svelte +++ b/tools/ui/tests/stories/a11y/ScrollCarousel.a11y.stories.svelte @@ -1,15 +1,16 @@ @@ -33,10 +34,10 @@ >
- +
-
+
@@ -60,10 +61,10 @@ >
- + {#each [...Array(20).keys()] as i (i)}
{i}
{/each} -
+