feat: UI improvements

This commit is contained in:
Aleksander Grygier 2026-03-23 11:41:28 +01:00
parent 3507687382
commit 3994a39675
7 changed files with 394 additions and 92 deletions

View File

@ -105,6 +105,11 @@
return null;
}
function getEnabledToolCount(group: ToolGroup): number {
if (isGroupDisabled(group)) return 0;
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.function.name)).length;
}
function handleToolsSubMenuOpen(open: boolean) {
if (open) {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
@ -337,26 +342,44 @@
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{group.tools.length}
{getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
{#if groupDisabled && hoveredGroup === group.label && group.serverId}
<Switch
checked={false}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() =>
group.serverId && toggleServerForChat(group.serverId)}
class="mr-2 shrink-0"
/>
<Tooltip.Root>
<Tooltip.Trigger>
<Switch
checked={false}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() =>
group.serverId && toggleServerForChat(group.serverId)}
class="mr-2 shrink-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Enable {group.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Checkbox
{checked}
{indeterminate}
disabled={groupDisabled}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
/>
<Tooltip.Root>
<Tooltip.Trigger>
<Checkbox
{checked}
{indeterminate}
disabled={groupDisabled}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
/>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
@ -365,7 +388,7 @@
{#each group.tools as tool (tool.function.name)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors {groupDisabled
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors {groupDisabled
? 'pointer-events-none opacity-40'
: 'hover:bg-muted/50'}"
onclick={() =>
@ -381,7 +404,7 @@
class="h-4 w-4 shrink-0"
/>
<span class="min-w-0 flex-1 truncate">
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{tool.function.name}
</span>
</button>

View File

@ -1,8 +1,29 @@
<script lang="ts">
import { Plus, MessageSquare, Zap, FolderOpen } from '@lucide/svelte';
import { page } from '$app/state';
import {
Plus,
MessageSquare,
Zap,
FolderOpen,
PencilRuler,
ChevronDown,
ChevronRight,
Loader2
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Tooltip from '$lib/components/ui/tooltip';
import { Switch } from '$lib/components/ui/switch';
import * as Sheet from '$lib/components/ui/sheet';
import { FILE_TYPE_ICONS } from '$lib/constants';
import { FILE_TYPE_ICONS, TOOLTIP_DELAY_DURATION } from '$lib/constants';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { toolsStore, type ToolGroup } from '$lib/stores/tools.svelte';
import { ToolSource } from '$lib/enums';
import { SvelteSet } from 'svelte/reactivity';
import { TruncatedText } from '$lib/components/app';
interface Props {
class?: string;
@ -32,6 +53,61 @@
let sheetOpen = $state(false);
let expandedGroups = new SvelteSet<string>();
let groups = $derived(toolsStore.toolGroups);
let activeGroups = $derived(
groups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.isMcpServerEnabledForChat(g.serverId)
)
);
let totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
function isGroupDisabled(group: ToolGroup): boolean {
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.isMcpServerEnabledForChat(group.serverId)
);
}
let hoveredGroup = $state<string | null>(null);
function getGroupCheckedState(group: (typeof groups)[number]): {
checked: boolean;
indeterminate: boolean;
} {
return {
checked: toolsStore.isGroupFullyEnabled(group),
indeterminate: toolsStore.isGroupPartiallyEnabled(group)
};
}
function getFavicon(group: { source: ToolSource; label: string }): string | null {
if (group.source !== ToolSource.MCP) return null;
for (const server of mcpStore.getServersSorted()) {
if (mcpStore.getServerLabel(server) === group.label) {
return mcpStore.getServerFavicon(server.id);
}
}
return null;
}
function handleToolsSubMenuOpen(open: boolean) {
if (open) {
if (toolsStore.builtinTools.length === 0 && !toolsStore.loading) {
toolsStore.fetchBuiltinTools();
}
mcpStore.runHealthChecksForServers(mcpStore.getServersSorted().filter((s) => s.enabled));
}
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
function handleMcpPromptClick() {
sheetOpen = false;
onMcpPromptClick?.();
@ -72,7 +148,7 @@
<Plus class="h-4 w-4" />
</Button>
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0">
<Sheet.Content side="bottom" class="max-h-[85vh] gap-0 overflow-y-auto">
<Sheet.Header>
<Sheet.Title>Add to chat</Sheet.Title>
@ -81,38 +157,50 @@
</Sheet.Description>
</Sheet.Header>
<div class="flex flex-col gap-1 overflow-y-auto px-1.5 pb-2">
<!-- Images -->
<button
type="button"
class={sheetItemClass}
disabled={!hasVisionModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#if hasVisionModality}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
<span>Images</span>
<span>Images</span>
</button>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<FILE_TYPE_ICONS.image class="h-4 w-4 shrink-0" />
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Requires vision model</span>
{/if}
</button>
<span>Images</span>
</button>
</Tooltip.Trigger>
<!-- Audio -->
<button
type="button"
class={sheetItemClass}
disabled={!hasAudioModality}
onclick={handleSheetFileUpload}
>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<Tooltip.Content side="right">
<p>Image processing requires a vision model</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<span>Audio Files</span>
{#if hasAudioModality}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
{#if !hasAudioModality}
<span class="ml-auto text-xs text-muted-foreground">Requires audio model</span>
{/if}
</button>
<span>Audio Files</span>
</button>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<FILE_TYPE_ICONS.audio class="h-4 w-4 shrink-0" />
<span>Audio Files</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>Audio files processing requires an audio model</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.text class="h-4 w-4 shrink-0" />
@ -120,21 +208,197 @@
<span>Text Files</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
{#if hasVisionModality}
<button type="button" class={sheetItemClass} onclick={handleSheetFileUpload}>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
<span>PDF Files</span>
<span>PDF Files</span>
</button>
{:else}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} disabled>
<FILE_TYPE_ICONS.pdf class="h-4 w-4 shrink-0" />
{#if !hasVisionModality}
<span class="ml-auto text-xs text-muted-foreground">Text-only</span>
{/if}
<span>PDF Files</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>PDFs will be converted to text. Image-based PDFs may not work properly.</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
<span>System Message</span>
</button>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{#if !page.params.id}
Add custom system message for a new conversation
{:else}
Inject custom system message at the beginning of the conversation
{/if}
</p>
</Tooltip.Content>
</Tooltip.Root>
<div class="my-2 border-t"></div>
<button type="button" class={sheetItemClass} onclick={() => handleToolsSubMenuOpen(true)}>
<PencilRuler class="h-4 w-4 shrink-0" />
<span>Tools</span>
</button>
<button type="button" class={sheetItemClass} onclick={handleSheetSystemPromptClick}>
<MessageSquare class="h-4 w-4 shrink-0" />
{#if totalToolCount === 0 && groups.length === 0}
<div class="px-3 py-4 text-center text-sm text-muted-foreground">
{#if toolsStore.loading}
<Loader2 class="mx-auto mb-1 h-4 w-4 animate-spin" />
Loading tools...
{:else if toolsStore.error}
Failed to load tools
{:else}
No tools available
{/if}
</div>
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each groups as group (group.label)}
{@const groupDisabled = isGroupDisabled(group)}
{@const isExpanded = expandedGroups.has(group.label)}
{@const { checked, indeterminate } = groupDisabled
? { checked: false, indeterminate: false }
: getGroupCheckedState(group)}
{@const favicon = getFavicon(group)}
<span>System Message</span>
</button>
<Collapsible.Root
open={isExpanded}
onOpenChange={() => {
if (expandedGroups.has(group.label)) {
expandedGroups.delete(group.label);
} else {
expandedGroups.add(group.label);
}
}}
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex items-center gap-1"
onmouseenter={() => {
if (groupDisabled) hoveredGroup = group.label;
}}
onmouseleave={() => {
if (hoveredGroup === group.label) hoveredGroup = null;
}}
>
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50 {groupDisabled
? 'opacity-40'
: ''}"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
src={favicon}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{group.tools.length}
</span>
</Collapsible.Trigger>
{#if groupDisabled && hoveredGroup === group.label && group.serverId}
<Tooltip.Root>
<Tooltip.Trigger>
<Switch
checked={false}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() =>
group.serverId && toggleServerForChat(group.serverId)}
class="mr-2 shrink-0"
/>
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Enable {group.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<Tooltip.Root>
<Tooltip.Trigger>
<Checkbox
{checked}
{indeterminate}
disabled={groupDisabled}
onCheckedChange={() => toolsStore.toggleGroup(group)}
class="mr-2 h-4 w-4 shrink-0 {groupDisabled ? 'opacity-40' : ''}"
/>
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as tool (tool.function.name)}
<button
type="button"
class="flex w-full items-center gap-2 rounded px-2 py-1 text-left text-sm transition-colors {groupDisabled
? 'pointer-events-none opacity-40'
: 'hover:bg-muted/50'}"
onclick={() => !groupDisabled && toolsStore.toggleTool(tool.function.name)}
>
<Checkbox
checked={groupDisabled
? false
: toolsStore.isToolEnabled(tool.function.name)}
disabled={groupDisabled}
onCheckedChange={() =>
!groupDisabled && toolsStore.toggleTool(tool.function.name)}
class="h-4 w-4 shrink-0"
/>
<TruncatedText
text={tool.function.name}
class="min-w-0 flex-1 truncate"
showTooltip={true}
/>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/each}
</div>
{/if}
{#if hasMcpPromptsSupport}
<button type="button" class={sheetItemClass} onclick={handleMcpPromptClick}>

View File

@ -6,8 +6,6 @@
ChatFormActionAttachmentsSheet,
ChatFormActionRecord,
ChatFormActionSubmit,
McpServersSelector,
McpServersSheet,
ModelsSelector,
ModelsSelectorSheet
} from '$lib/components/app';
@ -22,6 +20,7 @@
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages, conversationsStore } from '$lib/stores/conversations.svelte';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import McpActiveServersAvatars from '$lib/components/app/mcp/McpActiveServersAvatars.svelte';
interface Props {
canSend?: boolean;
@ -215,17 +214,7 @@
</div>
<div class="ml-auto flex items-center gap-2">
{#if isMobile.current}
<McpServersSheet
{disabled}
onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
/>
{:else}
<McpServersSelector
{disabled}
onSettingsClick={() => (showChatSettingsDialogWithMcpSection = true)}
/>
{/if}
<McpActiveServersAvatars onClick={() => (showChatSettingsDialogWithMcpSection = true)} />
{#if isMobile.current}
<ModelsSelectorSheet

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import * as Tooltip from '$lib/components/ui/tooltip';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { HealthCheckStatus } from '$lib/enums';
@ -7,9 +8,10 @@
interface Props {
class?: string;
onClick?: () => void;
}
let { class: className = '' }: Props = $props();
let { class: className = '', onClick }: Props = $props();
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
@ -28,30 +30,41 @@
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, MAX_DISPLAYED_MCP_AVATARS)
.map((s) => ({ id: s.id, url: mcpStore.getServerFavicon(s.id) }))
.map((s) => ({
id: s.id,
name: mcpStore.getServerDisplayName(s.id),
url: mcpStore.getServerFavicon(s.id)
}))
.filter((f) => f.url !== null)
);
</script>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class={cn('inline-flex items-center gap-1.5', className)}>
<button class={cn('inline-flex items-center gap-1.5', className)} onclick={onClick}>
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<Tooltip.Root>
<Tooltip.Trigger>
<div class="box-shadow-lg overflow-hidden rounded-full bg-muted ring-1 ring-muted">
<img
src={favicon.url}
alt=""
class="h-4 w-4"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{favicon.name}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
{#if extraServersCount > 0}
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
{/if}
</div>
</button>
{/if}

View File

@ -2,6 +2,7 @@
import { Settings } from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import { DropdownMenuSearchable, McpActiveServersAvatars } from '$lib/components/app';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
@ -136,12 +137,19 @@
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
<Tooltip.Root>
<Tooltip.Trigger>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{isEnabledForChat ? 'Disable' : 'Enable'} {getServerLabel(server)}</p>
</Tooltip.Content>
</Tooltip.Root>
</button>
{/each}
</div>

View File

@ -228,7 +228,7 @@
: 'text-muted-foreground',
sheetOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 10.5rem), 20rem)"
style="max-width: min(calc(100cqw - 9rem), 20rem)"
disabled={disabled || updating}
onclick={() => handleOpenChange(true)}
>

View File

@ -4,4 +4,9 @@
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
<TooltipPrimitive.Trigger
bind:ref
data-slot="tooltip-trigger"
class="cursor-pointer"
{...restProps}
/>