feat: Improve mobile UI for Settings Dialog (#16084)

* feat: Improve mobile UI for Settings Dialog

* chore: update webui build output

* fix: Linting errors

* chore: update webui build output
This commit is contained in:
Aleksander Grygier
2025-09-19 09:52:27 +02:00
committed by GitHub
parent 4b8560ab56
commit 4067f07fc5
8 changed files with 313 additions and 173 deletions

Binary file not shown.

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
# Script to install pre-commit and post-commit hooks for webui # Script to install pre-commit and post-commit hooks for webui
# Pre-commit: formats code and builds, stashes unstaged changes # Pre-commit: formats, lints, checks, and builds code, stashes unstaged changes
# Post-commit: automatically unstashes changes # Post-commit: automatically unstashes changes
REPO_ROOT=$(git rev-parse --show-toplevel) REPO_ROOT=$(git rev-parse --show-toplevel)
@@ -44,6 +44,18 @@ if git diff --cached --name-only | grep -q "^tools/server/webui/"; then
exit 1 exit 1
fi fi
# Run the lint command
npm run lint
# Check if lint command succeeded
if [ $? -ne 0 ]; then
echo "Error: npm run lint failed"
if [ $STASH_CREATED -eq 0 ]; then
echo "You can restore your unstaged changes with: git stash pop"
fi
exit 1
fi
# Run the check command # Run the check command
npm run check npm run check
@@ -112,7 +124,7 @@ if [ $? -eq 0 ]; then
echo " Post-commit: $POST_COMMIT_HOOK" echo " Post-commit: $POST_COMMIT_HOOK"
echo "" echo ""
echo "The hooks will automatically:" echo "The hooks will automatically:"
echo " • Format and build webui code before commits" echo " • Format, lint, check, and build webui code before commits"
echo " • Stash unstaged changes during the process" echo " • Stash unstaged changes during the process"
echo " • Restore your unstaged changes after the commit" echo " • Restore your unstaged changes after the commit"
echo "" echo ""

View File

@@ -121,3 +121,15 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
@layer utilities {
.scrollbar-hide {
/* Hide scrollbar for Chrome, Safari and Opera */
&::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none;
scrollbar-width: none;
}
}

View File

@@ -1,15 +1,20 @@
<script lang="ts"> <script lang="ts">
import { Settings, Funnel, AlertTriangle, Brain, Cog, Monitor, Sun, Moon } from '@lucide/svelte'; import {
import { ChatSettingsFooter, ChatSettingsSection } from '$lib/components/app'; Settings,
import { Checkbox } from '$lib/components/ui/checkbox'; Funnel,
AlertTriangle,
Brain,
Cog,
Monitor,
Sun,
Moon,
ChevronLeft,
ChevronRight
} from '@lucide/svelte';
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area'; import { ScrollArea } from '$lib/components/ui/scroll-area';
import * as Select from '$lib/components/ui/select'; import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
import { supportsVision } from '$lib/stores/server.svelte';
import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte'; import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
import { setMode } from 'mode-watcher'; import { setMode } from 'mode-watcher';
import type { Component } from 'svelte'; import type { Component } from 'svelte';
@@ -224,12 +229,20 @@
let localConfig: SettingsConfigType = $state({ ...config() }); let localConfig: SettingsConfigType = $state({ ...config() });
let originalTheme: string = $state(''); let originalTheme: string = $state('');
let canScrollLeft = $state(false);
let canScrollRight = $state(false);
let scrollContainer: HTMLDivElement | undefined = $state();
function handleThemeChange(newTheme: string) { function handleThemeChange(newTheme: string) {
localConfig.theme = newTheme; localConfig.theme = newTheme;
setMode(newTheme as 'light' | 'dark' | 'system'); setMode(newTheme as 'light' | 'dark' | 'system');
} }
function handleConfigChange(key: string, value: string | boolean) {
localConfig[key] = value;
}
function handleClose() { function handleClose() {
if (localConfig.theme !== originalTheme) { if (localConfig.theme !== originalTheme) {
setMode(originalTheme as 'light' | 'dark' | 'system'); setMode(originalTheme as 'light' | 'dark' | 'system');
@@ -298,18 +311,63 @@
onOpenChange?.(false); onOpenChange?.(false);
} }
function scrollToCenter(element: HTMLElement) {
if (!scrollContainer) return;
const containerRect = scrollContainer.getBoundingClientRect();
const elementRect = element.getBoundingClientRect();
const elementCenter = elementRect.left + elementRect.width / 2;
const containerCenter = containerRect.left + containerRect.width / 2;
const scrollOffset = elementCenter - containerCenter;
scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
}
function scrollLeft() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
}
function scrollRight() {
if (!scrollContainer) return;
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
}
function updateScrollButtons() {
if (!scrollContainer) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
canScrollLeft = scrollLeft > 0;
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
}
$effect(() => { $effect(() => {
if (open) { if (open) {
localConfig = { ...config() }; localConfig = { ...config() };
originalTheme = config().theme as string; originalTheme = config().theme as string;
setTimeout(updateScrollButtons, 100);
}
});
$effect(() => {
if (scrollContainer) {
updateScrollButtons();
} }
}); });
</script> </script>
<Dialog.Root {open} onOpenChange={handleClose}> <Dialog.Root {open} onOpenChange={handleClose}>
<Dialog.Content class="flex h-[64vh] flex-col gap-0 p-0" style="max-width: 48rem;"> <Dialog.Content
<div class="flex flex-1 overflow-hidden"> class="z-999999 flex h-[100vh] flex-col gap-0 rounded-none p-0 md:h-[64vh] md:rounded-lg"
<div class="w-64 border-r border-border/30 p-6"> style="max-width: 48rem;"
>
<div class="flex flex-1 flex-col overflow-hidden md:flex-row">
<!-- Desktop Sidebar -->
<div class="hidden w-64 border-r border-border/30 p-6 md:block">
<nav class="space-y-1 py-2"> <nav class="space-y-1 py-2">
<Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title> <Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
@@ -329,134 +387,79 @@
</nav> </nav>
</div> </div>
<ScrollArea class="flex-1"> <!-- Mobile Header with Horizontal Scrollable Menu -->
<div class="space-y-6 p-6"> <div class="flex flex-col md:hidden">
<ChatSettingsSection title={currentSection.title} Icon={currentSection.icon}> <div class="border-b border-border/30 py-4">
{#each currentSection.fields as field (field.key)} <Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
<div class="space-y-2">
{#if field.type === 'input'}
<Label for={field.key} class="block text-sm font-medium">
{field.label}
</Label>
<Input <!-- Horizontal Scrollable Category Menu with Navigation -->
id={field.key} <div class="relative flex items-center" style="scroll-padding: 1rem;">
value={String(localConfig[field.key] || '')} <button
onchange={(e) => (localConfig[field.key] = e.currentTarget.value)} class="absolute left-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollLeft
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`} ? 'opacity-100'
class="max-w-md" : 'pointer-events-none opacity-0'}"
/> onclick={scrollLeft}
{#if field.help || SETTING_CONFIG_INFO[field.key]} aria-label="Scroll left"
<p class="mt-1 text-xs text-muted-foreground"> >
{field.help || SETTING_CONFIG_INFO[field.key]} <ChevronLeft class="h-4 w-4" />
</p> </button>
{/if}
{:else if field.type === 'textarea'}
<Label for={field.key} class="block text-sm font-medium">
{field.label}
</Label>
<Textarea <div
id={field.key} class="scrollbar-hide overflow-x-auto py-2"
value={String(localConfig[field.key] || '')} bind:this={scrollContainer}
onchange={(e) => (localConfig[field.key] = e.currentTarget.value)} onscroll={updateScrollButtons}
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`} >
class="min-h-[100px] max-w-2xl" <div class="flex min-w-max gap-2">
/> {#each settingSections as section (section.title)}
{#if field.help || SETTING_CONFIG_INFO[field.key]} <button
<p class="mt-1 text-xs text-muted-foreground"> class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {activeSection ===
{field.help || SETTING_CONFIG_INFO[field.key]} section.title
</p> ? 'bg-accent text-accent-foreground'
{/if} : 'text-muted-foreground'}"
{:else if field.type === 'select'} onclick={(e: MouseEvent) => {
{@const selectedOption = field.options?.find( activeSection = section.title;
(opt: { value: string; label: string; icon?: Component }) => scrollToCenter(e.currentTarget as HTMLElement);
opt.value === localConfig[field.key]
)}
<Label for={field.key} class="block text-sm font-medium">
{field.label}
</Label>
<Select.Root
type="single"
value={localConfig[field.key]}
onValueChange={(value) => {
if (field.key === 'theme' && value) {
handleThemeChange(value);
} else {
localConfig[field.key] = value;
}
}} }}
> >
<Select.Trigger class="max-w-md"> <section.icon class="h-4 w-4 flex-shrink-0" />
<div class="flex items-center gap-2"> <span>{section.title}</span>
{#if selectedOption?.icon} </button>
{@const IconComponent = selectedOption.icon} {/each}
<IconComponent class="h-4 w-4" />
{/if}
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
</div>
</Select.Trigger>
<Select.Content>
{#if field.options}
{#each field.options as option (option.value)}
<Select.Item value={option.value} label={option.label}>
<div class="flex items-center gap-2">
{#if option.icon}
{@const IconComponent = option.icon}
<IconComponent class="h-4 w-4" />
{/if}
{option.label}
</div>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === 'checkbox'}
{@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()}
<div class="flex items-start space-x-3">
<Checkbox
id={field.key}
checked={Boolean(localConfig[field.key])}
disabled={isDisabled}
onCheckedChange={(checked) => (localConfig[field.key] = checked)}
class="mt-1"
/>
<div class="space-y-1">
<label
for={field.key}
class="cursor-pointer text-sm leading-none font-medium {isDisabled
? 'text-muted-foreground'
: ''}"
>
{field.label}
</label>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{:else if field.key === 'pdfAsImage' && !supportsVision()}
<p class="text-xs text-muted-foreground">
PDF-to-image processing requires a vision-capable model. PDFs will be
processed as text.
</p>
{/if}
</div>
</div>
{/if}
</div> </div>
{/each} </div>
</ChatSettingsSection>
<button
class="absolute right-2 z-10 flex h-6 w-6 items-center justify-center rounded-full bg-muted shadow-md backdrop-blur-sm transition-opacity hover:bg-accent {canScrollRight
? 'opacity-100'
: 'pointer-events-none opacity-0'}"
onclick={scrollRight}
aria-label="Scroll right"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div>
<ScrollArea class="max-h-[calc(100vh-13.5rem)] flex-1">
<div class="space-y-6 p-4 md:p-6">
<div>
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
<div class="space-y-6">
<ChatSettingsFields
fields={currentSection.fields}
{localConfig}
onConfigChange={handleConfigChange}
onThemeChange={handleThemeChange}
isMobile={false}
/>
</div>
</div>
<div class="mt-8 border-t pt-6"> <div class="mt-8 border-t pt-6">
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
@@ -467,6 +470,6 @@
</ScrollArea> </ScrollArea>
</div> </div>
<ChatSettingsFooter onClose={handleClose} onReset={handleReset} onSave={handleSave} /> <ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -0,0 +1,145 @@
<script lang="ts">
import { Checkbox } from '$lib/components/ui/checkbox';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import { Textarea } from '$lib/components/ui/textarea';
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
import { supportsVision } from '$lib/stores/server.svelte';
import type { Component } from 'svelte';
interface Props {
fields: SettingsFieldConfig[];
localConfig: SettingsConfigType;
onConfigChange: (key: string, value: string | boolean) => void;
onThemeChange?: (theme: string) => void;
isMobile?: boolean;
}
let { fields, localConfig, onConfigChange, onThemeChange, isMobile = false }: Props = $props();
</script>
{#each fields as field (field.key)}
<div class="space-y-2">
{#if field.type === 'input'}
<Label for={field.key} class="block text-sm font-medium">
{field.label}
</Label>
<Input
id={field.key}
value={String(localConfig[field.key] || '')}
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
class={isMobile ? 'w-full' : 'max-w-md'}
/>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === 'textarea'}
<Label for={field.key} class="block text-sm font-medium">
{field.label}
</Label>
<Textarea
id={field.key}
value={String(localConfig[field.key] || '')}
onchange={(e) => onConfigChange(field.key, e.currentTarget.value)}
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
class={isMobile ? 'min-h-[100px] w-full' : 'min-h-[100px] max-w-2xl'}
/>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === 'select'}
{@const selectedOption = field.options?.find(
(opt: { value: string; label: string; icon?: Component }) =>
opt.value === localConfig[field.key]
)}
<Label for={field.key} class="block text-sm font-medium">
{field.label}
</Label>
<Select.Root
type="single"
value={localConfig[field.key]}
onValueChange={(value) => {
if (field.key === 'theme' && value && onThemeChange) {
onThemeChange(value);
} else {
onConfigChange(field.key, value);
}
}}
>
<Select.Trigger class={isMobile ? 'w-full' : 'max-w-md'}>
<div class="flex items-center gap-2">
{#if selectedOption?.icon}
{@const IconComponent = selectedOption.icon}
<IconComponent class="h-4 w-4" />
{/if}
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
</div>
</Select.Trigger>
<Select.Content>
{#if field.options}
{#each field.options as option (option.value)}
<Select.Item value={option.value} label={option.label}>
<div class="flex items-center gap-2">
{#if option.icon}
{@const IconComponent = option.icon}
<IconComponent class="h-4 w-4" />
{/if}
{option.label}
</div>
</Select.Item>
{/each}
{/if}
</Select.Content>
</Select.Root>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="mt-1 text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{/if}
{:else if field.type === 'checkbox'}
{@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()}
<div class="flex items-start space-x-3">
<Checkbox
id={field.key}
checked={Boolean(localConfig[field.key])}
disabled={isDisabled}
onCheckedChange={(checked) => onConfigChange(field.key, checked)}
class="mt-1"
/>
<div class="space-y-1">
<label
for={field.key}
class="cursor-pointer text-sm leading-none font-medium {isDisabled
? 'text-muted-foreground'
: ''}"
>
{field.label}
</label>
{#if field.help || SETTING_CONFIG_INFO[field.key]}
<p class="text-xs text-muted-foreground">
{field.help || SETTING_CONFIG_INFO[field.key]}
</p>
{:else if field.key === 'pdfAsImage' && !supportsVision()}
<p class="text-xs text-muted-foreground">
PDF-to-image processing requires a vision-capable model. PDFs will be processed as
text.
</p>
{/if}
</div>
</div>
{/if}
</div>
{/each}

View File

@@ -2,16 +2,11 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
interface Props { interface Props {
onClose?: () => void;
onReset?: () => void; onReset?: () => void;
onSave?: () => void; onSave?: () => void;
} }
let { onClose, onReset, onSave }: Props = $props(); let { onReset, onSave }: Props = $props();
function handleClose() {
onClose?.();
}
function handleReset() { function handleReset() {
onReset?.(); onReset?.();
@@ -25,9 +20,5 @@
<div class="flex justify-between border-t border-border/30 p-6"> <div class="flex justify-between border-t border-border/30 p-6">
<Button variant="outline" onclick={handleReset}>Reset to default</Button> <Button variant="outline" onclick={handleReset}>Reset to default</Button>
<div class="flex gap-2"> <Button onclick={handleSave}>Save settings</Button>
<Button variant="outline" onclick={handleClose}>Close</Button>
<Button onclick={handleSave}>Save</Button>
</div>
</div> </div>

View File

@@ -1,23 +0,0 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
interface Props {
children: Snippet;
title: string;
Icon: Component;
}
let { children, title, Icon }: Props = $props();
</script>
<div>
<div class="mb-6 flex items-center gap-2 border-b border-border/30 pb-6">
<Icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{title}</h3>
</div>
<div class="space-y-6">
{@render children()}
</div>
</div>

View File

@@ -22,8 +22,8 @@ export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte'; export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte'; export { default as ChatSettingsDialog } from './chat/ChatSettings/ChatSettingsDialog.svelte';
export { default as ChatSettingsSection } from './chat/ChatSettings/ChatSettingsSection.svelte';
export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte'; export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsFooter.svelte';
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte'; export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte'; export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';