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 ChatFormActionAddButton from './ChatFormActionAddButton.svelte';
import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte'; import ChatFormActionAddDropdown from './ChatFormActionAddDropdown.svelte';
import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte'; import ChatFormActionAddSheet from './ChatFormActionAddSheet.svelte';
import { isMobile } from '$lib/stores'; import { deviceStore } from '$lib/stores';
</script> </script>
{#if isMobile.current} {#if deviceStore.isMobile}
<ChatFormActionAddSheet> <ChatFormActionAddSheet>
{#snippet trigger({ disabled, onclick })} {#snippet trigger({ disabled, onclick })}
<ChatFormActionAddButton {disabled} {onclick} /> <ChatFormActionAddButton {disabled} {onclick} />
@@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import { ModelsSelectorDropdown, ModelsSelectorSheet } from '$lib/components/app'; 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 { interface Props {
disabled?: boolean; disabled?: boolean;
@@ -170,7 +176,7 @@
} }
</script> </script>
{#if isMobile.current} {#if deviceStore.isMobile}
<ModelsSelectorSheet <ModelsSelectorSheet
disabled={disabled || isOffline} disabled={disabled || isOffline}
bind:this={selectorModelRef} bind:this={selectorModelRef}
@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import ContextGaugeDial from './ContextGaugeDial.svelte'; import ContextGaugeDial from './ContextGaugeDial.svelte';
import { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { import {
chatStore,
conversationsStore,
gaugeTriggerClick, gaugeTriggerClick,
gaugeTriggerEnter, gaugeTriggerEnter,
gaugeTriggerKeydown, gaugeTriggerKeydown,
gaugeTriggerLeave, gaugeTriggerLeave,
gaugeTriggerPointerDown 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'; import { untrack } from 'svelte';
const gauge = useContextGauge(); const gauge = useContextGauge();
@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte'; import ContextGaugeDetailRow from './ContextGaugeDetailRow.svelte';
import { gaugePopup } from './gauge-popup.svelte';
import { ChevronDown } from '@lucide/svelte'; import { ChevronDown } from '@lucide/svelte';
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import { STATS_UNITS } from '$lib/constants'; import { STATS_UNITS } from '$lib/constants';
import { gaugePopup } from '$lib/stores/context-gauge-popup.svelte';
interface Props { interface Props {
currentRead: number; currentRead: number;
@@ -2,8 +2,13 @@
import { colorLevelBgClass, colorLevelTextClass } from './context-gauge'; import { colorLevelBgClass, colorLevelTextClass } from './context-gauge';
import ContextGaugeDetails from './ContextGaugeDetails.svelte'; import ContextGaugeDetails from './ContextGaugeDetails.svelte';
import ContextGaugeLoadModel from './ContextGaugeLoadModel.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 { useContextGauge } from '$lib/hooks/use-context-gauge.svelte';
import { gaugeCardEnter, gaugeCardLeave, gaugePopup, gaugePopupClose } from '$lib/stores';
import { formatParameters } from '$lib/utils/formatters'; import { formatParameters } from '$lib/utils/formatters';
const gauge = useContextGauge(); const gauge = useContextGauge();
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { isMobile } from '$lib/stores'; import { deviceStore } from '$lib/stores';
import { autoResizeTextarea } from '$lib/utils'; import { autoResizeTextarea } from '$lib/utils';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -37,7 +37,7 @@
} }
export function focus() { export function focus() {
if (isMobile.current) return; if (deviceStore.isMobile) return;
textareaElement?.focus({ preventScroll: true }); textareaElement?.focus({ preventScroll: true });
} }
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants'; import { CODE_BLOCK, CODE_TOKEN_ATTR, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString, ChatFormInputRichTokenKind, ColorMode } from '$lib/enums'; 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 { ChatFormInputRichToken } from '$lib/types';
import type { SourceHistoryEntry } from '$lib/utils'; import type { SourceHistoryEntry } from '$lib/utils';
import { import {
@@ -750,7 +750,7 @@
syncEmptyState(); syncEmptyState();
document.addEventListener('selectionchange', handleSelectionChange); document.addEventListener('selectionchange', handleSelectionChange);
if (!isMobile.current) { if (!deviceStore.isMobile) {
rootElement?.focus({ preventScroll: true }); rootElement?.focus({ preventScroll: true });
} }
}); });
@@ -792,7 +792,7 @@
} }
export function focus() { export function focus() {
if (isMobile.current) return; if (deviceStore.isMobile) return;
rootElement?.focus({ preventScroll: true }); rootElement?.focus({ preventScroll: true });
} }
@@ -8,7 +8,7 @@
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums'; import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte'; import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.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 type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils'; import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -130,7 +130,7 @@
return searchError ? `Search failed - ${searchError}` : 'No matching files or folders'; return searchError ? `Search failed - ${searchError}` : 'No matching files or folders';
}); });
const showTooltip = $derived(!isMobile.current); const showTooltip = $derived(!deviceStore.isMobile);
$effect(() => { $effect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
@@ -11,7 +11,7 @@
import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts'; import { setChatMessageActionsContext, setChatMessageEditContext } from '$lib/contexts';
import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums'; import { AgenticSectionType, AttachmentType, MessageRole } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service'; import { DatabaseService } from '$lib/services/database.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores'; import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
import type { import type {
ChatMessageActions, ChatMessageActions,
ChatMessageDeletionInfo, ChatMessageDeletionInfo,
@@ -304,7 +304,7 @@
// After the system message flow ends, hand focus to the main chat form // After the system message flow ends, hand focus to the main chat form
function focusMainChatForm() { function focusMainChatForm() {
if (isMobile.current) return; if (deviceStore.isMobile) return;
document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus(); document.querySelector<HTMLTextAreaElement>('.chat-screen-form-wrapper textarea')?.focus();
} }
@@ -21,8 +21,7 @@
import { import {
chatStore, chatStore,
conversationsStore, conversationsStore,
device, deviceStore,
isMobile,
serverStore, serverStore,
settingsStore settingsStore
} from '$lib/stores'; } from '$lib/stores';
@@ -32,7 +31,7 @@
let { showCenteredEmpty = false } = $props(); let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived( let disableAutoScroll = $derived(
Boolean(settingsStore.config.disableAutoScroll) || isMobile.current Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
); );
let isMobileUserScrolledUp = $state(false); let isMobileUserScrolledUp = $state(false);
let mobileScrollDownHint = $state(false); let mobileScrollDownHint = $state(false);
@@ -52,11 +51,11 @@
let hasPropsError = $derived(!!serverStore.error); let hasPropsError = $derived(!!serverStore.error);
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming()); let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
let chatFormBottomPosition = $derived.by(() => { 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'; return '0.5rem';
}); });
@@ -84,7 +83,7 @@
}); });
function handleMobileScroll() { function handleMobileScroll() {
if (!isMobile.current) return; if (!deviceStore.isMobile) return;
const container = scroll.chatScrollContainer; const container = scroll.chatScrollContainer;
@@ -184,7 +183,7 @@
} }
function handleSendLikeScroll() { function handleSendLikeScroll() {
if (!isMobile.current) { if (!deviceStore.isMobile) {
autoScroll.enable(); autoScroll.enable();
} }
@@ -197,7 +196,7 @@
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble' '.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
) as HTMLElement | null; ) as HTMLElement | null;
if (isMobile.current) { if (deviceStore.isMobile) {
// Keep the last user message bubble just above the input on mobile // Keep the last user message bubble just above the input on mobile
const bubbleHeight = lastUserBubble?.scrollHeight ?? 0; const bubbleHeight = lastUserBubble?.scrollHeight ?? 0;
const baseHeight = container.scrollHeight - innerHeight; const baseHeight = container.scrollHeight - innerHeight;
@@ -220,7 +219,7 @@
} }
}, 100); }, 100);
if (isMobile.current) { if (deviceStore.isMobile) {
autoScroll.setDisabled(disableAutoScroll); autoScroll.setDisabled(disableAutoScroll);
mobileScrollDownHint = true; mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500; mobileScrollDownHintLockedUntil = Date.now() + 500;
@@ -243,7 +242,8 @@
$effect(() => { $effect(() => {
const shouldDisableAutoScroll = const shouldDisableAutoScroll =
settingsStore.config.disableAutoScroll || (isMobile.current && isCurrentConversationLoading); settingsStore.config.disableAutoScroll ||
(deviceStore.isMobile && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll); autoScroll.setDisabled(shouldDisableAutoScroll);
@@ -266,7 +266,7 @@
autoScroll.enable(); autoScroll.enable();
} }
if (isMobile.current && isCurrentConversationLoading) { if (deviceStore.isMobile && isCurrentConversationLoading) {
mobileScrollDownHint = true; mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500; mobileScrollDownHintLockedUntil = Date.now() + 500;
} }
@@ -318,9 +318,9 @@
<div <div
class={[ class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200', 'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
device.isStandalone deviceStore.isStandalone
? 'bottom-6 right-4 left-4' ? 'bottom-6 right-4 left-4'
: device.isIOSSafari : deviceStore.isIOSSafari
? 'bottom-1 left-2 right-2' ? 'bottom-1 left-2 right-2'
: 'bottom-2 right-2 left-2', : 'bottom-2 right-2 left-2',
isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4' isEmpty ? 'md:bottom-[calc(50dvh-7rem)] 2xl:bottom-[calc(50dvh-4rem)]' : 'md:bottom-4'
@@ -336,7 +336,7 @@
{/if} {/if}
<div class="pointer-events-none flex flex-col gap-6 items-center w-full"> <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 <ChatScreenActionScrollDown
onclick={() => { onclick={() => {
mobileScrollDownHint = false; mobileScrollDownHint = false;
@@ -3,7 +3,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import { ChatForm } from '$lib/components/app'; import { ChatForm } from '$lib/components/app';
import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte'; import { useDraftMessages } from '$lib/hooks/use-draft-messages.svelte';
import { isMobile } from '$lib/stores'; import { deviceStore } from '$lib/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
interface Props { interface Props {
@@ -120,13 +120,13 @@
} }
onMount(() => { onMount(() => {
if (!isMobile.current) { if (!deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100); setTimeout(focusFormUnlessCaptured, 100);
} }
}); });
afterNavigate((navigation) => { afterNavigate((navigation) => {
if (navigation?.from != null && !isMobile.current) { if (navigation?.from != null && !deviceStore.isMobile) {
setTimeout(focusFormUnlessCaptured, 100); setTimeout(focusFormUnlessCaptured, 100);
} }
}); });
@@ -14,7 +14,7 @@
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte'; import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte'; import { useMarqueeSelection } from '$lib/hooks/use-marquee-selection.svelte';
import { RouterService } from '$lib/services/router.service'; 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 { buildConversationTree } from '$lib/utils';
import { circIn } from 'svelte/easing'; import { circIn } from 'svelte/easing';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@@ -36,7 +36,7 @@
let logoHovered = $state(false); let logoHovered = $state(false);
const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null); const isStripExpanded = $derived(isExpandedMode || hoveredTooltip !== null);
const isOnMobile = $derived(isMobile.current); const isOnMobile = $derived(deviceStore.isMobile);
const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean); const alwaysShowOnDesktop = $derived(settingsStore.config.alwaysShowSidebarOnDesktop as boolean);
$effect(() => { $effect(() => {
@@ -65,7 +65,7 @@
}); });
$effect(() => { $effect(() => {
if (isMobile.current && page.url.hash.includes(ROUTES.SEARCH)) { if (deviceStore.isMobile && page.url.hash.includes(ROUTES.SEARCH)) {
isExpandedMode = false; isExpandedMode = false;
} }
}); });
@@ -227,7 +227,7 @@
} }
async function selectConversation(id: string) { async function selectConversation(id: string) {
if (isMobile.current) { if (deviceStore.isMobile) {
scheduleMobileCollapse(); 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)]', '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)]', 'md:h-[calc(100dvh-1.125rem)]',
isExpandedMode && isExpandedMode &&
(device.isStandalone (deviceStore.isStandalone
? 'h-[calc(100dvh-2rem)]' ? 'h-[calc(100dvh-2rem)]'
: device.isIOSDevice : deviceStore.isIOSDevice
? 'h-[calc(100dvh-0.5rem)]' ? 'h-[calc(100dvh-0.5rem)]'
: 'h-[calc(100dvh-1rem)]'), : 'h-[calc(100dvh-1rem)]'),
'rounded-3xl md:rounded-2xl', 'rounded-3xl md:rounded-2xl',
@@ -353,7 +353,7 @@
{#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)} {#if isOnMobile || (isExpandedMode && !alwaysShowOnDesktop)}
<div <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 !isExpandedMode
? 'opacity-0 h-0!' ? 'opacity-0 h-0!'
: ''}" : ''}"
@@ -361,7 +361,7 @@
out:fade={{ duration: 100 }} out:fade={{ duration: 100 }}
> >
<ActionIcon <ActionIcon
icon={isMobile.current ? X : PanelLeftClose} icon={deviceStore.isMobile ? X : PanelLeftClose}
size="lg" size="lg"
iconSize="h-4.5 w-4.5 md:h-4 md:w-4" 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!" 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>
<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' ? '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 }} in:fade={{ duration: 200 }}
out:fade={{ duration: 200 }} out:fade={{ duration: 200 }}
> >
@@ -395,7 +395,7 @@
isSearchModeActive = true; isSearchModeActive = true;
}} }}
onNewChat={() => { onNewChat={() => {
if (isMobile.current) { if (deviceStore.isMobile) {
scheduleMobileCollapse(); scheduleMobileCollapse();
} }
}} }}
@@ -12,7 +12,7 @@
SIDEBAR_ACTIONS_ITEMS SIDEBAR_ACTIONS_ITEMS
} from '$lib/constants'; } from '$lib/constants';
import { TooltipSide } from '$lib/enums'; import { TooltipSide } from '$lib/enums';
import { isMobile } from '$lib/stores'; import { deviceStore } from '$lib/stores';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { circIn } from 'svelte/easing'; import { circIn } from 'svelte/easing';
@@ -42,7 +42,7 @@
let showIcons = $state(false); let showIcons = $state(false);
let searchInputRef = $state<HTMLInputElement | null>(null); let searchInputRef = $state<HTMLInputElement | null>(null);
const isOnMobile = $derived(isMobile.current); const isOnMobile = $derived(deviceStore.isMobile);
$effect(() => { $effect(() => {
if (isSearchModeActive && searchInputRef) { if (isSearchModeActive && searchInputRef) {
@@ -107,7 +107,7 @@
> >
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)} {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)} {@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 itemHref = isSearchOnMobile ? ROUTES.SEARCH : item.route}
{@const itemOnClick = item.route {@const itemOnClick = item.route
? () => { ? () => {
@@ -156,7 +156,7 @@
<div class="{className} flex-col gap-1 hidden md:flex"> <div class="{className} flex-col gap-1 hidden md:flex">
{#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)} {#each SIDEBAR_ACTIONS_ITEMS as item, i (item.tooltip)}
{@const isActive = isItemActive(item)} {@const isActive = isItemActive(item)}
{@const isSearchOnMobile = item.icon === Search && isMobile.current} {@const isSearchOnMobile = item.icon === Search && deviceStore.isMobile}
{@const itemOnClick = item.route {@const itemOnClick = item.route
? () => { ? () => {
onNewChat?.(); onNewChat?.();
+1 -1
View File
@@ -58,7 +58,7 @@ export function usePwa() {
// PWA pages update via the service worker path; the storage check is the non-PWA fallback only // PWA pages update via the service worker path; the storage check is the non-PWA fallback only
if (navigator.serviceWorker?.controller) return; if (navigator.serviceWorker?.controller) return;
const currentVersion = versionStore.value; const currentVersion = versionStore.frontend;
if (!currentVersion) return; if (!currentVersion) return;
@@ -1,45 +0,0 @@
/**
* buildInfoStore - llama.cpp build information
*
* Reads the build version from `build.json` — embedded at llama.cpp build time
* with the llama.cpp build number (LLAMA_BUILD_NUMBER). Shown in the UI when
* `showBuildVersion` is enabled.
*
* In dev mode (via `npm run dev`), falls back to `import.meta.env.DEV`'s truthy
* value since the artifact is not produced.
*/
import { browser } from '$app/environment';
import { base } from '$app/paths';
let build = $state<string>('');
async function loadBuild() {
if (!browser) return;
if (import.meta.env.DEV) {
build = 'dev';
return;
}
try {
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
build = data.version ?? '';
}
} catch {
// build.json missing or unreachable - leave as empty string
}
}
loadBuild();
export const buildInfoStore = {
get value(): string {
return build;
}
};
-3
View File
@@ -109,9 +109,6 @@ class ChatStore {
private isEditModeActive = $state(false); private isEditModeActive = $state(false);
private addFilesHandler: ((files: File[]) => void) | null = $state(null); private addFilesHandler: ((files: File[]) => void) | null = $state(null);
pendingEditMessageId = $state<string | null>(null); pendingEditMessageId = $state<string | null>(null);
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
private _pendingDraftMessage = $state<string>(''); private _pendingDraftMessage = $state<string>('');
private _pendingDraftFiles = $state<ChatUploadedFile[]>([]); private _pendingDraftFiles = $state<ChatUploadedFile[]>([]);
@@ -100,14 +100,6 @@ class ConversationsStore {
localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort); localStorage.setItem(REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY, this.pendingReasoningEffort);
} }
/**
* Callback for updating message content in chatStore.
* Registered by chatStore to enable cross-store updates without circular dependency.
*/
private messageUpdateCallback:
| ((messageId: string, updates: Partial<DatabaseMessage>) => void)
| null = null;
/** In-flight init run; shared by concurrent callers, reset on failure to allow retry */ /** In-flight init run; shared by concurrent callers, reset on failure to allow retry */
private initPromise: Promise<void> | null = null; private initPromise: Promise<void> | null = null;
@@ -143,23 +135,6 @@ class ConversationsStore {
return this.initPromise; return this.initPromise;
} }
/**
* Alias for init() for backward compatibility.
*/
async initialize(): Promise<void> {
return this.init();
}
/**
* Register a callback for message updates from other stores.
* Called by chatStore during initialization.
*/
registerMessageUpdateCallback(
callback: (messageId: string, updates: Partial<DatabaseMessage>) => void
): void {
this.messageUpdateCallback = callback;
}
/** /**
* *
* *
+49 -30
View File
@@ -1,5 +1,17 @@
/**
* deviceStore - Browser environment signals
*
* Device capabilities, OS theme and viewport in one class store:
* deviceStore.isMobile, deviceStore.isIOSDevice / isIOSSafari / isWKWebView /
* isStandalone, deviceStore.systemTheme.isDark.
*
* UA-derived flags are static for the session; isStandalone and systemTheme
* track live media query changes.
*/
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { MEDIA_QUERIES } from '$lib/constants'; import { DEFAULT_MOBILE_BREAKPOINT, MEDIA_QUERIES } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity';
/** /**
* iOS UA token detection. * iOS UA token detection.
@@ -17,53 +29,60 @@ const UA_PATTERNS = {
WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/ WEBVIEW_IOS: /CriOS|FxiOS|EdgiOS|GSA/
} as const; } as const;
interface DeviceContext { class DeviceStore {
/** Any iOS/iPadOS device, regardless of which app or browser embeds the page. */ /** Any iOS/iPadOS device, regardless of which app or browser embeds the page. */
isIOSDevice: boolean; readonly isIOSDevice: boolean = false;
/** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */ /** The Safari browser app on iOS, excluding other iOS browsers and WKWebViews. */
isIOSSafari: boolean; readonly isIOSSafari: boolean = false;
/** Any WKWebView context on iOS: in-app browsers, embedded web views, and the /** Any WKWebView context on iOS: in-app browsers, embedded web views, and the
* third-party iOS browsers (all of which share the WKWebView engine). */ * third-party iOS browsers (all of which share the WKWebView engine). */
isWKWebView: boolean; readonly isWKWebView: boolean = false;
/** PWA standalone mode: the page was launched from the home screen icon. */ /** PWA standalone mode: the page was launched from the home screen icon. */
isStandalone: boolean; isStandalone = $state(false);
} /** OS color scheme preference; the user override lives in settingsStore. */
readonly systemTheme = $state({ isDark: false });
const SERVER_DEFAULT: DeviceContext = { private mobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
isIOSDevice: false,
isIOSSafari: false,
isStandalone: false,
isWKWebView: false
};
function detect(): DeviceContext { get isMobile(): boolean {
if (!browser) return SERVER_DEFAULT; return this.mobile.current;
}
constructor() {
if (!browser) return;
const ua = navigator.userAgent; const ua = navigator.userAgent;
const isTouch = navigator.maxTouchPoints > 0; const isTouch = navigator.maxTouchPoints > 0;
const isIOSDevice = UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
this.isIOSDevice =
UA_PATTERNS.IOS_PHONE.test(ua) || (UA_PATTERNS.MACINTOSH.test(ua) && isTouch);
// Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own // Safari keeps 'Safari/' in the UA; non-Safari iOS browsers emit their own
// token instead. WKWebView typically omits 'Safari/' entirely. // token instead. WKWebView typically omits 'Safari/' entirely.
const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua); const hasSafariToken = UA_PATTERNS.SAFARI.test(ua) && !UA_PATTERNS.WEBVIEW_IOS.test(ua);
const isIOSSafari = isIOSDevice && hasSafariToken;
const isWKWebView = isIOSDevice && !hasSafariToken; this.isIOSSafari = this.isIOSDevice && hasSafariToken;
this.isWKWebView = this.isIOSDevice && !hasSafariToken;
// navigator.standalone is the legacy iOS-only flag (deprecated but still // navigator.standalone is the legacy iOS-only flag (deprecated but still
// present); display-mode: standalone is the modern standard (Safari 16.4+). // present); display-mode: standalone is the modern standard (Safari 16.4+).
const isStandalone = this.isStandalone =
window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches || window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE).matches ||
(navigator as Navigator & { standalone?: boolean }).standalone === true; (navigator as Navigator & { standalone?: boolean }).standalone === true;
this.systemTheme.isDark = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches;
return { isIOSDevice, isIOSSafari, isStandalone, isWKWebView }; // isStandalone and systemTheme can change at runtime (e.g. user installs the
} // PWA while the tab is open); the UA-derived flags are static for the session
const standaloneMql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
export const device = $state<DeviceContext>(detect()); standaloneMql.addEventListener('change', (e) => {
this.isStandalone = e.matches;
if (browser) {
// isStandalone can change at runtime (e.g. user installs the PWA while the
// tab is open); the UA-derived flags are static for the session.
const mql = window.matchMedia(MEDIA_QUERIES.DISPLAY_MODE_STANDALONE);
mql.addEventListener('change', (e) => {
device.isStandalone = e.matches;
}); });
const darkMql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
darkMql.addEventListener('change', (e) => {
this.systemTheme.isDark = e.matches;
});
}
} }
export const deviceStore = new DeviceStore();
+1 -21
View File
@@ -53,26 +53,6 @@ export { permissionsStore } from './permissions.svelte';
export { toolsStore } from './tools.svelte'; export { toolsStore } from './tools.svelte';
// ENVIRONMENT / META // ENVIRONMENT / META
export { buildInfoStore } from './build-info.svelte';
export { versionStore } from './version.svelte'; export { versionStore } from './version.svelte';
export { device } from './device.svelte'; export { deviceStore } from './device.svelte';
export { viewport, isMobile } from './viewport.svelte';
export { theme } from './theme.svelte';
export {
gaugePopup,
gaugePopupClose,
gaugeTriggerPointerDown,
gaugeTriggerClick,
gaugeTriggerKeydown,
gaugeTriggerEnter,
gaugeTriggerLeave,
gaugeCardEnter,
gaugeCardLeave
} from './context-gauge-popup.svelte';
export { persisted } from './persisted.svelte';
@@ -1,51 +0,0 @@
import { browser } from '$app/environment';
type PersistedValue<T> = {
get value(): T;
set value(newValue: T);
};
export function persisted<T>(key: string, initialValue: T): PersistedValue<T> {
let value = initialValue;
if (browser) {
try {
const stored = localStorage.getItem(key);
if (stored !== null) {
value = JSON.parse(stored) as T;
}
} catch (error) {
console.warn(`Failed to load ${key}:`, error);
}
}
const persist = (next: T) => {
if (!browser) {
return;
}
try {
if (next === null || next === undefined) {
localStorage.removeItem(key);
return;
}
localStorage.setItem(key, JSON.stringify(next));
} catch (error) {
console.warn(`Failed to persist ${key}:`, error);
}
};
return {
get value() {
return value;
},
set value(newValue: T) {
value = newValue;
persist(newValue);
}
};
}
+2 -2
View File
@@ -40,9 +40,9 @@ import {
} from '$lib/constants'; } from '$lib/constants';
import { ColorMode } from '$lib/enums'; import { ColorMode } from '$lib/enums';
import { ParameterSyncService } from '$lib/services/parameter-sync.service'; import { ParameterSyncService } from '$lib/services/parameter-sync.service';
import { deviceStore } from '$lib/stores/device.svelte';
// direct imports between stores, not via the barrel, to avoid circular deps // direct imports between stores, not via the barrel, to avoid circular deps
import { serverStore } from '$lib/stores/server.svelte'; import { serverStore } from '$lib/stores/server.svelte';
import { isMobile } from '$lib/stores/viewport.svelte';
import type { SettingsExportType } from '$lib/types'; import type { SettingsExportType } from '$lib/types';
import { import {
configToParameterRecord, configToParameterRecord,
@@ -138,7 +138,7 @@ class SettingsStore {
// Default sendOnEnter to false on mobile when the user has no saved preference // Default sendOnEnter to false on mobile when the user has no saved preference
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) { if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
if (isMobile.current) { if (deviceStore.isMobile) {
this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false; this.config[SETTINGS_KEYS.SEND_ON_ENTER] = false;
} }
} }
-14
View File
@@ -1,14 +0,0 @@
import { browser } from '$app/environment';
import { MEDIA_QUERIES } from '$lib/constants';
export const theme = $state({
isSystemDark: browser && window.matchMedia(MEDIA_QUERIES.PREFERS_DARK).matches
});
if (browser) {
const mql = window.matchMedia(MEDIA_QUERIES.PREFERS_DARK);
mql.addEventListener('change', (e) => {
theme.isSystemDark = e.matches;
});
}
+34 -17
View File
@@ -1,44 +1,61 @@
/** /**
* versionStore - Frontend build version * versionStore - Build version information
* *
* Reads from SvelteKit's `_app/version.json` — generated by the @vite-pwa/sveltekit * - `build`: llama.cpp build number from `build.json`, embedded at llama.cpp
* plugin. The version string changes on every build, so comparing it against * build time (LLAMA_BUILD_NUMBER). Shown in the UI when `showBuildVersion`
* localStorage reliably detects server upgrades. * is enabled.
* - `frontend`: frontend build version from SvelteKit's `_app/version.json`,
* generated by the @vite-pwa/sveltekit plugin. Changes on every build, so
* comparing it against localStorage reliably detects server upgrades.
* *
* In dev mode, falls back to `'dev'`. * In dev mode both fall back to `'dev'`.
*/ */
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { base } from '$app/paths'; import { base } from '$app/paths';
let version = $state<string>(''); class VersionStore {
build = $state<string>('');
frontend = $state<string>('');
async function loadVersion() { constructor() {
if (!browser) return; if (!browser) return;
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
version = 'dev'; this.build = 'dev';
this.frontend = 'dev';
return; return;
} }
void this.load();
}
private async load(): Promise<void> {
try {
const res = await fetch(`${base}/build.json`, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
this.build = data.version ?? '';
}
} catch {
// build.json missing or unreachable - leave as empty string
}
try { try {
const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' }); const res = await fetch(`${base}/_app/version.json`, { cache: 'no-store' });
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
version = data.version ?? ''; this.frontend = data.version ?? '';
} }
} catch { } catch {
// _app/version.json missing or unreachable - leave as empty string // version.json missing or unreachable - leave as empty string
}
} }
} }
loadVersion(); export const versionStore = new VersionStore();
export const versionStore = {
get value(): string {
return version;
}
};
@@ -1,9 +0,0 @@
import { browser } from '$app/environment';
import { DEFAULT_MOBILE_BREAKPOINT } from '$lib/constants';
import { MediaQuery } from 'svelte/reactivity';
export const viewport = $state({
width: browser ? window.innerWidth : 0
});
export const isMobile = new MediaQuery(`max-width: ${DEFAULT_MOBILE_BREAKPOINT - 1}px`);
+1 -1
View File
@@ -77,7 +77,7 @@
onMount(async () => { onMount(async () => {
if (!conversationsStore.isInitialized) { if (!conversationsStore.isInitialized) {
await conversationsStore.initialize(); await conversationsStore.init();
} }
conversationsStore.clearActiveConversation(); conversationsStore.clearActiveConversation();
+7 -8
View File
@@ -19,15 +19,14 @@
import { usePwa } from '$lib/hooks/use-pwa.svelte'; import { usePwa } from '$lib/hooks/use-pwa.svelte';
import { RouterService } from '$lib/services/router.service'; import { RouterService } from '$lib/services/router.service';
import { import {
buildInfoStore,
chatStore, chatStore,
conversationsStore, conversationsStore,
isMobile, deviceStore,
mcpStore, mcpStore,
modelsStore, modelsStore,
serverStore, serverStore,
settingsStore, settingsStore,
theme versionStore
} from '$lib/stores'; } from '$lib/stores';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
@@ -55,7 +54,7 @@
const { needRefresh, updateServiceWorker } = pwa; const { needRefresh, updateServiceWorker } = pwa;
function updateFavicon() { function updateFavicon() {
const dark = theme.isSystemDark; const dark = deviceStore.systemTheme.isDark;
let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null; let icoLink = document.querySelector(FAVICON_SELECTORS.ICO_48X48) as HTMLLinkElement | null;
@@ -153,7 +152,7 @@
} }
$effect(() => { $effect(() => {
void theme.isSystemDark; void deviceStore.systemTheme.isDark;
updateFavicon(); updateFavicon();
}); });
@@ -274,7 +273,7 @@
<div class="flex flex-col md:flex-row"> <div class="flex flex-col md:flex-row">
<SidebarNavigation <SidebarNavigation
onSearchClick={() => { onSearchClick={() => {
if (isMobile.current) { if (deviceStore.isMobile) {
goto(ROUTES.SEARCH); goto(ROUTES.SEARCH);
} else if (chatSidebar?.activateSearchMode) { } else if (chatSidebar?.activateSearchMode) {
chatSidebar.activateSearchMode(); chatSidebar.activateSearchMode();
@@ -294,8 +293,8 @@
<!-- PWA update prompt + version --> <!-- PWA update prompt + version -->
<div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1"> <div class="fixed right-4 bottom-4 z-9999 flex flex-col items-end gap-1">
{#if showBuildVersion && buildInfoStore.value} {#if showBuildVersion && versionStore.build}
<span class="text-[10px] tabular-nums text-muted-foreground">{buildInfoStore.value}</span> <span class="text-[10px] tabular-nums text-muted-foreground">{versionStore.build}</span>
{/if} {/if}
<PwaRefreshAlert <PwaRefreshAlert
+2 -2
View File
@@ -5,7 +5,7 @@
import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app'; import { SearchInput, SidebarNavigationSearchResults } from '$lib/components/app';
import { ROUTES } from '$lib/constants'; import { ROUTES } from '$lib/constants';
import { RouterService } from '$lib/services/router.service'; import { RouterService } from '$lib/services/router.service';
import { chatStore, conversationsStore, isMobile } from '$lib/stores'; import { chatStore, conversationsStore, deviceStore } from '$lib/stores';
let searchQuery = $state(''); let searchQuery = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null); let searchInputRef = $state<HTMLInputElement | null>(null);
@@ -23,7 +23,7 @@
// Search page is intended for mobile; on desktop the sidebar already exposes // Search page is intended for mobile; on desktop the sidebar already exposes
// in-place search, so bounce back to a chat. // in-place search, so bounce back to a chat.
$effect(() => { $effect(() => {
if (browser && !isMobile.current) { if (browser && !deviceStore.isMobile) {
goto(ROUTES.NEW_CHAT, { replaceState: true }); goto(ROUTES.NEW_CHAT, { replaceState: true });
} }
}); });