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 - ); -}