Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
19eb0cb
ui : add browser-style conversation tabs store
allozaur Aug 17, 2026
4389efa
ui : add temporary new-chat tabs
allozaur Aug 17, 2026
f46de6b
ui : render conversation tab bar in chat layout
allozaur Aug 17, 2026
36a12a3
ui : route new-chat entry points through tabs
allozaur Aug 17, 2026
ed33fab
ui : track sidebar expanded state in a shared ui store
allozaur Aug 20, 2026
e0312d6
chat : add opt-in conversation tabs setting
allozaur Aug 20, 2026
190f170
chat : add browser-style conversation tabs with a new-chat screen
allozaur Aug 20, 2026
c7ef9f7
chat : turn tab bar into a horizontally scrollable carousel
allozaur Aug 20, 2026
8b9cd32
chat : restyle the scroll-to-bottom button to match tab styling
allozaur Aug 20, 2026
975c22e
chat : add close-tab keyboard shortcut
allozaur Aug 20, 2026
338468c
chat : soften tab bar fade and dim inactive tabs
allozaur Aug 20, 2026
3dcff15
feat: Add stop button to tabs
allozaur Aug 20, 2026
096dbaa
refactor: Componentize
allozaur Aug 20, 2026
d900ec7
ui : fix carousel scrollability detection
allozaur Aug 21, 2026
6568871
ui : add unified ScrollCarousel component
allozaur Aug 21, 2026
fed4740
ui : migrate carousels to ScrollCarousel
allozaur Aug 21, 2026
495fe44
ui : improve chat tabs carousel UX
allozaur Aug 21, 2026
6622549
refactor: Naming
allozaur Aug 21, 2026
26da5a3
chat : add keyboard shortcut to jump between conversation tabs
allozaur Aug 21, 2026
7b67452
chat : make the whole tab item act as a link
allozaur Aug 21, 2026
1b183b4
chat : adjust tab bar width and use a shared offset variable
allozaur Aug 21, 2026
0ed2ffb
chat : account for the tab bar offset in the assistant min-height
allozaur Aug 21, 2026
b4c672c
refactor: Post-review fixes
allozaur Aug 21, 2026
e5af81c
ui : restore deep links on the chat start page
allozaur Aug 21, 2026
4a0a228
ui : fix tab item link nesting and centralize tab constants
allozaur Aug 21, 2026
707e7da
ui : tidy scroll carousel hook and keep mobile header arrows on
allozaur Aug 21, 2026
890b459
ui : keep the new-chat tab across reloads and fall back on close
allozaur Aug 21, 2026
89776eb
ui : don't block startup on the conversation list
allozaur Aug 21, 2026
01d0f13
ui: fix routing nits
allozaur Aug 21, 2026
54b74f2
chore: Update doc comments
allozaur Aug 21, 2026
e4c77b8
refactor: Mark fire-and-forget openNewChat calls as `void`
allozaur Aug 21, 2026
128dd1f
chat: fix the deep-linked prompt, the tab width and the tab shortcuts
ServeurpersoCom Aug 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -64,6 +65,6 @@
{/if}
</button>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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' : ''}
Expand All @@ -35,6 +33,6 @@
onclick={() => handleResourceClick(attachment.resource.uri)}
/>
{/each}
</HorizontalScrollCarousel>
</ScrollCarousel>
</div>
{/if}
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,15 @@
--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));

@media (width > 768px) {
--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)
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
136 changes: 136 additions & 0 deletions tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
Original file line number Diff line number Diff line change
@@ -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}
Comment thread
allozaur marked this conversation as resolved.
Resolved
</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>
Loading
Loading