ui: Stores consolidation refactor (#27238)

* ui: Remove dead code from stores

- persisted() helper was exported but never used
- messageUpdateCallback / registerMessageUpdateCallback were never wired up
- conversationsStore.initialize() alias, single caller moved to init()

* ui: Merge device, theme and viewport into a single deviceStore

All three are reactive browser-environment signals, now exposed as one
class store: deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari
/ isWKWebView / isStandalone and deviceStore.systemTheme.isDark. The
systemTheme name disambiguates the OS preference from the user theme
preference in settingsStore. Drops the unused viewport export (only
isMobile was consumed).

* ui: Merge build info into version store

One VersionStore class with build (llama.cpp build number from
build.json) and frontend (PWA version from _app/version.json),
matching the class pattern of the other stores.

* ui: Colocate context gauge popup state with its components

The gauge popup state is local UI state shared only by the
ChatFormContextGauge subtree, so it lives next to its consumers
instead of the app-scope stores barrel.
This commit is contained in:
Aleksander Grygier
2026-08-18 16:37:26 +02:00
committed by GitHub
parent 04b569142d
commit fdf4c64604
28 changed files with 181 additions and 303 deletions
@@ -2,10 +2,10 @@
import ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
</script>
{#if isMobile.current}
{#if deviceStore.isMobile}
<ChatFormActionAddSheet>
{#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} />
@@ -1,6 +1,12 @@
<script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app';
import { chatStore, conversationsStore, isMobile, modelsStore, serverStore } from '$lib/stores';
import {
chatStore,
conversationsStore,
deviceStore,
modelsStore,
serverStore
} from '$lib/stores';
interface Props {
disabled?: boolean;
@@ -170,7 +176,7 @@
}
</script>
{#if isMobile.current}
{#if deviceStore.isMobile}
<ModelsSelectorSheet
disabled={disabled || isOffline}
bind:this={selectorModelRef}
@@ -1,15 +1,14 @@
<script lang="ts">
import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import {
chatStore,
conversationsStore,
gaugeTriggerClick,
gaugeTriggerEnter,
gaugeTriggerKeydown,
gaugeTriggerLeave,
gaugeTriggerPointerDown
} from '$lib/stores';
} from './gauge-popup.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { chatStore, conversationsStore } from '$lib/stores';
import { untrack } from 'svelte';
const gauge = useContextGauge();
@@ -1,9 +1,9 @@
<script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { gaugePopup } from './gauge-popup.svelte';
import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants';
import { gaugePopup } from '$lib/stores/context-gauge-popup.svelte';
interface Props {
currentRead: number;
@@ -2,8 +2,13 @@
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.svelte';
import {
gaugeCardEnter,
gaugeCardLeave,
gaugePopup,
gaugePopupClose
} from './gauge-popup.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { gaugeCardEnter, gaugeCardLeave, gaugePopup, gaugePopupClose } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge();
@@ -0,0 +1,99 @@
// 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({ bottom: 0, centerX: 0, detailsOpen: false, open: false });
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);
}
@@ -1,5 +1,5 @@
<script lang="ts">
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte';
@@ -37,7 +37,7 @@
}
export function focus() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
textareaElement?.focus({ preventScroll: true });
}
@@ -1,7 +1,7 @@
<script lang="ts">
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { ChatFormInputRichToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils';
import {
@@ -750,7 +750,7 @@
syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) {
if (!deviceStore.isMobile) {
rootElement?.focus({ preventScroll: true });
}
});
@@ -792,7 +792,7 @@
}
export function focus() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
rootElement?.focus({ preventScroll: true });
}
@@ -8,7 +8,7 @@
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { isMobile, settingsStore, toolsStore } from '$lib/stores';
import { deviceStore, settingsStore, toolsStore } from '$lib/stores';
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -130,7 +130,7 @@
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
});
const showTooltip = $derived(!isMobile.current);
const showTooltip = $derived(!deviceStore.isMobile);
$effect(() => {
if (typeof window === 'undefined') return;
@@ -11,7 +11,7 @@
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import type {
ChatMessageActions,
ChatMessageDeletionInfo,
@@ -304,7 +304,7 @@
// After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() {
if (isMobile.current) return;
if (deviceStore.isMobile) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
}
@@ -21,8 +21,7 @@
import {
chatStore,
conversationsStore,
device,
isMobile,
deviceStore,
serverStore,
settingsStore
} from '$lib/stores';
@@ -32,7 +31,7 @@
let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived(
Boolean(settingsStore.config.disableAutoScroll) || isMobile.current
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
);
let isMobileUserScrolledUp = $state(false);
let mobileScrollDownHint = $state(false);
@@ -52,11 +51,11 @@
let hasPropsError = $derived(!!serverStore.error);
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!isMobile.current) return '1rem';
if (!deviceStore.isMobile) return '1rem';
if (device.isStandalone) return '1.5rem';
if (deviceStore.isStandalone) return '1.5rem';
if (device.isIOSSafari) return '0.25rem';
if (deviceStore.isIOSSafari) return '0.25rem';
return '0.5rem';
});
@@ -84,7 +83,7 @@
});
function handleMobileScroll() {
if (!isMobile.current) return;
if (!deviceStore.isMobile) return;
const container = scroll.chatScrollContainer;
@@ -184,7 +183,7 @@
}
function handleSendLikeScroll() {
if (!isMobile.current) {
if (!deviceStore.isMobile) {
autoScroll.enable();
}
@@ -197,7 +196,7 @@
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
) as HTMLElement | null;
if (isMobile.current) {
if (deviceStore.isMobile) {
// Keep the last user message bubble just above the input on mobile
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
const baseHeight = container.scrollHeight - innerHeight;
@@ -220,7 +219,7 @@
}
}, 100);
if (isMobile.current) {
if (deviceStore.isMobile) {
autoScroll.setDisabled(disableAutoScroll);
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
@@ -243,7 +242,8 @@
$effect(() => {
const shouldDisableAutoScroll =
settingsStore.config.disableAutoScroll || (isMobile.current && isCurrentConversationLoading);
settingsStore.config.disableAutoScroll ||
(deviceStore.isMobile && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll);
@@ -266,7 +266,7 @@
autoScroll.enable();
}
if (isMobile.current && isCurrentConversationLoading) {
if (deviceStore.isMobile && isCurrentConversationLoading) {
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
}
@@ -318,9 +318,9 @@
<div
class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
device.isStandalone
deviceStore.isStandalone
? 'bottom-6 right-4 left-4'
: device.isIOSSafari
: deviceStore.isIOSSafari
? 'bottom-1 left-2 right-2'
: 'bottom-2 right-2 left-2',
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
@@ -336,7 +336,7 @@
{/if}
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
{#if (isMobile.current ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
{#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
<ChatScreenActionScrollDown
onclick={() => {
mobileScrollDownHint = false;
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import { ChatForm } from '$lib/components/app';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import { onMount } from 'svelte';
interface Props {
@@ -120,13 +120,13 @@
}
onMount(() => {
if (!isMobile.current) {
if (!deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100);
}
});
afterNavigate((navigation) => {
if (navigation?.from != null && !isMobile.current) {
if (navigation?.from != null && !deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100);
}
});
@@ -14,7 +14,7 @@
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, device, isMobile, settingsStore } from '$lib/stores';
import { chatStore, conversationsStore, deviceStore, settingsStore } from '$lib/stores';
import { buildConversationTree } from '$lib/utils';
import { circIn } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity';
@@ -36,7 +36,7 @@
let logoHovered = $state(false);
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
const isOnMobile = $derived(isMobile.current);
const isOnMobile = $derived(deviceStore.isMobile);
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
$effect(() => {
@@ -65,7 +65,7 @@
});
$effect(() => {
if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) {
if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
isExpandedMode = false;
}
});
@@ -227,7 +227,7 @@
}
async function selectConversation(id: string) {
if (isMobile.current) {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
@@ -315,9 +315,9 @@
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
'md:h-[calc(100dvh-1.125rem)]',
isExpandedMode &&
(device.isStandalone
(deviceStore.isStandalone
? 'h-[calc(100dvh-2rem)]'
: device.isIOSDevice
: deviceStore.isIOSDevice
? 'h-[calc(100dvh-0.5rem)]'
: 'h-[calc(100dvh-1rem)]'),
'rounded-3xl md:rounded-2xl',
@@ -353,7 +353,7 @@
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
<div
class="flex items-center transition-all duration-150 ease-out {isMobile.current &&
class="flex items-center transition-all duration-150 ease-out {deviceStore.isMobile &&
!isExpandedMode
? 'opacity-0 h-0!'
: ''}"
@@ -361,7 +361,7 @@
out:fade={{ duration: 100 }}
>
<ActionIcon
icon={isMobile.current ? X : PanelLeftClose}
icon={deviceStore.isMobile ? X : PanelLeftClose}
size="lg"
iconSize="h-4.5 w-4.5 md:h-4 md:w-4"
class="backdrop-blur-none md:h-9 md:w-9 h-10 w-10 rounded-full mr-1 hover:bg-accent!"
@@ -375,9 +375,9 @@
</div>
<div
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {isMobile.current
class="mt-2 flex min-h-0 flex-1 flex-col gap-4 md:gap-1 {deviceStore.isMobile
? 'transition-[opacity,height] duration-200 ease-out'
: ''} {isMobile.current && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
: ''} {deviceStore.isMobile && !isExpandedMode ? 'opacity-0 !h-0' : ''}"
in:fade={{ duration: 200 }}
out:fade={{ duration: 200 }}
>
@@ -395,7 +395,7 @@
isSearchModeActive = true;
}}
onNewChat={() => {
if (isMobile.current) {
if (deviceStore.isMobile) {
scheduleMobileCollapse();
}
}}
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants';
import { TooltipSide } from '$lib/enums';
import { isMobile } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { Component } from 'svelte';
import { onMount } from 'svelte';
import { circIn } from 'svelte/easing';
@@ -42,7 +42,7 @@
let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null);
const isOnMobile = $derived(isMobile.current);
const isOnMobile = $derived(deviceStore.isMobile);
$effect(() => {
if (isSearchModeActive && searchInputRef) {
@@ -107,7 +107,7 @@
>
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
{@const itemOnClick = item.route
? () => {
@@ -156,7 +156,7 @@
<div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && isMobile.current}
{@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = item.route
? () => {
onNewChat?.();