Skip to content

Commit 4067f07

Browse files
authored
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
1 parent 4b8560a commit 4067f07

File tree

8 files changed

+315
-175
lines changed

8 files changed

+315
-175
lines changed

tools/server/public/index.html.gz

1.03 KB
Binary file not shown.

tools/server/webui/scripts/install-git-hooks.sh

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/bin/bash
22

33
# Script to install pre-commit and post-commit hooks for webui
4-
# Pre-commit: formats code and builds, stashes unstaged changes
4+
# Pre-commit: formats, lints, checks, and builds code, stashes unstaged changes
55
# Post-commit: automatically unstashes changes
66

77
REPO_ROOT=$(git rev-parse --show-toplevel)
@@ -44,6 +44,18 @@ if git diff --cached --name-only | grep -q "^tools/server/webui/"; then
4444
exit 1
4545
fi
4646
47+
# Run the lint command
48+
npm run lint
49+
50+
# Check if lint command succeeded
51+
if [ $? -ne 0 ]; then
52+
echo "Error: npm run lint failed"
53+
if [ $STASH_CREATED -eq 0 ]; then
54+
echo "You can restore your unstaged changes with: git stash pop"
55+
fi
56+
exit 1
57+
fi
58+
4759
# Run the check command
4860
npm run check
4961
@@ -112,7 +124,7 @@ if [ $? -eq 0 ]; then
112124
echo " Post-commit: $POST_COMMIT_HOOK"
113125
echo ""
114126
echo "The hooks will automatically:"
115-
echo " • Format and build webui code before commits"
127+
echo " • Format, lint, check, and build webui code before commits"
116128
echo " • Stash unstaged changes during the process"
117129
echo " • Restore your unstaged changes after the commit"
118130
echo ""

tools/server/webui/src/app.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,3 +121,15 @@
121121
@apply bg-background text-foreground;
122122
}
123123
}
124+
125+
@layer utilities {
126+
.scrollbar-hide {
127+
/* Hide scrollbar for Chrome, Safari and Opera */
128+
&::-webkit-scrollbar {
129+
display: none;
130+
}
131+
/* Hide scrollbar for IE, Edge and Firefox */
132+
-ms-overflow-style: none;
133+
scrollbar-width: none;
134+
}
135+
}

tools/server/webui/src/lib/components/app/chat/ChatSettings/ChatSettingsDialog.svelte

