feat: UI improvements
This commit is contained in:
parent
653f85fedd
commit
f3734b5b7c
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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)]'
|
||||
: ''}"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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?.()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`;
|
||||
|
|
|
|||
Loading…
Reference in New Issue