ui: remove render effects (#26083)

* ui: remove viewport fade in and smooth autoscroll bottom snap

fadeInView mounted every message and markdown block at opacity 0 and
relied on an IntersectionObserver to reveal it. When the observer never
fires (blocks mounted offscreen during long agentic loops) the content
stays invisible forever while still present in the DOM. Remove the
action, its orphaned isElementInViewport util and all call sites:
blocks now render visible immediately.

AutoScrollController.scrollToBottom defaulted to behavior smooth and is
invoked every 100 ms while streaming. Each tick restarts an easing
animation toward a moving scrollHeight, producing a random elastic bump
of a few pixels when the user reaches the bottom and autoscroll
reengages. Default to instant scrolling; the user facing scroll down
button keeps its smooth behavior.

* ui: skip rendering of offscreen chat messages via content-visibility

Apply content-visibility auto with contain-intrinsic-size to chat
messages so the browser skips layout and paint for messages outside
the viewport. The DOM stays complete: component state, find-in-page,
text selection, and the mutation based autoscroll are unaffected, and
browsers without support simply ignore the properties.

* ui: remove conversation switch fade

Switching conversations faded the message list out and in over 500 ms
plus a 300 ms route delay, deferring the message refresh behind two
requestAnimationFrame calls. Remove the fade, its navigation hooks and
dead state, and refresh messages directly so switching is only bound
by actual render time.

* ui: describe present behavior in comments and drop unused parameter

* ui: anchor the context gauge popup to the form with plain CSS

The stats card was portaled to body and repositioned in script on
every ancestor scroll event, trailing the page by one frame while
streaming. Render it as an absolutely positioned sibling of the input
box inside the already relative form, so nothing runs during scroll.

The card sits just above the dial, centered on it and overlapping the
textarea, from a single measurement of the dial center and top taken
when it opens; the dial and the card share the same positioning frame,
so the values stay exact while the card is open. Mouse pointers open
on hover with a grace delay to reach the card, touch pointers toggle
on tap, any press outside the card and the dial closes it, and Enter
and Space toggle from the keyboard. The card lives outside the input
box because its overflow-hidden and backdrop-filter would clip any
positioned descendant.

* ui: extract context gauge popup constants and relocate its state store

Move the placement values and the close grace delay to
lib/constants/context-gauge-popup.ts, matching the auto-scroll
constants layout, and move the popup state module from the component
folder to lib/stores where runes modules live in this codebase.

* ui: declare the context gauge popup card ref as $state
This commit is contained in:
Pascal
2026-07-24 21:43:23 +02:00
committed by GitHub
parent 88bfee1429
commit 96013c5112
14 changed files with 251 additions and 196 deletions
@@ -1,49 +0,0 @@
import { isElementInViewport } from '$lib/utils/viewport';
/**
* Svelte action that fades in an element when it enters the viewport.
* Uses IntersectionObserver for efficient viewport detection.
*
* If skipIfVisible is set and the element is already visible in the viewport
* when the action attaches (e.g. a markdown block promoted from unstable
* during streaming), the fade is skipped entirely to avoid a flash.
*/
export function fadeInView(
node: HTMLElement,
options: { duration?: number; y?: number; delay?: number; skipIfVisible?: boolean } = {}
) {
const { duration = 300, y = 0, delay = 0, skipIfVisible = false } = options;
if (skipIfVisible && isElementInViewport(node)) {
return;
}
node.style.opacity = '0';
node.style.transform = `translateY(${y}px)`;
node.style.transition = `opacity ${duration}ms ease-out, transform ${duration}ms ease-out`;
$effect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setTimeout(() => {
requestAnimationFrame(() => {
node.style.opacity = '1';
node.style.transform = 'translateY(0)';
});
}, delay);
observer.disconnect();
}
}
},
{ threshold: 0.05 }
);
observer.observe(node);
return () => {
observer.disconnect();
};
});
}
@@ -25,6 +25,7 @@
SpecialFileType
} from '$lib/enums';
import { config } from '$lib/stores/settings.svelte';
import ContextGaugePopup from './ChatFormContextGauge/ContextGaugePopup.svelte';
import { modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
@@ -556,6 +557,8 @@
/>
</div>
</div>
<ContextGaugePopup />
</form>
<DialogMcpResourcesBrowser
@@ -1,14 +1,16 @@
<script lang="ts">
import { untrack } from 'svelte';
import * as HoverCard from '$lib/components/ui/hover-card';
import { activeConversation, activeMessages } from '$lib/stores/conversations.svelte';
import { chatStore, isChatStreaming, isLoading } from '$lib/stores/chat.svelte';
import { formatParameters } from '$lib/utils/formatters';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDial from './ContextGaugeDial.svelte';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import {
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerKeydown,
gaugeTriggerLeave,
gaugeTriggerPointerDown
} from '$lib/stores/context-gauge-popup.svelte';
const gauge = useContextGauge();
@@ -34,75 +36,19 @@
$effect(() => {
gauge.startMonitoring();
});
const showProgressBar = $derived(
gauge.contextTotal !== null &&
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);
</script>
<HoverCard.Root>
<HoverCard.Trigger class="flex h-5 w-5 cursor-default items-center justify-center">
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
</HoverCard.Trigger>
<HoverCard.Content
side="bottom"
class="z-50 w-64 rounded-lg border border-border/50 bg-popover p-3 text-popover-foreground shadow-lg"
>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-medium">Context</span>
<span class="text-muted-foreground">·</span>
<span class="font-mono text-muted-foreground">
{formatParameters(gauge.contextUsed)}
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
</span>
</div>
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
modelId={gauge.activeModelId}
isLoading={gauge.isActiveModelLoading}
onLoad={gauge.loadModel}
/>
{:else if showProgressBar}
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
gauge.colorLevel
)}"
style="width: {gauge.contextPercent}%"
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
{formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
</span>
</div>
{:else}
<div class="text-xs text-muted-foreground">No context info available</div>
{/if}
{#if gauge.hasAnyUsage}
<ContextGaugeDetails
currentRead={gauge.currentRead}
currentFresh={gauge.currentFresh}
currentCache={gauge.currentCache}
currentOutput={gauge.currentOutput}
kvTotal={gauge.kvTotal}
cumulativeRead={gauge.cumulativeRead}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
averageTokensPerSecond={gauge.averageTokensPerSecond}
transientDetails={gauge.transientDetails}
/>
{/if}
</div>
</HoverCard.Content>
</HoverCard.Root>
<div
role="button"
tabindex="0"
aria-label="Context usage"
data-context-gauge-trigger
class="flex h-5 w-5 cursor-default items-center justify-center"
onclick={gaugeTriggerClick}
onkeydown={gaugeTriggerKeydown}
onpointerdown={gaugeTriggerPointerDown}
onpointerenter={gaugeTriggerEnter}
onpointerleave={gaugeTriggerLeave}
>
<ContextGaugeDial percent={gauge.contextPercent} level={gauge.colorLevel} />
</div>
@@ -0,0 +1,106 @@
<script lang="ts">
import { formatParameters } from '$lib/utils/formatters';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import {
gaugePopup,
gaugeCardEnter,
gaugeCardLeave,
gaugePopupClose
} from '$lib/stores/context-gauge-popup.svelte';
const gauge = useContextGauge();
let cardEl = $state<HTMLElement | null>(null);
// Any press outside the card and outside the dial closes the card.
// Presses on the dial are excluded because the dial handles its own
// toggle; the listener only exists while the card is open.
$effect(() => {
if (!gaugePopup.open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (cardEl?.contains(target)) return;
if (target instanceof Element && target.closest('[data-context-gauge-trigger]')) return;
gaugePopupClose();
};
document.addEventListener('pointerdown', onPointerDown, true);
return () => document.removeEventListener('pointerdown', onPointerDown, true);
});
const showProgressBar = $derived(
gauge.contextTotal !== null &&
gauge.contextTotal > 0 &&
(gauge.activeModelId !== null || gauge.isActiveModelLoaded)
);
</script>
{#if gaugePopup.open}
<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"
style="left: {gaugePopup.centerX}px; bottom: {gaugePopup.bottom}px"
onpointerenter={gaugeCardEnter}
onpointerleave={gaugeCardLeave}
>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-medium">Context</span>
<span class="text-muted-foreground">·</span>
<span class="font-mono text-muted-foreground">
{formatParameters(gauge.contextUsed)}
/ {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'}
</span>
</div>
{#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded}
<ContextGaugeLoadModel
modelId={gauge.activeModelId}
isLoading={gauge.isActiveModelLoading}
onLoad={gauge.loadModel}
/>
{:else if showProgressBar}
<div class="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
class="h-full rounded-full transition-all duration-300 {colorLevelBgClass(
gauge.colorLevel
)}"
style="width: {gauge.contextPercent}%"
></div>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>
<span class={colorLevelTextClass(gauge.colorLevel)}>{gauge.contextPercent}%</span> used
</span>
<span>
{formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining
</span>
</div>
{:else}
<div class="text-xs text-muted-foreground">No context info available</div>
{/if}
{#if gauge.hasAnyUsage}
<ContextGaugeDetails
currentRead={gauge.currentRead}
currentFresh={gauge.currentFresh}
currentCache={gauge.currentCache}
currentOutput={gauge.currentOutput}
kvTotal={gauge.kvTotal}
cumulativeRead={gauge.cumulativeRead}
cumulativeOutput={gauge.cumulativeOutput}
cumulativeCacheTotal={gauge.cumulativeCacheTotal}
averageTokensPerSecond={gauge.averageTokensPerSecond}
transientDetails={gauge.transientDetails}
/>
{/if}
</div>
</div>
{/if}
@@ -7,7 +7,6 @@
import { SYSTEM_MESSAGE_PLACEHOLDER } from '$lib/constants';
import { REASONING_TAGS } from '$lib/constants/agentic';
import { MessageRole, AttachmentType, AgenticSectionType } from '$lib/enums';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import {
ChatMessageAssistant,
ChatMessageUser,
@@ -328,7 +327,7 @@
}
</script>
<div use:fadeInView class="chat-message">
<div class="chat-message">
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
@@ -398,3 +397,15 @@
/>
{/if}
</div>
<style>
/*
* The browser skips layout and paint for messages outside the
* viewport. contain-intrinsic-size reuses the last rendered size
* once known; 500px sizes messages that have never been rendered.
*/
.chat-message {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
</style>
@@ -1,6 +1,5 @@
<script lang="ts">
import { ActionIcon, ChatMessageEditForm, ChatMessageUserBubble } from '$lib/components/app';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import { ArrowUp, Edit, Trash2 } from '@lucide/svelte';
import { useMessageEditContext } from '$lib/hooks/use-message-edit-context.svelte';
@@ -30,7 +29,6 @@
</script>
<div
use:fadeInView
aria-label="Pending user message"
class="group flex flex-col items-end gap-3 transition-opacity hover:opacity-80 md:gap-2 {className} sticky bottom-32"
role="group"
@@ -1,6 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { beforeNavigate, afterNavigate } from '$app/navigation';
import { ChatMessage, ChatMessageUserPending } from '$lib/components/app';
import { setChatActionsContext } from '$lib/contexts';
import { MessageRole } from '$lib/enums';
@@ -35,9 +33,6 @@
let { messages = [], onUserAction, onMessagesReady }: Props = $props();
let allConversationMessages = $state<DatabaseMessage[]>([]);
let isVisible = $state(false);
let previousConversationId = $state<string | null>(null);
let previousRouteId = $state<string | null>(null);
const currentConfig = config();
@@ -123,26 +118,10 @@
}
}
// Track conversation changes to trigger transition even on same route
// Refresh messages whenever the active conversation changes
$effect(() => {
const conversation = activeConversation();
const currentId = conversation?.id ?? null;
if (currentId !== previousConversationId && previousConversationId !== null) {
// Conversation changed - trigger fade out/in
isVisible = false;
requestAnimationFrame(() => {
refreshAllMessages();
previousConversationId = currentId;
requestAnimationFrame(() => {
isVisible = true;
});
});
} else {
previousConversationId = currentId;
if (conversation) {
refreshAllMessages();
}
if (activeConversation()) {
refreshAllMessages();
}
});
@@ -152,23 +131,6 @@
onMessagesReady?.(displayMessages.length);
});
onMount(() => {
requestAnimationFrame(() => {
isVisible = true;
});
});
beforeNavigate((navigation) => {
isVisible = false;
previousRouteId = navigation.from?.route.id ?? null;
});
afterNavigate(() => {
requestAnimationFrame(() => {
isVisible = true;
});
});
let siblingInfoByMessageId = $derived(buildSiblingInfoMap(allConversationMessages));
let displayMessages = $derived.by(() => {
@@ -272,11 +234,7 @@
});
</script>
<div
class="transition-opacity duration-500 ease-out
{isVisible ? 'opacity-100' : 'opacity-0'}
{previousRouteId === '/(chat)/chat/[id]' ? '' : 'delay-300'}"
>
<div>
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)}
<ChatMessage
class="mx-auto mt-12 w-full max-w-3xl"
@@ -1,7 +1,6 @@
<script lang="ts">
import { ICON_CLASS_DEFAULT } from '$lib/constants/css-classes';
import { AlertTriangle, Loader2, RefreshCw } from '@lucide/svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
import * as Alert from '$lib/components/ui/alert';
import { serverError, serverLoading, serverStatus, serverStore } from '$lib/stores/server.svelte';
@@ -10,10 +9,7 @@
</script>
{#if hasError}
<div
class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1"
use:fadeInView={{ y: 10, duration: 250 }}
>
<div class="pointer-events-auto mx-auto mb-4 max-w-[48rem] px-1">
<Alert.Root variant={isLoadingModel ? 'default' : 'destructive'}>
{#if isLoadingModel}
<Loader2 class="{ICON_CLASS_DEFAULT} animate-spin" />
@@ -78,7 +78,6 @@
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import type { DatabaseMessageExtra } from '$lib/types/database';
import { config } from '$lib/stores/settings.svelte';
import { fadeInView } from '$lib/actions/fade-in-view.svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
@@ -828,7 +827,7 @@
: ''}"
>
{#each renderedBlocks as block (block.id)}
<div class="markdown-block" data-block-id={block.id} use:fadeInView={{ skipIfVisible: true }}>
<div class="markdown-block" data-block-id={block.id}>
{@html block.html}
</div>
{/each}
@@ -0,0 +1,8 @@
// Half of the card width, matching the w-64 class on the card.
export const CONTEXT_GAUGE_CARD_HALF_WIDTH_PX = 128;
// Minimum distance kept between the card and the form edges.
export const CONTEXT_GAUGE_EDGE_MARGIN_PX = 8;
// Gap between the top of the dial and the bottom edge of the card.
export const CONTEXT_GAUGE_DIAL_GAP_PX = 8;
// Grace delay before closing, letting the pointer travel from dial to card.
export const CONTEXT_GAUGE_CLOSE_GRACE_MS = 150;
+1
View File
@@ -12,6 +12,7 @@ export * from './recommended-mcp-servers';
export * from './storage';
export * from './attachment-menu';
export * from './auto-scroll';
export * from './context-gauge-popup';
export * from './binary-detection';
export * from './built-in-tools';
export * from './cache';
@@ -84,11 +84,11 @@ export class AutoScrollController {
}
/**
* Scrolls the container to the bottom.
* Scrolls the container to the bottom instantly.
*/
scrollToBottom(behavior: ScrollBehavior = 'smooth'): void {
scrollToBottom(): void {
if (this._disabled || !this._container) return;
this._container.scrollTo({ top: this._container.scrollHeight, behavior });
this._container.scrollTop = this._container.scrollHeight;
}
/**
@@ -0,0 +1,90 @@
// Shared state for the context gauge popup. The dial and the card live in
// different DOM subtrees, so open state and placement are coordinated here.
// centerX and bottom place the card just above the dial: both are measured
// once at open time, relative to the closest form ancestor; the dial and
// the card share that positioning frame, so the values stay exact for the
// whole time the card is open.
// Mouse pointers open on hover with a short grace delay to travel from
// dial to card; touch pointers toggle on tap.
import {
CONTEXT_GAUGE_CARD_HALF_WIDTH_PX,
CONTEXT_GAUGE_CLOSE_GRACE_MS,
CONTEXT_GAUGE_DIAL_GAP_PX,
CONTEXT_GAUGE_EDGE_MARGIN_PX
} from '$lib/constants';
let closeTimer: ReturnType<typeof setTimeout> | undefined;
let lastPointerType = '';
export const gaugePopup = $state({ open: false, centerX: 0, bottom: 0 });
function openFrom(trigger: HTMLElement): void {
clearTimeout(closeTimer);
const frame = trigger.closest('form');
if (frame) {
const frameRect = frame.getBoundingClientRect();
const triggerRect = trigger.getBoundingClientRect();
const centerX = triggerRect.left + triggerRect.width / 2 - frameRect.left;
const min = CONTEXT_GAUGE_CARD_HALF_WIDTH_PX + CONTEXT_GAUGE_EDGE_MARGIN_PX;
const max = frameRect.width - CONTEXT_GAUGE_CARD_HALF_WIDTH_PX - CONTEXT_GAUGE_EDGE_MARGIN_PX;
gaugePopup.centerX = Math.min(Math.max(centerX, min), Math.max(min, max));
gaugePopup.bottom = frameRect.bottom - triggerRect.top + CONTEXT_GAUGE_DIAL_GAP_PX;
}
gaugePopup.open = true;
}
function toggleFrom(trigger: HTMLElement): void {
if (gaugePopup.open) {
clearTimeout(closeTimer);
gaugePopup.open = false;
} else {
openFrom(trigger);
}
}
export function gaugePopupClose(): void {
clearTimeout(closeTimer);
gaugePopup.open = false;
}
export function gaugeTriggerPointerDown(event: PointerEvent): void {
lastPointerType = event.pointerType;
}
export function gaugeTriggerClick(event: MouseEvent): void {
if (lastPointerType !== 'touch') return;
toggleFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerKeydown(event: KeyboardEvent): void {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
toggleFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerEnter(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
openFrom(event.currentTarget as HTMLElement);
}
export function gaugeTriggerLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
scheduleClose();
}
export function gaugeCardEnter(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
clearTimeout(closeTimer);
}
export function gaugeCardLeave(event: PointerEvent): void {
if (event.pointerType !== 'mouse') return;
scheduleClose();
}
function scheduleClose(): void {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
gaugePopup.open = false;
}, CONTEXT_GAUGE_CLOSE_GRACE_MS);
}
-12
View File
@@ -1,12 +0,0 @@
/**
* Check if an element is within the current viewport.
*/
export function isElementInViewport(node: HTMLElement): boolean {
const rect = node.getBoundingClientRect();
return (
rect.top < window.innerHeight &&
rect.bottom > 0 &&
rect.left < window.innerWidth &&
rect.right > 0
);
}