feat: UI improvements

This commit is contained in:
Aleksander Grygier 2026-01-07 13:53:25 +01:00
parent bc07e0723d
commit 10e5ad1396
5 changed files with 234 additions and 226 deletions

View File

@ -35,7 +35,7 @@ export { default as ChatSettingsFooter } from './chat/ChatSettings/ChatSettingsF
export { default as ChatSettingsFields } from './chat/ChatSettings/ChatSettingsFields.svelte';
export { default as ChatSettingsImportExportTab } from './chat/ChatSettings/ChatSettingsImportExportTab.svelte';
export { default as ChatSettingsParameterSourceIndicator } from './chat/ChatSettings/ChatSettingsParameterSourceIndicator.svelte';
export { default as McpSettingsSection } from './chat/ChatSettings/McpSettingsSection.svelte';
export { default as McpSettingsSection } from './mcp/McpSettingsSection.svelte';
export { default as ChatSidebar } from './chat/ChatSidebar/ChatSidebar.svelte';
export { default as ChatSidebarConversationItem } from './chat/ChatSidebar/ChatSidebarConversationItem.svelte';

View File

@ -6,7 +6,7 @@
import { cn } from '$lib/components/ui/utils';
import { SearchableDropdownMenu } from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { config } from '$lib/stores/settings.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/config/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
@ -24,38 +24,32 @@
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let currentConfig = $derived(config());
let searchQuery = $state('');
// MCP servers state
let mcpServers = $derived<MCPServerSettingsEntry[]>(
parseMcpServerSettings(currentConfig.mcpServers)
);
let mcpServers = $derived.by(() => {
return parseMcpServerSettings(settingsStore.config.mcpServers);
});
// Usage stats for sorting by popularity
let mcpUsageStats = $derived(parseMcpServerUsageStats(currentConfig.mcpServerUsageStats));
let hasMcpServers = $derived(mcpServers.length > 0);
let mcpUsageStats = $derived(parseMcpServerUsageStats(settingsStore.config.mcpServerUsageStats));
// Get usage count for a server
function getServerUsageCount(serverId: string): number {
return mcpUsageStats[serverId] || 0;
}
// Helper to check if server is enabled for current chat (per-chat override or global)
function isServerEnabledForChat(server: MCPServerSettingsEntry): boolean {
return conversationsStore.isMcpServerEnabledForChat(server.id, server.enabled);
}
// Helper to check if server has per-chat override
function hasPerChatOverride(serverId: string): boolean {
return conversationsStore.getMcpServerOverride(serverId) !== undefined;
}
// Servers enabled for current chat (considering per-chat overrides)
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => isServerEnabledForChat(s) && s.url.trim())
);
// Filter out servers with health check errors
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpGetHealthCheckState(s.id);
@ -65,21 +59,21 @@
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
// Sort servers: globally enabled first (by popularity), then rest (by popularity)
let sortedMcpServers = $derived(
[...mcpServers].sort((a, b) => {
// First: globally enabled servers come first
if (a.enabled !== b.enabled) return a.enabled ? -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 getServerDisplayName(a).localeCompare(getServerDisplayName(b));
})
);
// Filtered servers for display
let filteredMcpServers = $derived(() => {
const query = searchQuery.toLowerCase().trim();
if (query) {
@ -89,19 +83,16 @@
return name.includes(query) || url.includes(query);
});
}
// When not searching, show max 4 items
return sortedMcpServers.slice(0, 4);
});
// Count of extra servers beyond the 3 shown as favicons (excluding error servers)
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
// Toggle server enabled state for current chat (per-chat override only)
async function toggleServerForChat(serverId: string, globalEnabled: boolean) {
await conversationsStore.toggleMcpServerForChat(serverId, globalEnabled);
}
// Get display name for server
function getServerDisplayName(server: MCPServerSettingsEntry): string {
if (server.name) return server.name;
try {
@ -114,7 +105,6 @@
}
}
// Get favicon URL for server
function getFaviconUrl(server: MCPServerSettingsEntry): string | null {
try {
const url = new URL(server.url);
@ -134,10 +124,8 @@
.filter((f) => f.url !== null)
);
// All servers with valid URLs (for health checks)
let serversWithUrls = $derived(mcpServers.filter((s) => s.url.trim()));
// Run health checks on mount for ALL servers with URLs
onMount(() => {
for (const server of serversWithUrls) {
if (!mcpHasHealthCheck(server.id)) {
@ -147,93 +135,110 @@
});
</script>
<SearchableDropdownMenu
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers().length === 0}
{disabled}
>
{#snippet trigger()}
<button
type="button"
class={cn(
'inline-flex cursor-pointer items-center 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',
hasEnabledMcpServers ? 'text-foreground' : 'text-muted-foreground',
className
)}
{disabled}
aria-label="MCP Servers"
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
{#if hasMcpServers}
<SearchableDropdownMenu
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers().length === 0}
{disabled}
>
{#snippet trigger()}
<button
type="button"
class={cn(
'inline-flex cursor-pointer items-center 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',
hasEnabledMcpServers ? 'text-foreground' : 'text-muted-foreground',
className
)}
{disabled}
aria-label="MCP Servers"
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
<span class="mx-1.5 font-medium">MCP</span>
<span class="mx-1.5 font-medium">MCP</span>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<img
src={favicon.url}
alt=""
class="h-3.5 w-3.5 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/each}
</div>
{#if extraServersCount > 0}
<span class="ml-1 text-muted-foreground">+{extraServersCount}</span>
{/if}
{/if}
<ChevronDown class="h-3 w-3.5" />
</button>
{/snippet}
{#each filteredMcpServers() as server (server.id)}
{@const healthState = mcpGetHealthCheckState(server.id)}
{@const hasError = healthState.status === 'error'}
{@const isEnabledForChat = isServerEnabledForChat(server)}
{@const hasOverride = hasPerChatOverride(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)}
<img
src={favicon.url}
src={getFaviconUrl(server)}
alt=""
class="h-3.5 w-3.5 rounded-sm"
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/each}
{/if}
<span class="truncate text-sm">{getServerDisplayName(server)}</span>
{#if hasError}
<span class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{:else if server.enabled}
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary"
>Global</span
>
{/if}
</div>
{#if extraServersCount > 0}
<span class="ml-1 text-muted-foreground">+{extraServersCount}</span>
{/if}
{/if}
<ChevronDown class="h-3 w-3.5" />
</button>
{/snippet}
{#each filteredMcpServers() as server (server.id)}
{@const healthState = mcpGetHealthCheckState(server.id)}
{@const hasError = healthState.status === 'error'}
{@const isEnabledForChat = isServerEnabledForChat(server)}
{@const hasOverride = hasPerChatOverride(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)}
<img
src={getFaviconUrl(server)}
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">{getServerDisplayName(server)}</span>
{#if hasError}
<span class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{:else if server.enabled}
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary"
>Global</span
>
{/if}
<Switch
checked={isEnabledForChat}
onCheckedChange={() => toggleServerForChat(server.id, server.enabled)}
disabled={hasError}
class={hasOverride ? 'ring-2 ring-primary/50 ring-offset-1' : ''}
/>
</div>
<Switch
checked={isEnabledForChat}
onCheckedChange={() => toggleServerForChat(server.id, server.enabled)}
disabled={hasError}
class={hasOverride ? 'ring-2 ring-primary/50 ring-offset-1' : ''}
/>
</div>
{/each}
{/each}
{#snippet footer()}
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={onSettingsClick}>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</SearchableDropdownMenu>
{#snippet footer()}
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={onSettingsClick}>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</SearchableDropdownMenu>
{:else}
<button
type="button"
class={cn(
'inline-flex cursor-pointer items-center 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',
'text-muted-foreground',
className
)}
{disabled}
aria-label="MCP Servers"
onclick={onSettingsClick}
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
<span class="mx-1.5 font-medium">MCP</span>
</button>
{/if}

View File

@ -7,7 +7,6 @@
ChevronDown,
ChevronRight,
Pencil,
Check,
X,
ExternalLink
} from '@lucide/svelte';
@ -15,7 +14,8 @@
import { Switch } from '$lib/components/ui/switch';
import * as Card from '$lib/components/ui/card';
import * as Collapsible from '$lib/components/ui/collapsible';
import McpServerForm from './McpServerForm.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import {
mcpGetHealthCheckState,
@ -36,7 +36,6 @@
let { server, displayName, faviconUrl, onToggle, onUpdate, onDelete }: Props = $props();
// Get health state from store
let healthState = $derived<HealthCheckState>(mcpGetHealthCheckState(server.id));
let isHealthChecking = $derived(healthState.status === 'loading');
let isConnected = $derived(healthState.status === 'success');
@ -45,15 +44,14 @@
let tools = $derived(healthState.status === 'success' ? healthState.tools : []);
let toolsCount = $derived(tools.length);
// Expandable details state
let isExpanded = $state(false);
// Edit mode state - default to edit mode if no URL
let showDeleteDialog = $state(false);
let isEditing = $state(!server.url.trim());
let editUrl = $state(server.url);
let editHeaders = $state(server.headers || '');
// Validation
let urlError = $derived.by(() => {
if (!editUrl.trim()) return 'URL is required';
try {
@ -66,7 +64,6 @@
let canSave = $derived(!urlError);
// Run health check on first mount if not already checked and server is enabled with URL
onMount(() => {
if (!mcpHasHealthCheck(server.id) && server.enabled && server.url.trim()) {
mcpRunHealthCheck(server);
@ -84,11 +81,12 @@
}
function cancelEditing() {
// Only allow cancel if server has valid URL
if (server.url.trim()) {
editUrl = server.url;
editHeaders = server.headers || '';
isEditing = false;
} else {
onDelete();
}
}
@ -99,51 +97,27 @@
headers: editHeaders.trim() || undefined
});
isEditing = false;
// Run health check after saving
if (server.enabled && editUrl.trim()) {
setTimeout(() => mcpRunHealthCheck({ ...server, url: editUrl.trim() }), 100);
}
}
</script>
<Card.Root class="!gap-1.5 bg-muted/30 p-4">
<Card.Root class="!gap-4 bg-muted/30 p-4">
{#if isEditing}
<!-- Edit Mode -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="font-medium">Configure Server</p>
<div class="flex items-center gap-1">
{#if server.url.trim()}
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={cancelEditing}
aria-label="Cancel"
>
<X class="h-3.5 w-3.5" />
</Button>
{/if}
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600 hover:bg-green-100 hover:text-green-700 dark:text-green-500 dark:hover:bg-green-950"
onclick={saveEditing}
disabled={!canSave}
aria-label="Save"
>
<Check 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"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={cancelEditing}
aria-label="Cancel"
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<McpServerForm
@ -154,12 +128,21 @@
urlError={editUrl ? urlError : null}
id={server.id}
/>
<div class="flex items-center justify-end">
<Button
variant="default"
size="sm"
onclick={saveEditing}
disabled={!canSave}
aria-label="Save"
>
{server.url.trim() ? 'Update' : 'Add'}
</Button>
</div>
</div>
{:else}
<!-- View Mode -->
<!-- Header Row -->
<div class="flex items-center justify-between gap-3">
<!-- Left: Favicon + Name + Status Badge -->
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
@ -199,18 +182,15 @@
{/if}
</div>
<!-- Right: Switch -->
<div class="flex shrink-0 items-center">
<Switch checked={server.enabled} onCheckedChange={onToggle} />
</div>
</div>
<!-- Error Message -->
{#if isError && errorMessage}
<p class="mt-3 text-xs text-destructive">{errorMessage}</p>
{/if}
<!-- Actions Row (when no tools available) -->
{#if tools.length === 0 && server.url.trim()}
<div class="mt-3 flex items-center justify-end gap-1">
<Button
@ -236,72 +216,92 @@
variant="ghost"
size="icon"
class="hover:text-destructive-foreground h-7 w-7 text-destructive hover:bg-destructive"
onclick={onDelete}
onclick={() => (showDeleteDialog = true)}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
{/if}
{/if}
<!-- Expandable Details + Actions Row (when tools available) -->
{#if tools.length > 0}
<Collapsible.Root bind:open={isExpanded}>
<div class="flex items-center justify-between gap-3">
<Collapsible.Trigger
class="flex flex-1 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>{toolsCount} tools available · Show details</span>
</Collapsible.Trigger>
<div class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={startEditing}
aria-label="Edit"
{#if tools.length > 0}
<Collapsible.Root bind:open={isExpanded}>
<div class="flex items-center justify-between gap-3">
<Collapsible.Trigger
class="flex flex-1 items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={handleHealthCheck}
disabled={isHealthChecking}
aria-label="Refresh"
>
<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"
onclick={onDelete}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5" />
{:else}
<ChevronRight class="h-3.5 w-3.5" />
{/if}
<span>{toolsCount} tools available · Show details</span>
</Collapsible.Trigger>
<div class="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={startEditing}
aria-label="Edit"
>
<Pencil class="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
class="h-7 w-7"
onclick={handleHealthCheck}
disabled={isHealthChecking}
aria-label="Refresh"
>
<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"
onclick={() => (showDeleteDialog = true)}
aria-label="Delete"
>
<Trash2 class="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
<Collapsible.Content class="mt-2">
<div class="max-h-64 space-y-3 overflow-y-auto">
{#each tools as tool (tool.name)}
<div>
<Badge variant="secondary">{tool.name}</Badge>
{#if tool.description}
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
{/if}
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Content class="mt-2">
<div class="max-h-64 space-y-3 overflow-y-auto">
{#each tools as tool (tool.name)}
<div>
<Badge variant="secondary">{tool.name}</Badge>
{#if tool.description}
<p class="mt-1 text-xs text-muted-foreground">{tool.description}</p>
{/if}
</div>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
{/if}
</Card.Root>
<AlertDialog.Root bind:open={showDeleteDialog}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Delete Server</AlertDialog.Title>
<AlertDialog.Description>
Are you sure you want to delete <strong>{displayName}</strong>? This action cannot be
undone.
</AlertDialog.Description>
</AlertDialog.Header>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action
class="text-destructive-foreground bg-destructive hover:bg-destructive/90"
onclick={onDelete}
>
Delete
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>

View File

@ -1,12 +1,12 @@
<script lang="ts">
import { Plus, X, Check } from '@lucide/svelte';
import { Plus, X } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { parseMcpServerSettings } from '$lib/config/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import type { SettingsConfigType } from '$lib/types/settings';
import { DEFAULT_MCP_CONFIG } from '$lib/constants/mcp';
import McpServerCard from './McpServerCard.svelte';
import McpServerCard from '$lib/components/app/mcp/McpServerCard.svelte';
import McpServerForm from './McpServerForm.svelte';
interface Props {
@ -121,9 +121,9 @@
<!-- Add New Server Form -->
{#if isAddingServer}
<Card.Root class="bg-muted/30 p-4">
<div class="mb-3 flex items-center justify-between">
<p class="font-medium">Add New Server</p>
<div class="flex items-center gap-1">
<div class="space-y-4">
<div class="flex items-center justify-between">
<p class="font-medium">Add New Server</p>
<Button
variant="ghost"
size="icon"
@ -133,26 +133,29 @@
>
<X class="h-3.5 w-3.5" />
</Button>
</div>
<McpServerForm
url={newServerUrl}
headers={newServerHeaders}
onUrlChange={(v) => (newServerUrl = v)}
onHeadersChange={(v) => (newServerHeaders = v)}
urlError={newServerUrl ? newServerUrlError : null}
id="new-server"
/>
<div class="flex items-center justify-end">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-green-600 hover:bg-green-100 hover:text-green-700 dark:text-green-500 dark:hover:bg-green-950"
variant="default"
size="sm"
onclick={saveNewServer}
disabled={!!newServerUrlError}
aria-label="Save"
>
<Check class="h-3.5 w-3.5" />
Add
</Button>
</div>
</div>
<McpServerForm
url={newServerUrl}
headers={newServerHeaders}
onUrlChange={(v) => (newServerUrl = v)}
onHeadersChange={(v) => (newServerHeaders = v)}
urlError={newServerUrl ? newServerUrlError : null}
id="new-server"
/>
</Card.Root>
{/if}