Lines changed: 141 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
<script lang="ts">
2-
import { Settings, Funnel, AlertTriangle, Brain, Cog, Monitor, Sun, Moon } from '@lucide/svelte';
3-
import { ChatSettingsFooter, ChatSettingsSection } from '$lib/components/app';
4-
import { Checkbox } from '$lib/components/ui/checkbox';
2+
import {
3+
Settings,
4+
Funnel,
5+
AlertTriangle,
6+
Brain,
7+
Cog,
8+
Monitor,
9+
Sun,
10+
Moon,
11+
ChevronLeft,
12+
ChevronRight
13+
} from '@lucide/svelte';
14+
import { ChatSettingsFooter, ChatSettingsFields } from '$lib/components/app';
515
import * as Dialog from '$lib/components/ui/dialog';
6-
import { Input } from '$lib/components/ui/input';
7-
import Label from '$lib/components/ui/label/label.svelte';
816
import { ScrollArea } from '$lib/components/ui/scroll-area';
9-
import * as Select from '$lib/components/ui/select';
10-
import { Textarea } from '$lib/components/ui/textarea';
11-
import { SETTING_CONFIG_DEFAULT, SETTING_CONFIG_INFO } from '$lib/constants/settings-config';
12-
import { supportsVision } from '$lib/stores/server.svelte';
17+
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
1318
import { config, updateMultipleConfig, resetConfig } from '$lib/stores/settings.svelte';
1419
import { setMode } from 'mode-watcher';
1520
import type { Component } from 'svelte';
@@ -224,12 +229,20 @@
224229
let localConfig: SettingsConfigType = $state({ ...config() });
225230
let originalTheme: string = $state('');
226231
232+
let canScrollLeft = $state(false);
233+
let canScrollRight = $state(false);
234+
let scrollContainer: HTMLDivElement | undefined = $state();
235+
227236
function handleThemeChange(newTheme: string) {
228237
localConfig.theme = newTheme;
229238
230239
setMode(newTheme as 'light' | 'dark' | 'system');
231240
}
232241
242+
function handleConfigChange(key: string, value: string | boolean) {
243+
localConfig[key] = value;
244+
}
245+
233246
function handleClose() {
234247
if (localConfig.theme !== originalTheme) {
235248
setMode(originalTheme as 'light' | 'dark' | 'system');
@@ -298,18 +311,63 @@
298311
onOpenChange?.(false);
299312
}
300313
314+
function scrollToCenter(element: HTMLElement) {
315+
if (!scrollContainer) return;
316+
317+
const containerRect = scrollContainer.getBoundingClientRect();
318+
const elementRect = element.getBoundingClientRect();
319+
320+
const elementCenter = elementRect.left + elementRect.width / 2;
321+
const containerCenter = containerRect.left + containerRect.width / 2;
322+
const scrollOffset = elementCenter - containerCenter;
323+
324+
scrollContainer.scrollBy({ left: scrollOffset, behavior: 'smooth' });
325+
}
326+
327+
function scrollLeft() {
328+
if (!scrollContainer) return;
329+
330+
scrollContainer.scrollBy({ left: -250, behavior: 'smooth' });
331+
}
332+
333+
function scrollRight() {
334+
if (!scrollContainer) return;
335+
336+
scrollContainer.scrollBy({ left: 250, behavior: 'smooth' });
337+
}
338+
339+
function updateScrollButtons() {
340+
if (!scrollContainer) return;
341+
342+
const { scrollLeft, scrollWidth, clientWidth } = scrollContainer;
343+
canScrollLeft = scrollLeft > 0;
344+
canScrollRight = scrollLeft < scrollWidth - clientWidth - 1; // -1 for rounding
345+
}
346+
301347
$effect(() => {
302348
if (open) {
303349
localConfig = { ...config() };
304350
originalTheme = config().theme as string;
351+
352+
setTimeout(updateScrollButtons, 100);
353+
}
354+
});
355+
356+
$effect(() => {
357+
if (scrollContainer) {
358+
updateScrollButtons();
305359
}
306360
});
307361
</script>
308362

309363
<Dialog.Root {open} onOpenChange={handleClose}>
310-
<Dialog.Content class="flex h-[64vh] flex-col gap-0 p-0" style="max-width: 48rem;">
311-
<div class="flex flex-1 overflow-hidden">
312-
<div class="w-64 border-r border-border/30 p-6">
364+
<Dialog.Content
365+
class="z-999999 flex h-[100vh] flex-col gap-0 rounded-none p-0 md:h-[64vh] md:rounded-lg"
366+
style="max-width: 48rem;"
367+
>
368+
<div class="flex flex-1 flex-col overflow-hidden md:flex-row">
369+
<!-- Desktop Sidebar -->
370+
<div class="hidden w-64 border-r border-border/30 p-6 md:block">
313371
<nav class="space-y-1 py-2">
314372
<Dialog.Title class="mb-6 flex items-center gap-2">Settings</Dialog.Title>
315373

@@ -329,134 +387,79 @@
329387
</nav>
330388
</div>
331389

332-
<ScrollArea class="flex-1">
333-
<div class="space-y-6 p-6">
334-
<ChatSettingsSection title={currentSection.title} Icon={currentSection.icon}>
335-
{#each currentSection.fields as field (field.key)}
336-
<div class="space-y-2">
337-
{#if field.type === 'input'}
338-
<Label for={field.key} class="block text-sm font-medium">
339-
{field.label}
340-
</Label>
341-
342-
<Input
343-
id={field.key}
344-
value={String(localConfig[field.key] || '')}
345-
onchange={(e) => (localConfig[field.key] = e.currentTarget.value)}
346-
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
347-
class="max-w-md"
348-
/>
349-
{#if field.help || SETTING_CONFIG_INFO[field.key]}
350-
<p class="mt-1 text-xs text-muted-foreground">
351-
{field.help || SETTING_CONFIG_INFO[field.key]}
352-
</p>
353-
{/if}
354-
{:else if field.type === 'textarea'}
355-
<Label for={field.key} class="block text-sm font-medium">
356-
{field.label}
357-
</Label>
358-
359-
<Textarea
360-
id={field.key}
361-
value={String(localConfig[field.key] || '')}
362-
onchange={(e) => (localConfig[field.key] = e.currentTarget.value)}
363-
placeholder={`Default: ${SETTING_CONFIG_DEFAULT[field.key] || 'none'}`}
364-
class="min-h-[100px] max-w-2xl"
365-
/>
366-
{#if field.help || SETTING_CONFIG_INFO[field.key]}
367-
<p class="mt-1 text-xs text-muted-foreground">
368-
{field.help || SETTING_CONFIG_INFO[field.key]}
369-
</p>
370-
{/if}
371-
{:else if field.type === 'select'}
372-
{@const selectedOption = field.options?.find(
373-
(opt: { value: string; label: string; icon?: Component }) =>
374-
opt.value === localConfig[field.key]
375-
)}
376-
377-
<Label for={field.key} class="block text-sm font-medium">
378-
{field.label}
379-
</Label>
380-
381-
<Select.Root
382-
type="single"
383-
value={localConfig[field.key]}
384-
onValueChange={(value) => {
385-
if (field.key === 'theme' && value) {
386-
handleThemeChange(value);
387-
} else {
388-
localConfig[field.key] = value;
389-
}
390+
<!-- Mobile Header with Horizontal Scrollable Menu -->
391+
<div class="flex flex-col md:hidden">
392+
<div class="border-b border-border/30 py-4">
393+
<Dialog.Title class="mb-6 flex items-center gap-2 px-4">Settings</Dialog.Title>
394+
395+
<!-- Horizontal Scrollable Category Menu with Navigation -->
396+
<div class="relative flex items-center" style="scroll-padding: 1rem;">
397+
<button
398+
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
399+
? 'opacity-100'
400+
: 'pointer-events-none opacity-0'}"
401+
onclick={scrollLeft}
402+
aria-label="Scroll left"
403+
>
404+
<ChevronLeft class="h-4 w-4" />
405+
</button>
406+
407+
<div
408+
class="scrollbar-hide overflow-x-auto py-2"
409+
bind:this={scrollContainer}
410+
onscroll={updateScrollButtons}
411+
>
412+
<div class="flex min-w-max gap-2">
413+
{#each settingSections as section (section.title)}
414+
<button
415+
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 ===
416+
section.title
417+
? 'bg-accent text-accent-foreground'
418+
: 'text-muted-foreground'}"
419+
onclick={(e: MouseEvent) => {
420+
activeSection = section.title;
421+
scrollToCenter(e.currentTarget as HTMLElement);
390422
}}
391423
>
392-
<Select.Trigger class="max-w-md">
393-
<div class="flex items-center gap-2">
394-
{#if selectedOption?.icon}
395-
{@const IconComponent = selectedOption.icon}
396-
<IconComponent class="h-4 w-4" />
397-
{/if}
398-
399-
{selectedOption?.label || `Select ${field.label.toLowerCase()}`}
400-
</div>
401-
</Select.Trigger>
402-
<Select.Content>
403-
{#if field.options}
404-
{#each field.options as option (option.value)}
405-
<Select.Item value={option.value} label={option.label}>
406-
<div class="flex items-center gap-2">
407-
{#if option.icon}
408-
{@const IconComponent = option.icon}
409-
<IconComponent class="h-4 w-4" />
410-
{/if}
411-
{option.label}
412-
</div>
413-
</Select.Item>
414-
{/each}
415-
{/if}
416-
</Select.Content>
417-
</Select.Root>
418-
{#if field.help || SETTING_CONFIG_INFO[field.key]}
419-
<p class="mt-1 text-xs text-muted-foreground">
420-
{field.help || SETTING_CONFIG_INFO[field.key]}
421-
</p>
422-
{/if}
423-
{:else if field.type === 'checkbox'}
424-
{@const isDisabled = field.key === 'pdfAsImage' && !supportsVision()}
425-
<div class="flex items-start space-x-3">
426-
<Checkbox
427-
id={field.key}
428-
checked={Boolean(localConfig[field.key])}
429-
disabled={isDisabled}
430-
onCheckedChange={(checked) => (localConfig[field.key] = checked)}
431-
class="mt-1"
432-
/>
433-
434-
<div class="space-y-1">
435-
<label
436-
for={field.key}
437-
class="cursor-pointer text-sm leading-none font-medium {isDisabled
438-
? 'text-muted-foreground'
439-
: ''}"
440-
>
441-
{field.label}
442-
</label>
443-
444-
{#if field.help || SETTING_CONFIG_INFO[field.key]}
445-
<p class="text-xs text-muted-foreground">
446-
{field.help || SETTING_CONFIG_INFO[field.key]}
447-
</p>
448-
{:else if field.key === 'pdfAsImage' && !supportsVision()}
449-
<p class="text-xs text-muted-foreground">
450-
PDF-to-image processing requires a vision-capable model. PDFs will be
451-
processed as text.
452-
</p>
453-
{/if}
454-
</div>
455-
</div>
456-
{/if}
424+
<section.icon class="h-4 w-4 flex-shrink-0" />
425+
<span>{section.title}</span>
426+
</button>
427+
{/each}
457428
</div>
458-
{/each}
459-
</ChatSettingsSection>
429+
</div>
430+
431+
<button
432+
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
433+
? 'opacity-100'
434+
: 'pointer-events-none opacity-0'}"
435+
onclick={scrollRight}
436+
aria-label="Scroll right"
437+
>
438+
<ChevronRight class="h-4 w-4" />
439+
</button>
440+
</div>
441+
</div>
442+
</div>
443+
444+
<ScrollArea class="max-h-[calc(100vh-13.5rem)] flex-1">
445+
<div class="space-y-6 p-4 md:p-6">
446+
<div>
447+
<div class="mb-6 flex hidden items-center gap-2 border-b border-border/30 pb-6 md:flex">
448+
<currentSection.icon class="h-5 w-5" />
449+
450+
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
451+
</div>
452+
453+
<div class="space-y-6">
454+
<ChatSettingsFields
455+
fields={currentSection.fields}
456+
{localConfig}
457+
onConfigChange={handleConfigChange}
458+
onThemeChange={handleThemeChange}
459+
isMobile={false}
460+
/>
461+
</div>
462+
</div>
460463

461464
<div class="mt-8 border-t pt-6">
462465
<p class="text-xs text-muted-foreground">
@@ -467,6 +470,6 @@
467470
</ScrollArea>
468471
</div>
469472

470-
<ChatSettingsFooter onClose={handleClose} onReset={handleReset} onSave={handleSave} />
473+
<ChatSettingsFooter onReset={handleReset} onSave={handleSave} />
471474
</Dialog.Content>
472475
</Dialog.Root>

0 commit comments

Comments
 (0)