Files
koboldcpp/tools/ui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte
T
Aleksander Grygier fdf4c64604 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.
2026-08-18 16:37:26 +02:00

377 lines
11 KiB
Svelte

<script lang="ts">
import ChatScreenActionScrollDown from './ChatScreenActionScrollDown.svelte';
import ChatScreenDialogsAndAlerts from './ChatScreenDialogsAndAlerts.svelte';
import ChatScreenGreeting from './ChatScreenGreeting.svelte';
import { page } from '$app/state';
import {
ChatMessages,
ChatScreenDragOverlay,
ChatScreenForm,
ChatScreenServerError,
ChatScreenStreamResumeStatus,
ServerLoadingSplash
} from '$lib/components/app';
import { LANDING_SETTLE_MAX_MS, LANDING_STABLE_FRAMES, ROUTES } from '$lib/constants';
import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte';
import { useChatScreenActiveModel } from '$lib/hooks/use-chat-screen-active-model.svelte';
import { useChatScreenDragAndDrop } from '$lib/hooks/use-chat-screen-drag-and-drop.svelte';
import { useChatScreenFileUpload } from '$lib/hooks/use-chat-screen-file-upload.svelte';
import { useChatScreenScroll } from '$lib/hooks/use-chat-screen-scroll.svelte';
import { useKeyboardShortcuts } from '$lib/hooks/use-keyboard-shortcuts.svelte';
import {
chatStore,
conversationsStore,
deviceStore,
serverStore,
settingsStore
} from '$lib/stores';
import { parseFilesToMessageExtras } from '$lib/utils/browser-only';
import { onDestroy, onMount, tick } from 'svelte';
let { showCenteredEmpty = false } = $props();
let disableAutoScroll = $derived(
Boolean(settingsStore.config.disableAutoScroll) || deviceStore.isMobile
);
let isMobileUserScrolledUp = $state(false);
let mobileScrollDownHint = $state(false);
let mobileScrollDownHintLockedUntil = $state(0);
let emptyFileNames = $state<string[]>([]);
let initialMessage = $state('');
let showDeleteDialog = $state(false);
let showEmptyFileDialog = $state(false);
let isEmpty = $derived(
showCenteredEmpty &&
!conversationsStore.activeConversation &&
conversationsStore.activeMessages.length === 0 &&
!chatStore.isLoading
);
let activeErrorDialog = $derived(chatStore.errorDialogState);
let isServerLoading = $derived(serverStore.loading);
let hasPropsError = $derived(!!serverStore.error);
let isCurrentConversationLoading = $derived(chatStore.isLoading || chatStore.isStreaming());
let chatFormBottomPosition = $derived.by(() => {
if (!deviceStore.isMobile) return '1rem';
if (deviceStore.isStandalone) return '1.5rem';
if (deviceStore.isIOSSafari) return '0.25rem';
return '0.5rem';
});
const autoScroll = createAutoScrollController();
const scroll = useChatScreenScroll(autoScroll);
const activeModel = useChatScreenActiveModel();
const fileUpload = useChatScreenFileUpload({
activeModelId: () => activeModel.activeModelId,
capabilities: () => ({
hasAudio: activeModel.hasAudioModality,
hasVideo: activeModel.hasVideoModality,
hasVision: activeModel.hasVisionModality
})
});
const dragAndDrop = useChatScreenDragAndDrop({
onDrop: fileUpload.handleFileUpload
});
const { handleKeydown } = useKeyboardShortcuts({
deleteActiveConversation: () => {
if (conversationsStore.activeConversation) {
showDeleteDialog = true;
}
}
});
function handleMobileScroll() {
if (!deviceStore.isMobile) return;
const container = scroll.chatScrollContainer;
if (!container) return;
const distanceFromBottom =
container.scrollHeight - container.clientHeight - container.scrollTop;
isMobileUserScrolledUp = distanceFromBottom > 300;
}
async function handleDeleteConfirm() {
const conversation = conversationsStore.activeConversation;
if (conversation) {
await conversationsStore.deleteConversation(conversation.id);
}
showDeleteDialog = false;
}
async function handleSendMessage(message: string, files?: ChatUploadedFile[]): Promise<boolean> {
const plainFiles = files ? $state.snapshot(files) : undefined;
const result = plainFiles
? await parseFilesToMessageExtras(plainFiles, activeModel.activeModelId ?? undefined)
: undefined;
if (result?.emptyFiles && result.emptyFiles.length > 0) {
emptyFileNames = result.emptyFiles;
showEmptyFileDialog = true;
if (files) {
const emptyFileNamesSet = new Set(result.emptyFiles);
fileUpload.uploadedFiles = fileUpload.uploadedFiles.filter(
(file) => !emptyFileNamesSet.has(file.name)
);
}
return false;
}
handleSendLikeScroll();
await chatStore.sendMessage(message, result?.extras);
return true;
}
let lastScrolledConversationId: string | null = null;
// Lands at the bottom of a conversation the first time its messages
// render, whether the route comes from another conversation or from a
// non-conversation route. The page keeps growing after the first pin
// without DOM mutations (content-visibility size realizations, syntax
// highlight passes), so the instant pin repeats every frame until the
// height settles, bailing out on user scroll or conversation change.
async function handleMessagesReady(messageCount: number) {
if (messageCount === 0) return;
const id = conversationsStore.activeConversation?.id ?? null;
if (!id || id === lastScrolledConversationId) return;
lastScrolledConversationId = id;
await tick();
autoScroll.scrollToBottom();
const container = scroll.chatScrollContainer;
if (!container) return;
const started = performance.now();
let stableFrames = 0;
let lastHeight = container.scrollHeight;
const settle = () => {
if (autoScroll.userScrolledUp) return;
if (conversationsStore.activeConversation?.id !== id) return;
autoScroll.scrollToBottom();
const height = container.scrollHeight;
stableFrames = height === lastHeight ? stableFrames + 1 : 0;
lastHeight = height;
if (stableFrames >= LANDING_STABLE_FRAMES) return;
if (performance.now() - started > LANDING_SETTLE_MAX_MS) return;
requestAnimationFrame(settle);
};
requestAnimationFrame(settle);
}
function handleSendLikeScroll() {
if (!deviceStore.isMobile) {
autoScroll.enable();
}
setTimeout(() => {
const container = scroll.chatScrollContainer;
if (!container) return;
const lastUserBubble = container.querySelector(
'.chat-message:nth-last-child(2) .chat-message-user .chat-message-user-bubble'
) as HTMLElement | null;
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;
container.scrollTo({
behavior: 'smooth',
top: bubbleHeight > 0 ? baseHeight - bubbleHeight : baseHeight
});
} else if (lastUserBubble) {
// On desktop, place the last user message near the top of the viewport
const topPadding = 24;
const bubbleRect = lastUserBubble.getBoundingClientRect();
container.scrollTo({
behavior: 'smooth',
top: Math.max(0, container.scrollTop + bubbleRect.top - topPadding)
});
} else {
autoScroll.scrollToBottom();
}
}, 100);
if (deviceStore.isMobile) {
autoScroll.setDisabled(disableAutoScroll);
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
}
}
function handleErrorDialogOpenChange(open: boolean) {
if (!open) {
chatStore.dismissErrorDialog();
}
}
async function handleSystemPromptAdd(draft: { message: string; files: ChatUploadedFile[] }) {
if (draft.message || draft.files.length > 0) {
chatStore.savePendingDraft(draft.message, draft.files);
}
await chatStore.addSystemPrompt();
}
$effect(() => {
const shouldDisableAutoScroll =
settingsStore.config.disableAutoScroll ||
(deviceStore.isMobile && isCurrentConversationLoading);
autoScroll.setDisabled(shouldDisableAutoScroll);
if (!shouldDisableAutoScroll) {
autoScroll.enable();
}
});
onMount(() => {
const pendingDraft = chatStore.consumePendingDraft();
if (pendingDraft) {
initialMessage = pendingDraft.message;
fileUpload.uploadedFiles = pendingDraft.files;
}
autoScroll.startObserving();
if (!disableAutoScroll) {
autoScroll.enable();
}
if (deviceStore.isMobile && isCurrentConversationLoading) {
mobileScrollDownHint = true;
mobileScrollDownHintLockedUntil = Date.now() + 500;
}
handleMobileScroll();
});
onDestroy(() => autoScroll.destroy());
</script>
{#if dragAndDrop.isDragOver}
<ChatScreenDragOverlay />
{/if}
<svelte:window
onkeydown={handleKeydown}
onscroll={(e) => {
scroll.handleScroll(e);
handleMobileScroll();
if (e.isTrusted && Date.now() > mobileScrollDownHintLockedUntil) {
mobileScrollDownHint = false;
}
}}
/>
{#if isServerLoading}
<ServerLoadingSplash />
{:else}
<div
class="chat-screen flex grow flex-col min-h-[calc(100dvh-1rem)] md:min-h-full px-4 md:py-0 pt-12 pb-48 md:pb-4"
style:--chat-form-bottom-position={chatFormBottomPosition}
ondragenter={dragAndDrop.dragHandlers.dragenter}
ondragleave={dragAndDrop.dragHandlers.dragleave}
ondragover={dragAndDrop.dragHandlers.dragover}
ondrop={dragAndDrop.dragHandlers.drop}
role="main"
>
{#if !isEmpty}
<ChatMessages
messages={conversationsStore.activeMessages}
onMessagesReady={handleMessagesReady}
onUserAction={() => {
handleSendLikeScroll();
}}
/>
{/if}
<div
class={[
'pointer-events-none md:sticky fixed mt-auto transition-all duration-200',
deviceStore.isStandalone
? 'bottom-6 right-4 left-4'
: 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'
]}
style:padding-top={!isEmpty ? 'var(--chat-form-padding-top)' : undefined}
>
<ChatScreenGreeting {isEmpty} />
<ChatScreenServerError />
{#if page.params.id}
<ChatScreenStreamResumeStatus />
{/if}
<div class="pointer-events-none flex flex-col gap-6 items-center w-full">
{#if (deviceStore.isMobile ? mobileScrollDownHint || isMobileUserScrolledUp : autoScroll.userScrolledUp) && page.url.hash.includes(ROUTES.CHAT) && page.params.id}
<ChatScreenActionScrollDown
onclick={() => {
mobileScrollDownHint = false;
scroll.chatScrollContainer?.scrollTo({
behavior: 'smooth',
top: scroll.chatScrollContainer.scrollHeight
});
}}
/>
{/if}
</div>
<ChatScreenForm
class="pointer-events-auto conversation-chat-form"
disabled={hasPropsError || chatStore.isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
onFileRemove={fileUpload.handleFileRemove}
onFileUpload={fileUpload.handleFileUpload}
onSend={handleSendMessage}
onStop={() => chatStore.stopGeneration()}
onSystemPromptAdd={handleSystemPromptAdd}
bind:uploadedFiles={fileUpload.uploadedFiles}
/>
</div>
</div>
{/if}
<ChatScreenDialogsAndAlerts
{showDeleteDialog}
{handleDeleteConfirm}
{showEmptyFileDialog}
{emptyFileNames}
{activeErrorDialog}
{handleErrorDialogOpenChange}
{fileUpload}
/>