feat: UI improvements

This commit is contained in:
Aleksander Grygier 2026-01-05 11:53:53 +01:00
parent 653f85fedd
commit f3734b5b7c
18 changed files with 728 additions and 554 deletions

View File

@ -37,7 +37,7 @@
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--code-background: oklch(0.975 0 0);
--code-background: oklch(0.985 0 0);
--code-foreground: oklch(0.145 0 0);
--layer-popover: 1000000;
}
@ -114,14 +114,19 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--chat-form-area-height: 24rem;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
scrollbar-width: thin;
scrollbar-gutter: stable;
}
}

View File

@ -1,37 +1,23 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Square, Settings, ChevronDown, Search } from '@lucide/svelte';
import { Square } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { cn } from '$lib/components/ui/utils';
import {
ChatFormActionFileAttachments,
ChatFormActionRecord,
ChatFormActionSubmit,
DialogMcpServersSettings,
McpSelector,
ModelsSelector
} from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { FileTypeCategory } from '$lib/enums';
import { getFileTypeCategory } from '$lib/utils';
import { config } from '$lib/stores/settings.svelte';
import { modelsStore, modelOptions, selectedModelId } from '$lib/stores/models.svelte';
import { isRouterMode } from '$lib/stores/server.svelte';
import { chatStore } from '$lib/stores/chat.svelte';
import {
activeMessages,
usedModalities,
conversationsStore
} from '$lib/stores/conversations.svelte';
import { activeMessages, usedModalities } from '$lib/stores/conversations.svelte';
import { useModelChangeValidation } from '$lib/hooks/use-model-change-validation.svelte';
import { parseMcpServerSettings, parseMcpServerUsageStats } from '$lib/config/mcp';
import type { MCPServerSettingsEntry } from '$lib/types/mcp';
import {
mcpGetHealthCheckState,
mcpHasHealthCheck,
mcpRunHealthCheck
} from '$lib/stores/mcp.svelte';
import { parseMcpServerSettings } from '$lib/config/mcp';
interface Props {
canSend?: boolean;
@ -180,127 +166,10 @@
});
let showMcpDialog = $state(false);
let mcpSearchQuery = $state('');
// MCP servers state
let mcpServers = $derived<MCPServerSettingsEntry[]>(
parseMcpServerSettings(currentConfig.mcpServers)
);
// Usage stats for sorting by popularity
let mcpUsageStats = $derived(parseMcpServerUsageStats(currentConfig.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);
return healthState.status !== 'error';
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let mcpServers = $derived(parseMcpServerSettings(currentConfig.mcpServers));
let hasMcpServers = $derived(mcpServers.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 and limited servers for dropdown display
let displayedMcpServers = $derived(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (query) {
// When searching, show all matching results
return sortedMcpServers.filter((s) => {
const name = getServerDisplayName(s).toLowerCase();
const url = s.url.toLowerCase();
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)
// Global state is only changed from MCP Settings Dialog
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 {
const url = new URL(server.url);
const host = url.hostname.replace(/^(www\.|mcp\.)/, '');
const name = host.split('.')[0] || 'Unknown';
return name.charAt(0).toUpperCase() + name.slice(1);
} catch {
return 'New Server';
}
}
// Get favicon URLs for enabled servers (max 3)
function getFaviconUrl(server: MCPServerSettingsEntry): string | null {
try {
const url = new URL(server.url);
const hostnameParts = url.hostname.split('.');
const rootDomain =
hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : url.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s) }))
.filter((f) => f.url !== null)
);
// All servers with valid URLs (for health checks - check all regardless of enabled state)
let serversWithUrls = $derived(mcpServers.filter((s) => s.url.trim()));
// Run health checks on mount for ALL servers with URLs (to show metadata in dropdown)
onMount(() => {
for (const server of serversWithUrls) {
if (!mcpHasHealthCheck(server.id)) {
mcpRunHealthCheck(server);
}
}
});
</script>
<div class="flex w-full items-center gap-3 {className}" style="container-type: inline-size">
@ -315,126 +184,21 @@
/>
{#if hasMcpServers}
<DropdownMenu.Root>
<DropdownMenu.Trigger {disabled}>
<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'
)}
{disabled}
aria-label="MCP Servers"
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
<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)}
<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 hasEnabledMcpServers && extraServersCount > 0}
<span class="ml-1 text-muted-foreground">+{extraServersCount}</span>
{/if}
{/if}
<ChevronDown class="h-3 w-3.5" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-72">
<!-- Search Input -->
<div class="px-2 pb-2">
<div class="relative">
<Search
class="absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
/>
<input
type="text"
placeholder="Search servers..."
bind:value={mcpSearchQuery}
class="h-8 w-full rounded-md border border-input bg-background pr-3 pl-8 text-sm placeholder:text-muted-foreground focus:ring-1 focus:ring-ring focus:outline-none"
/>
</div>
</div>
<!-- Servers (sorted by popularity then alphabetically, max 4 when no search) -->
<div class="max-h-48 overflow-y-auto">
{#each displayedMcpServers() 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-1.5">
<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}
</div>
<Switch
checked={isEnabledForChat}
onCheckedChange={() => toggleServerForChat(server.id, server.enabled)}
disabled={hasError}
class={hasOverride ? 'ring-2 ring-primary/50 ring-offset-1' : ''}
/>
</div>
{/each}
{#if displayedMcpServers().length === 0}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No servers found
</div>
{/if}
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => (showMcpDialog = true)}
>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<McpSelector {disabled} onSettingsClick={() => (showMcpDialog = true)} />
{/if}
</div>
<ModelsSelector
{disabled}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText={true}
useGlobalSelection={true}
onModelChange={handleModelChange}
/>
<div class="ml-auto flex items-center gap-1.5">
<ModelsSelector
class="max-w-[4rem]"
{disabled}
bind:this={selectorModelRef}
currentModel={conversationModel}
forceForegroundText={true}
useGlobalSelection={true}
onModelChange={handleModelChange}
/>
</div>
{#if isLoading}
<Button

View File

@ -10,10 +10,7 @@
import { MarkdownContent, SyntaxHighlightedCode } from '$lib/components/app';
import { config } from '$lib/stores/settings.svelte';
import { Wrench, Loader2 } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import CollapsibleInfoCard from './CollapsibleInfoCard.svelte';
interface Props {
content: string;
@ -104,8 +101,9 @@
);
// Partial pending match (has START and NAME but ARGS still streaming)
// Capture everything after TOOL_ARGS_BASE64: until the end
const partialWithNameMatch = remainingContent.match(
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:([^>]*)$/
/<<<AGENTIC_TOOL_CALL_START>>>\n<<<TOOL_NAME:(.+?)>>>\n<<<TOOL_ARGS_BASE64:([\s\S]*)$/
);
// Very early match (just START marker, maybe partial NAME)
@ -150,11 +148,31 @@
}
}
// Try to decode partial base64 args
const partialArgsBase64 = partialWithNameMatch[2] || '';
let partialArgs = '';
if (partialArgsBase64) {
try {
// Try to decode - may fail if incomplete base64
partialArgs = decodeURIComponent(escape(atob(partialArgsBase64)));
} catch {
// If decoding fails, try padding the base64
try {
const padded =
partialArgsBase64 + '=='.slice(0, (4 - (partialArgsBase64.length % 4)) % 4);
partialArgs = decodeURIComponent(escape(atob(padded)));
} catch {
// Show raw base64 if all decoding fails
partialArgs = '';
}
}
}
sections.push({
type: 'tool_call_streaming',
content: '',
toolName: partialWithNameMatch[1],
toolArgs: undefined,
toolArgs: partialArgs || undefined,
toolResult: undefined
});
} else if (earlyMatch) {
@ -218,86 +236,79 @@
<MarkdownContent content={section.content} />
</div>
{:else if section.type === 'tool_call_streaming'}
<!-- Early streaming state - show minimal UI while markers are being received -->
<div class="my-4">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<div class="flex items-center gap-2 p-3 text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{#if section.toolName}
<span class="font-mono text-sm font-medium">{section.toolName}</span>
{/if}
<span class="text-xs italic">preparing...</span>
<!-- Streaming state - show CollapsibleInfoCard with live streaming args -->
<CollapsibleInfoCard
open={isExpanded(index, true)}
class="my-2"
icon={Loader2}
iconClass="h-4 w-4 animate-spin"
title={section.toolName || 'Tool call'}
subtitle="streaming..."
onToggle={() => toggleExpanded(index, true)}
>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Arguments:</span>
<Loader2 class="h-3 w-3 animate-spin" />
</div>
</Card>
</div>
{#if section.toolArgs}
<SyntaxHighlightedCode
code={formatToolArgs(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
{:else}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Receiving arguments...
</div>
{/if}
</div>
</CollapsibleInfoCard>
{:else if section.type === 'tool_call' || section.type === 'tool_call_pending'}
{@const isPending = section.type === 'tool_call_pending'}
<Collapsible.Root open={isExpanded(index, isPending)} class="my-2">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger
class="flex w-full cursor-pointer items-center justify-between p-3"
onclick={() => toggleExpanded(index, isPending)}
>
<div class="flex items-center gap-2 text-muted-foreground">
{#if isPending}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
<Wrench class="h-4 w-4" />
{/if}
<span class="font-mono text-sm font-medium">{section.toolName}</span>
{#if isPending}
<span class="text-xs italic">executing...</span>
{/if}
</div>
{@const toolIcon = isPending ? Loader2 : Wrench}
{@const toolIconClass = isPending ? 'h-4 w-4 animate-spin' : 'h-4 w-4'}
<CollapsibleInfoCard
open={isExpanded(index, isPending)}
class="my-2"
icon={toolIcon}
iconClass={toolIconClass}
title={section.toolName || ''}
subtitle={isPending ? 'executing...' : undefined}
onToggle={() => toggleExpanded(index, isPending)}
>
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatToolArgs(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />
<span class="sr-only">Toggle tool call content</span>
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Result:</span>
{#if isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted">
<!-- prettier-ignore -->
<pre class="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed"><code>{section.toolResult}</code></pre>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="border-t border-muted px-3 pb-3">
{#if section.toolArgs && section.toolArgs !== '{}'}
<div class="pt-3">
<div class="my-3 text-xs text-muted-foreground">Arguments:</div>
<SyntaxHighlightedCode
code={formatToolArgs(section.toolArgs)}
language="json"
maxHeight="20rem"
class="text-xs"
/>
</div>
{/if}
<div class="pt-3">
<div class="my-3 flex items-center gap-2 text-xs text-muted-foreground">
<span>Result:</span>
{#if isPending}
<Loader2 class="h-3 w-3 animate-spin" />
{/if}
</div>
{#if section.toolResult}
<div class="overflow-auto rounded-lg border border-border bg-muted">
<!-- prettier-ignore -->
<pre class="m-0 overflow-x-auto whitespace-pre-wrap p-4 font-mono text-xs leading-relaxed"><code>{section.toolResult}</code></pre>
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
{/if}
</div>
{:else if isPending}
<div class="rounded bg-muted/30 p-2 text-xs text-muted-foreground italic">
Waiting for result...
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>
{/if}
</div>
</CollapsibleInfoCard>
{/if}
{/each}
</div>

View File

@ -131,12 +131,12 @@
type="button"
>
<Card
class="rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
class="overflow-y-auto rounded-[1.125rem] !border-2 !border-dashed !border-border/50 bg-muted px-3.75 py-1.5 data-[multiline]:py-2.5"
data-multiline={isMultiline ? '' : undefined}
style="border: 2px dashed hsl(var(--border));"
style="border: 2px dashed hsl(var(--border)); max-height: calc(100dvh - var(--chat-form-area-height));"
>
<div
class="relative overflow-hidden transition-all duration-300 {isExpanded
class="relative transition-all duration-300 {isExpanded
? 'cursor-text select-text'
: 'select-none'}"
style={!isExpanded && showExpandButton
@ -145,7 +145,10 @@
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md {isExpanded ? 'cursor-text' : ''}">
<MarkdownContent class="markdown-system-content" content={message.content} />
<MarkdownContent
class="markdown-system-content overflow-auto"
content={message.content}
/>
</div>
{:else}
<span

View File

@ -1,10 +1,7 @@
<script lang="ts">
import { Brain } from '@lucide/svelte';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import { config } from '$lib/stores/settings.svelte';
import CollapsibleInfoCard from './CollapsibleInfoCard.svelte';
interface Props {
class?: string;
@ -31,38 +28,15 @@
});
</script>
<Collapsible.Root bind:open={isExpanded} class="mb-6 {className}">
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger class="flex cursor-pointer items-center justify-between p-3">
<div class="flex items-center gap-2 text-muted-foreground">
<Brain class="h-4 w-4" />
<span class="text-sm font-medium">
{isStreaming ? 'Reasoning...' : 'Reasoning'}
</span>
</div>
<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />
<span class="sr-only">Toggle reasoning content</span>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="border-t border-muted px-3 pb-3">
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{reasoningContent ?? ''}
</div>
</div>
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>
<CollapsibleInfoCard
bind:open={isExpanded}
class="mb-6 {className}"
icon={Brain}
title={isStreaming ? 'Reasoning...' : 'Reasoning'}
>
<div class="pt-3">
<div class="text-xs leading-relaxed break-words whitespace-pre-wrap">
{reasoningContent ?? ''}
</div>
</div>
</CollapsibleInfoCard>

View File

@ -123,8 +123,9 @@
{#if message.content.trim()}
<Card
class="max-w-[80%] rounded-[1.125rem] border-none bg-primary px-3.75 py-1.5 text-primary-foreground data-[multiline]:py-2.5"
class="max-w-[80%] overflow-y-auto rounded-[1.125rem] border-none bg-primary/5 px-3.75 py-1.5 text-foreground backdrop-blur-md data-[multiline]:py-2.5 dark:bg-primary/15"
data-multiline={isMultiline ? '' : undefined}
style="max-height: calc(100dvh - var(--chat-form-area-height));"
>
{#if currentConfig.renderUserContentAsMarkdown}
<div bind:this={messageElement} class="text-md">

View File

@ -0,0 +1,83 @@
<script lang="ts">
/**
* CollapsibleInfoCard - Reusable collapsible card component
*
* Used for displaying thinking content, tool calls, and other collapsible information
* with a consistent UI pattern.
*/
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { Card } from '$lib/components/ui/card';
import type { Snippet } from 'svelte';
import type { Component } from 'svelte';
interface Props {
/** Whether the card is expanded */
open?: boolean;
/** CSS class for the root element */
class?: string;
/** Icon component to display */
icon?: Component;
/** Custom icon class (for animations like spin) */
iconClass?: string;
/** Title text */
title: string;
/** Optional subtitle/status text */
subtitle?: string;
/** Optional click handler for the trigger */
onToggle?: () => void;
/** Content to display in the collapsible section */
children: Snippet;
}
let {
open = $bindable(false),
class: className = '',
icon: Icon,
iconClass = 'h-4 w-4',
title,
subtitle,
onToggle,
children
}: Props = $props();
</script>
<Collapsible.Root bind:open class={className}>
<Card class="gap-0 border-muted bg-muted/30 py-0">
<Collapsible.Trigger
class="flex w-full cursor-pointer items-center justify-between p-3"
onclick={onToggle}
>
<div class="flex items-center gap-2 text-muted-foreground">
{#if Icon}
<Icon class={iconClass} />
{/if}
<span class="font-mono text-sm font-medium">{title}</span>
{#if subtitle}
<span class="text-xs italic">{subtitle}</span>
{/if}
</div>
<div
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'h-6 w-6 p-0 text-muted-foreground hover:text-foreground'
})}
>
<ChevronsUpDownIcon class="h-4 w-4" />
<span class="sr-only">Toggle content</span>
</div>
</Collapsible.Trigger>
<Collapsible.Content>
<div
class="overflow-y-auto border-t border-muted px-3 pb-3"
style="max-height: calc(100dvh - var(--chat-form-area-height));"
>
{@render children()}
</div>
</Collapsible.Content>
</Card>
</Collapsible.Root>

View File

@ -14,7 +14,7 @@
</script>
<header
class="md:background-transparent pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end bg-background/40 p-4 backdrop-blur-xl duration-200 ease-linear {sidebar.open
class="pointer-events-none fixed top-0 right-0 left-0 z-50 flex items-center justify-end p-4 duration-200 ease-linear {sidebar.open
? 'md:left-[var(--sidebar-width)]'
: ''}"
>

View File

@ -63,7 +63,7 @@
<div class="chat-processing-info-container pointer-events-none" class:visible={showProcessingInfo}>
<div class="chat-processing-info-content">
{#each processingDetails as detail (detail)}
<span class="chat-processing-info-detail pointer-events-auto">{detail}</span>
<span class="chat-processing-info-detail pointer-events-auto backdrop-blur-sm">{detail}</span>
{/each}
</div>
</div>
@ -73,7 +73,7 @@
position: sticky;
top: 0;
z-index: 10;
padding: 1.5rem 1rem;
padding: 0 1rem 0.75rem;
opacity: 0;
transform: translateY(50%);
transition:
@ -100,7 +100,6 @@
color: var(--muted-foreground);
font-size: 0.75rem;
padding: 0.25rem 0.75rem;
background: var(--muted);
border-radius: 0.375rem;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;

View File

@ -23,6 +23,7 @@ export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessag
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
export { default as ChatMessageThinkingBlock } from './chat/ChatMessages/ChatMessageThinkingBlock.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as CollapsibleInfoCard } from './chat/ChatMessages/CollapsibleInfoCard.svelte';
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
@ -68,9 +69,14 @@ export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.sve
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte';
export { default as SearchInput } from './misc/SearchInput.svelte';
export { default as SearchableDropdownMenu } from './misc/SearchableDropdownMenu.svelte';
export { default as SyntaxHighlightedCode } from './misc/SyntaxHighlightedCode.svelte';
export { default as ModelsSelector } from './models/ModelsSelector.svelte';
// MCP
export { default as McpSelector } from './mcp/McpSelector.svelte';
// Server
export { default as ServerStatus } from './server/ServerStatus.svelte';

View File

@ -0,0 +1,239 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ChevronDown, Settings } from '@lucide/svelte';
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 { config } 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';
import {
mcpGetHealthCheckState,
mcpHasHealthCheck,
mcpRunHealthCheck
} from '$lib/stores/mcp.svelte';
interface Props {
class?: string;
disabled?: boolean;
onSettingsClick?: () => void;
}
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)
);
// Usage stats for sorting by popularity
let mcpUsageStats = $derived(parseMcpServerUsageStats(currentConfig.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);
return healthState.status !== 'error';
})
);
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) {
return sortedMcpServers.filter((s) => {
const name = getServerDisplayName(s).toLowerCase();
const url = s.url.toLowerCase();
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 {
const url = new URL(server.url);
const host = url.hostname.replace(/^(www\.|mcp\.)/, '');
const name = host.split('.')[0] || 'Unknown';
return name.charAt(0).toUpperCase() + name.slice(1);
} catch {
return 'New Server';
}
}
// Get favicon URL for server
function getFaviconUrl(server: MCPServerSettingsEntry): string | null {
try {
const url = new URL(server.url);
const hostnameParts = url.hostname.split('.');
const rootDomain =
hostnameParts.length >= 2 ? hostnameParts.slice(-2).join('.') : url.hostname;
return `https://www.google.com/s2/favicons?domain=${rootDomain}&sz=32`;
} catch {
return null;
}
}
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s) }))
.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)) {
mcpRunHealthCheck(server);
}
}
});
</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;" />
<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)}
<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={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}
</div>
<Switch
checked={isEnabledForChat}
onCheckedChange={() => toggleServerForChat(server.id, server.enabled)}
disabled={hasError}
class={hasOverride ? 'ring-2 ring-primary/50 ring-offset-1' : ''}
/>
</div>
{/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>

View File

@ -677,25 +677,33 @@
/* Code blocks */
div :global(.code-block-wrapper) {
scrollbar-width: thin;
scrollbar-gutter: stable;
margin: 1.5rem 0;
border-radius: 0.75rem;
overflow: hidden;
border: 1px solid var(--border);
overflow: auto;
border: 1px solid color-mix(in oklch, var(--border) 30%, transparent);
background: var(--code-background);
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
max-height: calc(100dvh - var(--chat-form-area-height));
}
:global(.dark) div :global(.code-block-wrapper) {
border-color: color-mix(in oklch, var(--border) 20%, transparent);
}
div :global(.code-block-header) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: hsl(var(--muted) / 0.5);
border-bottom: 1px solid var(--border);
padding: 0.5rem 1rem 0;
font-size: 0.875rem;
position: sticky;
top: 0;
}
div :global(.code-language) {
color: var(--code-foreground);
color: var(--color-foreground);
font-weight: 500;
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
@ -735,7 +743,7 @@
div :global(.code-block-wrapper pre) {
background: transparent;
padding: 1rem;
padding: 0.5rem;
margin: 0;
overflow-x: auto;
border-radius: 0;

View File

@ -0,0 +1,95 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { cn } from '$lib/components/ui/utils';
import { SearchInput } from '$lib/components/app';
interface Props {
/** Controlled open state */
open?: boolean;
/** Callback when open state changes */
onOpenChange?: (open: boolean) => void;
/** Search input placeholder */
placeholder?: string;
/** Search input value (bindable) */
searchValue?: string;
/** Callback when search value changes */
onSearchChange?: (value: string) => void;
/** Callback for search input keydown events */
onSearchKeyDown?: (event: KeyboardEvent) => void;
/** Content alignment */
align?: 'start' | 'center' | 'end';
/** Content width class */
contentClass?: string;
/** Max height for the list area */
listMaxHeight?: string;
/** Empty state message */
emptyMessage?: string;
/** Whether to show empty state */
isEmpty?: boolean;
/** Whether the trigger is disabled */
disabled?: boolean;
/** Trigger content */
trigger: Snippet;
/** List items content */
children: Snippet;
/** Optional footer content */
footer?: Snippet;
}
let {
open = $bindable(false),
onOpenChange,
placeholder = 'Search...',
searchValue = $bindable(''),
onSearchChange,
onSearchKeyDown,
align = 'start',
contentClass = 'w-72',
listMaxHeight = 'max-h-48',
emptyMessage = 'No items found',
isEmpty = false,
disabled = false,
trigger,
children,
footer
}: Props = $props();
function handleOpenChange(newOpen: boolean) {
open = newOpen;
if (!newOpen) {
searchValue = '';
onSearchChange?.('');
}
onOpenChange?.(newOpen);
}
</script>
<DropdownMenu.Root bind:open onOpenChange={handleOpenChange}>
<DropdownMenu.Trigger {disabled}>
{@render trigger()}
</DropdownMenu.Trigger>
<DropdownMenu.Content {align} class={cn(contentClass)}>
<div class="mb-2 p-1">
<SearchInput
{placeholder}
bind:value={searchValue}
onInput={onSearchChange}
onKeyDown={onSearchKeyDown}
/>
</div>
<div class={cn('overflow-y-auto', listMaxHeight)}>
{@render children()}
{#if isEmpty}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">{emptyMessage}</div>
{/if}
</div>
{#if footer}
<DropdownMenu.Separator />
{@render footer()}
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@ -75,7 +75,7 @@
style="max-height: {maxHeight}; max-width: {maxWidth};"
>
<!-- Needs to be formatted as single line for proper rendering -->
<pre class="m-0 overflow-x-auto p-4"><code class="hljs text-sm leading-relaxed"
<pre class="m-0 overflow-x-auto"><code class="hljs text-sm leading-relaxed"
>{@html highlightedHtml}</code
></pre>
</div>

View File

@ -1,8 +1,7 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import { onMount } from 'svelte';
import { ChevronDown, EyeOff, Loader2, MicOff, Package, Power } from '@lucide/svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/components/ui/utils';
import {
modelsStore,
@ -17,7 +16,7 @@
import { usedModalities, conversationsStore } from '$lib/stores/conversations.svelte';
import { ServerModelStatus } from '$lib/enums';
import { isRouterMode } from '$lib/stores/server.svelte';
import { DialogModelInformation, SearchInput } from '$lib/components/app';
import { DialogModelInformation, SearchableDropdownMenu } from '$lib/components/app';
import type { ModelOption } from '$lib/types/models';
interface Props {
@ -142,7 +141,6 @@
});
let searchTerm = $state('');
let searchInputRef = $state<HTMLInputElement | null>(null);
let highlightedIndex = $state<number>(-1);
let filteredOptions: ModelOption[] = $derived(
@ -179,7 +177,7 @@
});
});
// Handle changes to the model selector pop-down or the model dialog, depending on if the server is in
// Handle changes to the model selector dropdown or the model dialog, depending on if the server is in
// router mode or not.
function handleOpenChange(open: boolean) {
if (loading || updating) return;
@ -190,11 +188,6 @@
searchTerm = '';
highlightedIndex = -1;
// Focus search input after popover opens
tick().then(() => {
requestAnimationFrame(() => searchInputRef?.focus());
});
modelsStore.fetchRouterModels().then(() => {
modelsStore.fetchModalitiesForLoadedModels();
});
@ -347,178 +340,170 @@
{@const selectedOption = getDisplayOption()}
{#if isRouter}
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
<Popover.Trigger
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`,
!isCurrentModelInCache()
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: isHighlightedCurrentModelActive
<SearchableDropdownMenu
bind:open={isOpen}
onOpenChange={handleOpenChange}
bind:searchValue={searchTerm}
placeholder="Search models..."
onSearchKeyDown={handleSearchKeyDown}
align="end"
contentClass="w-96 max-w-[calc(100vw-2rem)]"
listMaxHeight="max-h-[50dvh]"
emptyMessage="No models found."
isEmpty={filteredOptions.length === 0 && isCurrentModelInCache()}
disabled={disabled || updating}
>
{#snippet trigger()}
<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`,
!isCurrentModelInCache()
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
? 'text-foreground'
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 6.5rem), 32rem)"
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />
: isHighlightedCurrentModelActive
? 'text-foreground'
: 'text-muted-foreground',
isOpen ? 'text-foreground' : ''
)}
style="max-width: min(calc(100cqw - 6.5rem), 24rem)"
disabled={disabled || updating}
>
<Package class="h-3.5 w-3.5" />
<span class="truncate font-medium">
{selectedOption?.model || 'Select model'}
</span>
<span class="truncate font-medium">
{selectedOption?.model || 'Select model'}
</span>
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
{#if updating}
<Loader2 class="h-3 w-3.5 animate-spin" />
{:else}
<ChevronDown class="h-3 w-3.5" />
{/if}
</button>
{/snippet}
<div class="models-list">
{#if !isCurrentModelInCache() && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
disabled
>
<span class="truncate">{selectedOption?.name || currentModel}</span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
</Popover.Trigger>
{#if filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#each filteredOptions as option, index (option.id)}
{@const status = getModelStatus(option.model)}
{@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isCompatible = isModelCompatible(option)}
{@const isHighlighted = index === highlightedIndex}
{@const missingModalities = getMissingModalities(option)}
<Popover.Content
class="group/popover-content w-96 max-w-[calc(100vw-2rem)] p-0"
align="end"
sideOffset={8}
collisionPadding={16}
>
<div class="flex max-h-[50dvh] flex-col overflow-hidden">
<div
class="order-1 shrink-0 border-b p-4 group-data-[side=top]/popover-content:order-2 group-data-[side=top]/popover-content:border-t group-data-[side=top]/popover-content:border-b-0"
class={cn(
'group flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
isCompatible
? 'cursor-pointer hover:bg-muted focus:bg-muted'
: 'cursor-not-allowed opacity-50',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: isCompatible
? 'hover:bg-accent hover:text-accent-foreground'
: '',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={isSelected || isHighlighted}
aria-disabled={!isCompatible}
tabindex={isCompatible ? 0 : -1}
onclick={() => isCompatible && handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleSelect(option.id);
}
}}
>
<SearchInput
id="model-search"
placeholder="Search models..."
bind:value={searchTerm}
bind:ref={searchInputRef}
onClose={() => handleOpenChange(false)}
onKeyDown={handleSearchKeyDown}
/>
</div>
<div
class="models-list order-2 min-h-0 flex-1 overflow-y-auto group-data-[side=top]/popover-content:order-1"
>
{#if !isCurrentModelInCache() && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
type="button"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 px-4 py-2 text-left text-sm text-red-400"
role="option"
aria-selected="true"
aria-disabled="true"
disabled
>
<span class="truncate">{selectedOption?.name || currentModel}</span>
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
<div class="my-1 h-px bg-border"></div>
{/if}
{#if filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#each filteredOptions as option, index (option.id)}
{@const status = getModelStatus(option.model)}
{@const isLoaded = status === ServerModelStatus.LOADED}
{@const isLoading = status === ServerModelStatus.LOADING}
{@const isSelected = currentModel === option.model || activeId === option.id}
{@const isCompatible = isModelCompatible(option)}
{@const isHighlighted = index === highlightedIndex}
{@const missingModalities = getMissingModalities(option)}
<span class="min-w-0 flex-1 truncate">{option.model}</span>
<div
class={cn(
'group flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition focus:outline-none',
isCompatible
? 'cursor-pointer hover:bg-muted focus:bg-muted'
: 'cursor-not-allowed opacity-50',
isSelected || isHighlighted
? 'bg-accent text-accent-foreground'
: isCompatible
? 'hover:bg-accent hover:text-accent-foreground'
: '',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
)}
role="option"
aria-selected={isSelected || isHighlighted}
aria-disabled={!isCompatible}
tabindex={isCompatible ? 0 : -1}
onclick={() => isCompatible && handleSelect(option.id)}
onmouseenter={() => (highlightedIndex = index)}
onkeydown={(e) => {
if (isCompatible && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
handleSelect(option.id);
}
}}
>
<span class="min-w-0 flex-1 truncate">{option.model}</span>
{#if missingModalities}
<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
{#if missingModalities.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<EyeOff class="h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>No vision support</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if missingModalities.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<MicOff class="h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>No audio support</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
{#if isLoading}
{#if missingModalities}
<span class="flex shrink-0 items-center gap-1 text-muted-foreground/70">
{#if missingModalities.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
<EyeOff class="h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Loading model...</p>
<p>No vision support</p>
</Tooltip.Content>
</Tooltip.Root>
{:else if isLoaded}
{/if}
{#if missingModalities.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
onclick={(e) => {
e.stopPropagation();
modelsStore.unloadModel(option.model);
}}
>
<span
class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
></span>
<Power
class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
/>
</button>
<MicOff class="h-3.5 w-3.5" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Unload model</p>
<p>No audio support</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
{/if}
</div>
{/each}
</span>
{/if}
{#if isLoading}
<Tooltip.Root>
<Tooltip.Trigger>
<Loader2 class="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Loading model...</p>
</Tooltip.Content>
</Tooltip.Root>
{:else if isLoaded}
<Tooltip.Root>
<Tooltip.Trigger>
<button
type="button"
class="relative ml-2 flex h-4 w-4 shrink-0 items-center justify-center"
onclick={(e) => {
e.stopPropagation();
modelsStore.unloadModel(option.model);
}}
>
<span
class="mr-2 h-2 w-2 rounded-full bg-green-500 transition-opacity group-hover:opacity-0"
></span>
<Power
class="absolute mr-2 h-4 w-4 text-red-500 opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-600"
/>
</button>
</Tooltip.Trigger>
<Tooltip.Content class="z-[9999]">
<p>Unload model</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<span class="mx-2 h-2 w-2 rounded-full bg-muted-foreground/50"></span>
{/if}
</div>
</div>
</Popover.Content>
</Popover.Root>
{/each}
</div>
</SearchableDropdownMenu>
{:else}
<button
class={cn(

View File

@ -42,7 +42,7 @@
bind:this={ref}
data-slot="badge"
{href}
class={cn(badgeVariants({ variant }), className)}
class={cn(badgeVariants({ variant }), className, 'backdrop-blur-sm')}
{...restProps}
>
{@render children?.()}

View File

@ -19,7 +19,7 @@
data-slot="dropdown-menu-content"
{sideOffset}
class={cn(
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
'z-50 max-h-(--bits-dropdown-menu-content-available-height) min-w-[8rem] origin-(--bits-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border border-border bg-popover p-1.5 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 dark:border-border/20',
className
)}
{...restProps}

View File

@ -2,8 +2,9 @@ export const BOX_BORDER =
'border border-border/30 focus-within:border-border dark:border-border/20 dark:focus-within:border-border';
export const INPUT_CLASSES = `
bg-muted/70 dark:bg-muted/85
{BOX_BORDER}
bg-muted/50 dark:bg-muted/75
${BOX_BORDER}
shadow-sm
outline-none
text-foreground
`;