feat: UI improvements

This commit is contained in:
Aleksander Grygier 2026-04-03 14:07:15 +02:00
parent 1dafe26599
commit c374e3e286
14 changed files with 214 additions and 131 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -18,7 +18,7 @@
<div style="display: contents">
<script>
{
__sveltekit_dduflh = {
__sveltekit_e2p3su = {
base: new URL('.', location).pathname.slice(0, -1)
};

View File

@ -13,6 +13,7 @@
disabled?: boolean;
onclick: (e?: MouseEvent) => void;
'aria-label'?: string;
tooltipSide?: 'top' | 'right' | 'bottom' | 'left';
}
let {
@ -23,6 +24,7 @@
class: className = '',
disabled = false,
iconSize = 'h-3 w-3',
tooltipSide = 'top',
onclick,
'aria-label': ariaLabel
}: Props = $props();
@ -44,7 +46,7 @@
</Button>
</Tooltip.Trigger>
<Tooltip.Content>
<Tooltip.Content side={tooltipSide}>
<p>{tooltip}</p>
</Tooltip.Content>
</Tooltip.Root>

View File

@ -11,7 +11,7 @@
<header
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-2 duration-200 ease-linear md:p-4 {sidebar.open
? 'md:left-[var(--sidebar-width)]'
: ''}"
: ''} {sidebar.isResizing ? '!duration-0' : ''}"
>
<div class="pointer-events-auto flex items-center space-x-2">
<Button

View File

@ -18,6 +18,7 @@
import { getPreviewText } from '$lib/utils';
import ChatSidebarActions from './ChatSidebarActions.svelte';
import ChatSidebarFooter from './ChatSidebarFooter.svelte';
import { APP_NAME } from '$lib/constants';
const sidebar = Sidebar.useSidebar();
@ -157,7 +158,7 @@
>
<div class="flex items-center justify-between">
<a href="#/" onclick={handleMobileSidebarItemClick}>
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">llama.cpp</h1>
<h1 class="inline-flex items-center gap-1 px-2 text-xl font-semibold">{APP_NAME}</h1>
</a>
<Button

View File

