Commit dc9e3bed4 for llama.cpp
commit dc9e3bed421ad4a0b7ca427cbe063d27c6bba427
Author: Aleksander Grygier <aleksander.grygier@gmail.com>
Date: Mon Oct 26 09:13:08 2026 +0100
ui: UX improvements (#31030)
* ui : land opened conversations at their bottom before the first paint
Pin the page to its bottom with a ResizeObserver until it stops resizing or the reader interacts, and mount the last rows eagerly so the landing frame shows real content.
Assisted by: Claude (Opus 5.5 Medium)
* ui : make conversation tabs browser-style
A plain navigation shows the chat in the current tab instead of adding one, and the new-chat tab turns into the conversation it starts. New tabs come only from explicit actions: the + button, Cmd/Ctrl-click or middle-click on a sidebar chat, and its Open in new tab menu item, which open in the background. Closing a tab moves to its neighbour among the tabs the view shows.
Assisted by: Claude (Opus 5.5 Medium)
* ui : share the tab surface and tab bar fade styles
Move the tab item surface (shape, resting and active looks) to TAB_SURFACE_* class constants and the fade under the tab bar to a global chat-top-fade class, so other elements can match the tabs.
Assisted by: Claude (Opus 5.5 Medium)
* ui : fit the chat column below the tab bar
The tab bar reported 1.25rem of its height while it takes 3.3125rem (pt-3.25 + h-10), so a short conversation still left a few pixels to scroll. Use the bar's full height and drop the extra 1rem from the column's minimum height, so it fills the viewport exactly; the centered new-chat form moves up by the same 1rem to keep its place.
Assisted by: Claude (Opus 5.5 Medium)
* ui : stop the page from bouncing past its ends
Elastic overscroll on the root scroller dragged the sticky and fixed bars (tab bar, chat form) along at the top and bottom of a chat, and they jumped back on release. Set overscroll-behavior: none on html and body.
Assisted by: Claude (Opus 5.5 Medium)
* ui : stop scrolled dialogs from bouncing past their ends
Dialogs scroll on their own (the settings panel, the search results), so the page rule did not reach them and their sticky navigation still jumped on overscroll. Turn off overscroll on dialog panels and on the scroll containers inside them. Only scroll containers: Firefox counts an overflow-hidden box such as truncated text as one, and `none` there kept the wheel from scrolling the list around it.
Assisted by: Claude (Opus 5.5 Medium)
* ui : fit a first exchange opened by the working directory row
The last answer's minimum height leaves room for the last question only, so the synthetic row that a new chat starts with (Set working directory to ...) made a short first exchange overflow by that row. When everything above the last question is synthetic, count those rows as part of it.
Assisted by: Claude (Opus 5.5 Medium)
* ui : keep following a streaming response until the reader scrolls up
Auto-scroll stopped whenever the page moved up, including moves the
reader did not make: scroll anchoring and blocks re-rendering shorter
while a response streams. Only the reader's wheel, touch, scroll keys
or scrollbar drag now count as scrolling up.
The send scroll also looked up the new user bubble by position, which
picked an older message when the answer row had not rendered yet.
Assisted by: Claude (Opus 5.5 Medium)
* ui : fade the chat form between its centered and bottom positions
The form no longer slides across the column between the new-chat center
and the bottom. It fades out drifting toward where it goes and fades
back in where it lands, quicker when opening a chat than when going back
to a new one. Its wrapper keeps the same height in both positions, so
the messages never shift while it moves.
The new-chat greeting fades in as the form settles, also on a refresh or
a first visit. Reduced motion keeps only the fades.
Assisted by: Claude (Opus 5.5 Medium)
diff --git a/tools/ui/src/app.css b/tools/ui/src/app.css
index 94f9ff0b9..53464de8c 100644
--- a/tools/ui/src/app.css
+++ b/tools/ui/src/app.css
@@ -174,6 +174,23 @@
scrollbar-gutter: stable;
}
+ /* no elastic bounce past the ends of the page: it drags the sticky and
+ fixed bars (tab bar, chat form) along and they jump back on release */
+ html,
+ body {
+ overscroll-behavior: none;
+ }
+
+ /* same for dialogs, whose panels and lists scroll on their own with sticky
+ headers and footers. Scroll containers only: Firefox treats an
+ overflow-hidden box (truncated text) as one, and with `none` it would
+ keep the wheel from reaching the list around it */
+ :is([role='dialog'], [role='alertdialog']),
+ :is([role='dialog'], [role='alertdialog'])
+ :is(.overflow-auto, .overflow-y-auto, [data-slot='scroll-area-viewport']) {
+ overscroll-behavior: none;
+ }
+
/* Global scrollbar styling - visible only on hover */
* {
scrollbar-width: thin;
@@ -223,6 +240,17 @@
scrollbar-width: none;
}
+ /* Fade at the top edge of the chat, under the tab bar or a sticky header */
+ .chat-top-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%
+ );
+ }
+
.shimmer-text {
background: linear-gradient(
90deg,
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
index dac55caff..15683c3c3 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageAssistant/ChatMessageAssistant.svelte
@@ -96,17 +96,37 @@
'.chat-message-user'
) as HTMLElement | null;
- if (!userMessageEl) {
+ if (!previousChatMessage || !userMessageEl) {
lastUserMessageHeight = 0;
return;
}
+ // a first exchange opened only by synthetic rows (e.g. the working
+ // directory notice) must fit the screen as well, so they count as part
+ // of the user turn; real earlier messages still scroll away above it
+ const leadingRows: Element[] = [];
+
+ for (
+ let row = previousChatMessage.previousElementSibling;
+ row;
+ row = row.previousElementSibling
+ ) {
+ leadingRows.unshift(row);
+ }
+
+ const leadingRowsAreSynthetic =
+ leadingRows.length > 0 &&
+ leadingRows.every((row) => row.classList.contains('chat-message--synthetic'));
const updateHeight = () => {
const rect = userMessageEl.getBoundingClientRect();
const marginTop = Math.round(parseFloat(getComputedStyle(userMessageEl).marginTop));
+ const leadingHeight = leadingRowsAreSynthetic
+ ? previousChatMessage.getBoundingClientRect().top -
+ leadingRows[0].getBoundingClientRect().top
+ : 0;
- lastUserMessageHeight = Math.round(rect.height + marginTop);
+ lastUserMessageHeight = Math.round(rect.height + marginTop + leadingHeight);
};
updateHeight();
@@ -115,6 +135,10 @@
resizeObserver.observe(userMessageEl);
+ if (leadingRowsAreSynthetic) {
+ for (const row of leadingRows) resizeObserver.observe(row);
+ }
+
return () => {
resizeObserver.disconnect();
};
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
index 0078225c0..e7acb9cc6 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessages.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import LazyChatMessage from './LazyChatMessage.svelte';
import { ChatMessageUserPending } from '$lib/components/app';
+ import { LANDING_EAGER_ROWS } from '$lib/constants';
import { MessageRole } from '$lib/enums';
import { agenticStore, chatStore, conversationsStore, settingsStore } from '$lib/stores';
import type { ChatMessageActions } from '$lib/types';
@@ -237,14 +238,15 @@
});
</script>
-<!-- Re-created per conversation, so the CSS fade-in below plays on every
- navigation into a chat route. -->
+<!-- Re-created per conversation, so each one starts from fresh rows that
+ mount eagerly at its bottom. -->
{#key conversationsStore.activeConversation?.id ?? 'new'}
<div class="chat-messages">
- {#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages } (message.id)}
+ {#each displayMessages as { isLastAssistantMessage, isLastUserMessage, message, nextAssistantMessage, siblingInfo, toolMessages }, index (message.id)}
<LazyChatMessage
{chatActions}
class="mx-auto mt-12 w-full max-w-3xl"
+ eager={index >= displayMessages.length - LANDING_EAGER_ROWS}
{isLastAssistantMessage}
{isLastUserMessage}
{message}
diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte
index f9667bbbb..4bcc66da3 100644
--- a/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatMessages/LazyChatMessage.svelte
@@ -6,6 +6,8 @@
interface Props {
chatActions: ChatMessageActions;
class?: string;
+ /** Mount right away instead of waiting for the row to near the viewport. */
+ eager?: boolean;
isLastAssistantMessage?: boolean;
isLastUserMessage?: boolean;
message: DatabaseMessage;
@@ -17,6 +19,7 @@
let {
chatActions,
class: className = '',
+ eager = false,
isLastAssistantMessage = false,
isLastUserMessage = false,
message,
@@ -34,8 +37,17 @@
let mounted = $state(false);
let wrapperEl: HTMLDivElement | undefined = $state();
+ // eager rows render in the same pass as the list, so a conversation
+ // landing at its bottom never paints empty placeholders there; latch it,
+ // since a row stops being eager once newer messages push it up
+ let shouldMount = $derived(mounted || eager);
+
+ $effect(() => {
+ if (eager) mounted = true;
+ });
+
$effect(() => {
- if (mounted || !wrapperEl) return;
+ if (shouldMount || !wrapperEl) return;
const observer = new IntersectionObserver(
(entries) => {
@@ -65,10 +77,11 @@
<div
bind:this={wrapperEl}
+ class:chat-message--eager={eager}
class:chat-message--synthetic={Boolean(message.isSynthetic)}
class="chat-message"
>
- {#if mounted}
+ {#if shouldMount}
<ChatMessage
{chatActions}
class={className}
@@ -102,4 +115,13 @@
.chat-message--synthetic {
--chat-message-intrinsic-size: 40px;
}
+
+ /*
+ * A freshly created row is skipped for a frame until its viewport
+ * proximity is determined, so the rows a conversation lands on would paint
+ * as empty placeholders. They sit at the bottom anyway, so lay them out.
+ */
+ .chat-message--eager {
+ content-visibility: visible;
+ }
</style>
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
index 6cea95d0d..542493ff0 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
@@ -11,7 +11,16 @@
ChatScreenStreamResumeStatus,
ServerLoadingSplash
} from '$lib/components/app';
- import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
+ import {
+ CHAT_FORM_MOVE_IN_EASING,
+ CHAT_FORM_MOVE_IN_MS,
+ CHAT_FORM_MOVE_OUT_EASING,
+ CHAT_FORM_MOVE_OUT_MS,
+ CHAT_FORM_MOVE_SHIFT_PX,
+ CHAT_FORM_MOVE_TO_BOTTOM_IN_MS,
+ CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS,
+ ROUTES
+ } from '$lib/constants';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
@@ -26,7 +35,7 @@
settingsStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
- import { onDestroy, onMount, tick } from 'svelte';
+ import { onDestroy, onMount, tick, untrack } from 'svelte';
let { showCenteredEmpty = false } = $props();
@@ -43,6 +52,10 @@
let isEmpty = $derived(
showCenteredEmpty && conversationsStore.activeMessages.length === 0 && !chatStore.isLoading
);
+ // where the form is drawn: follows `isEmpty` once the form has faded out
+ let isFormCentered = $state(untrack(() => isEmpty));
+ let isFormMoving = false;
+ let formWrapper: HTMLElement | undefined = $state();
let activeErrorDialog = $derived(chatStore.errorDialogState);
let isServerLoading = $derived(serverStore.loading);
let hasPropsError = $derived(!!serverStore.error);
@@ -133,11 +146,11 @@
let lastScrolledConversationId: string | null = null;
// Lands at the bottom of a conversation the first time its messages
- // render, whether the route comes from another conversation or from a
- // non-conversation route. The page keeps growing after the first pin
- // without DOM mutations (content-visibility size realizations, syntax
- // highlight passes), so the instant pin repeats every frame until the
- // height settles, bailing out on user scroll or conversation change.
+ // render, whether the route comes from another conversation, from a
+ // non-conversation route or from a page refresh. Runs before the first
+ // paint of the new rows, and the landing pin keeps the bottom in place
+ // while the rows settle. Independent of the auto-scroll setting, which
+ // only governs following a streaming response.
async function handleMessagesReady(messageCount: number) {
if (messageCount === 0) return;
@@ -147,38 +160,60 @@
lastScrolledConversationId = id;
await tick();
- autoScroll.scrollToBottom();
-
- const container = scroll.chatScrollContainer;
-
- if (!container) return;
-
- const started = performance.now();
-
- let stableFrames = 0;
- let lastHeight = container.scrollHeight;
-
- const settle = () => {
- if (autoScroll.userScrolledUp) return;
- if (conversationsStore.activeConversation?.id !== id) return;
+ if (conversationsStore.activeConversation?.id !== id) return;
- autoScroll.scrollToBottom();
- const height = container.scrollHeight;
-
- stableFrames = height === lastHeight ? stableFrames + 1 : 0;
- lastHeight = height;
-
- if (stableFrames >= LANDING_STABLE_FRAMES) return;
-
- if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
+ scroll.landAtBottom();
+ }
- requestAnimationFrame(settle);
- };
+ /**
+ * Moves the form between its centered and bottom positions: it fades out
+ * where it is, drifting toward where it goes, and fades
+ * back in from the other side once it is there. Reduced motion keeps only
+ * the fade. Re-reads `isEmpty` after each move, so a flip back mid-move
+ * ends where the screen is.
+ */
+ async function moveForm() {
+ if (isFormMoving) return;
+
+ isFormMoving = true;
+
+ while (formWrapper && isFormCentered !== isEmpty) {
+ const toCenter = isEmpty;
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
+ const shift = reduceMotion ? 0 : (toCenter ? -1 : 1) * CHAT_FORM_MOVE_SHIFT_PX;
+ const shown = { opacity: 1, transform: 'translateY(0)' };
+ // `transform` composes with the `translate` that centers the form
+ const leave = formWrapper.animate(
+ [shown, { opacity: 0, transform: `translateY(${shift}px)` }],
+ {
+ duration: toCenter ? CHAT_FORM_MOVE_OUT_MS : CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS,
+ easing: CHAT_FORM_MOVE_OUT_EASING,
+ fill: 'forwards'
+ }
+ );
+
+ await leave.finished.catch(() => {});
+
+ isFormCentered = toCenter;
+ await tick();
+
+ // cancelled in the same frame the entry starts, so it never flashes
+ leave.cancel();
+ formWrapper?.animate([{ opacity: 0, transform: `translateY(${-shift}px)` }, shown], {
+ duration: toCenter ? CHAT_FORM_MOVE_IN_MS : CHAT_FORM_MOVE_TO_BOTTOM_IN_MS,
+ easing: CHAT_FORM_MOVE_IN_EASING
+ });
+ }
- requestAnimationFrame(settle);
+ isFormCentered = isEmpty;
+ isFormMoving = false;
}
+ $effect(() => {
+ if (isEmpty !== isFormCentered) untrack(() => void moveForm());
+ });
+
function handleSendLikeScroll() {
if (!deviceStore.isMobile) {
autoScroll.enable();
@@ -189,9 +224,12 @@
if (!container) return;
- const lastUserBubble = container.querySelector(
- '.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
- ) as HTMLElement | null;
+ // the bubble of the message just sent: the last user bubble, whether or
+ // not the answer row below it has rendered yet
+ const userBubbles = container.querySelectorAll<HTMLElement>(
+ '.chat-message-user .chat-message-user-bubble'
+ );
+ const lastUserBubble = userBubbles[userBubbles.length - 1] ?? null;
if (deviceStore.isMobile) {
// Keep the last user message bubble just above the input on mobile
@@ -295,7 +333,7 @@
{:else}
<div
style:--chat-form-bottom-position={chatFormBottomPosition}
- 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"
+ class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-[calc(100dvh-var(--chat-tabs-offset,0px))] px-4 md:py-0 pt-12 pb-48 md:pb-4"
ondragenter={dragAndDrop.dragHandlers.dragenter}
ondragleave={dragAndDrop.dragHandlers.dragleave}
ondragover={dragAndDrop.dragHandlers.dragover}
@@ -313,23 +351,26 @@
{/if}
<div
- style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
+ bind:this={formWrapper}
+ style:padding-top="var(--chat-form-padding-top)"
class={[
- // animate the centered->bottomed move with transform, not bottom:
- // layout-property transitions need the main thread every frame and
- // stutter while a long conversation loads; transform transitions
- // run on the compositor and stay smooth
- 'pointer-events-none md:sticky fixed mt-auto transition-transform duration-200',
+ // moves between the centered (empty chat) and bottomed positions with
+ // a fade, see moveForm. Its height in the page is the same in both
+ // (the padding stays, the desktop greeting floats above the form),
+ // so the messages never shift while it moves
+ 'pointer-events-none md:sticky fixed mt-auto',
deviceStore.isStandalone
? 'bottom-6 right-4 left-4'
: deviceStore.isIOSSafari
? 'bottom-1 left-2 right-2'
: 'bottom-2 right-2 left-2',
'md:bottom-4',
- isEmpty ? 'md:translate-y-[calc(-50dvh+8rem)] 2xl:translate-y-[calc(-50dvh+5rem)]' : ''
+ isFormCentered
+ ? 'md:translate-y-[calc(-50dvh+7rem)] 2xl:translate-y-[calc(-50dvh+4rem)]'
+ : ''
]}
>
- <ChatScreenGreeting {isEmpty} />
+ <ChatScreenGreeting isEmpty={isFormCentered} />
<ChatScreenServerError />
diff --git a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
index 5af00ebb4..4174eec2c 100644
--- a/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreenGreeting.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { CHAT_GREETING_ENTER_DELAY_MS } from '$lib/constants';
import { serverStore } from '$lib/stores';
interface Props {
@@ -6,18 +7,47 @@
}
let { isEmpty = false }: Props = $props();
+
+ let hasEntered = $state(false);
+
+ // Starts the entry only once the greeting has been painted hidden, so it
+ // plays on a refresh or a first visit too, when the first frames are busy
+ // loading the app, not just after the form moves.
+ $effect(() => {
+ if (!isEmpty) {
+ hasEntered = false;
+
+ return;
+ }
+
+ let frame = requestAnimationFrame(() => {
+ frame = requestAnimationFrame(() => (hasEntered = true));
+ });
+
+ return () => cancelAnimationFrame(frame);
+ });
+
+ let enterClass = $derived([
+ 'transition-opacity duration-300 ease-[cubic-bezier(0.23,1,0.32,1)]',
+ !hasEntered && 'opacity-0'
+ ]);
</script>
<div
class={[
'pointer-events-none mb-4 hidden px-4 text-center text-balance',
- isEmpty && 'mb-[calc(50dvh-8rem)] md:mb-8 pointer-events-auto block!'
+ isEmpty && 'mb-[calc(50dvh-8rem)] md:mb-8 pointer-events-auto block!',
+ // out of the page flow on desktop, just above the form: inside the form
+ // wrapper's top padding, so showing it never changes the wrapper's height
+ isEmpty && 'md:absolute md:inset-x-0 md:bottom-[calc(100%-var(--chat-form-padding-top))]'
]}
>
- <h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
+ <div style:transition-delay="{CHAT_GREETING_ENTER_DELAY_MS}ms" class={enterClass}>
+ <h1 class="mb-2 text-2xl font-semibold tracking-tight md:text-3xl">Hello there</h1>
- <p class="text-muted-foreground md:text-lg">
- {serverStore.props?.modalities?.audio ? 'Record audio, type a message ' : 'Type a message'} or upload
- files to get started
- </p>
+ <p class="text-muted-foreground md:text-lg">
+ {serverStore.props?.modalities?.audio ? 'Record audio, type a message ' : 'Type a message'} or upload
+ files to get started
+ </p>
+ </div>
</div>
diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
index 423a746e4..6a47029b1 100644
--- a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabs.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import ChatTabsItem from './ChatTabsItem.svelte';
import ChatTabsNewChatButton from './ChatTabsNewChatButton.svelte';
+ import { SquarePen } from '@lucide/svelte';
import { page } from '$app/state';
import { ScrollCarousel } from '$lib/components/app';
import {
@@ -12,30 +13,35 @@
} from '$lib/constants';
import { useScrollCarousel } from '$lib/hooks/use-scroll-carousel.svelte';
import { chatStore, conversationsStore, tabsStore, uiStore } from '$lib/stores';
+ import { resolveTabView } from '$lib/utils';
import { tick } from 'svelte';
const carousel = useScrollCarousel();
- let activeId = $derived(page.params.id ?? NEW_CHAT_TAB_ID);
+ let view = $derived(resolveTabView(page, tabsStore.openTabs));
+
+ let activeId = $derived(view.activeId);
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)
- }))
+ view.ids.map((id) =>
+ id === NEW_CHAT_TAB_ID
+ ? { href: view.hrefFor(id), icon: SquarePen, id, name: NEW_CHAT_LABEL }
+ : {
+ href: view.hrefFor(id),
+ id,
+ name:
+ 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 showNewChatButton = $derived(!view.ids.includes(NEW_CHAT_TAB_ID));
let loadingIds = $derived(new Set(chatStore.getAllLoadingChats()));
function handleClose(id: string) {
- void tabsStore.close(id, activeId);
+ void tabsStore.close(id, id === activeId, view);
}
function handleStop(id: string, event: MouseEvent) {
@@ -82,9 +88,12 @@
<nav
aria-label="Open conversations"
- 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}"
+ class={[
+ 'group sticky pl-1 top-0 z-10 hidden md:block chat-top-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
+ ]}
>
<div class="relative">
<ScrollCarousel
@@ -106,7 +115,9 @@
{/each}
{#if showNewChatButton}
- <ChatTabsNewChatButton onclick={() => void conversationsStore.openNewChat()} />
+ <ChatTabsNewChatButton
+ onclick={() => void tabsStore.openInNewTab(NEW_CHAT_TAB_ID, { activate: true })}
+ />
{/if}
</ScrollCarousel>
@@ -123,15 +134,3 @@
></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>
diff --git a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
index ba223c4ad..30a0af931 100644
--- a/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
+++ b/tools/ui/src/lib/components/app/chat/ChatTabs/ChatTabsItem.svelte
@@ -1,13 +1,22 @@
<script lang="ts">
- import { Loader2, Square, SquarePen, X } from '@lucide/svelte';
+ import { Loader2, Square, 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';
+ import {
+ ICON_CLASS_SM,
+ ICON_CLASS_XS,
+ TAB_SURFACE_ACTIVE_CLASS,
+ TAB_SURFACE_CLASS,
+ TAB_SURFACE_IDLE_CLASS,
+ UI_DATA_ATTRS
+ } from '$lib/constants';
+ import type { Component } from 'svelte';
interface Tab {
+ href: string;
+ /** Marks the new-chat tab. */
+ icon?: Component;
id: string;
- isNewChat: boolean;
name: string;
}
@@ -33,8 +42,6 @@
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
@@ -59,18 +66,17 @@
<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',
+ TAB_SURFACE_CLASS,
+ 'relative max-w-52 min-w-0 shrink-0 gap-1 pr-1 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'
+ isActive ? TAB_SURFACE_ACTIVE_CLASS : TAB_SURFACE_IDLE_CLASS
)}
>
<a
aria-current={isActive ? 'page' : undefined}
aria-label={tab.name}
class="absolute inset-0 z-0 rounded-lg"
- {href}
+ href={tab.href}
onauxclick={(e) => onAuxClick?.(tab.id, e)}
onclick={handleActivate}
></a>
@@ -102,8 +108,8 @@
</Tooltip.Root>
{/if}
- {#if tab.isNewChat}
- <SquarePen
+ {#if tab.icon}
+ <tab.icon
class="pointer-events-none {ICON_CLASS_SM} shrink-0 transition-opacity {contentOpacity}"
/>
{/if}
diff --git a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
index f89753b3b..3cdd40e90 100644
--- a/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
+++ b/tools/ui/src/lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte
@@ -5,6 +5,7 @@
ListChecks,
Loader2,
MoreHorizontal,
+ PanelTopOpen,
Pencil,
Pin,
PinOff,
@@ -17,7 +18,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { FORK_TREE_DEPTH_PADDING, ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { RouterService } from '$lib/services/router.service';
- import { chatStore, conversationsStore } from '$lib/stores';
+ import { chatStore, conversationsStore, settingsStore, tabsStore } from '$lib/stores';
import { onMount } from 'svelte';
interface Props {
@@ -72,6 +73,20 @@
onStop?.(conversation.id);
}
+ let canOpenInNewTab = $derived(Boolean(settingsStore.config.conversationTabs));
+
+ function openInNewTab() {
+ void tabsStore.openInNewTab(conversation.id);
+ }
+
+ // middle-click opens a background tab, like a link in a browser
+ function handleAuxClick(event: MouseEvent) {
+ if (event.button !== 1 || isSelectionMode || !canOpenInNewTab) return;
+
+ event.preventDefault();
+ openInNewTab();
+ }
+
function handleTogglePin() {
conversationsStore.toggleConversationPin(conversation.id);
}
@@ -104,6 +119,8 @@
function handleSelect(event: MouseEvent) {
if (isSelectionMode) {
onSelectionClick?.(conversation.id, { shiftKey: event.shiftKey });
+ } else if ((event.metaKey || event.ctrlKey) && canOpenInNewTab) {
+ openInNewTab();
} else {
onSelect?.(conversation.id);
}
@@ -161,6 +178,7 @@
? 'is-selection-mode'
: ''} px-2"
{...{ [UI_DATA_ATTRS.CONVERSATION_ROW]: conversation.id }}
+ onauxclick={handleAuxClick}
onclick={(e) => handleSelect(e)}
onfocusin={handleMouseOver}
onfocusout={(e) => {
@@ -245,13 +263,26 @@
<DropdownMenuActions
bind:open={dropdownOpen}
actions={[
+ ...(canOpenInNewTab
+ ? [
+ {
+ icon: PanelTopOpen,
+ label: 'Open in new tab',
+ onclick: (e: Event) => {
+ e.stopPropagation();
+ openInNewTab();
+ }
+ }
+ ]
+ : []),
{
icon: conversation.pinned ? PinOff : Pin,
label: conversation.pinned ? 'Unpin' : 'Pin',
onclick: (e: Event) => {
e.stopPropagation();
handleTogglePin();
- }
+ },
+ separator: canOpenInNewTab
},
{
icon: Pencil,
diff --git a/tools/ui/src/lib/constants/auto-scroll.constants.ts b/tools/ui/src/lib/constants/auto-scroll.constants.ts
index 67c5f9301..7e5948aec 100644
--- a/tools/ui/src/lib/constants/auto-scroll.constants.ts
+++ b/tools/ui/src/lib/constants/auto-scroll.constants.ts
@@ -1,10 +1,16 @@
export const AUTO_SCROLL_INTERVAL = 100;
+// How long after the reader's wheel, touch or scroll key an upward scroll
+// still counts as theirs. Scrolls with no such input (scroll anchoring,
+// blocks re-rendering, programmatic scrolls) never stop auto-scroll.
+export const USER_SCROLL_INTENT_MS = 250;
// Conversation landing: the page keeps growing after the first bottom pin
-// without DOM mutations (content-visibility size realizations, syntax
-// highlight passes), so the pin repeats every frame until the height holds
-// for this many consecutive frames, bounded by the time cap below.
-export const LANDING_STABLE_FRAMES = 10;
-export const LANDING_SETTLE_MAX_MS = 1000;
+// (lazy rows mounting, content-visibility size realizations, syntax
+// highlight passes), so the pin holds until the page has not resized for
+// this long or the reader interacts.
+export const LANDING_QUIET_MS = 1000;
+// Rows at the end of the list that mount eagerly, so the first painted
+// frame of a conversation already shows real content at the bottom.
+export const LANDING_EAGER_ROWS = 8;
// Chat main view: tight threshold because scroll-here events come from
// discrete assistant-message appends.
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;
diff --git a/tools/ui/src/lib/constants/chat-form.constants.ts b/tools/ui/src/lib/constants/chat-form.constants.ts
index 9fb786f92..fcdaf17e7 100644
--- a/tools/ui/src/lib/constants/chat-form.constants.ts
+++ b/tools/ui/src/lib/constants/chat-form.constants.ts
@@ -6,3 +6,25 @@ export const PROMPT_CONTENT_SEPARATOR = '\n\n';
export const CLIPBOARD_CONTENT_QUOTE_PREFIX = '"';
export const PROMPT_TRIGGER_PREFIX = '/';
export const NEW_CHAT_DRAFT_KEY = '__new_chat__';
+
+/**
+ * The chat form moving between its centered (new chat) and bottom positions:
+ * it fades out drifting toward where it goes, then fades back in where it
+ * lands, instead of sliding across the column. The exit uses a gentler curve
+ * so the form stays visible while it travels; the entry settles quickly.
+ */
+export const CHAT_FORM_MOVE_OUT_MS = 140;
+export const CHAT_FORM_MOVE_IN_MS = 180;
+// opening a chat moves the form to the bottom: quicker, so the chat is
+// there right away
+export const CHAT_FORM_MOVE_TO_BOTTOM_OUT_MS = 80;
+export const CHAT_FORM_MOVE_TO_BOTTOM_IN_MS = 120;
+export const CHAT_FORM_MOVE_SHIFT_PX = 16;
+export const CHAT_FORM_MOVE_OUT_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)';
+export const CHAT_FORM_MOVE_IN_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';
+
+/**
+ * The new-chat greeting fades in as the form settles: it starts
+ * halfway through the form's fade-in so both read as one motion.
+ */
+export const CHAT_GREETING_ENTER_DELAY_MS = CHAT_FORM_MOVE_IN_MS / 2;
diff --git a/tools/ui/src/lib/constants/css-classes.constants.ts b/tools/ui/src/lib/constants/css-classes.constants.ts
index a8f00df4c..d90ca2a5f 100644
--- a/tools/ui/src/lib/constants/css-classes.constants.ts
+++ b/tools/ui/src/lib/constants/css-classes.constants.ts
@@ -25,6 +25,18 @@ export const DIALOG_SUBMENU_CONTENT = 'w-60';
export const PANEL_CLOSE_MOBILE_CLASS =
'max-md:grid max-md:size-10 max-md:place-items-center max-md:rounded-full max-md:text-muted-foreground max-md:opacity-100 max-md:hover:bg-accent max-md:hover:text-accent-foreground';
+/** Shape of a chat tab, shared with the breadcrumb items next to the project tabs. */
+export const TAB_SURFACE_CLASS =
+ 'flex h-8 items-center rounded-lg border text-sm whitespace-nowrap backdrop-blur-xl';
+
+/** Tab surface at rest: invisible until hovered. */
+export const TAB_SURFACE_IDLE_CLASS =
+ 'border-transparent hover:bg-primary/10 hover:border-border/10 hover:shadow-sm';
+
+/** Tab surface of the open tab. */
+export const TAB_SURFACE_ACTIVE_CLASS =
+ 'bg-muted/60 border-border/10 shadow-sm text-accent-foreground hover:bg-primary/15';
+
/** Selects the focused chat-form input (either renderer) to restore focus after model actions. */
export const CHAT_INPUT_FOCUS_SELECTOR =
'[data-slot="input-area"] textarea, [data-slot="input-area"] [contenteditable="true"]';
diff --git a/tools/ui/src/lib/enums/keyboard.enums.ts b/tools/ui/src/lib/enums/keyboard.enums.ts
index 3fde816f6..8bfb7441f 100644
--- a/tools/ui/src/lib/enums/keyboard.enums.ts
+++ b/tools/ui/src/lib/enums/keyboard.enums.ts
@@ -14,9 +14,11 @@ export enum KeyboardKey {
E_UPPER = 'E',
ENTER = 'Enter',
ESCAPE = 'Escape',
+ HOME = 'Home',
K_LOWER = 'k',
O_LOWER = 'o',
O_UPPER = 'O',
+ PAGE_UP = 'PageUp',
SPACE = ' ',
TAB = 'Tab',
X_LOWER = 'x',
diff --git a/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts b/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
index d55574efe..319ea22ed 100644
--- a/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts
@@ -1,4 +1,11 @@
-import { AUTO_SCROLL_AT_BOTTOM_THRESHOLD, AUTO_SCROLL_INTERVAL } from '$lib/constants';
+import {
+ AUTO_SCROLL_AT_BOTTOM_THRESHOLD,
+ AUTO_SCROLL_INTERVAL,
+ USER_SCROLL_INTENT_MS
+} from '$lib/constants';
+import { KeyboardKey } from '$lib/enums';
+
+const SCROLL_UP_KEYS: string[] = [KeyboardKey.ARROW_UP, KeyboardKey.HOME, KeyboardKey.PAGE_UP];
export interface AutoScrollOptions {
disabled?: boolean;
@@ -11,17 +18,26 @@ export interface AutoScrollOptions {
* - Auto-scrolls to bottom during streaming/loading
* - Stops auto-scroll when user manually scrolls up
* - Resumes auto-scroll when user scrolls back to bottom
+ *
+ * Only the reader's own input (wheel, touch, scroll keys, scrollbar drag)
+ * counts as scrolling up: the page also moves up on its own while a
+ * response streams (scroll anchoring, blocks re-rendering shorter, the
+ * send animation), and that must not stop following the response.
*/
export class AutoScrollController {
private _autoScrollEnabled = $state(true);
private _container: HTMLElement | undefined;
private _disabled: boolean;
+ private _draggingScrollbar = false;
private _lastScrollTop = $state(0);
+ private _lastUserIntentAt = 0;
private _mutationObserver: MutationObserver | null = null;
private _observerEnabled = false;
private _rafPending = false;
private _scrollInterval: ReturnType<typeof setInterval> | undefined;
+ private _unbindUserIntent: (() => void) | null = null;
private _userScrolledUp = $state(false);
+
get autoScrollEnabled(): boolean {
return this._autoScrollEnabled;
}
@@ -40,6 +56,8 @@ export class AutoScrollController {
destroy(): void {
this.stopInterval();
this._doStopObserving();
+ this._unbindUserIntent?.();
+ this._unbindUserIntent = null;
}
/**
@@ -62,8 +80,10 @@ export class AutoScrollController {
const distanceFromBottom = scrollHeight - clientHeight - scrollTop;
const isScrollingUp = scrollTop < this._lastScrollTop;
const isAtBottom = distanceFromBottom < AUTO_SCROLL_AT_BOTTOM_THRESHOLD;
+ const isUserScrolling =
+ this._draggingScrollbar || performance.now() - this._lastUserIntentAt < USER_SCROLL_INTENT_MS;
- if (isScrollingUp && !isAtBottom) {
+ if (isScrollingUp && !isAtBottom && isUserScrolling) {
this._userScrolledUp = true;
this._autoScrollEnabled = false;
} else if (isAtBottom && this._userScrolledUp) {
@@ -100,8 +120,14 @@ export class AutoScrollController {
*/
setContainer(container: HTMLElement | undefined): void {
this._doStopObserving();
+ this._unbindUserIntent?.();
+ this._unbindUserIntent = null;
this._container = container;
+ if (container) {
+ this._unbindUserIntent = this._bindUserIntent(container);
+ }
+
if (this._observerEnabled && container && !this._disabled) {
this._doStartObserving();
}
@@ -185,6 +211,52 @@ export class AutoScrollController {
}
}
+ /**
+ * Records the reader's own scroll input, so `handleScroll` can tell their
+ * scrolling from the page moving on its own.
+ */
+ private _bindUserIntent(container: HTMLElement): () => void {
+ const isPage = container === document.documentElement;
+ const target: HTMLElement | Window = isPage ? window : container;
+ const markIntent = () => {
+ this._lastUserIntentAt = performance.now();
+ };
+ const onWheel = (event: WheelEvent) => {
+ if (event.deltaY < 0) markIntent();
+ };
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (
+ SCROLL_UP_KEYS.includes(event.key) ||
+ (event.key === KeyboardKey.SPACE && event.shiftKey)
+ ) {
+ markIntent();
+ }
+ };
+ // a press right of the content box lands on the scrollbar
+ const onPointerDown = (event: PointerEvent) => {
+ const left = isPage ? 0 : container.getBoundingClientRect().left;
+
+ this._draggingScrollbar = event.clientX >= left + container.clientWidth;
+ };
+ const onPointerUp = () => {
+ this._draggingScrollbar = false;
+ };
+
+ target.addEventListener('wheel', onWheel as EventListener, { passive: true });
+ target.addEventListener('touchmove', markIntent, { passive: true });
+ target.addEventListener('keydown', onKeyDown as EventListener);
+ target.addEventListener('pointerdown', onPointerDown as EventListener);
+ window.addEventListener('pointerup', onPointerUp);
+
+ return () => {
+ target.removeEventListener('wheel', onWheel as EventListener);
+ target.removeEventListener('touchmove', markIntent);
+ target.removeEventListener('keydown', onKeyDown as EventListener);
+ target.removeEventListener('pointerdown', onPointerDown as EventListener);
+ window.removeEventListener('pointerup', onPointerUp);
+ };
+ }
+
private _doStartObserving(): void {
if (!this._container || this._mutationObserver) return;
diff --git a/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts b/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts
index 004db9bcc..faf06bb8a 100644
--- a/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-chat-screen-scroll.svelte.ts
@@ -8,25 +8,83 @@
*/
import type { AutoScrollController } from './use-auto-scroll.svelte';
-import { afterNavigate, beforeNavigate } from '$app/navigation';
+import { afterNavigate, beforeNavigate, disableScrollHandling, onNavigate } from '$app/navigation';
+import { LANDING_QUIET_MS } from '$lib/constants';
+import { onDestroy } from 'svelte';
+
+const LANDING_INTENT_EVENTS = ['keydown', 'pointerdown', 'touchstart', 'wheel'] as const;
export function useChatScreenScroll(autoScroll: AutoScrollController) {
let chatScrollContainer: HTMLElement | undefined = $state();
let isNavigating = $state(false);
+ let stopLanding: (() => void) | null = null;
function handleScroll(event: UIEvent) {
- // Ignore scroll events caused by navigation layout changes or by our own
- // programmatic scrolls so they don't accidentally disable auto-scroll.
+ // Ignore scroll events caused by navigation layout changes. Programmatic
+ // and layout scrolls cannot stop auto-scroll: only the reader's own
+ // input counts as scrolling up (see AutoScrollController.handleScroll).
if (isNavigating || !event.isTrusted) return;
autoScroll.handleScroll();
}
+ /**
+ * Pins the page to its bottom while a freshly opened conversation settles.
+ * ResizeObserver callbacks run after layout and before paint, so every
+ * height change (lazy rows mounting, content-visibility realizing a row's
+ * size, highlight passes) is corrected in the frame that caused it and no
+ * frame is ever painted off the bottom. Stops on the reader's first input
+ * or once the page has not resized for `LANDING_QUIET_MS`.
+ */
+ function landAtBottom() {
+ stopLanding?.();
+
+ const container = chatScrollContainer;
+
+ if (!container) return;
+
+ let quietTimer: ReturnType<typeof setTimeout> | undefined;
+
+ const pin = () => {
+ container.scrollTop = container.scrollHeight;
+ clearTimeout(quietTimer);
+ quietTimer = setTimeout(stop, LANDING_QUIET_MS);
+ };
+ const observer = new ResizeObserver(pin);
+ const stop = () => {
+ observer.disconnect();
+ clearTimeout(quietTimer);
+
+ for (const type of LANDING_INTENT_EVENTS) {
+ window.removeEventListener(type, stop, { capture: true });
+ }
+
+ if (stopLanding === stop) stopLanding = null;
+ };
+
+ stopLanding = stop;
+
+ for (const type of LANDING_INTENT_EVENTS) {
+ window.addEventListener(type, stop, { capture: true, passive: true });
+ }
+
+ pin();
+ observer.observe(document.body);
+ }
+
beforeNavigate(() => {
isNavigating = true;
+ stopLanding?.();
autoScroll.resetScrollState();
});
+ // Opening a conversation lands at its bottom once its messages load, so
+ // SvelteKit's own reset (or restore, on back/forward) would only move the
+ // previous conversation, still on screen, to its top for a few frames.
+ onNavigate((navigation) => {
+ if (navigation.to?.params?.id) disableScrollHandling();
+ });
+
afterNavigate(() => {
setTimeout(() => {
isNavigating = false;
@@ -39,10 +97,13 @@ export function useChatScreenScroll(autoScroll: AutoScrollController) {
autoScroll.setContainer(chatScrollContainer);
});
+ onDestroy(() => stopLanding?.());
+
return {
get chatScrollContainer() {
return chatScrollContainer;
},
- handleScroll
+ handleScroll,
+ landAtBottom
};
}
diff --git a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts
index eef1bc332..915badae9 100644
--- a/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts
+++ b/tools/ui/src/lib/hooks/use-keyboard-shortcuts.svelte.ts
@@ -63,7 +63,7 @@ export function useKeyboardShortcuts(callbacks: KeyboardShortcutsCallbacks) {
page.params.id ?? (page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : undefined);
if (activeId) {
- void tabsStore.close(activeId, activeId);
+ void tabsStore.close(activeId, true);
}
}
diff --git a/tools/ui/src/lib/stores/conversations/index.svelte.ts b/tools/ui/src/lib/stores/conversations/index.svelte.ts
index c4fea2e4e..86f7e3412 100644
--- a/tools/ui/src/lib/stores/conversations/index.svelte.ts
+++ b/tools/ui/src/lib/stores/conversations/index.svelte.ts
@@ -156,7 +156,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId));
this.clearActiveConversation();
- await tabsStore.close(activeId, activeId);
+ await tabsStore.closeConversation(activeId);
} else {
tabsStore.removeTabs([...idsToRemove]);
}
@@ -340,7 +340,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
tabsStore.removeTabs([...idsToRemove].filter((id) => id !== activeId));
this.clearActiveConversation();
- await tabsStore.close(activeId, activeId);
+ await tabsStore.closeConversation(activeId);
} else {
tabsStore.removeTabs([...idsToRemove]);
}
@@ -361,7 +361,7 @@ class ConversationsStore implements ConversationsPreferencesHost {
if (this.activeConversation?.id === convId) {
this.clearActiveConversation();
- await tabsStore.close(convId, convId);
+ await tabsStore.closeConversation(convId);
} else {
tabsStore.removeTabs([convId]);
}
diff --git a/tools/ui/src/lib/stores/tabs.svelte.ts b/tools/ui/src/lib/stores/tabs.svelte.ts
index 04b476cb6..f21f84cc2 100644
--- a/tools/ui/src/lib/stores/tabs.svelte.ts
+++ b/tools/ui/src/lib/stores/tabs.svelte.ts
@@ -1,16 +1,18 @@
/**
* 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}).
+ * Tracks which conversations and the new-chat screen are open as tabs in the
+ * chat layout, in order. Conversation tabs hold the conversation id; the
+ * new-chat tab of the bare `#/` route is the `NEW_CHAT_TAB_ID` sentinel.
+ *
+ * Tabs open like in a browser: a plain navigation shows the new target in
+ * the current tab, and only `openInNewTab()` adds a tab.
*
* **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.
+ * `closeConversation()` 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.
*/
@@ -19,42 +21,57 @@ 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 type { TabView } from '$lib/utils/chat-tabs';
import { untrack } from 'svelte';
-class TabsStore {
+export class TabsStore {
/** Ordered tab ids: conversation ids and the `NEW_CHAT_TAB_ID` sentinel */
openTabs = $state<string[]>([]);
+ /** Target of the last route the chat layout reported, the tab a plain navigation reuses */
+ private current: string | null = null;
+
/** False until init() has read the persisted tabs; save() is a no-op before that */
private initialized = false;
+ /** Route synced before init, applied once the persisted tabs are loaded */
+ private pendingTarget: string | null = null;
+
/** 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));
+ await goto(this.hrefFor(id));
}
/** Remove all tabs (e.g. after deleting all conversations) */
clear(): void {
this.openTabs = [];
+ this.current = null;
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.
+ * neighbor (or the right one when the closed tab was leftmost) among the
+ * tabs the view shows, falling back to the view's start when none remain.
* @param id - Tab id to close
- * @param activeTabId - Tab id of the current route, if any
+ * @param isActive - Whether the tab is the current route
+ * @param view - Visible tabs and their links; all tabs when omitted
*/
- 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);
- }
+ async close(
+ id: string,
+ isActive: boolean,
+ view: Pick<TabView, 'fallbackHref' | 'hrefFor' | 'ids'> = {
+ fallbackHref: ROUTES.START,
+ hrefFor: (tabId) => this.hrefFor(tabId),
+ ids: this.openTabs
+ }
+ ): Promise<void> {
+ const idx = view.ids.indexOf(id);
+
+ if (!this.openTabs.includes(id)) {
+ // tab not tracked (e.g. Conversation tabs are off); still leave the
+ // closed route for the view's start
+ if (isActive) await goto(view.fallbackHref);
return;
}
@@ -62,23 +79,27 @@ class TabsStore {
this.openTabs = this.openTabs.filter((tabId) => tabId !== id);
this.save();
- if (id !== activeTabId) return;
+ if (!isActive) return;
- const target = (idx > 0 ? this.openTabs[idx - 1] : this.openTabs[0]) ?? null;
+ const visible = view.ids.filter((tabId) => tabId !== id);
+ const next = (idx > 0 ? visible[idx - 1] : visible[0]) ?? null;
- if (target) {
- await goto(target === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(target));
- } else {
- await goto(ROUTES.START);
- }
+ await goto(next ? view.hrefFor(next) : view.fallbackHref);
+ }
+
+ /** Close the tab of the conversation open in the route, e.g. after deleting it */
+ async closeConversation(id: string): Promise<void> {
+ await this.close(id, true);
+ }
+
+ hrefFor(id: string): string {
+ return id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id);
}
/**
- * Load persisted tabs, dropping conversation ids that no longer exist.
+ * Load persisted tabs, dropping conversation ids that no longer exist,
+ * then apply the route synced while loading.
* 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 {
@@ -86,16 +107,29 @@ class TabsStore {
// 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.openTabs = this.load().filter((id) => id === NEW_CHAT_TAB_ID || validIds.includes(id));
this.initialized = true;
this.save();
+
+ if (this.pendingTarget) {
+ this.syncWithRoute(this.pendingTarget);
+ this.pendingTarget = null;
+ }
+ }
+
+ /**
+ * Add a tab at the end. Without `activate` the tab opens in the
+ * background, like a middle-click in a browser.
+ * @param id - What the new tab shows
+ * @param options.activate - Also navigate to the tab
+ */
+ async openInNewTab(id: string, { activate = false } = {}): Promise<void> {
+ if (!this.openTabs.includes(id)) {
+ this.openTabs = [...this.openTabs, id];
+ this.save();
+ }
+
+ if (activate) await this.activate(id);
}
/**
@@ -104,8 +138,7 @@ class TabsStore {
* @param ids - Tab ids to drop
*/
removeTabs(ids: string[]): void {
- const removed = new Set(ids);
- const next = this.openTabs.filter((id) => !removed.has(id));
+ const next = this.openTabs.filter((id) => !ids.includes(id));
if (next.length !== this.openTabs.length) {
this.openTabs = next;
@@ -115,19 +148,35 @@ class TabsStore {
/**
* 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
+ * navigation. A target already open activates its tab; otherwise the tab
+ * of the previous route now shows it, and only a navigation from a page
+ * without a tab adds one.
+ * @param target - Conversation id or `NEW_CHAT_TAB_ID`
*/
- syncWithRoute(id: string): void {
+ syncWithRoute(target: 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();
+ if (!this.initialized) {
+ this.pendingTarget = target;
+
+ return;
}
+
+ const previous = this.current;
+
+ this.current = target;
+
+ if (this.openTabs.includes(target)) return;
+
+ const idx = previous ? this.openTabs.indexOf(previous) : -1;
+
+ this.openTabs =
+ idx === -1
+ ? [...this.openTabs, target]
+ : this.openTabs.map((id, i) => (i === idx ? target : id));
+ this.save();
});
}
@@ -144,7 +193,7 @@ class TabsStore {
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
+ // write would clobber them
if (!browser || !this.initialized) return;
localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(this.openTabs));
diff --git a/tools/ui/src/lib/utils/chat-tabs.ts b/tools/ui/src/lib/utils/chat-tabs.ts
new file mode 100644
index 000000000..7bb13af47
--- /dev/null
+++ b/tools/ui/src/lib/utils/chat-tabs.ts
@@ -0,0 +1,38 @@
+import { NEW_CHAT_TAB_ID, ROUTES } from '$lib/constants';
+import { RouterService } from '$lib/services/router.service';
+
+interface TabRoute {
+ params: Partial<Record<string, string>>;
+ route: { id: string | null };
+}
+
+/** Which tabs a route shows and where they lead. */
+export interface TabView {
+ /** Tab of the current route; null on a page without one. */
+ activeId: string | null;
+ /** Where to go when the last visible tab closes. */
+ fallbackHref: string;
+ hrefFor: (id: string) => string;
+ /** Visible tab ids, in strip order. */
+ ids: string[];
+}
+
+/**
+ * Tab target of the current route: a conversation id or the new-chat
+ * screen. Null on routes without tabs.
+ */
+export function resolveTabTarget(page: TabRoute): string | null {
+ if (page.params.id) return page.params.id;
+
+ return page.route.id === '/(chat)' ? NEW_CHAT_TAB_ID : null;
+}
+
+/** Tabs of the current route: every open tab, linked to its chat. */
+export function resolveTabView(page: TabRoute, openTabs: string[]): TabView {
+ return {
+ activeId: resolveTabTarget(page),
+ fallbackHref: ROUTES.START,
+ hrefFor: (id) => (id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id)),
+ ids: openTabs
+ };
+}
diff --git a/tools/ui/src/lib/utils/index.ts b/tools/ui/src/lib/utils/index.ts
index a5f2af55f..c6f77d6a3 100644
--- a/tools/ui/src/lib/utils/index.ts
+++ b/tools/ui/src/lib/utils/index.ts
@@ -215,6 +215,8 @@ export {
type PathQuery
} from './working-directory';
+export { resolveTabTarget, resolveTabView, type TabView } from './chat-tabs';
+
// Shared `file_glob_search` runner with a short-lived result cache
export { runGlobSearch, runGlobSearchWithChildren } from './glob-search';
diff --git a/tools/ui/src/routes/(chat)/+layout.svelte b/tools/ui/src/routes/(chat)/+layout.svelte
index 5be56dd56..84cdfeefe 100644
--- a/tools/ui/src/routes/(chat)/+layout.svelte
+++ b/tools/ui/src/routes/(chat)/+layout.svelte
@@ -23,7 +23,9 @@
});
</script>
-<div class={showTabs ? 'md:[--chat-tabs-offset:1.25rem]' : ''}>
+<!-- the offset is the tab bar's full height (pt-3.25 + h-10), so the chat
+ column still fits the viewport and only scrolls when the messages overflow -->
+<div class={showTabs ? 'md:[--chat-tabs-offset:3.3125rem]' : ''}>
{#if showTabs}
<ChatTabs />
{/if}
diff --git a/tools/ui/src/routes/+layout.svelte b/tools/ui/src/routes/+layout.svelte
index 70d3b7d8b..2bec18b6c 100644
--- a/tools/ui/src/routes/+layout.svelte
+++ b/tools/ui/src/routes/+layout.svelte
@@ -125,20 +125,6 @@
}
}
- // 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]));
- }
- });
// the models page returns to wherever it was reached from, by any kind of navigation
afterNavigate(() => uiStore.rememberModelsReturnRoute());
diff --git a/tools/ui/tests/client/chat-tabs-store.svelte.test.ts b/tools/ui/tests/client/chat-tabs-store.svelte.test.ts
new file mode 100644
index 000000000..19f7e4436
--- /dev/null
+++ b/tools/ui/tests/client/chat-tabs-store.svelte.test.ts
@@ -0,0 +1,117 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
+
+import { goto } from '$app/navigation';
+import { CONVERSATION_TABS_LOCALSTORAGE_KEY, NEW_CHAT_TAB_ID } from '$lib/constants';
+import { TabsStore } from '$lib/stores/tabs.svelte';
+
+const gotoMock = vi.mocked(goto);
+
+function freshStore(persisted?: unknown, validIds: string[] = []) {
+ localStorage.removeItem(CONVERSATION_TABS_LOCALSTORAGE_KEY);
+
+ if (persisted !== undefined) {
+ localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(persisted));
+ }
+
+ const tabs = new TabsStore();
+
+ tabs.init(validIds);
+
+ return tabs;
+}
+
+beforeEach(() => {
+ gotoMock.mockClear();
+});
+
+/**
+ * A plain navigation shows the new target in the current tab; only an
+ * explicit new-tab action grows the strip.
+ */
+describe('tabsStore route sync', () => {
+ it('creates the first tab, then navigates it in place', () => {
+ const tabs = freshStore();
+
+ tabs.syncWithRoute('a');
+ tabs.syncWithRoute('b');
+ tabs.syncWithRoute('c');
+
+ expect(tabs.openTabs).toEqual(['c']);
+ });
+
+ it('activates an open tab instead of replacing the current one', () => {
+ const tabs = freshStore(['a', 'b'], ['a', 'b']);
+
+ tabs.syncWithRoute('b');
+ tabs.syncWithRoute('a');
+
+ expect(tabs.openTabs).toEqual(['a', 'b']);
+ });
+
+ it('turns the new-chat tab into the conversation it creates', async () => {
+ const tabs = freshStore();
+
+ tabs.syncWithRoute('a');
+ await tabs.openInNewTab(NEW_CHAT_TAB_ID, { activate: true });
+ tabs.syncWithRoute(NEW_CHAT_TAB_ID);
+ tabs.syncWithRoute('b');
+
+ expect(tabs.openTabs).toEqual(['a', 'b']);
+ });
+
+ it('waits for init before applying the route', () => {
+ localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(['a', 'b']));
+
+ const tabs = new TabsStore();
+
+ tabs.syncWithRoute('b');
+ tabs.init(['a', 'b']);
+
+ expect(tabs.openTabs).toEqual(['a', 'b']);
+ });
+});
+
+describe('tabsStore explicit tabs', () => {
+ it('opens new tabs at the end, in the background, without duplicates', async () => {
+ const tabs = freshStore();
+
+ tabs.syncWithRoute('a');
+ await tabs.openInNewTab('b');
+ await tabs.openInNewTab('c');
+ await tabs.openInNewTab('b');
+
+ expect(tabs.openTabs).toEqual(['a', 'b', 'c']);
+ expect(gotoMock).not.toHaveBeenCalled();
+ });
+
+ it('closes the active tab to its left neighbour', async () => {
+ const tabs = freshStore();
+
+ tabs.syncWithRoute('a');
+ await tabs.openInNewTab('b');
+ tabs.syncWithRoute('b');
+ await tabs.close('b', true);
+
+ expect(tabs.openTabs).toEqual(['a']);
+ expect(gotoMock).toHaveBeenLastCalledWith('#/chat/a');
+ });
+
+ it('falls back to the new-chat screen when the last tab closes', async () => {
+ const tabs = freshStore();
+
+ tabs.syncWithRoute('a');
+ await tabs.close('a', true);
+
+ expect(gotoMock).toHaveBeenLastCalledWith('#/');
+ });
+});
+
+describe('tabsStore persistence', () => {
+ it('prunes deleted conversations and keeps the new-chat tab', () => {
+ const tabs = freshStore(['a', NEW_CHAT_TAB_ID, 'gone'], ['a']);
+
+ expect(tabs.openTabs).toEqual(['a', NEW_CHAT_TAB_ID]);
+ });
+});