+++ /dev/null
-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();
- };
- });
-}
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';
/>
</div>
</div>
+
+ <ContextGaugePopup />
</form>
<DialogMcpResourcesBrowser
<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();
$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>
--- /dev/null
+<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}
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,
}
</script>
-<div use:fadeInView class="chat-message">
+<div class="chat-message">
{#if message.role === MessageRole.SYSTEM}
<ChatMessageSystem
bind:textareaElement
/>
{/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>
<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';
</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"
<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';
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();
}
}
- // 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();
}
});
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(() => {
});
</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"
<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';
</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" />
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[];
: ''}"
>
{#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}
--- /dev/null
+// 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;
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';
}
/**
- * 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;
}
/**
--- /dev/null
+// 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);
+}
+++ /dev/null
-/**
- * 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
- );
-}