@ -5,6 +5,7 @@
import { McpLogo } from '$lib/components/app/mcp';
import { Database, Settings, Search, SquarePen } from '@lucide/svelte';
import { fade } from 'svelte/transition';
import { circIn } from 'svelte/easing';
import { onMount, type Component } from 'svelte';
interface Props {
@ -14,7 +15,9 @@
let { sidebarOpen, onSearchClick }: Props = $props();
const TRANSITION_DURATION = 200;
const TRANSITION_DURATION = 250;
const TRANSITION_DELAY_MULTIPLIER = 150;
const TRANSITION_EASING = circIn;
let isMcpActive = $derived(page.route.id === '/settings/mcp');
let isImportExportActive = $derived(page.route.id === '/settings/import-export');
@ -72,25 +75,31 @@
let showIcons = $derived(mounted && !sidebarOpen);
</script>
<!-- Spacer to reserve space for icon strip in the flex layout -->
<div
class="hidden shrink-0 transition-[width] duration-200 ease-linear md:block {sidebarOpen
? 'w-0'
: 'w-[calc(var(--sidebar-width-icon)+1.5rem)]'}"
></div>
<!-- Desktop: icon strip, fixed position so it stays stationary and only fades -->
<aside
class="fixed top-0 bottom-0 left-0 z-10 hidden w-[calc(var(--sidebar-width-icon)+1.5rem)] flex-col items-center justify-between py-3 transition-opacity duration-200 ease-linear md:flex {sidebarOpen
? 'pointer-events-none opacity-0'
: 'opacity-100'}"
>
<div class="mt-12 flex flex-col items-center gap-1">
{#each topIcons as item (item.tooltip)}
{#each topIcons as item, i (item.tooltip)}
{#if showIcons}
<div in:fade={{ duration: TRANSITION_DURATION, delay: TRANSITION_DURATION }}>
<div
in:fade={{
duration: TRANSITION_DURATION,
delay: TRANSITION_DELAY_MULTIPLIER + i * TRANSITION_DELAY_MULTIPLIER,
easing: TRANSITION_EASING
}}
>
<ActionIcon
icon={item.icon}
tooltip={item.tooltip}
tooltipSide="right"
size="lg"
iconSize="h-4 w-4"
class="h-9 w-9 rounded-full hover:bg-accent! {item.activeClass ?? ''}"
@ -100,10 +109,18 @@
{/if}
{/each}
</div>
<div class="flex flex-col items-center gap-1">
{#each bottomIcons as item (item.tooltip)}
{#each bottomIcons as item, i (item.tooltip)}
{#if showIcons}
<div in:fade={{ duration: TRANSITION_DURATION, delay: TRANSITION_DURATION }}>
<div
in:fade={{
duration: TRANSITION_DURATION,
delay:
TRANSITION_DELAY_MULTIPLIER + (topIcons.length + i) * TRANSITION_DELAY_MULTIPLIER,
easing: TRANSITION_EASING
}}
>
<ActionIcon
icon={item.icon}
tooltip={item.tooltip}

View File

@ -1,6 +1,7 @@
export const SIDEBAR_COOKIE_NAME = 'sidebar:state';
export const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
export const SIDEBAR_WIDTH = '18rem';
export const SIDEBAR_MIN_WIDTH = '18rem';
export const SIDEBAR_MAX_WIDTH = '32rem';
export const SIDEBAR_WIDTH_MOBILE = '18rem';
export const SIDEBAR_WIDTH_ICON = '3rem';
export const SIDEBAR_KEYBOARD_SHORTCUT = 'b';

View File

@ -1,6 +1,6 @@
import { IsMobile } from '$lib/hooks/is-mobile.svelte.js';
import { getContext, setContext } from 'svelte';
import { SIDEBAR_KEYBOARD_SHORTCUT } from './constants.js';
import { SIDEBAR_KEYBOARD_SHORTCUT, SIDEBAR_MIN_WIDTH } from './constants.js';
type Getter<T> = () => T;
@ -24,6 +24,8 @@ class SidebarState {
readonly props: SidebarStateProps;
open = $derived.by(() => this.props.open());
openMobile = $state(false);
sidebarWidth = $state(SIDEBAR_MIN_WIDTH);
isResizing = $state(false);
setOpen: SidebarStateProps['setOpen'];
#isMobile: IsMobile;
state = $derived.by(() => (this.open ? 'expanded' : 'collapsed'));

View File

@ -4,7 +4,8 @@
import {
SIDEBAR_COOKIE_MAX_AGE,
SIDEBAR_COOKIE_NAME,
SIDEBAR_WIDTH,
SIDEBAR_MIN_WIDTH,
SIDEBAR_MAX_WIDTH,
SIDEBAR_WIDTH_ICON
} from './constants.js';
import { setSidebar } from './context.svelte.js';
@ -38,7 +39,7 @@
<div
data-slot="sidebar-wrapper"
style="--sidebar-width: {SIDEBAR_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
style="--sidebar-width: {sidebar.sidebarWidth}; --sidebar-min-width: {SIDEBAR_MIN_WIDTH}; --sidebar-max-width: {SIDEBAR_MAX_WIDTH}; --sidebar-width-icon: {SIDEBAR_WIDTH_ICON}; {style}"
class={cn(
'group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar',
className

View File

@ -24,7 +24,9 @@
size="icon-lg"
class="rounded-full backdrop-blur-lg {className} {sidebar.open
? 'top-1.5'
: 'top-0'} md:left-[14.5rem]"
: 'top-0'} md:left-[calc(var(--sidebar-width)-3.25rem)] {sidebar.isResizing
? '!duration-0'
: ''}"
type="button"
onclick={(e) => {
onclick?.(e);

View File

@ -1,6 +1,7 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/components/ui/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import { SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH } from './constants.js';
import { useSidebar } from './context.svelte.js';
let {
@ -18,6 +19,40 @@
} = $props();
const sidebar = useSidebar();
function remToPx(rem: string): number {
const val = parseFloat(rem);
const fontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
return val * fontSize;
}
function handleResizePointerDown(e: PointerEvent) {
if (sidebar.isMobile) return;
e.preventDefault();
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
const minPx = remToPx(SIDEBAR_MIN_WIDTH);
const maxPx = remToPx(SIDEBAR_MAX_WIDTH);
sidebar.isResizing = true;
function onPointerMove(ev: PointerEvent) {
const newWidth = side === 'left' ? ev.clientX : window.innerWidth - ev.clientX;
const clamped = Math.min(maxPx, Math.max(minPx, newWidth));
sidebar.sidebarWidth = `${clamped}px`;
}
function onPointerUp() {
sidebar.isResizing = false;
target.removeEventListener('pointermove', onPointerMove);
target.removeEventListener('pointerup', onPointerUp);
}
target.addEventListener('pointermove', onPointerMove);
target.addEventListener('pointerup', onPointerUp);
}
</script>
{#if collapsible === 'none'}
@ -46,6 +81,7 @@
data-slot="sidebar-gap"
class={cn(
'relative bg-transparent transition-[width] duration-200 ease-linear',
sidebar.isResizing && '!duration-0',
'w-0',
variant === 'floating'
? 'md:w-[calc(var(--sidebar-width)+0.75rem)]'
@ -62,6 +98,7 @@
data-slot="sidebar-container"
class={cn(
'fixed inset-y-0 z-[900] flex w-[calc(100dvw-1.5rem)] duration-200 ease-linear md:z-0 md:w-(--sidebar-width)',
sidebar.isResizing && '!duration-0',
variant === 'floating'
? [
'transition-[left,right,width,opacity]',
@ -94,6 +131,24 @@
>
{@render children?.()}
</div>
<!-- Resize handle -->
{#if side === 'left'}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-slot="sidebar-resize-handle"
class="absolute inset-y-0 right-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
class:bg-sidebar-border={sidebar.isResizing}
onpointerdown={handleResizePointerDown}
></div>
{:else}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
data-slot="sidebar-resize-handle"
class="absolute inset-y-0 left-0 z-50 hidden w-1.5 cursor-ew-resize touch-none select-none hover:bg-sidebar-border/50 active:bg-sidebar-border md:block"
class:bg-sidebar-border={sidebar.isResizing}
onpointerdown={handleResizePointerDown}
></div>
{/if}
</div>
</div>
{/if}

View File

@ -1,2 +1,3 @@
export const FORK_TREE_DEPTH_PADDING = 8;
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
export const APP_NAME = 'llama.cpp';

View File

@ -7,6 +7,7 @@
import { onMount } from 'svelte';
import { page } from '$app/state';
import { replaceState } from '$app/navigation';
import { APP_NAME } from '$lib/constants';
let qParam = $derived(page.url.searchParams.get('q'));
let modelParam = $derived(page.url.searchParams.get('model'));
@ -87,7 +88,7 @@
</script>
<svelte:head>
<title>llama.cpp</title>
<title>{APP_NAME}</title>
</svelte:head>
<DialogModelNotAvailable