From 96013c51121fcabbe00b70095f104962abe1ec14 Mon Sep 17 00:00:00 2001 From: Pascal Date: Fri, 24 Jul 2026 21:43:23 +0200 Subject: [PATCH] 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 --- .../ui/src/lib/actions/fade-in-view.svelte.ts | 49 -------- .../app/chat/ChatForm/ChatForm.svelte | 3 + .../ChatFormContextGauge.svelte | 96 ++++------------ .../ContextGaugePopup.svelte | 106 ++++++++++++++++++ .../ChatMessage/ChatMessage.svelte | 15 ++- .../ChatMessageUserPending.svelte | 2 - .../app/chat/ChatMessages/ChatMessages.svelte | 50 +-------- .../ChatScreen/ChatScreenServerError.svelte | 6 +- .../MarkdownContent/MarkdownContent.svelte | 3 +- .../src/lib/constants/context-gauge-popup.ts | 8 ++ tools/ui/src/lib/constants/index.ts | 1 + .../src/lib/hooks/use-auto-scroll.svelte.ts | 6 +- .../lib/stores/context-gauge-popup.svelte.ts | 90 +++++++++++++++ tools/ui/src/lib/utils/viewport.ts | 12 -- 14 files changed, 251 insertions(+), 196 deletions(-) delete mode 100644 tools/ui/src/lib/actions/fade-in-view.svelte.ts create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte create mode 100644 tools/ui/src/lib/constants/context-gauge-popup.ts create mode 100644 tools/ui/src/lib/stores/context-gauge-popup.svelte.ts delete mode 100644 tools/ui/src/lib/utils/viewport.ts diff --git a/tools/ui/src/lib/actions/fade-in-view.svelte.ts b/tools/ui/src/lib/actions/fade-in-view.svelte.ts deleted file mode 100644 index 9a5918131..000000000 --- a/tools/ui/src/lib/actions/fade-in-view.svelte.ts +++ /dev/null @@ -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(); - }; - }); -} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 9b2077b8d..85683908c 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -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 @@ /> + + 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) - ); - - - - - - -
-
- Context - · - - {formatParameters(gauge.contextUsed)} - / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'} - -
- - {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded} - - {:else if showProgressBar} -
-
-
- -
- - {gauge.contextPercent}% used - - - {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining - -
- {:else} -
No context info available
- {/if} - - {#if gauge.hasAnyUsage} - - {/if} -
-
-
+
+ +
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte new file mode 100644 index 000000000..81acdbaf7 --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte @@ -0,0 +1,106 @@ + + +{#if gaugePopup.open} +
+
+
+ Context + · + + {formatParameters(gauge.contextUsed)} + / {gauge.contextTotal !== null ? formatParameters(gauge.contextTotal) : '-'} + +
+ + {#if gauge.activeModelId !== null && !gauge.isActiveModelLoaded} + + {:else if showProgressBar} +
+
+
+ +
+ + {gauge.contextPercent}% used + + + {formatParameters((gauge.contextTotal ?? 0) - gauge.contextUsed)} remaining + +
+ {:else} +
No context info available
+ {/if} + + {#if gauge.hasAnyUsage} + + {/if} +
+
+{/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte index 560bf73ab..8e8a14ac3 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte @@ -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 @@ } -
+
{#if message.role === MessageRole.SYSTEM} {/if}
+ + diff --git a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte index 58b3a42e0..1cc79fe6b 100644 --- a/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageUser/ChatMessageUserPending.svelte @@ -1,6 +1,5 @@
- 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([]); - let isVisible = $state(false); - let previousConversationId = $state(null); - let previousRouteId = $state(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 @@ }); -
+
{#each displayMessages as { message, toolMessages, isLastAssistantMessage, isLastUserMessage, nextAssistantMessage, siblingInfo } (message.id)} 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 @@ {#if hasError} -
+
{#if isLoadingModel} diff --git a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte index 8ac7f9448..2c17f4234 100644 --- a/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte +++ b/tools/ui/src/lib/components/app/content/MarkdownContent/MarkdownContent.svelte @@ -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)} -
+
{@html block.html}
{/each} diff --git a/tools/ui/src/lib/constants/context-gauge-popup.ts b/tools/ui/src/lib/constants/context-gauge-popup.ts new file mode 100644 index 000000000..fe2e6a1b5 --- /dev/null +++ b/tools/ui/src/lib/constants/context-gauge-popup.ts @@ -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; diff --git a/tools/ui/src/lib/constants/index.ts b/tools/ui/src/lib/constants/index.ts index 68b5992c2..a80e0cb63 100644 --- a/tools/ui/src/lib/constants/index.ts +++ b/tools/ui/src/lib/constants/index.ts @@ -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'; 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 8107f08d0..f2ad50dff 100644 --- a/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts +++ b/tools/ui/src/lib/hooks/use-auto-scroll.svelte.ts @@ -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; } /** diff --git a/tools/ui/src/lib/stores/context-gauge-popup.svelte.ts b/tools/ui/src/lib/stores/context-gauge-popup.svelte.ts new file mode 100644 index 000000000..441edb3ac --- /dev/null +++ b/tools/ui/src/lib/stores/context-gauge-popup.svelte.ts @@ -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 | 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); +} diff --git a/tools/ui/src/lib/utils/viewport.ts b/tools/ui/src/lib/utils/viewport.ts deleted file mode 100644 index 9e9b7aff3..000000000 --- a/tools/ui/src/lib/utils/viewport.ts +++ /dev/null @@ -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 - ); -}