feat: UI improvements

This commit is contained in:
Aleksander Grygier 2026-01-23 15:09:50 +01:00
parent 963711cccb
commit 9c391d8e0d
15 changed files with 255 additions and 127 deletions

View File

@ -14,11 +14,11 @@
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary: oklch(0.95 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent: oklch(0.95 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.875 0 0);
@ -51,7 +51,7 @@
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary: oklch(0.29 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);

View File

@ -289,11 +289,11 @@
/>
<div
class="flex-column relative min-h-[48px] items-center rounded-3xl px-5 py-3 shadow-sm transition-all focus-within:shadow-md"
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
onpaste={handlePaste}
>
<ChatFormTextarea
bind:this={textareaRef}
class="px-2 py-1 md:py-0"
bind:value={message}
onKeydown={handleKeydown}
{disabled}

View File

@ -33,11 +33,7 @@
onMcpServersClick?.();
}
const fileUploadTooltipText = $derived.by(() => {
return !hasVisionModality
? 'Text files and PDFs supported. Images, audio, and video require vision models.'
: 'Add files, prompts and MCP Servers';
});
const fileUploadTooltipText = 'Add files, system prompt or MCP Servers';
</script>
<div class="flex items-center gap-1 {className}">
@ -46,8 +42,9 @@
<Tooltip.Root>
<Tooltip.Trigger>
<Button
class="file-upload-button h-8 w-8 rounded-full bg-transparent p-0 text-muted-foreground hover:bg-foreground/10 hover:text-foreground"
class="file-upload-button h-8 w-8 rounded-full p-0"
{disabled}
variant="secondary"
type="button"
>
<span class="sr-only">{fileUploadTooltipText}</span>

View File

