Skip to content
Merged
28 changes: 28 additions & 0 deletions tools/ui/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,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;
Expand Down Expand Up @@ -221,6 +238,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -115,6 +135,10 @@

resizeObserver.observe(userMessageEl);

if (leadingRowsAreSynthetic) {
for (const row of leadingRows) resizeObserver.observe(row);
}

return () => {
resizeObserver.disconnect();
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +19,7 @@
let {
chatActions,
class: className = '',
eager = false,
isLastAssistantMessage = false,
isLastUserMessage = false,
message,
Expand All @@ -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) => {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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>
131 changes: 86 additions & 45 deletions tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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);
Expand Down Expand Up @@ -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;

Expand All @@ -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();
Expand All @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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 />

Expand Down
Loading
Loading