const gauge = useContextGauge();
+ // The gauge hook wraps a processing state instance that only follows the
+ // live stream while its own monitoring flag is set, so the card instance
+ // starts monitoring like the dial does.
+ $effect(() => {
+ gauge.startMonitoring();
+ });
+
let cardEl = $state<HTMLElement | null>(null);
// Any press outside the card and outside the dial closes the card.
<div
role="status"
bind:this={cardEl}
- class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
+ class="absolute z-50 w-64 -translate-x-1/2 rounded-lg border border-border/50 bg-popover p-3 text-sm text-popover-foreground shadow-lg ring-1 ring-foreground/10"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
onpointerenter={gaugeCardEnter}
onpointerleave={gaugeCardLeave}
import { config } from '$lib/stores/settings.svelte';
import { serverLoading, serverError } from '$lib/stores/server.svelte';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
- import { onDestroy, onMount } from 'svelte';
+ import { onDestroy, onMount, tick } from 'svelte';
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
- import { ROUTES } from '$lib/constants';
+ import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
let { showCenteredEmpty = false } = $props();
return true;
}
+ 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.
+ async function handleMessagesReady(messageCount: number) {
+ if (messageCount === 0) return;
+ const id = activeConversation()?.id ?? null;
+ if (!id || id === lastScrolledConversationId) return;
+ 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 (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;
+ requestAnimationFrame(settle);
+ };
+ requestAnimationFrame(settle);
+ }
+
function handleSendLikeScroll() {
if (!isMobile.current) {
autoScroll.enable();
{#if !isEmpty}
<ChatMessages
messages={activeMessages()}
+ onMessagesReady={handleMessagesReady}
onUserAction={() => {
handleSendLikeScroll();
}}
export const AUTO_SCROLL_INTERVAL = 100;
+// 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;
// Chat main view: tight threshold because scroll-here events come from
// discrete assistant-message appends.
export const AUTO_SCROLL_AT_BOTTOM_THRESHOLD = 10;