@ -6,7 +6,7 @@
ChatFormActionRecord,
ChatFormActionSubmit,
DialogMcpServersSettings,
McpSelector,
McpActiveServersAvatars,
ModelsSelector
} from '$lib/components/app';
import { FileTypeCategory } from '$lib/enums';
@ -16,7 +16,6 @@
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import { activeMessages } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
interface Props {
canSend?: boolean;
@ -158,11 +157,10 @@
}
let showMcpDialog = $state(false);
let hasAvailableMcpServers = $derived(mcpStore.hasAvailableServers());
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
<div class="mr-auto flex items-center gap-1.5">
<div class="mr-auto flex items-center gap-2">
<ChatFormActionFileAttachments
{disabled}
{hasAudioModality}
@ -172,9 +170,7 @@
onMcpServersClick={() => (showMcpDialog = true)}
/>
{#if hasAvailableMcpServers}
<McpSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
{/if}
<McpActiveServersAvatars {disabled} onSettingsClick={() => (showMcpDialog = true)} />
</div>
<div class="ml-auto flex items-center gap-1.5">

View File

@ -79,6 +79,7 @@ export { default as ModelsSelector } from './models/ModelsSelector.svelte';
// MCP
export { default as McpActiveServersAvatars } from './mcp/McpActiveServersAvatars.svelte';
export { default as McpSelector } from './mcp/McpSelector.svelte';
export { default as McpSettingsSection } from './mcp/McpSettingsSection.svelte';

View File

@ -0,0 +1,67 @@
<script lang="ts">
import { cn } from '$lib/components/ui/utils';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { getFaviconUrl } from '$lib/utils/mcp';
import { HealthCheckStatus } from '$lib/enums';
interface Props {
class?: string;
disabled?: boolean;
onSettingsClick?: () => void;
}
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => conversationsStore.isMcpServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== HealthCheckStatus.Error;
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
.filter((f) => f.url !== null)
);
</script>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<button
type="button"
class={cn(
'inline-flex items-center gap-1.5 rounded-sm py-1',
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
className
)}
onclick={onSettingsClick}
{disabled}
aria-label="MCP Servers"
>
<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>
{/each}
</div>
{#if extraServersCount > 0}
<span class="text-xs text-muted-foreground">+{extraServersCount}</span>
{/if}
</button>
{/if}

View File

@ -1,13 +1,13 @@
<script lang="ts">
import { ChevronDown, Settings } from '@lucide/svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { cn } from '$lib/components/ui/utils';
import { SearchableDropdownMenu } from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { parseMcpServerSettings, getMcpServerLabel, getFaviconUrl } from '$lib/utils/mcp';
import { getMcpServerLabel, getFaviconUrl } from '$lib/utils/mcp';
import type { MCPServerSettingsEntry } from '$lib/types';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
@ -22,75 +22,41 @@
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let searchQuery = $state('');
// Only show servers that are enabled in settings (available for use)
let mcpServers = $derived.by(() => {
return parseMcpServerSettings(settingsStore.config.mcpServers).filter((s) => s.enabled);
});
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let mcpUsageStats = $derived(mcpStore.getUsageStats());
function getServerUsageCount(serverId: string): number {
return mcpUsageStats[serverId] || 0;
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
function getServerLabel(server: MCPServerSettingsEntry): string {
return getMcpServerLabel(server, mcpStore.getHealthCheckState(server.id));
}
let isLoading = $derived(mcpStore.isAnyServerLoading());
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => isServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== 'error';
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let sortedMcpServers = $derived(
[...mcpServers].sort((a, b) => {
// First: enabled for chat servers come first
const aEnabled = isServerEnabledForChat(a.id);
const bEnabled = isServerEnabledForChat(b.id);
if (aEnabled !== bEnabled) return aEnabled ? -1 : 1;
// Then sort by usage count (descending)
const usageA = getServerUsageCount(a.id);
const usageB = getServerUsageCount(b.id);
if (usageB !== usageA) return usageB - usageA;
// Then alphabetically by name
return getServerLabel(a).localeCompare(getServerLabel(b));
})
);
let filteredMcpServers = $derived(() => {
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
let filteredMcpServers = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
if (query) {
return sortedMcpServers.filter((s) => {
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
}
return sortedMcpServers.slice(0, 4);
return mcpServers;
});
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
.filter((f) => f.url !== null)
);
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
function getServerLabel(server: MCPServerSettingsEntry): string {
return getMcpServerLabel(server, mcpStore.getHealthCheckState(server.id));
}
function handleDropdownOpen(open: boolean) {
@ -99,12 +65,13 @@
}
}
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
.filter((f) => f.url !== null)
);
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
</script>
{#if hasMcpServers}
@ -112,7 +79,7 @@
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers().length === 0}
isEmpty={filteredMcpServers.length === 0}
{disabled}
onOpenChange={handleDropdownOpen}
>
@ -154,37 +121,58 @@
</button>
{/snippet}
{#each filteredMcpServers() as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.Error}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<div class="max-h-64 overflow-y-auto">
{#if isLoading}
{#each mcpServers as server (server.id)}
<div class="flex items-center justify-between gap-2 px-2 py-2">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Skeleton class="h-4 w-4 shrink-0 rounded-sm" />
<Skeleton class="h-4 w-24" />
</div>
<Skeleton class="h-5 w-9 rounded-full" />
</div>
{/each}
{:else}
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.Error}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<div class="flex items-center justify-between gap-2 px-2 py-2">
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server.url)}
<img
src={getFaviconUrl(server.url)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server.url)}
<img
src={getFaviconUrl(server.url)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{/if}
</div>
<Switch
checked={isEnabledForChat}
onCheckedChange={() => toggleServerForChat(server.id)}
disabled={hasError}
/>
</div>
{/each}
</button>
{/each}
{/if}
</div>
{#snippet footer()}
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={onSettingsClick}>

View File

@ -18,12 +18,13 @@
interface Props {
server: MCPServerSettingsEntry;
faviconUrl: string | null;
enabled?: boolean;
onToggle: (enabled: boolean) => void;
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
onDelete: () => void;
}
let { server, faviconUrl, onToggle, onUpdate, onDelete }: Props = $props();
let { server, faviconUrl, enabled, onToggle, onUpdate, onDelete }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(getMcpServerLabel(server, healthState));
@ -116,7 +117,7 @@
<McpServerCardHeader
{displayName}
{faviconUrl}
enabled={server.enabled}
enabled={enabled ?? server.enabled}
{onToggle}
{serverInfo}
{capabilities}

View File

@ -16,6 +16,7 @@
<Button variant="ghost" size="icon" class="h-7 w-7" onclick={onEdit} aria-label="Edit">
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
@ -26,10 +27,11 @@
>
<RefreshCw class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive/10"
onclick={onDelete}
aria-label="Delete"
>

View File

@ -3,13 +3,16 @@
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { getFaviconUrl } from '$lib/utils/mcp';
import type { MCPServerSettingsEntry } from '$lib/types';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { McpServerCard } from '$lib/components/app/mcp/McpServerCard';
import McpServerForm from './McpServerForm.svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
// Get servers from store
let servers = $derived<MCPServerSettingsEntry[]>(mcpStore.getServers());
// Use store methods for consistent sorting logic
let rawServers = $derived(mcpStore.getServers());
let servers = $derived(mcpStore.getServersSorted());
let isLoading = $derived(mcpStore.isAnyServerLoading());
// New server form state
let isAddingServer = $state(false);
@ -112,17 +115,58 @@
</div>
{/if}
{#if servers.length > 0}
{#if rawServers.length > 0}
<div class="space-y-3">
{#each servers as server (server.id)}
<McpServerCard
{server}
faviconUrl={getFaviconUrl(server.url)}
onToggle={(enabled) => mcpStore.updateServer(server.id, { enabled })}
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/each}
{#if isLoading}
<!-- Show skeleton cards while health checks are in progress -->
{#each rawServers as server (server.id)}
<Card.Root class="grid gap-3 p-4">
<!-- Header: favicon + name + version ... toggle -->
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<Skeleton class="h-5 w-5 rounded" />
<Skeleton class="h-5 w-28" />
<Skeleton class="h-5 w-12 rounded-full" />
</div>
<Skeleton class="h-6 w-11 rounded-full" />
</div>
<!-- Capability badges -->
<div class="flex flex-wrap gap-1.5">
<Skeleton class="h-5 w-14 rounded-full" />
<Skeleton class="h-5 w-12 rounded-full" />
<Skeleton class="h-5 w-16 rounded-full" />
</div>
<!-- Tools & Connection info -->
<div class="space-y-1.5">
<Skeleton class="h-4 w-40" />
<Skeleton class="h-4 w-52" />
</div>
<!-- Protocol version -->
<Skeleton class="h-3.5 w-36" />
<!-- Action buttons -->
<div class="flex justify-end gap-2">
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
<Skeleton class="h-8 w-8 rounded" />
</div>
</Card.Root>
{/each}
{:else}
{#each servers as server (server.id)}
<McpServerCard
{server}
faviconUrl={getFaviconUrl(server.url)}
enabled={conversationsStore.isMcpServerEnabledForChat(server.id)}
onToggle={async () => await conversationsStore.toggleMcpServerForChat(server.id)}
onUpdate={(updates) => mcpStore.updateServer(server.id, updates)}
onDelete={() => mcpStore.removeServer(server.id)}
/>
{/each}
{/if}
</div>
{/if}
</div>

View File

@ -46,7 +46,7 @@
<div class="relative {className}">
<Search
class="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
class="absolute top-1/2 left-3 z-10 h-4 w-4 -translate-y-1/2 transform text-muted-foreground"
/>
<Input

View File

@ -85,7 +85,7 @@
/>
</div>
<div class={cn('overflow-y-auto', 'max-h-[--bits-dropdown-menu-content-available-height]')}>
<div class={cn('overflow-y-auto', 'max-h-[10rem]')}>
{@render children()}
{#if isEmpty}

View File

@ -277,7 +277,7 @@
<button
type="button"
class={cn(
`inline-flex cursor-pointer items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
`inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60`,
!isCurrentModelInCache()
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
@ -287,7 +287,7 @@
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 11.5rem), 20rem)"
style="max-width: min(calc(100cqw - 9rem), 20rem)"
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />

View File

@ -13,7 +13,7 @@
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
ghost: 'hover:text-accent-foreground hover:bg-muted-foreground/10',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {

View File

@ -22,7 +22,8 @@
import { mcpClient } from '$lib/clients/mcp.client';
import type { HealthCheckState, MCPServerSettingsEntry, McpServerUsageStats } from '$lib/types';
import type { McpServerOverride } from '$lib/types/database';
import { buildMcpClientConfig, parseMcpServerSettings } from '$lib/utils/mcp';
import { buildMcpClientConfig, parseMcpServerSettings, getMcpServerLabel } from '$lib/utils/mcp';
import { HealthCheckStatus } from '$lib/enums';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
@ -165,12 +166,43 @@ class MCPStore {
*/
/**
* Get all configured MCP servers from settings
* Get all configured MCP servers from settings (unsorted).
*/
getServers(): MCPServerSettingsEntry[] {
return parseMcpServerSettings(config().mcpServers);
}
/**
* Check if any server is still loading (idle or connecting).
*/
isAnyServerLoading(): boolean {
const servers = this.getServers();
return servers.some((s) => {
const state = this.getHealthCheckState(s.id);
return state.status === HealthCheckStatus.Idle || state.status === HealthCheckStatus.Connecting;
});
}
/**
* Get servers sorted alphabetically by display label.
* Returns unsorted list while health checks are in progress to prevent UI jumping.
*/
getServersSorted(): MCPServerSettingsEntry[] {
const servers = this.getServers();
// Don't sort while any server is still loading - prevents UI jumping
if (this.isAnyServerLoading()) {
return servers;
}
// Sort alphabetically by display label once all health checks are done
return [...servers].sort((a, b) => {
const labelA = getMcpServerLabel(a, this.getHealthCheckState(a.id));
const labelB = getMcpServerLabel(b, this.getHealthCheckState(b.id));
return labelA.localeCompare(labelB);
});
}
/**
* Add a new MCP server
*/