Compare commits

...
Author SHA1 Message Date
Aleksander Grygier 4d2d1c2437 chore: Format 2026-08-25 15:50:54 +02:00
Aleksander Grygier 0b24f4a36c ui: move dialog close button to the sticky header
Assisted-by: pi
2026-08-25 15:37:25 +02:00
Aleksander Grygier 515b993632 ui: show modality icons instead of file submenu in chat add menu
Assisted-by: pi
2026-08-25 15:37:25 +02:00
Aleksander Grygier c390ec891b fix(ui): adjust MCP dialogs for new base sizing and close add sheet before opening settings 2026-08-25 15:37:25 +02:00
Aleksander Grygier 3d7ca1a82a refactor(ui): remove settings route and URL-based settings navigation 2026-08-25 15:36:24 +02:00
Aleksander Grygier 8573111704 feat(ui): open settings as dialog from sidebar 2026-08-25 15:36:18 +02:00
Aleksander Grygier 29776f6a48 refactor(ui): switch SettingsChat to in-app section navigation 2026-08-25 15:36:18 +02:00
Aleksander Grygier d72e57944a feat(ui): add DialogSettingsChat dialog 2026-08-25 15:36:14 +02:00
Aleksander Grygier dcbf990808 feat(ui): make base dialog responsive and support sticky headers 2026-08-25 15:36:14 +02:00
Aleksander Grygier 553f3bc944 ui: replace per-conversation MCP overrides with per-conversation tool policy
MCP server enabled state is now global (server.enabled); per-conversation
control moves to disabled tool keys and categories seeded into each new
conversation. Aligns the add sheet with the dropdown options and flattens
MCP tool groups in the tools submenu.

Assisted-by: pi
2026-08-25 15:36:05 +02:00
Aleksander Grygier 45b0c1756a chore: Format 2026-08-25 15:35:49 +02:00
Aleksander Grygier a8aef5d05e ui : remove unused MCP servers submenu component
The submenu was replaced by the MCP servers dialog, so delete the component
and its export.

Assisted-by: pi
2026-08-25 15:35:49 +02:00
Aleksander Grygier c2ef793ea3 ui : remove mcp-servers route and sidebar entry
MCP servers are now managed in a dialog, so drop the dedicated route and the
sidebar icon that navigated to it.

Assisted-by: pi
2026-08-25 15:35:43 +02:00
Aleksander Grygier b9c3bb6ec5 ui : browse MCP resources from the server card
Make the Resources capability badge clickable so it opens the MCP resources
browser dialog, and drop the page-only chrome from SettingsMcpServers.

Assisted-by: pi
2026-08-25 15:35:43 +02:00
Aleksander Grygier 20ec1c9539 ui : open MCP servers in a dialog from the chat form
Replace the MCP servers submenu with a single "MCP Servers" item that opens
a new DialogMcpServers dialog instead of navigating to the /mcp-servers route.

Assisted-by: pi
2026-08-25 15:35:00 +02:00
Aleksander Grygier c68470019d ui : fix eslint issues in chat form and model selector 2026-08-25 15:34:56 +02:00
Aleksander Grygier dfd6ed8564 feat: Enable microphone input as default for audio models 2026-08-25 15:34:19 +02:00
Aleksander Grygier 7af64f3a29 ui : add raw model id tooltip to model selector options 2026-08-25 15:34:19 +02:00
Aleksander Grygier 8961962316 ui : make model option hover and focus highlight override the active state 2026-08-25 15:34:06 +02:00
Aleksander Grygier 2fab2f41b2 ui : move model list into a submenu within the model selector 2026-08-25 15:34:06 +02:00
Aleksander Grygier 4d6874bbda ui : add show-org-name-in-trigger display setting 2026-08-25 15:34:01 +02:00
Aleksander Grygier 3d0da6f4eb ui : keep reasoning submenu visible regardless of model state 2026-08-25 15:34:01 +02:00
Aleksander Grygier 2190337d6c ui : show reasoning and modality icons on model options and search by modality 2026-08-25 15:33:42 +02:00
Aleksander Grygier 7c8ece9a3a ui : strip trailing container-format segments from parsed model names 2026-08-25 15:33:37 +02:00
64 changed files with 1393 additions and 1480 deletions
@@ -1,5 +1,5 @@
<script lang="ts">
import { Eye, Mic, Video } from '@lucide/svelte';
import { Image, Mic, Video } from '@lucide/svelte';
import { ModelModality } from '$lib/enums';
interface Props {
@@ -19,7 +19,7 @@
]}
>
{#if modality === ModelModality.VISION}
<Eye class="h-3 w-3" />
<Image class="h-3 w-3" />
Vision (Image)
{:else if modality === ModelModality.VIDEO}
@@ -8,7 +8,8 @@
ChatFormInputFileInputInvisible,
ChatFormMcpResourcesList,
ChatFormPickers,
DialogMcpResourcesBrowser
DialogMcpResourcesBrowser,
DialogMcpServers
} from '$lib/components/app';
import {
CLIPBOARD_CONTENT_QUOTE_PREFIX,
@@ -151,9 +152,8 @@
getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () =>
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()),
hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
hasPrompts: () => mcpStore.hasPromptsCapability(),
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
@@ -183,6 +183,9 @@
let isResourceDialogOpen = $state(false);
let preSelectedResourceUri = $state<string | undefined>(undefined);
// MCP Servers Dialog State
let isMcpServersDialogOpen = $state(false);
let currentConfig = $derived(settingsStore.config);
let pasteLongTextToFileLength = $derived.by(() => {
@@ -618,6 +621,7 @@
onFileUpload={handleFileUpload}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ files: uploadedFiles, message: value })}
@@ -630,7 +634,7 @@
<ContextGaugePopup />
{#if toolsStore.hasEnabledCwdTools}
{#if conversationsStore.preferences.hasEnabledCwdTools()}
<ChatFormCurrentWorkingDirectory
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
@@ -656,3 +660,5 @@
}}
preSelectedUri={preSelectedResourceUri}
/>
<DialogMcpServers bind:open={isMcpServersDialogOpen} />
@@ -1,10 +1,6 @@
<script lang="ts">
import { File, FolderOpen, MessageSquare, Plus, Zap } from '@lucide/svelte';
import {
ChatFormActionAddMcpServersSubmenu,
ChatFormActionAddReasoningSubmenu,
ChatFormActionAddToolsSubmenu
} from '$lib/components/app';
import { File, Image, MessageSquare, Mic, Plus, Video } from '@lucide/svelte';
import { ChatFormActionAddToolsSubmenu, McpLogo } from '$lib/components/app';
import { buttonVariants } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -12,10 +8,10 @@
import {
ATTACHMENT_FILE_ITEMS,
ATTACHMENT_TOOLTIP_TEXT,
ICON_CLASS_DEFAULT,
TOOLTIP_DELAY_DURATION
ICON_CLASS_DEFAULT
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { AttachmentAction, AttachmentItemEnabledWhen } from '$lib/enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
interface Props {
@@ -31,11 +27,6 @@
// must not restore focus to the trigger on close
let suppressCloseAutoFocus = false;
function handleMcpSettingsClick() {
dropdownOpen = false;
chatFormActions.onMcpSettingsClick?.();
}
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
@@ -54,6 +45,18 @@
dropdownOpen = false;
}
);
const FILE_MODALITY_ICONS: Record<string, { icon: typeof Image; label: string }> = {
[AttachmentItemEnabledWhen.HAS_AUDIO_MODALITY]: { icon: Mic, label: 'Audio' },
[AttachmentItemEnabledWhen.HAS_VIDEO_MODALITY]: { icon: Video, label: 'Video' },
[AttachmentItemEnabledWhen.HAS_VISION_MODALITY]: { icon: Image, label: 'Vision' }
};
const supportedModalities = $derived.by(() =>
ATTACHMENT_FILE_ITEMS.filter((item) => attachmentMenu.isItemEnabled(item.enabledWhen))
.map((item) => FILE_MODALITY_ICONS[item.enabledWhen ?? ''])
.filter((modality) => modality !== undefined)
);
</script>
<div class="flex items-center gap-1 {className}">
@@ -93,54 +96,32 @@
}
}}
>
<ChatFormActionAddReasoningSubmenu />
<DropdownMenu.Separator />
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<File class={ICON_CLASS_DEFAULT} />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[AttachmentAction.FILE_UPLOAD]()}
>
<File class={ICON_CLASS_DEFAULT} />
<span class="flex min-w-0 items-center gap-2">
<span>Add files</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48">
{#each ATTACHMENT_FILE_ITEMS as item (item.id)}
{@const enabled = attachmentMenu.isItemEnabled(item.enabledWhen)}
{#if enabled}
<DropdownMenu.Item
class="{item.class ?? ''} flex cursor-pointer items-center gap-2"
onclick={() => attachmentMenu.callbacks[item.action]()}
>
<item.icon class={ICON_CLASS_DEFAULT} />
{#if supportedModalities.length > 0}
<span class="flex items-center gap-0.75 text-muted-foreground">
{#each supportedModalities as modality (modality.label)}
<Tooltip.Root>
<Tooltip.Trigger>
<modality.icon class="size-2.75" />
</Tooltip.Trigger>
<span>{item.label}</span>
</DropdownMenu.Item>
{:else if item.disabledTooltip}
<Tooltip.Root delayDuration={TOOLTIP_DELAY_DURATION}>
<Tooltip.Trigger tabindex={-1}>
{#snippet child({ props })}
<div {...props} class="cursor-default">
<DropdownMenu.Item
class="{item.class ?? ''} flex items-center gap-2"
disabled
>
<item.icon class={ICON_CLASS_DEFAULT} />
<span>{item.label}</span>
</DropdownMenu.Item>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>{item.disabledTooltip}</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<Tooltip.Content>
<p>{modality.label}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</span>
{/if}
</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
@@ -156,31 +137,14 @@
<ChatFormActionAddToolsSubmenu />
<ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpSettingsClick}
>
<McpLogo class={ICON_CLASS_DEFAULT} />
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
>
<Zap class={ICON_CLASS_DEFAULT} />
<span>MCP Prompt</span>
</DropdownMenu.Item>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpResourcesClick}
>
<FolderOpen class={ICON_CLASS_DEFAULT} />
<span>MCP Resources</span>
</DropdownMenu.Item>
{/if}
<span>MCP Servers</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
@@ -1,152 +0,0 @@
<script lang="ts">
import { Plus, Settings } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { DropdownMenuSearchable, McpLogo, McpServerIdentity } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import type { MCPServerSettingsEntry } from '$lib/types';
interface Props {
onMcpSettingsClick?: () => void;
}
let { onMcpSettingsClick }: Props = $props();
let mcpSearchQuery = $state('');
// Every configured server is listed; `enabled` is an on/off state,
// not a visibility filter, so a disabled server stays toggleable.
let mcpServers = $derived(mcpStore.getServers());
let hasMcpServers = $derived(mcpServers.length > 0);
let filteredMcpServers = $derived.by(() => {
const query = mcpSearchQuery.toLowerCase().trim();
if (!query) return mcpServers;
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
});
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.preferences.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.preferences.toggleMcpServerForChat(serverId);
}
function handleMcpSubMenuOpen(open: boolean) {
if (open) {
mcpSearchQuery = '';
mcpStore.runHealthChecksForServers(mcpServers);
}
}
function handleMcpSettingsClick() {
onMcpSettingsClick?.();
goto(`${hasMcpServers ? '' : '?add'}${ROUTES.MCP_SERVERS}`);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Sub onOpenChange={handleMcpSubMenuOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class={ICON_CLASS_DEFAULT} />
<span>MCP Servers</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-72 pt-0">
{#if hasMcpServers}
<DropdownMenuSearchable
bind:searchValue={mcpSearchQuery}
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
placeholder="Search servers..."
>
<div class="max-h-64 overflow-y-auto">
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
{@const displayName = getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
<button
class="flex w-full items-center justify-between gap-2 rounded-sm px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
disabled={hasError}
onclick={() => !hasError && toggleServerForChat(server.id)}
type="button"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
<div class="min-w-0 flex-1">
<McpServerIdentity
{displayName}
{faviconUrl}
iconClass={ICON_CLASS_DEFAULT}
iconRounded="rounded-sm"
nameClass="text-sm"
showVersion={false}
/>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onCheckedChange={() => toggleServerForChat(server.id)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/each}
</div>
{#snippet footer()}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Settings class={ICON_CLASS_DEFAULT} />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</DropdownMenuSearchable>
{:else}
<div class="px-2 py-3 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={handleMcpSettingsClick}
>
<Plus class={ICON_CLASS_DEFAULT} />
<span>Add MCP Servers</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Root>
@@ -0,0 +1,51 @@
<script lang="ts">
import { FolderOpen, Server, Zap } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
const chatFormActions = getChatFormActionsContext();
function handleServersClick() {
chatFormActions.onMcpSettingsClick?.();
}
</script>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<McpLogo class={ICON_CLASS_DEFAULT} />
<span>MCP</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="w-48">
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={handleServersClick}>
<Server class={ICON_CLASS_DEFAULT} />
<span>Servers</span>
</DropdownMenu.Item>
{#if chatFormActions.hasMcpPromptsSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpPromptClick}
>
<Zap class={ICON_CLASS_DEFAULT} />
<span>Prompts</span>
</DropdownMenu.Item>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<DropdownMenu.Item
class="flex cursor-pointer items-center gap-2"
onclick={chatFormActions.onMcpResourcesClick}
>
<FolderOpen class={ICON_CLASS_DEFAULT} />
<span>Resources</span>
</DropdownMenu.Item>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -8,70 +8,68 @@
const reasoning = useReasoningMenu();
</script>
{#if reasoning.modelSupportsThinking}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.thinkingEnabled}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
{#if reasoning.isReasoningActive}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span
class="text-sm inline-flex gap-2 {!reasoning.thinkingEnabled
? 'text-muted-foreground'
: ''}"
>
Reasoning
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
<span
class="text-sm inline-flex gap-2 {!reasoning.isReasoningActive
? 'text-muted-foreground'
: ''}"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
Reasoning
<span class="flex-1">{level.label}</span>
<span class="capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</span>
</DropdownMenu.SubTrigger>
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
<DropdownMenu.SubContent
class="w-60 bg-popover p-1.5 text-popover-foreground shadow-md outline-none"
>
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<DropdownMenu.Item
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-2 py-1.75 text-left text-sm transition-colors hover:bg-accent {reasoning.isSelected(
level
)
? 'bg-accent'
: ''}"
onclick={() => reasoning.select(level)}
>
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<span class="flex-1">{level.label}</span>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
{#if tokenLabel}
<span class="text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
{#if level.hasInfo}
<Tooltip.Root>
<Tooltip.Trigger>
<Info class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="left">
<p>Maximum reasoning effort with extended context usage</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
@@ -1,18 +1,10 @@
<script lang="ts">
import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import {
Check,
ChevronDown,
ChevronRight,
Lightbulb,
LightbulbOff,
PencilRuler
} from '@lucide/svelte';
import { File, MessageSquare } from '@lucide/svelte';
import { ChevronDown, ChevronRight, PencilRuler } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible';
import * as Sheet from '$lib/components/ui/sheet';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip';
import {
ATTACHMENT_FILE_ITEMS,
@@ -20,12 +12,10 @@
TOOLTIP_DELAY_DURATION
} from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { conversationsStore, mcpStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
import type { Snippet } from 'svelte';
interface Props {
@@ -38,10 +28,8 @@
const chatFormActions = getChatFormActionsContext();
let sheetOpen = $state(false);
let reasoningExpanded = $state(false);
let filesExpanded = $state(true);
let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
@@ -63,15 +51,12 @@
);
const toolsPanel = useToolsPanel();
const reasoning = useReasoningMenu();
const sheetItemClass =
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left text-sm transition-colors hover:bg-accent active:bg-accent disabled:cursor-not-allowed disabled:opacity-50';
const sheetItemRowClass =
'flex w-full items-center justify-between gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors hover:bg-accent';
let mcpServers = $derived(mcpStore.getServers());
</script>
<div class="flex items-center gap-1 {className}">
@@ -88,65 +73,6 @@
</Sheet.Header>
<div class="flex flex-col gap-1 px-1.5 pb-2">
{#if reasoning.modelSupportsThinking}
<Collapsible.Root
onOpenChange={(open) => (reasoningExpanded = open)}
open={reasoningExpanded}
>
<Collapsible.Trigger class={sheetItemClass}>
{#if reasoningExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
{:else}
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
{#if reasoning.thinkingEnabled}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-amber-400" />
{:else if reasoning.isOff}
<LightbulbOff class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{:else}
<Lightbulb class="{ICON_CLASS_DEFAULT} shrink-0 text-muted-foreground" />
{/if}
<span class="flex-1">Reasoning</span>
<span class="text-xs capitalize text-muted-foreground">
{reasoning.currentEffort}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each reasoning.levels as level (level.value)}
{@const tokenLabel = reasoning.tokenLabel(level)}
<button
class:bg-accent={reasoning.isSelected(level)}
class={sheetItemRowClass}
onclick={() => reasoning.select(level)}
type="button"
>
<div class="flex min-w-0 items-center gap-3">
{#if reasoning.isSelected(level)}
<Check class="{ICON_CLASS_DEFAULT} shrink-0 text-foreground" />
{:else}
<div class="{ICON_CLASS_DEFAULT} shrink-0"></div>
{/if}
<span class="text-sm">{level.label}</span>
</div>
{#if tokenLabel}
<span class="shrink-0 text-[11px] text-muted-foreground opacity-60">
{tokenLabel}
</span>
{/if}
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/if}
<Collapsible.Root onOpenChange={(open) => (filesExpanded = open)} open={filesExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if filesExpanded}
@@ -194,80 +120,15 @@
</Collapsible.Content>
</Collapsible.Root>
<Collapsible.Root onOpenChange={(open) => (mcpExpanded = open)} open={mcpExpanded}>
<Collapsible.Trigger class={sheetItemClass}>
{#if mcpExpanded}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" />
{:else}
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
<span class="flex-1">MCP Servers</span>
<span class="text-xs text-muted-foreground">
{mcpServers.length} server{mcpServers.length !== 1 ? 's' : ''}
</span>
</Collapsible.Trigger>
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each mcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.ERROR}
{@const displayName = mcpStore.getServerLabel(server)}
{@const faviconUrl = mcpStore.getServerFavicon(server.id)}
{@const isEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
)}
<button
class={sheetItemRowClass}
disabled={hasError}
onclick={() =>
!hasError && conversationsStore.preferences.toggleMcpServerForChat(server.id)}
type="button"
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if faviconUrl}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={faviconUrl}
/>
{/if}
<span class="min-w-0 truncate text-sm">{displayName}</span>
</div>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>
Error
</span>
{:else}
<Switch
checked={isEnabled}
onCheckedChange={() =>
conversationsStore.preferences.toggleMcpServerForChat(server.id)}
/>
{/if}
</button>
{/each}
{#if mcpServers.length === 0}
<div class="px-3 py-2 text-center text-sm text-muted-foreground">
No MCP servers configured
</div>
{/if}
</div>
</Collapsible.Content>
</Collapsible.Root>
<span>System Message</span>
</button>
{#if toolsPanel.totalToolCount > 0}
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
@@ -289,40 +150,12 @@
<Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.activeGroups as group (group.key)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{#each toolsPanel.categoryGroups as group (group.key)}
{@render sheetGroupRow(group)}
{/each}
<button
class={sheetItemRowClass}
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{enabledCount}/{group.tools.length}
</span>
<Checkbox
{checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{#each toolsPanel.mcpGroups as group (group.key)}
{@render sheetGroupRow(group)}
{/each}
</div>
</Collapsible.Content>
@@ -331,38 +164,54 @@
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
onclick={() => {
sheetOpen = false;
chatFormActions.onMcpSettingsClick?.();
}}
type="button"
>
<MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" />
<span>System Message</span>
<span>MCP Servers</span>
</button>
{#if chatFormActions.hasMcpPromptsSupport}
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_PROMPT_CLICK]()}
type="button"
>
<Zap class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>MCP Prompt</span>
</button>
{/if}
{#if chatFormActions.hasMcpResourcesSupport}
<button
class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.MCP_RESOURCES_CLICK]()}
type="button"
>
<FolderOpen class="{ICON_CLASS_DEFAULT} shrink-0" />
<span>MCP Resources</span>
</button>
{/if}
</div>
</Sheet.Content>
</Sheet.Root>
</div>
{#snippet sheetGroupRow(group: ToolGroup)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<button
class="{sheetItemRowClass} {groupDisabled ? 'opacity-50' : ''}"
onclick={() => toolsPanel.toggleGroupByKey(group.key)}
type="button"
>
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="min-w-0 flex-1 truncate text-sm font-medium">{group.label}</span>
<span class="shrink-0 text-xs text-muted-foreground">
{enabledCount}/{group.tools.length}
</span>
<Checkbox
{checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
onclick={(e) => e.stopPropagation()}
/>
</button>
{/snippet}
@@ -7,6 +7,7 @@
import { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
@@ -62,95 +63,107 @@
{/if}
{:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.activeGroups as group (group.key)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{#each toolsPanel.categoryGroups as group (group.key)}
{@render groupRow(group)}
{/each}
<Collapsible.Root
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Checkbox
{...props}
{checked}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as entry (entry.key)}
{@const enabled = toolsStore.isToolEnabled(entry.key)}
<button
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50"
onclick={() => toolsStore.toggleTool(entry.key)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
>
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{entry.definition.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{#each toolsPanel.mcpGroups as group (group.key)}
{@render groupRow(group)}
{/each}
</div>
{/if}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{#snippet groupRow(group: ToolGroup)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)}
{@const checked = toolsPanel.isGroupChecked(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<Collapsible.Root
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)}
open={isExpanded}
>
<div class="flex items-center gap-1 {groupDisabled ? 'opacity-50' : ''}">
<Collapsible.Trigger
class="flex min-w-0 flex-1 items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
{#if isExpanded}
<ChevronDown class="h-3.5 w-3.5 shrink-0" />
{:else}
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
{#if favicon}
<img
alt=""
class="{ICON_CLASS_DEFAULT} shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
src={favicon}
/>
{/if}
<span class="truncate">{group.label}</span>
</span>
<span class="ml-auto shrink-0 text-xs text-muted-foreground">
{toolsPanel.getEnabledToolCount(group)}/{group.tools.length}
</span>
</Collapsible.Trigger>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<Checkbox
{...props}
{checked}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checked ? 'Disable' : 'Enable'}
{group.tools.length} tool{group.tools.length !== 1 ? 's' : ''}
</p>
</Tooltip.Content>
</Tooltip.Root>
</div>
<Collapsible.Content>
<div class="ml-4 flex flex-col gap-0.5 border-l border-border/50 pl-2">
{#each group.tools as entry (entry.key)}
{@const enabled = toolsPanel.isToolEnabled(entry)}
{@const parentDisabled = toolsPanel.isToolParentDisabled(entry)}
<button
class="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-muted/50 {parentDisabled
? 'opacity-50'
: ''}"
onclick={() => toolsPanel.toggleTool(entry)}
type="button"
>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
data-slot="checkbox"
data-state={enabled ? 'checked' : 'unchecked'}
>
{#if enabled}
<Check class="size-3.5" />
{/if}
</span>
<span class="min-w-0 flex-1 truncate font-mono text-[12px]">
{entry.definition.function.name}
</span>
</button>
{/each}
</div>
</Collapsible.Content>
</Collapsible.Root>
{/snippet}
@@ -1,6 +1,5 @@
<script lang="ts">
import { SkipForward, Square } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
ChatFormActionModels,
@@ -10,7 +9,7 @@
ChatFormContextGauge
} from '$lib/components/app';
import { Button } from '$lib/components/ui/button';
import { ICON_CLASS_DEFAULT, ROUTES } from '$lib/constants';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services';
@@ -34,6 +33,7 @@
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
let {
@@ -47,6 +47,7 @@
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onMicClick,
onStop,
onSystemPromptClick,
@@ -57,17 +58,9 @@
let currentConfig = $derived(settingsStore.config);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
let hasMcpPromptsSupport = $derived.by(() => mcpStore.hasPromptsCapability());
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => mcpStore.hasResourcesCapability());
let hasAudioModality = $state(false);
let hasVideoModality = $state(false);
@@ -163,7 +156,7 @@
return onMcpResourcesClick;
},
get onMcpSettingsClick() {
return () => goto(ROUTES.MCP_SERVERS);
return onMcpSettingsClick;
},
get onSystemPromptClick() {
return onSystemPromptClick;
@@ -9,7 +9,7 @@
} from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity';
@@ -87,8 +87,7 @@
isLoading = true;
try {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized();
if (!initialized) {
prompts = [];
+5 -13
View File
@@ -221,25 +221,17 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for managing MCP servers in the chat form.
* Dropdown submenu for MCP prompts and resources in the chat form.
*
* Displays a searchable list of enabled MCP servers with toggle switches
* to enable/disable each server for chat. Shows server favicon, health status,
* and a "Manage MCP Servers" settings link.
*
* Features:
* - Search/filter servers by name or URL
* - Per-server toggle to enable/disable for chat
* - Health check indicator (shows "Error" badge for failed servers)
* - Server favicon display
* - Settings link to manage MCP server configuration
* Shows an "MCP" sub-menu item with entries for MCP Prompts and MCP
* Resources. Only visible when the server supports them.
*
* @example
* ```svelte
* <ChatFormActionAddMcpServersSubmenu onMcpSettingsClick={handleMcpSettingsClick} />
* <ChatFormActionAddMcpSubmenu />
* ```
*/
export { default as ChatFormActionAddMcpServersSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpServersSubmenu.svelte';
export { default as ChatFormActionAddMcpSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddMcpSubmenu.svelte';
/**
* Dropdown submenu for selecting reasoning effort level.
@@ -8,7 +8,7 @@
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity';
@@ -48,8 +48,7 @@
});
async function loadResources() {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized();
if (initialized) {
await mcpStore.fetchAllResources();
@@ -253,7 +252,7 @@
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content class="max-h-[80vh] !max-w-4xl overflow-hidden p-0">
<Dialog.Content class="max-h-[80vh] md:max-w-4xl! w-full! overflow-hidden p-0">
<Dialog.Header class="border-b border-border/30 px-6 py-4">
<Dialog.Title class="flex items-center gap-2">
<FolderOpen class="h-5 w-5" />
@@ -10,7 +10,7 @@
RECOMMENDED_MCP_SERVERS
} from '$lib/constants';
import { BooleanString, HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props {
@@ -234,8 +234,6 @@
useProxy: newServerUseProxy
});
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
@@ -246,7 +244,7 @@
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content class="sm:max-w-2xl">
<Dialog.Content class="max-w-2xl!">
<Dialog.Header>
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
</Dialog.Header>
@@ -0,0 +1,33 @@
<script lang="ts">
import { McpLogo } from '$lib/components/app';
import { SettingsMcpServers } from '$lib/components/app/settings';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-360! flex flex-col"
>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<McpLogo class="h-5 w-5" />
<span>MCP Servers</span>
</Dialog.Title>
</Dialog.Header>
<SettingsMcpServers class="mt-4" />
</Dialog.Content>
</Dialog.Root>
@@ -14,6 +14,7 @@
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="z-999999 grid max-h-full max-w-full! grid-rows-[1fr_auto] overflow-hidden p-0 md:h-[90vh] md:max-w-[90vw]!"
showCloseButton
>
<MermaidPreview {svgHtml} />
</Dialog.Content>
@@ -0,0 +1,34 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { SettingsChat } from '$lib/components/app/settings';
import * as Dialog from '$lib/components/ui/dialog';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
initialSection?: string;
}
let { initialSection, onOpenChange, open = $bindable(false) }: Props = $props();
function handleOpenChange(value: boolean) {
open = value;
onOpenChange?.(value);
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content
class="md:h-[calc(100vh-4rem)]! md:max-h-240! md:w-[calc(100vw-4rem)]! md:max-w-6xl! flex flex-col p-0 md:p-6 gap-0"
>
<Dialog.Header class="md:p-0 p-4">
<Dialog.Title class="flex items-center gap-2">
<Settings class="h-5 w-5" />
<span>Settings</span>
</Dialog.Title>
</Dialog.Header>
<SettingsChat {initialSection} onClose={() => (open = false)} onSectionChange={() => {}} />
</Dialog.Content>
</Dialog.Root>
@@ -18,6 +18,23 @@
*/
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
/**
* **DialogMcpServers** - MCP servers dialog shown from the chat form
*
* Shows the same MCP server list as the `/mcp-servers` route inside a modal
* dialog.
*/
export { default as DialogMcpServers } from './DialogMcpServers.svelte';
/**
* **DialogSettingsChat** - Chat settings shown in a modal dialog
*
* Wraps the full SettingsChat layout (sidebar, mobile header, fields, footer)
* inside a ShadCN Dialog instead of a dedicated route. Section switching is
* handled in-app via `onSectionChange` rather than URL navigation.
*/
export { default as DialogSettingsChat } from './DialogSettingsChat.svelte';
/**
* **DialogExportSettings** - Settings export dialog with sensitive data warning
*
@@ -3,7 +3,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT, MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import { mcpStore } from '$lib/stores';
interface Props {
class?: string;
@@ -13,11 +13,7 @@
let { class: className = '', onclick }: Props = $props();
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
let enabledMcpServersForChat = $derived(
mcpServers.filter(
(s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
)
);
let enabledMcpServersForChat = $derived(mcpServers.filter((s) => s.url.trim()));
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
@@ -1,13 +1,22 @@
<script lang="ts">
import { Database, FileText, ListChecks, MessageSquare, Sparkles, Wrench } from '@lucide/svelte';
import {
Database,
ExternalLink,
FileText,
ListChecks,
MessageSquare,
Sparkles,
Wrench
} from '@lucide/svelte';
import { Badge } from '$lib/components/ui/badge';
import type { MCPCapabilitiesInfo } from '$lib/types';
interface Props {
capabilities?: MCPCapabilitiesInfo;
onBrowseResources?: () => void;
}
let { capabilities }: Props = $props();
let { capabilities, onBrowseResources }: Props = $props();
</script>
{#if capabilities}
@@ -20,10 +29,24 @@
{/if}
{#if capabilities.server.resources}
<Badge class="h-5 gap-1 bg-blue-50 px-1.5 text-[10px] dark:bg-blue-950" variant="outline">
<Badge
class="h-5 cursor-pointer gap-1 bg-blue-50 px-1.5 text-[10px] transition-colors hover:bg-blue-100 dark:bg-blue-950 dark:hover:bg-blue-900"
onclick={onBrowseResources}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onBrowseResources?.();
}
}}
role="button"
tabindex={0}
variant="outline"
>
<Database class="h-3 w-3 text-blue-600 dark:text-blue-400" />
Resources
<ExternalLink class="h-3 w-3 text-blue-600 dark:text-blue-400" />
</Badge>
{/if}
@@ -22,9 +22,10 @@
onToggle: (enabled: boolean) => void;
onUpdate: (updates: Partial<MCPServerSettingsEntry>) => void;
onDelete: () => void;
onBrowseResources?: () => void;
}
let { enabled, onDelete, onToggle, onUpdate, server }: Props = $props();
let { enabled, onBrowseResources, onDelete, onToggle, onUpdate, server }: Props = $props();
let healthState = $derived<HealthCheckState>(mcpStore.getHealthCheckState(server.id));
let displayName = $derived(mcpStore.getServerLabel(server));
@@ -125,6 +126,7 @@
{displayName}
enabled={enabled ?? server.enabled}
{faviconUrl}
{onBrowseResources}
{onToggle}
{serverInfo}
{transportType}
@@ -191,12 +193,14 @@
</div>
{/if}
<McpServerCardActions
{isHealthChecking}
onDelete={handleDeleteClick}
onEdit={startEditing}
onRefresh={handleHealthCheck}
/>
<div class="flex items-center gap-2">
<McpServerCardActions
{isHealthChecking}
onDelete={handleDeleteClick}
onEdit={startEditing}
onRefresh={handleHealthCheck}
/>
</div>
</div>
{/if}
</Card.Root>
@@ -12,6 +12,7 @@
enabled: boolean;
disabled?: boolean;
onToggle: (enabled: boolean) => void;
onBrowseResources?: () => void;
serverInfo?: MCPServerInfo;
capabilities?: MCPCapabilitiesInfo;
transportType?: MCPTransportType;
@@ -23,6 +24,7 @@
displayName,
enabled,
faviconUrl,
onBrowseResources,
onToggle,
serverInfo,
transportType
@@ -57,7 +59,7 @@
{/if}
{#if capabilities}
<McpCapabilitiesBadges {capabilities} />
<McpCapabilitiesBadges {capabilities} {onBrowseResources} />
{/if}
</div>
{/if}
@@ -1,16 +1,22 @@
<script lang="ts">
import { Image, Lightbulb, Mic, Video } from '@lucide/svelte';
import { TruncatedText } from '$lib/components/app';
import * as Tooltip from '$lib/components/ui/tooltip';
import { ModelsService } from '$lib/services/models.service';
import { settingsStore } from '$lib/stores';
import type { ModelModalities } from '$lib/types/models';
interface Props {
modelId: string;
hideOrgName?: boolean;
showRaw?: boolean;
showRawTooltip?: boolean;
hideQuantization?: boolean;
hideTags?: boolean;
aliases?: string[];
tags?: string[];
modalities?: ModelModalities;
supportsThinking?: boolean;
class?: string;
}
@@ -20,8 +26,11 @@
hideOrgName = false,
hideQuantization,
hideTags,
modalities,
modelId,
showRaw = undefined,
showRawTooltip = false,
supportsThinking = false,
tags,
...rest
}: Props = $props();
@@ -42,6 +51,9 @@
let uniqueAliases = $derived([...new Set(aliases ?? [])]);
let uniqueTags = $derived([...new Set([...(parsed.tags ?? []), ...(tags ?? [])])]);
let hasModalityIcons = $derived(
supportsThinking || modalities?.vision || modalities?.video || modalities?.audio
);
let primaryAlias = $derived(uniqueAliases.length === 1 ? uniqueAliases[0] : null);
let displayName = $derived(primaryAlias ?? parsed.modelName ?? modelId);
@@ -50,37 +62,107 @@
{#if resolvedShowRaw}
<TruncatedText class="font-medium {className}" showTooltip={false} text={modelId} {...rest} />
{:else}
<span class="flex min-w-0 flex-wrap items-center gap-1 {className}" {...rest}>
{#snippet nameAndBadges()}
<span class="min-w-0 truncate font-medium">
{#if !hideOrgName && parsed.orgName}{parsed.orgName}/{/if}{displayName}
</span>
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
<span class="inline-flex items-center gap-1">
{#if parsed.params}
<span class={badgeClass}>
{parsed.params}{parsed.activatedParams ? `-${parsed.activatedParams}` : ''}
</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{#if parsed.quantization && !resolvedHideQuantization}
<span class={badgeClass}>
{parsed.quantization}
</span>
{/if}
{#if primaryAlias}
{#if primaryAlias !== parsed.modelName}
<span class={badgeClass}>{parsed.modelName ?? modelId}</span>
{/if}
{:else if uniqueAliases.length > 1}
{#each uniqueAliases as alias (alias)}
<span class={badgeClass}>{alias}</span>
{/each}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{/if}
</span>
{/snippet}
<span class="flex min-w-0 items-center gap-1.5 {className}" {...rest}>
{#if showRawTooltip}
<Tooltip.Root>
<Tooltip.Trigger class="flex min-w-0 items-center gap-1.5">
{@render nameAndBadges()}
</Tooltip.Trigger>
<Tooltip.Content>
<p>{modelId}</p>
</Tooltip.Content>
</Tooltip.Root>
{:else}
{@render nameAndBadges()}
{/if}
{#if uniqueTags.length > 0 && !resolvedHideTags}
{#each uniqueTags as tag (tag)}
<span class={tagBadgeClass}>{tag}</span>
{/each}
{#if hasModalityIcons}
<span class="inline-flex items-center gap-1.25 text-muted-foreground">
{#if supportsThinking}
<Tooltip.Root>
<Tooltip.Trigger>
<Lightbulb class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Reasoning</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.vision}
<Tooltip.Root>
<Tooltip.Trigger>
<Image class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Vision</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.video}
<Tooltip.Root>
<Tooltip.Trigger>
<Video class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Video</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if modalities?.audio}
<Tooltip.Root>
<Tooltip.Trigger>
<Mic class="h-3 w-3 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Audio</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</span>
{/if}
</span>
{/if}
@@ -1,8 +1,9 @@
<script lang="ts">
import ModelLoadHighlight from './ModelLoadHighlight.svelte';
import type { ModelItem } from './utils';
import { ChevronDown, Loader2 } from '@lucide/svelte';
import { ChevronDown, Lightbulb, Loader2 } from '@lucide/svelte';
import {
ChatFormActionAddReasoningSubmenu,
DialogModelInformation,
DropdownMenuSearchable,
ModelId,
@@ -11,10 +12,11 @@
} from '$lib/components/app';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Tooltip from '$lib/components/ui/tooltip';
import { MODEL_SELECTOR_ICON } from '$lib/constants';
import { MODEL_SELECTOR_ICON, SETTINGS_KEYS } from '$lib/constants';
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
import { modelsStore } from '$lib/stores';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { modelsStore, settingsStore } from '$lib/stores';
import { modelLoadFraction } from '$lib/utils';
interface Props {
@@ -37,6 +39,9 @@
let isOpen = $state(false);
let highlightedId = $state<string | null>(null);
// The model submenu opens together with the menu so the list and its search
// box are immediately available, as before the submenu was introduced
let modelSubOpen = $state(false);
const ms = useModelsSelector({
currentModel: () => currentModel,
@@ -44,24 +49,41 @@
onOpenChange: (open) => {
isOpen = open;
highlightedId = null;
if (open) {
// Defer submenu open so the Sub component is mounted first;
// setting bind:open synchronously can be lost if the Sub hasn't
// rendered yet.
queueMicrotask(() => {
if (isOpen) modelSubOpen = true;
});
} else {
modelSubOpen = false;
}
},
useGlobalSelection: () => useGlobalSelection
});
const reasoning = useReasoningMenu();
const showOrgNameInTrigger = $derived(
settingsStore.config[SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER] ?? false
);
$effect(() => {
void ms.searchTerm;
highlightedId = null;
});
// Focus the dropdown's search box without scrolling the page. bits-ui
// Focus the model submenu's search box without scrolling the page. bits-ui
// auto-focuses the opened content by default, which can yank the page
// scroll; we prevent that on the Content and refocus the search here.
$effect(() => {
if (!isOpen) return;
if (!isOpen || !modelSubOpen) return;
requestAnimationFrame(() => {
const search = document.querySelector<HTMLElement>(
'[data-slot="dropdown-menu-content"] input'
'[data-slot="dropdown-menu-sub-content"] input'
);
search?.focus({ preventScroll: true });
@@ -188,7 +210,7 @@
<DropdownMenu.Trigger
{...props}
class={[
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1.5 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
`relative inline-grid cursor-pointer grid-cols-[1fr_auto_1fr] items-center gap-1 rounded-sm bg-background px-1.5 py-1 text-xs shadow-sm transition hover:bg-muted-foreground/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-muted-foreground/15 dark:text-secondary-foreground`,
!ms.isCurrentModelInCache
? 'bg-red-400/10 !text-red-400 hover:bg-red-400/20 hover:text-red-400'
: forceForegroundText
@@ -203,16 +225,22 @@
>
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
<span class="flex min-w-0 items-center gap-1">
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{:else}
<span class="min-w-0 font-medium">Select model</span>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
</span>
{#if ms.updating || ms.isLoadingModel}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
@@ -236,73 +264,94 @@
<DropdownMenu.Content
align="end"
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
class="w-full md:min-w-64 md:max-w-80 max-w-[calc(100vw-2rem)]"
onOpenAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchValue={ms.searchTerm}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
>
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
<DropdownMenu.Sub bind:open={modelSubOpen}>
<DropdownMenu.SubTrigger class="flex cursor-pointer items-center gap-2">
<MODEL_SELECTOR_ICON class="h-4 w-4" />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{isSelected}
onInfoClick={ms.handleInfoClick}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
{#if selectedOption}
<ModelId
class="min-w-0 flex-1 overflow-hidden"
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/snippet}
{:else}
<span class="min-w-0 flex-1 truncate text-muted-foreground">No model</span>
{/if}
</DropdownMenu.SubTrigger>
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
/>
</div>
</DropdownMenuSearchable>
<DropdownMenu.SubContent class="w-100 max-w-[calc(100vw-2rem)] pt-0">
<DropdownMenuSearchable
emptyMessage="No models found."
isEmpty={ms.filteredOptions.length === 0 && ms.isCurrentModelInCache}
onSearchChange={(v) => ms.setSearchTerm(v)}
onSearchKeyDown={handleSearchKeyDown}
placeholder="Search models..."
searchValue={ms.searchTerm}
>
<div class="models-list">
{#if !ms.isCurrentModelInCache && currentModel}
<!-- Show unavailable model as first option (disabled) -->
<button
aria-disabled="true"
aria-selected="true"
class="flex w-full cursor-not-allowed items-center bg-red-400/10 p-2 text-left text-sm text-red-400"
disabled
role="option"
type="button"
>
<ModelId class="flex-1" hideQuantization modelId={currentModel} />
<span class="ml-2 text-xs whitespace-nowrap opacity-70">(not available)</span>
</button>
{/if}
{#if ms.filteredOptions.length === 0}
<p class="px-4 py-3 text-sm text-muted-foreground">No models found.</p>
{/if}
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
{@const { option } = item}
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
{@const isHighlighted = option.id === highlightedId}
{@const isFav = ms.isFavorite(option.model)}
<ModelsSelectorOption
{hideOrgName}
{isFav}
{isHighlighted}
{isSelected}
onInfoClick={ms.handleInfoClick}
onKeyDown={(event) => {
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
event.preventDefault();
void handleModelKeyAction(option.id, event.altKey);
}
}}
onMouseEnter={() => (highlightedId = option.id)}
onSelect={ms.handleSelect}
{option}
/>
{/snippet}
<ModelsSelectorList
activeId={ms.activeId}
{currentModel}
groups={ms.groupedFilteredOptions}
onInfoClick={ms.handleInfoClick}
onSelect={ms.handleSelect}
renderOption={modelOption}
sectionHeaderClass="my-1.5 px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none"
/>
</div>
</DropdownMenuSearchable>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<ChatFormActionAddReasoningSubmenu />
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
@@ -332,12 +381,16 @@
{#if selectedOption}
<ModelId
class="min-w-0 overflow-hidden"
hideOrgName={false}
hideOrgName={!showOrgNameInTrigger}
hideQuantization
modelId={selectedOption.model}
/>
{/if}
{#if reasoning.isReasoningActive}
<Lightbulb class="h-3.5 w-3.5 shrink-0 text-amber-400" />
{/if}
{#if ms.updating}
<Loader2 class="h-3 w-3.5 shrink-0 animate-spin" />
{/if}
@@ -58,6 +58,8 @@
let loadProgress = $derived(isLoading ? modelsStore.status.getLoadProgress(option.model) : null);
let loadPercent = $derived(Math.round(modelLoadFraction(loadProgress) * 100));
let loadTitle = $derived(modelLoadProgressText(loadProgress));
let modalities = $derived(option.modalities);
let supportsThinking = $derived(modelsStore.props.checkModelSupportsThinking(option.model));
</script>
<div
@@ -65,9 +67,11 @@
class={[
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
'cursor-pointer',
isSelected && 'bg-accent/50 text-accent-foreground',
isSelected && !isHighlighted && 'bg-accent/50',
isHighlighted && 'bg-accent',
!isSelected && !isHighlighted && 'hover:bg-muted',
(isSelected || isHighlighted) && 'text-accent-foreground',
'hover:bg-accent',
'focus:bg-accent',
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
]}
onclick={() => onSelect(option.id)}
@@ -81,7 +85,10 @@
aliases={option.aliases}
class="flex-1"
{hideOrgName}
{modalities}
modelId={option.model}
showRawTooltip
{supportsThinking}
tags={option.tags}
/>
@@ -1,3 +1,4 @@
import { ModelModality } from '$lib/enums';
import type { ModelOption } from '$lib/types/models';
import { SvelteMap } from 'svelte/reactivity';
@@ -17,6 +18,23 @@ export interface GroupedModelOptions {
available: OrgGroup[];
}
function matchesModality(option: ModelOption, term: string): boolean {
const modalities = option.modalities;
if (!modalities) return false;
switch (term) {
case ModelModality.VISION.toLowerCase():
return modalities.vision;
case ModelModality.AUDIO.toLowerCase():
return modalities.audio;
case ModelModality.VIDEO.toLowerCase():
return modalities.video;
default:
return false;
}
}
export function filterModelOptions(options: ModelOption[], searchTerm: string): ModelOption[] {
const term = searchTerm.trim().toLowerCase();
@@ -27,7 +45,8 @@ export function filterModelOptions(options: ModelOption[], searchTerm: string):
option.model.toLowerCase().includes(term) ||
option.name?.toLowerCase().includes(term) ||
option.aliases?.some((alias: string) => alias.toLowerCase().includes(term)) ||
option.tags?.some((tag: string) => tag.toLowerCase().includes(term))
option.tags?.some((tag: string) => tag.toLowerCase().includes(term)) ||
matchesModality(option, term)
);
}
@@ -5,6 +5,7 @@
import {
ActionIcon,
DialogConversationRename,
DialogSettingsChat,
Logo,
SidebarNavigationActions,
SidebarNavigationConversationList
@@ -91,6 +92,7 @@
let selectedIds = new SvelteSet<string>();
let renameDialogOpen = $state(false);
let settingsDialogOpen = $state(false);
let renameTargetConversationId = $state<string | null>(null);
let renameDraft = $state('');
let renameOriginalTitle = $state('');
@@ -308,7 +310,7 @@
<svelte:window bind:innerWidth onkeydown={handleKeydown} />
{#if innerWidth > 768 || (!page.url.hash.includes(ROUTES.SETTINGS) && !page.url.hash.includes(ROUTES.MCP_SERVERS) && !page.url.hash.includes(ROUTES.SEARCH))}
{#if innerWidth > 768 || !page.url.hash.includes(ROUTES.SEARCH)}
<aside
class={[
'fixed md:sticky top-2 left-2 md:left-0 md:ml-2 md:mt-2 pt-2 z-10 w-[calc(100dvw-1rem)]',
@@ -400,6 +402,7 @@
isSearchModeActive = false;
searchQuery = '';
}}
onSettingsClick={() => (settingsDialogOpen = true)}
/>
{#if uiStore.isSidebarExpanded || isOnMobile}
@@ -447,6 +450,8 @@
onConfirm={handleRenameConfirm}
/>
<DialogSettingsChat bind:open={settingsDialogOpen} />
<style>
aside {
@media (max-width: 768px) {
@@ -26,6 +26,7 @@
onSearchDeactivated?: () => void;
onSearchClick?: () => void;
onNewChat?: () => void;
onSettingsClick?: () => void;
}
let {
@@ -35,6 +36,7 @@
onNewChat,
onSearchClick,
onSearchDeactivated,
onSettingsClick,
searchQuery = $bindable('')
}: Props = $props();
@@ -115,14 +117,16 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -169,14 +173,16 @@
onNewChat?.();
void conversationsStore.openNewChat();
}
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
: item.action === SidebarAction.SETTINGS
? () => onSettingsClick?.()
: item.route
? () => {
onNewChat?.();
goto(item.route!);
}
: isSearchOnMobile
? undefined
: onSearchClick}
{@const itemTransition = {
delay: !initialized ? i * ICON_STRIP_TRANSITION_DELAY_MULTIPLIER : 0,
duration: ICON_STRIP_TRANSITION_DURATION,
@@ -1,7 +1,5 @@
<script lang="ts">
import { RefreshCw } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
SettingsChatDesktopSidebar,
SettingsChatFields,
@@ -18,21 +16,29 @@
SETTINGS_SECTION_SLUGS
} from '$lib/constants';
import { ColorMode } from '$lib/enums/ui.enums';
import { RouterService } from '$lib/services/router.service';
import { modelsStore, serverStore, settingsReferrer, settingsStore } from '$lib/stores';
import type { SettingsSection } from '$lib/types';
import { modelsStore, serverStore, settingsStore } from '$lib/stores';
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
import { setMode } from 'mode-watcher';
import { fade } from 'svelte/transition';
interface Props {
initialSection?: string;
getSectionHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
onClose?: () => void;
}
let { getSectionHref, initialSection }: Props = $props();
let { initialSection, onClose, onSectionChange }: Props = $props();
let activeSlug = $derived(
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
);
let activeSlug = $derived(initialSection ?? 'general');
function handleSectionChange(section: SettingsSectionTitle) {
const found = SETTINGS_CHAT_SECTIONS.find((s) => s.title === section);
if (found) {
activeSlug = found.slug;
}
onSectionChange?.(section);
}
let currentSection = $derived(
SETTINGS_CHAT_SECTIONS.find((section) => section.slug === activeSlug) ||
@@ -115,7 +121,7 @@
}
settingsStore.updateMultipleConfig(processedConfig);
goto(settingsReferrer.url);
onClose?.();
}
export function reset() {
@@ -123,32 +129,24 @@
}
</script>
<div in:fade={{ duration: 150 }} class="mx-auto flex h-full w-full flex-col md:pl-8">
<div class="flex flex-1 flex-col gap-4 md:flex-row">
<div in:fade={{ duration: 150 }} class="mx-auto flex h-full w-full flex-col">
<div class="flex flex-1 flex-col md:flex-row md:gap-4">
<SettingsChatDesktopSidebar
getHref={getSectionHref ??
((section: SettingsSection) => RouterService.settings(section.slug))}
isActive={(section: SettingsSection) => section.slug === activeSlug}
onSectionChange={handleSectionChange}
sections={SETTINGS_CHAT_SECTIONS}
/>
<SettingsChatMobileHeader
bind:this={mobileHeader}
getHref={getSectionHref ??
((section: SettingsSection) => RouterService.settings(section.slug))}
isActive={(section: SettingsSection) => section.slug === activeSlug}
onSectionChange={handleSectionChange}
sections={SETTINGS_CHAT_SECTIONS}
/>
<div class="mx-auto max-w-3xl flex-1">
<div class="space-y-6 p-4 md:p-6 md:pt-28">
<div class="mx-auto max-w-2xl px-4 flex-1 md:mt-4">
<div class="space-y-6 pt-3">
<div class="grid">
<div class="mb-6 flex items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.TOOLS}
<SettingsChatToolsTab />
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
@@ -1,54 +1,31 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
interface Props {
sections: SettingsSection[];
isActive: (section: SettingsSection) => boolean;
getHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
}
let { getHref, isActive, onSectionChange, sections }: Props = $props();
let { isActive, onSectionChange, sections }: Props = $props();
</script>
<div class="sticky top-2 hidden w-64 flex-col self-start bg-background py-4 md:flex gap-6">
<div class="flex items-center gap-2 py-2">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
<div class="sticky top-12 hidden w-64 flex-col self-start bg-background md:flex gap-6">
<nav class="space-y-1">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm no-underline transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
href={getHref(section)}
>
<section.icon class={ICON_CLASS_DEFAULT} />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => onSectionChange?.(section.title)}
>
<section.icon class={ICON_CLASS_DEFAULT} />
<span class="ml-2">{section.title}</span>
</a>
{:else}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => onSectionChange?.(section.title)}
>
<section.icon class={ICON_CLASS_DEFAULT} />
<span class="ml-2">{section.title}</span>
</button>
{/if}
<span class="ml-2">{section.title}</span>
</button>
{/each}
</nav>
</div>
@@ -1,5 +1,4 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { ScrollCarousel } from '$lib/components/app';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString } from '$lib/enums';
@@ -10,11 +9,10 @@
interface Props {
sections: SettingsSection[];
isActive: (section: SettingsSection) => boolean;
getHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
}
let { getHref, isActive, onSectionChange, sections }: Props = $props();
let { isActive, onSectionChange, sections }: Props = $props();
const carousel = useScrollCarousel();
@@ -37,51 +35,26 @@
}
</script>
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
<div class="border-b border-border/30 py-2">
<div class="flex flex-col bg-background md:hidden sticky top-13 z-50">
<div class="border-b border-border/30">
<ScrollCarousel alwaysShowArrows {carousel} containerClass="py-2" innerClass="gap-2">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
href={getHref(section)}
onclick={(e: MouseEvent) => {
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</a>
{:else}
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</button>
{/if}
<span>{section.title}</span>
</button>
{/each}
</ScrollCarousel>
</div>
@@ -29,7 +29,7 @@
}
</script>
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between p-6">
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between pb-4 md:pb-0">
<div class="flex gap-2">
<Button onclick={handleResetClick} variant="outline">
<RotateCcw class="h-3 w-3" />
@@ -1,16 +1,13 @@
<script lang="ts">
import McpLogo from '../mcp/McpLogo.svelte';
import { Plus, X } from '@lucide/svelte';
import { browser } from '$app/environment';
import { goto, replaceState } from '$app/navigation';
import { Plus } from '@lucide/svelte';
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpResourcesBrowser, DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
import { ROUTES } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import { mcpStore, toolsStore } from '$lib/stores';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
@@ -23,26 +20,7 @@
let servers = $derived(mcpStore.getServers());
let isAddingServer = $state(false);
let previousRouteId = $state<string | null>(null);
$effect(() => {
const currentId = page.route.id;
return () => {
previousRouteId = currentId;
};
});
function handleClose() {
const prevIsMcpServers = previousRouteId === '/mcp-servers';
if (browser && window.history.length > 1 && !prevIsMcpServers) {
history.back();
} else {
goto(ROUTES.START);
}
}
let isResourcesDialogOpen = $state(false);
onMount(() => {
if (page.url.searchParams.has('add')) {
@@ -71,25 +49,13 @@
}
</script>
<div in:fade={{ duration: 150 }} class="flex min-h-[calc(100dvh-4rem)] flex-col">
<div class="fixed top-4.5 right-4 z-50 md:hidden">
<ActionIcon icon={X} onclick={handleClose} tooltip="Close" />
</div>
<div
class="sticky top-0 z-10 mt-4 mb-2 flex items-start gap-4 md:p-4 p-0 px-4 md:justify-between md:px-8"
>
<div class="flex items-center gap-2">
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-lg font-semibold md:text-2xl">MCP Servers</h1>
</div>
</div>
<div in:fade={{ duration: 150 }} class="flex flex-col h-full">
<DialogMcpServerAddNew bind:open={isAddingServer} />
<DialogMcpResourcesBrowser bind:open={isResourcesDialogOpen} />
{#if servers.length === 0}
<div class="flex flex-1 items-center justify-center py-16">
<div class="flex flex-1 items-center justify-center pb-20 pt-10 my-auto">
<Empty.Root class="max-w-md">
<Empty.Header>
<Empty.Media variant="icon">
@@ -112,22 +78,21 @@
</div>
{:else}
<div
class="grid gap-3 {className}"
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
class="grid gap-4 {className}"
style="grid-template-columns: repeat(auto-fill, minmax(min(25rem, calc(100dvw - 4rem)), 1fr));"
>
{#each servers as server (server.id)}
{#if isServerPending(server.id, server.enabled)}
<McpServerCardSkeleton />
{:else}
<McpServerCard
enabled={conversationsStore.preferences.isMcpServerEnabledForChat(server.id)}
enabled={server.enabled}
onBrowseResources={() => (isResourcesDialogOpen = true)}
onDelete={() => mcpStore.removeServer(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
server.id
);
const wasEnabled = server.enabled;
await conversationsStore.preferences.toggleMcpServerForChat(server.id);
mcpStore.updateServer(server.id, { enabled: !wasEnabled });
if (!wasEnabled) {
// Promote the connection so tools/prompts/resources become
@@ -1,21 +1,21 @@
/**
* Full chat settings page layout with sidebar, mobile header, and content area.
* Manages local configuration state, section navigation, and context setup.
* Accepts an optional `initialSection` prop to override the URL-based section resolution.
* Accepts an optional `initialSection` prop to set the initial active section.
*/
export { default as SettingsChat } from './SettingsChat/SettingsChat.svelte';
/**
* Desktop sidebar navigation for chat settings.
* Displays a list of settings sections with icons and titles.
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
* Switches sections in-app via `onSectionChange`.
*/
export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSidebar.svelte';
/**
* Mobile header with a horizontally scrollable section picker for chat settings.
* Shows chevron buttons for scroll navigation and highlights the active section.
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
* Switches sections in-app via `onSectionChange`.
*/
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';
@@ -5,3 +5,9 @@
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
<style>
:global([data-dialog-close]) {
z-index: 999;
}
</style>
@@ -10,7 +10,7 @@
class: className,
portalProps,
ref = $bindable(null),
showCloseButton = true,
showCloseButton = false,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
@@ -25,7 +25,8 @@
<DialogPrimitive.Content
bind:ref
class={cn(
`fixed top-[50%] left-[50%] z-50 grid max-h-[100dvh] w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg md:max-h-[100vh]`,
`fixed top-[50%] left-[50%] z-50 grid translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border border-border/30 bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95`,
'max-h-[100dvh] max-w-full size-auto sm:max-w-lg md:w-auto md:size-auto md:max-w-[calc(100%-2rem)]',
className
)}
data-slot="dialog-content"
@@ -1,20 +1,43 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { cn, type WithElementRef } from '$lib/components/ui/utils';
import { Dialog as DialogPrimitive } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
let {
children,
class: className,
ref = $bindable(null),
showCloseButton = true,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
showCloseButton?: boolean;
} = $props();
</script>
<!--
Header is `sticky`, so it stays at the top while the dialog body scrolls. The close
button lives here (not in the body) so it sticks together with the title. `sticky`
makes it the containing block, so the close can be absolutely placed at its corner.
-->
<div
bind:this={ref}
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
class={cn(
'flex flex-col gap-2 text-center sm:text-left sticky top-0 z-50 bg-background md:bg-transparent',
className
)}
data-slot="dialog-header"
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="absolute top-0 right-0 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</div>
@@ -15,10 +15,6 @@ export const ROUTES = {
MCP_SERVERS: '#/mcp-servers',
/** Search — mobile-only full-page conversation search. */
SEARCH: '#/search',
/** Settings base — for dynamic settings URLs use RouterService. */
SETTINGS: '#/settings',
/** Exit destination for the settings view (fallback when no referrer). */
SETTINGS_EXIT: '#/',
/** Root — start of the app. */
START: '#/'
} as const;
@@ -54,6 +54,7 @@ export const SETTINGS_KEYS = {
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
// Display
SHOW_MESSAGE_STATS: 'showMessageStats',
SHOW_MODEL_ORG_NAME_IN_TRIGGER: 'showModelOrgNameInTrigger',
SHOW_MODEL_QUANTIZATION: 'showModelQuantization',
SHOW_MODEL_TAGS: 'showModelTags',
SHOW_RAW_MODEL_NAMES: 'showRawModelNames',
@@ -111,9 +111,8 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
defaultValue: true,
help: 'Automatically show microphone button instead of send button when textarea is empty for models with audio modality support.',
isExperimental: true,
key: SETTINGS_KEYS.AUTO_MIC_ON_EMPTY,
label: 'Show microphone on empty input',
type: SettingsFieldType.CHECKBOX
@@ -283,6 +282,13 @@ export const SETTINGS_REGISTRY: SettingsSectionEntry[] = [
label: 'Show model tags',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the organization name in the model selector trigger button.',
key: SETTINGS_KEYS.SHOW_MODEL_ORG_NAME_IN_TRIGGER,
label: 'Show organization name in model selector trigger',
type: SettingsFieldType.CHECKBOX
},
{
defaultValue: false,
help: 'Display the current build version in the bottom-right corner of the interface.',
@@ -20,6 +20,9 @@ export const DISABLED_TOOLS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledTool
/** Disabled tools keyed by stable selection identity, no migration from the name based key */
export const DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolKeys`;
/** Default disabled tool categories, seeded into newly created conversations */
export const DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.disabledToolCategories`;
export const FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
+1 -10
View File
@@ -1,6 +1,4 @@
import { ROUTES } from './routes.constants';
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import { SidebarAction, ToolSource } from '$lib/enums';
import type { DesktopIconStripItem } from '$lib/types';
@@ -64,15 +62,8 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
},
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
activeRouteId: '/mcp-servers',
icon: McpLogo,
route: ROUTES.MCP_SERVERS,
tooltip: 'MCP Servers'
},
{
activeUrlIncludes: '#/settings',
action: SidebarAction.SETTINGS,
icon: Settings,
route: `${ROUTES.SETTINGS}/general`,
tooltip: 'Settings'
}
];
+2 -1
View File
@@ -23,7 +23,8 @@ export enum ScrollCarouselVariant {
* Sidebar icon strip actions handled directly by the sidebar.
*/
export enum SidebarAction {
NEW_CHAT = 'new-chat'
NEW_CHAT = 'new-chat',
SETTINGS = 'settings'
}
/**
@@ -8,6 +8,7 @@ import { getConversationModel } from '$lib/utils';
export interface UseReasoningMenuReturn {
readonly modelSupportsThinking: boolean;
readonly thinkingEnabled: boolean;
readonly isReasoningActive: boolean;
readonly isOff: boolean;
readonly currentEffort: ReasoningEffort;
readonly levels: ReasoningEffortLevel[];
@@ -59,6 +60,12 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
const thinkingEnabled = $derived(
currentEffort !== ReasoningEffort.OFF && currentEffort !== ReasoningEffort.DEFAULT
);
// Thinking is effectively on (lightbulb lit) either when an explicit effort
// is selected, or when the effort is left at "Default" and the model
// supports thinking.
const isReasoningActive = $derived(
thinkingEnabled || (currentEffort === ReasoningEffort.DEFAULT && modelSupportsThinking)
);
return {
get currentEffort() {
@@ -67,6 +74,9 @@ export function useReasoningMenu(): UseReasoningMenuReturn {
get isOff() {
return currentEffort === ReasoningEffort.OFF;
},
get isReasoningActive() {
return isReasoningActive;
},
isSelected(level: ReasoningEffortLevel): boolean {
return currentEffort === level.value;
},
@@ -1,45 +0,0 @@
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants';
import { settingsReferrer } from '$lib/stores';
export interface ChatSettings {
reset: () => void;
}
export function useSettingsNavigation() {
const subroute = $state({
activePanel: 'chat' as 'chat' | 'settings' | 'mcp',
chatSettingsRef: undefined as ChatSettings | undefined
});
const isSettingsRoute = $derived(!!page.route.id?.startsWith('/settings'));
beforeNavigate(({ from, to }) => {
if (to?.route?.id?.startsWith('/settings') && !from?.route?.id?.startsWith('/settings')) {
settingsReferrer.url = window.location.hash || ROUTES.START;
}
});
$effect(() => {
if (subroute.activePanel === 'settings' && subroute.chatSettingsRef) {
subroute.chatSettingsRef.reset();
}
});
// Return to chat when navigating to a new route
$effect(() => {
void page.url;
subroute.activePanel = 'chat';
});
return {
get isSettingsRoute() {
return isSettingsRoute;
},
get panel() {
return subroute;
}
};
}
@@ -1,22 +1,27 @@
import { CLI_FLAGS } from '$lib/constants';
import { ToolSource } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
import type { ToolEntry, ToolGroup } from '$lib/types';
import { SvelteSet } from 'svelte/reactivity';
export interface UseToolsPanelReturn {
readonly expandedGroups: SvelteSet<string>;
readonly groups: ToolGroup[];
readonly activeGroups: ToolGroup[];
readonly categoryGroups: ToolGroup[];
readonly mcpGroups: ToolGroup[];
readonly totalToolCount: number;
readonly noToolsInfoMessage: string | null;
readonly mcpCategoryEnabled: boolean;
isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number;
getFavicon(group: ToolGroup): string | null;
isGroupDisabled(group: ToolGroup): boolean;
isToolEnabled(entry: ToolEntry): boolean;
isToolParentDisabled(entry: ToolEntry): boolean;
toggleTool(entry: ToolEntry): void;
toggleGroupExpanded(key: string): void;
/** Toggle all tools in a group by its stable key (avoids stale group object references). */
toggleGroupByKey(key: string): void;
toggleMcpCategory(): void;
handleOpen(): void;
}
@@ -26,19 +31,21 @@ export interface UseToolsPanelReturn {
* Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`)
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid
* duplicating group filtering, checked-state derivation, and favicon logic.
*
* All toggle state routes through `conversationsStore.preferences`: with an
* active conversation it edits that conversation's tool policy, on the
* new-chat screen it edits the global defaults seeded into new conversations.
*/
export function useToolsPanel(): UseToolsPanelReturn {
const expandedGroups = new SvelteSet<string>();
const groups = $derived(toolsStore.toolGroups);
const activeGroups = $derived(
groups.filter(
(g) =>
g.source !== ToolSource.MCP ||
!g.serverId ||
conversationsStore.preferences.isMcpServerEnabledForChat(g.serverId)
)
// non-MCP groups are 1:1 with tool categories; MCP tools group per server
const categoryGroups = $derived(groups.filter((g) => g.source !== ToolSource.MCP));
const mcpGroups = $derived(groups.filter((g) => g.source === ToolSource.MCP));
const totalToolCount = $derived(groups.reduce((n, g) => n + g.tools.length, 0));
const mcpCategoryEnabled = $derived(
conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP)
);
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
const noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null;
@@ -56,11 +63,11 @@ export function useToolsPanel(): UseToolsPanelReturn {
});
function isGroupChecked(group: ToolGroup): boolean {
return toolsStore.isGroupFullyEnabled(group);
return conversationsStore.preferences.isGroupChecked(group);
}
function getEnabledToolCount(group: ToolGroup): number {
return group.tools.filter((tool) => toolsStore.isToolEnabled(tool.key)).length;
return group.tools.filter((tool) => conversationsStore.preferences.isToolActive(tool)).length;
}
function getFavicon(group: ToolGroup): string | null {
@@ -70,13 +77,25 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
function isGroupDisabled(group: ToolGroup): boolean {
// MCP server groups gray out while the whole MCP category is off
return (
group.source === ToolSource.MCP &&
!!group.serverId &&
!conversationsStore.preferences.isMcpServerEnabledForChat(group.serverId)
!conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP)
);
}
function isToolEnabled(entry: ToolEntry): boolean {
return conversationsStore.preferences.isToolEnabled(entry.key);
}
function isToolParentDisabled(entry: ToolEntry): boolean {
return conversationsStore.preferences.isToolParentDisabled(entry);
}
function toggleTool(entry: ToolEntry): void {
void conversationsStore.preferences.toggleTool(entry.key);
}
function toggleGroupExpanded(key: string): void {
if (expandedGroups.has(key)) {
expandedGroups.delete(key);
@@ -87,11 +106,15 @@ export function useToolsPanel(): UseToolsPanelReturn {
function toggleGroupByKey(key: string): void {
// Find current group by key to get up-to-date tool references
const group = activeGroups.find((g) => g.key === key);
const group = groups.find((g) => g.key === key);
if (!group) return;
toolsStore.toggleGroup(group);
void conversationsStore.preferences.toggleGroup(group);
}
function toggleMcpCategory(): void {
void conversationsStore.preferences.toggleCategory(ToolSource.MCP);
}
function handleOpen(): void {
@@ -103,23 +126,30 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
return {
get activeGroups() {
return activeGroups;
get categoryGroups() {
return categoryGroups;
},
expandedGroups,
getEnabledToolCount,
getFavicon,
get groups() {
return groups;
},
handleOpen,
isGroupChecked,
isGroupDisabled,
isToolEnabled,
isToolParentDisabled,
get mcpCategoryEnabled() {
return mcpCategoryEnabled;
},
get mcpGroups() {
return mcpGroups;
},
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
toggleGroupByKey,
toggleGroupExpanded,
toggleMcpCategory,
toggleTool,
get totalToolCount() {
return totalToolCount;
}
-1
View File
@@ -307,7 +307,6 @@ export { SandboxService } from './sandbox.service';
*
* **Key Responsibilities:**
* - Build chat URLs for specific conversations: `RouterService.chat(id)` → `#/chat/:id`
* - Build settings URLs for sections: `RouterService.settings(section)` → `#/settings/:section`
*
* @see ROUTES in constants/routes.ts — static route base paths
*/
+59 -1
View File
@@ -11,6 +11,7 @@
import {
CONFIG_LOCALSTORAGE_KEY,
DB_APP_NAME_DEPRECATED,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
IDXDB_STORES,
IDXDB_TABLES,
LEGACY_AGENTIC_REGEX,
@@ -21,6 +22,7 @@ import {
STORAGE_APP_NAME_DEPRECATED
} from '$lib/constants';
import { BooleanString, MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import Dexie from 'dexie';
// Types
@@ -737,6 +739,61 @@ const mcpDefaultOverridesMergeMigration: Migration = {
);
}
};
const MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID = 'mcp-server-overrides-to-tool-policy-v1';
const mcpServerOverridesToToolPolicyMigration: Migration = {
description:
'Seed per-conversation disabled tool keys from the global defaults and legacy per-conversation MCP server overrides (legacy field preserved)',
id: MCP_SERVER_OVERRIDES_TO_TOOL_POLICY_MIGRATION_ID,
async run(): Promise<void> {
// The global disabled set used to apply to every conversation; it is now
// the defaults seeded into newly created conversations, so existing rows
// are seeded with it to keep their behavior unchanged.
let defaults: string[] = [];
try {
const raw = localStorage.getItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
defaults = parsed.filter((k): k is string => typeof k === 'string');
}
}
} catch {
return;
}
const db = await getDatabaseService();
const conversations = await db.getAllConversations();
let migratedCount = 0;
for (const conv of conversations) {
// re-run safety: a row that already has a policy is left alone
if (conv.disabledTools !== undefined) continue;
// A legacy per-conversation server disable becomes a server-scoped tool
// key (same format as toolsStore.getMcpServerToolsKey). Per-conversation
// enables are dropped: the global server flag governs now.
const serverGroupKeys = (conv.mcpServerOverrides ?? [])
.filter((o: McpServerOverride) => !o.enabled)
.map((o: McpServerOverride) => `mcp:${o.serverId}`);
const disabledTools = [...new Set([...defaults, ...serverGroupKeys])];
if (disabledTools.length === 0) continue;
await db.updateConversation(conv.id, { disabledTools });
migratedCount++;
}
if (import.meta.env.DEV && import.meta.env.VITE_DEBUG)
console.log(
`[Migration] MCP server overrides -> tool policy: updated ${migratedCount} conversations`
);
}
};
const migrations: Migration[] = [
localStorageMigration,
idxdbMigration,
@@ -746,7 +803,8 @@ const migrations: Migration[] = [
mcpDefaultEnabledMigration,
mcpDefaultOverridesMergeMigration,
configTypesMigration,
renderKeysMigration
renderKeysMigration,
mcpServerOverridesToToolPolicyMigration
];
export const MigrationService = {
+10 -1
View File
@@ -187,8 +187,17 @@ export class ModelsService {
// 6. Model name = segments before params; tags = remaining segments after params
const pivotIdx = paramsIdx !== MODEL_ID.NOT_FOUND ? paramsIdx : segments.length;
const modelSegments = segments.slice(0, pivotIdx);
result.modelName = segments.slice(0, pivotIdx).join(MODEL_ID.SEGMENT_SEPARATOR) || null;
// strip trailing container-format segments (e.g. GGUF) from the model name
while (
modelSegments.length > 0 &&
MODEL_ID.IGNORED_SEGMENTS.has(modelSegments[modelSegments.length - 1].toUpperCase())
) {
modelSegments.pop();
}
result.modelName = modelSegments.join(MODEL_ID.SEGMENT_SEPARATOR) || null;
if (paramsIdx !== MODEL_ID.NOT_FOUND) {
result.tags = segments.slice(paramsIdx + 1).filter((_, relIdx) => {
+1 -6
View File
@@ -1,8 +1,7 @@
/**
* RouterService - Builds app route paths
*
* Returns chat and settings route strings from a single source of truth
* (ROUTES). No state.
* Returns chat route strings from a single source of truth (ROUTES). No state.
*/
import { ROUTES } from '$lib/constants';
@@ -11,8 +10,4 @@ export class RouterService {
static chat(id: string): string {
return `${ROUTES.CHAT}/${id}`;
}
static settings(section: string): string {
return `${ROUTES.SETTINGS}/${section}`;
}
}
@@ -44,7 +44,6 @@ import type {
AgenticFlowParams,
AgenticFlowResult,
AgenticSession,
McpServerOverride,
MCPToolCall,
SettingsConfigType,
ToolExecutionResult
@@ -201,10 +200,10 @@ class AgenticStore {
return active;
}
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig {
getConfig(settings: SettingsConfigType): AgenticConfig {
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
const hasTools =
mcpStore.hasEnabledServers(perChatOverrides) ||
mcpStore.hasEnabledServers() ||
toolsStore.serverTools.length > 0 ||
toolsStore.browserTools.length > 0 ||
toolsStore.customTools.length > 0;
@@ -309,8 +308,8 @@ class AgenticStore {
flowRootMessageId,
messages,
options = {},
perChatOverrides,
signal
signal,
toolPolicy
} = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow
@@ -321,21 +320,24 @@ class AgenticStore {
await toolsStore.fetchServerTools();
}
const agenticConfig = this.getConfig(settingsStore.config, perChatOverrides);
const agenticConfig = this.getConfig(settingsStore.config);
if (!agenticConfig.enabled) return { handled: false };
const hasMcpServers = mcpStore.hasEnabledServers(perChatOverrides);
const hasMcpServers = mcpStore.hasEnabledServers();
if (hasMcpServers) {
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
const initialized = await mcpStore.ensureInitialized();
if (!initialized) {
console.log('[AgenticStore] MCP not initialized');
}
}
const tools = toolsStore.getEnabledToolsForLLM();
const tools = toolsStore.getEnabledToolsForLLM(
new Set(toolPolicy?.disabledTools ?? []),
new Set(toolPolicy?.disabledToolCategories ?? [])
);
if (tools.length === 0) {
return { handled: false };
+6 -3
View File
@@ -1132,7 +1132,10 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await DatabaseService.updateMessage(messageId, updates);
}
};
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
const toolPolicy = {
disabledToolCategories: conversationsStore.preferences.getDisabledToolCategories(),
disabledTools: conversationsStore.preferences.getDisabledTools()
};
{
const agenticResult = await agenticStore.runAgenticFlow({
@@ -1144,8 +1147,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {})
},
perChatOverrides,
signal: abortController.signal
signal: abortController.signal,
toolPolicy
});
if (agenticResult.handled) {
@@ -251,12 +251,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
*/
async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`;
// Working directory and reasoning effort picked on the new-chat screen
// get threaded into the new conversation here, then cleared so they
// don't bleed onto subsequent new chats.
// The tool policy is seeded from the current defaults: edits made inside
// the conversation afterwards live on its row and do not flow back into
// the defaults. Working directory picked on the new-chat screen gets
// threaded in here too, then cleared so it doesn't bleed onto subsequent
// new chats.
const conversation = await DatabaseService.createConversation(conversationName, {
cwd: this.preferences.pendingCwd ?? undefined,
reasoningEffort: this.preferences.pendingReasoningEffort
reasoningEffort: this.preferences.pendingReasoningEffort,
...this.preferences.getToolPolicySnapshot()
});
this.preferences.pendingCwd = null;
@@ -1,21 +1,22 @@
/**
* ConversationPreferences - Per-chat options with global fallback
*
* Owns the options that resolve per conversation: MCP server overrides,
* reasoning effort, and the working directory. Cwd and reasoning effort are
* buffered as pending state and threaded into the next created conversation
* by the host; MCP server overrides edit the sparse `mcpServerOverrides`
* list on the active row (new-chat toggles edit the server's global flag).
* Owns the options that resolve per conversation: the tool policy (disabled
* categories and tool keys), reasoning effort, and the working directory.
* Tool picks made on the empty new-chat screen edit the global defaults
* directly (they seed every newly created conversation); cwd and reasoning
* effort are buffered as pending state and threaded into the next created
* conversation by the host.
* Created and owned by conversationsStore; the host owns the conversation
* rows these options persist onto.
*/
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants';
import { ReasoningEffort } from '$lib/enums';
import { ReasoningEffort, ToolSource } from '$lib/enums';
import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp/index.svelte';
import type { McpServerOverride } from '$lib/types/database';
import { toolsStore } from '$lib/stores/tools.svelte';
import type { DatabaseConversation, ToolEntry, ToolGroup } from '$lib/types';
/** Load reasoning effort default from localStorage, DEFAULT defers to the server */
function loadReasoningEffortDefault(): ReasoningEffort {
@@ -48,6 +49,18 @@ export interface ConversationsPreferencesHost {
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
}
/** Effective disabled tool keys: conversation row, falling back to defaults. */
function buildDisabledTools(conv: DatabaseConversation | null): Set<string> {
return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]);
}
/** Effective disabled tool categories: conversation row, falling back to defaults. */
function buildDisabledToolCategories(conv: DatabaseConversation | null): Set<ToolSource> {
return new Set(
conv ? (conv.disabledToolCategories ?? []) : [...toolsStore.disabledToolCategories]
);
}
export class ConversationPreferences {
/**
* Working directory picked on the empty new-chat screen, before any
@@ -61,36 +74,35 @@ export class ConversationPreferences {
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
constructor(private host: ConversationsPreferencesHost) {}
private get _disabledToolCategories(): Set<ToolSource> {
return buildDisabledToolCategories(this.host.activeConversation);
}
/**
* Gets the effective override list for the current conversation:
* one entry per configured server, resolved per server. The stored
* per-conversation list is sparse and only holds explicit toggles.
*/
getAllMcpServerOverrides(): McpServerOverride[] {
const overrides = this.host.activeConversation?.mcpServerOverrides;
return mcpStore.getServers().map((s) => {
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
return { enabled: override?.enabled ?? s.enabled, serverId: s.id };
});
// getters, not $derived fields: lazy evaluation keeps them off the class
// field initialization order (host is assigned by the constructor), and
// reads of the underlying $state stay tracked in reactive contexts
private get _disabledTools(): Set<string> {
return buildDisabledTools(this.host.activeConversation);
}
/**
* Gets the effective MCP server override for a specific server.
* A per-conversation override wins when present; a server without one
* resolves to its `mcpServers[i].enabled` default.
*
*
* Tool Policy
*
*
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
const override = this.host.activeConversation?.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
if (override) return override;
constructor(private host: ConversationsPreferencesHost) {}
return this.getDefaultOverride(serverId);
/** Effective disabled tool categories for the current context, captured at flow start. */
getDisabledToolCategories(): ToolSource[] {
return [...this._disabledToolCategories];
}
/** Effective disabled tool keys for the current context, captured at flow start. */
getDisabledTools(): string[] {
return [...this._disabledTools];
}
/**
@@ -114,16 +126,56 @@ export class ConversationPreferences {
return this.pendingReasoningEffort;
}
/** Checks if an MCP server is enabled for the active conversation. */
isMcpServerEnabledForChat(serverId: string): boolean {
const override = this.getMcpServerOverride(serverId);
/** Defaults snapshot for seeding a newly created conversation. */
getToolPolicySnapshot(): { disabledTools?: string[]; disabledToolCategories?: ToolSource[] } {
const disabledTools = [...toolsStore.disabledTools];
const disabledToolCategories = [...toolsStore.disabledToolCategories];
return override?.enabled ?? false;
return {
disabledToolCategories: disabledToolCategories.length ? disabledToolCategories : undefined,
disabledTools: disabledTools.length ? disabledTools : undefined
};
}
/** Removes MCP server override for the active conversation. */
async removeMcpServerOverride(serverId: string): Promise<void> {
await this.setMcpServerOverride(serverId, undefined);
hasEnabledCwdTools(): boolean {
return toolsStore.hasEnabledCwdTools(this._disabledTools, this._disabledToolCategories);
}
isCategoryEnabled(source: ToolSource): boolean {
return !this._disabledToolCategories.has(source);
}
/** Group checkbox state: the category flag, or the server key for MCP groups. */
isGroupChecked(group: ToolGroup): boolean {
return group.source === ToolSource.MCP && group.serverId
? this.isServerToolsEnabled(group.serverId)
: this.isCategoryEnabled(group.source);
}
/** Server-scoped MCP group state: one key disables all of that server's tools. */
isServerToolsEnabled(serverId: string): boolean {
return this.isToolEnabled(toolsStore.getMcpServerToolsKey(serverId));
}
/** Effective state: own key, MCP server group key, and category all on. */
isToolActive(entry: ToolEntry): boolean {
return toolsStore.isEntryEnabled(entry, this._disabledTools, this._disabledToolCategories);
}
/** Own-level state: the tool key itself, ignoring category and server group. */
isToolEnabled(key: string): boolean {
return !this._disabledTools.has(key);
}
/** True when a parent level (category or MCP server group) disables this entry. */
isToolParentDisabled(entry: ToolEntry): boolean {
if (!this.isCategoryEnabled(entry.source)) return true;
return (
entry.source === ToolSource.MCP &&
!!entry.serverId &&
!this.isServerToolsEnabled(entry.serverId)
);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
@@ -165,57 +217,6 @@ export class ConversationPreferences {
this.pendingCwd = null;
}
/**
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
* (the single source of truth for new-chat defaults).
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.host.activeConversation) {
if (enabled !== undefined) {
mcpStore.updateServer(serverId, { enabled });
}
return;
}
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.host.activeConversation.mcpServerOverrides || []).map(
(o: McpServerOverride) => ({
enabled: o.enabled,
serverId: o.serverId
})
);
let newOverrides: McpServerOverride[];
if (enabled === undefined) {
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
} else {
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
newOverrides[existingIndex] = { enabled, serverId };
} else {
newOverrides = [...currentOverrides, { enabled, serverId }];
}
}
const overrides = newOverrides.length > 0 ? newOverrides : undefined;
const id = this.host.activeConversation.id;
this.host.applyConversationUpdate(id, {
mcpServerOverrides: overrides
});
await DatabaseService.updateConversation(id, {
mcpServerOverrides: overrides
});
}
/**
* Sets the reasoning effort for the active conversation.
* If no conversation exists, stores the global default.
@@ -229,33 +230,98 @@ export class ConversationPreferences {
return;
}
const id = this.host.activeConversation.id;
this.host.applyConversationUpdate(id, {
this.host.applyConversationUpdate(this.host.activeConversation.id, {
reasoningEffort: effort
});
await DatabaseService.updateConversation(id, {
await DatabaseService.updateConversation(this.host.activeConversation.id, {
reasoningEffort: effort
});
}
/** Toggles MCP server enabled state for the active conversation. */
async toggleMcpServerForChat(serverId: string): Promise<void> {
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
async toggleCategory(source: ToolSource): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
await this.setMcpServerOverride(serverId, !currentEnabled);
if (!conv) {
toolsStore.toggleCategory(source);
return;
}
const next = buildDisabledToolCategories(conv);
if (next.has(source)) next.delete(source);
else next.add(source);
await this.persistDisabledToolCategories(next);
}
async toggleGroup(group: ToolGroup): Promise<void> {
if (group.source === ToolSource.MCP && group.serverId) {
await this.toggleServerTools(group.serverId);
} else {
await this.toggleCategory(group.source);
}
}
async toggleServerTools(serverId: string): Promise<void> {
await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId));
}
/**
* Resolve the default enabled value for a server: its own `enabled`
* flag in `mcpServers`, so the global on/off state lives in one place.
*
*
* Reasoning Effort
*
*
*/
private getDefaultOverride(serverId: string): McpServerOverride | undefined {
const server = mcpStore.getServers().find((s) => s.id === serverId);
if (!server) return undefined;
async toggleTool(key: string): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
return { enabled: server.enabled, serverId };
if (!conv) {
toolsStore.toggleTool(key);
return;
}
const next = buildDisabledTools(conv);
if (next.has(key)) next.delete(key);
else next.add(key);
await this.persistDisabledTools(next);
}
private async persistDisabledToolCategories(disabled: Set<ToolSource>): Promise<void> {
const conv = this.host.activeConversation;
if (!conv) return;
const disabledToolCategories = disabled.size ? [...disabled] : undefined;
this.host.applyConversationUpdate(conv.id, { disabledToolCategories });
await DatabaseService.updateConversation(conv.id, { disabledToolCategories });
}
/**
*
*
* Working Directory
*
*
*/
private async persistDisabledTools(disabled: Set<string>): Promise<void> {
const conv = this.host.activeConversation;
if (!conv) return;
const disabledTools = disabled.size ? [...disabled] : undefined;
this.host.applyConversationUpdate(conv.id, { disabledTools });
await DatabaseService.updateConversation(conv.id, { disabledTools });
}
}
-2
View File
@@ -49,8 +49,6 @@ export { uiStore } from './ui.svelte';
// SETTINGS / UI PREFERENCES
export { settingsStore } from './settings/index.svelte';
export { settingsReferrer } from './settings/referrer.svelte';
export { permissionsStore } from './permissions.svelte';
// TOOLS
+19 -56
View File
@@ -37,7 +37,7 @@ import type {
Tool,
ToolExecutionResult
} from '$lib/types';
import type { DatabaseMessageExtraMcpResource, McpServerOverride } from '$lib/types/database';
import type { DatabaseMessageExtraMcpResource } from '$lib/types/database';
import type { SettingsConfigType } from '$lib/types/settings';
import {
detectMcpTransportFromUrl,
@@ -306,12 +306,12 @@ class MCPStore implements McpHealthHost {
return extras;
}
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
async ensureInitialized(): Promise<boolean> {
if (!browser) {
return false;
}
const mcpConfig = this.buildMcpClientConfig(settingsStore.config, perChatOverrides);
const mcpConfig = this.buildMcpClientConfig(settingsStore.config);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) {
@@ -512,14 +512,6 @@ class MCPStore implements McpHealthHost {
return this.connections;
}
getEnabledServersForConversation(
perChatOverrides?: McpServerOverride[]
): MCPServerSettingsEntry[] {
return this.getServers().filter((server) => {
return this.checkServerEnabled(server, perChatOverrides);
});
}
/**
* Check if a server already has an active connection that can be reused.
* Returns the existing connection if available.
@@ -811,8 +803,8 @@ class MCPStore implements McpHealthHost {
);
}
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides));
hasEnabledServers(): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config));
}
/**
@@ -820,18 +812,12 @@ class MCPStore implements McpHealthHost {
* Uses health check state since servers may not have active connections until
* the user actually sends a message or uses prompts.
*/
hasPromptsCapability(perChatOverrides?: McpServerOverride[]): boolean {
let enabledServerIds: Set<string>;
if (perChatOverrides !== undefined) {
enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId));
} else {
enabledServerIds = new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
}
hasPromptsCapability(): boolean {
const enabledServerIds = new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
if (enabledServerIds.size === 0) {
return false;
@@ -874,18 +860,12 @@ class MCPStore implements McpHealthHost {
* Uses health check state since servers may not have active connections until
* the user actually sends a message or uses prompts.
*/
hasResourcesCapability(perChatOverrides?: McpServerOverride[]): boolean {
let enabledServerIds: Set<string>;
if (perChatOverrides !== undefined) {
enabledServerIds = new Set(perChatOverrides.filter((o) => o.enabled).map((o) => o.serverId));
} else {
enabledServerIds = new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
}
hasResourcesCapability(): boolean {
const enabledServerIds = new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
if (enabledServerIds.size === 0) {
return false;
@@ -1185,10 +1165,7 @@ class MCPStore implements McpHealthHost {
/**
* Builds MCP client configuration from settings.
*/
private buildMcpClientConfig(
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
private buildMcpClientConfig(cfg: SettingsConfigType): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(cfg.mcpServers);
if (!rawServers.length) {
@@ -1198,7 +1175,7 @@ class MCPStore implements McpHealthHost {
const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) {
if (!this.checkServerEnabled(entry, perChatOverrides)) continue;
if (!entry.enabled) continue;
const normalized = this.buildServerConfig(entry);
@@ -1252,20 +1229,6 @@ class MCPStore implements McpHealthHost {
};
}
/**
* Checks if a server is enabled for a given chat.
* A per-chat override wins when present; a server without one resolves
* to its own `enabled` flag in `mcpServers`.
*/
private checkServerEnabled(
server: MCPServerSettingsEntry,
perChatOverrides?: McpServerOverride[]
): boolean {
const override = perChatOverrides?.find((o) => o.serverId === server.id);
return override?.enabled ?? server.enabled;
}
private createListChangedHandlers(serverName: string): ListChangedHandlers {
return {
prompts: {
@@ -1,19 +0,0 @@
/**
* settingsReferrer - Remembers the settings route to return to after exit
*
* Tracks the last settings section the user was on so the app can return
* there after a fallback exit. Standalone reactive value, no host.
*/
import { ROUTES } from '$lib/constants';
let _url = $state<string>(ROUTES.SETTINGS_EXIT);
export const settingsReferrer = {
get url() {
return _url;
},
set url(value: string) {
_url = value;
}
};
+108 -39
View File
@@ -12,6 +12,7 @@ import {
buildBrowserInfoToolDefinition,
buildGetDatetimeToolDefinition,
buildReadMediaToolDefinition,
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
HOME_TILDE,
TOOL_GROUP_LABELS,
@@ -37,6 +38,9 @@ import { SvelteMap, SvelteSet } from 'svelte/reactivity';
/** Stable selection identity for a tool, shared by the disabled set and the permission store */
class ToolsStore {
// default disabled tool categories, seeded into newly created conversations;
// the per-conversation policy lives on the conversation row
private _disabledToolCategories = $state(new SvelteSet<ToolSource>());
private _disabledTools = $state(new SvelteSet<string>());
private _error = $state<string | null>(null);
private _loading = $state(false);
@@ -150,6 +154,10 @@ class ToolsStore {
}
}
get disabledToolCategories(): ReadonlySet<ToolSource> {
return this._disabledToolCategories;
}
get disabledTools(): SvelteSet<string> {
return this._disabledTools;
}
@@ -158,26 +166,6 @@ class ToolsStore {
return this._error;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the user.
*/
get hasEnabledCwdTools(): boolean {
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this.cwdAwareTools.has(name) &&
!this._disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
/** Check if there are any enabled tools available (server, MCP, or custom) */
get hasEnabledTools(): boolean {
return this.getEnabledToolsForLLM().length > 0;
}
get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable;
}
@@ -271,17 +259,14 @@ class ToolsStore {
}
}
/**
* Enabled tool definitions for sending to the LLM.
* MCP tool schemas are normalized here so the wire payload is consistent
* across all four sources (server, browser/sandbox, MCP, custom JSON).
* The API identifies tools by name, so a name is sent at most once.
*/
getEnabledToolsForLLM(): OpenAIToolDefinition[] {
getEnabledToolsForLLM(
disabledTools: ReadonlySet<string> = this._disabledTools,
disabledCategories: ReadonlySet<ToolSource> = this._disabledToolCategories
): OpenAIToolDefinition[] {
const enabledNames = new SvelteSet<string>();
for (const entry of this.allTools) {
if (!this._disabledTools.has(entry.key)) {
if (this.isEntryEnabled(entry, disabledTools, disabledCategories)) {
enabledNames.add(entry.definition.function.name);
}
}
@@ -306,6 +291,11 @@ class ToolsStore {
return result;
}
/** Server-scoped tool key: disabling it disables all of that server's tools. */
getMcpServerToolsKey(serverId: string): string {
return `mcp:${serverId}`;
}
/** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null;
@@ -333,6 +323,26 @@ class ToolsStore {
return this.findEntryByName(toolName)?.source ?? null;
}
/**
* Check if a working directory is worth setting: at least one server tool
* that reads it is both served and left enabled by the given policy
* (defaults to the global defaults).
*/
hasEnabledCwdTools(
disabledTools: ReadonlySet<string> = this._disabledTools,
disabledCategories: ReadonlySet<ToolSource> = this._disabledToolCategories
): boolean {
if (disabledCategories.has(ToolSource.SERVER)) return false;
return this._serverTools.some((def) => {
const name = def.function.name;
return (
this.cwdAwareTools.has(name) && !disabledTools.has(this.toolKey(ToolSource.SERVER, name))
);
});
}
/**
* Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run.
@@ -357,11 +367,56 @@ class ToolsStore {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err);
}
try {
const stored = localStorage.getItem(DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) {
for (const key of parsed) {
if (Object.values(ToolSource).includes(key)) {
this._disabledToolCategories.add(key as ToolSource);
}
}
}
}
} catch (err) {
console.error('[ToolsStore] Failed to load disabled tool categories from localStorage:', err);
}
this.fetchServerTools();
}
isGroupFullyEnabled(group: ToolGroup): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key));
isCategoryEnabled(source: ToolSource): boolean {
return !this._disabledToolCategories.has(source);
}
/**
* Enabled tool definitions for sending to the LLM.
* MCP tool schemas are normalized here so the wire payload is consistent
* across all four sources (server, browser/sandbox, MCP, custom JSON).
* The API identifies tools by name, so a name is sent at most once.
*/
/**
* The single enable rule for a tool entry: its category must be on, its
* own key must not be disabled, and for MCP tools the server-scoped group
* key must not be disabled either.
*/
isEntryEnabled(
entry: ToolEntry,
disabledTools: ReadonlySet<string>,
disabledCategories: ReadonlySet<ToolSource>
): boolean {
if (disabledCategories.has(entry.source)) return false;
if (disabledTools.has(entry.key)) return false;
if (entry.source === ToolSource.MCP && entry.serverId) {
return !disabledTools.has(this.getMcpServerToolsKey(entry.serverId));
}
return true;
}
isToolEnabled(key: string): boolean {
@@ -394,6 +449,16 @@ class ToolsStore {
return this._serverHome;
}
setCategoryEnabled(source: ToolSource, enabled: boolean): void {
if (enabled) {
this._disabledToolCategories.delete(source);
} else {
this._disabledToolCategories.add(source);
}
this.persistDisabledToolCategories();
}
setToolEnabled(key: string, enabled: boolean): void {
if (enabled) {
this._disabledTools.delete(key);
@@ -402,15 +467,8 @@ class ToolsStore {
}
}
toggleGroup(group: ToolGroup): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
toggleCategory(source: ToolSource): void {
this.setCategoryEnabled(source, !this.isCategoryEnabled(source));
}
toggleTool(key: string): void {
@@ -602,6 +660,17 @@ class ToolsStore {
return normalized;
}
private persistDisabledToolCategories(): void {
try {
localStorage.setItem(
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledToolCategories])
);
} catch {
// ignore storage errors
}
}
private persistDisabledTools(): void {
try {
localStorage.setItem(
+8 -2
View File
@@ -15,7 +15,7 @@ import type {
DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile
} from './database';
import type { MessageRole } from '$lib/enums';
import type { MessageRole, ToolSource } from '$lib/enums';
import { AgenticSectionType, ContinueIntentKind, ToolCallType } from '$lib/enums';
/**
@@ -162,6 +162,12 @@ export interface AgenticFlowOptions {
/**
* Parameters for starting an agentic flow
*/
/** Per-conversation tool policy, captured at flow start */
export interface AgenticToolPolicy {
disabledToolCategories: ToolSource[];
disabledTools: string[];
}
export interface AgenticFlowParams {
conversationId: string;
/** ID of the flow's first assistant message, used to keep its stats live */
@@ -170,7 +176,7 @@ export interface AgenticFlowParams {
options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks;
signal?: AbortSignal;
perChatOverrides?: McpServerOverride[];
toolPolicy?: AgenticToolPolicy;
}
/**
+11 -1
View File
@@ -1,6 +1,11 @@
import { AttachmentType, ReasoningEffort } from '$lib/enums';
import { AttachmentType, ReasoningEffort, ToolSource } from '$lib/enums';
import type { ChatMessageTimings, ChatMessageType, ChatRole } from '$lib/types/chat';
/**
* @deprecated Legacy per-conversation MCP server flags. MCP server enabled
* state is global now; per-conversation tool policy lives in
* `disabledTools` / `disabledToolCategories`. Read by the migration only.
*/
export interface McpServerOverride {
serverId: string;
enabled: boolean;
@@ -11,10 +16,15 @@ export interface DatabaseConversation {
id: string;
lastModified: number;
name: string;
/** @deprecated See {@link McpServerOverride}. Kept on rows for downgrade compatibility. */
mcpServerOverrides?: McpServerOverride[];
thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort;
cwd?: string;
/** Tool keys disabled for this conversation, incl. server-scoped MCP group keys (`mcp:<serverId>`) */
disabledTools?: string[];
/** Tool categories disabled for this conversation */
disabledToolCategories?: ToolSource[];
forkedFromConversationId?: string;
pinned?: boolean;
}
@@ -1,5 +0,0 @@
<script lang="ts">
import { SettingsMcpServers } from '$lib/components/app/settings';
</script>
<SettingsMcpServers class="mx-auto w-full p-4 md:p-8 md:py-8" />
@@ -1,38 +0,0 @@
<script lang="ts">
import { X } from '@lucide/svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon } from '$lib/components/app';
import { ROUTES } from '$lib/constants';
let { children } = $props();
let previousRouteId = $state<string | null>(null);
$effect(() => {
const currentId = page.route.id;
return () => {
previousRouteId = currentId;
};
});
function handleClose() {
const prevIsSettings = previousRouteId?.startsWith('/settings');
if (browser && window.history.length > 1 && !prevIsSettings) {
history.back();
} else {
goto(ROUTES.SETTINGS_EXIT);
}
}
</script>
<div class="fixed top-4.5 right-4 z-50 md:hidden">
<ActionIcon icon={X} onclick={handleClose} tooltip="Close" />
</div>
<div class="min-h-full">
{@render children?.()}
</div>
@@ -1,15 +0,0 @@
<script lang="ts">
import { afterNavigate, replaceState } from '$app/navigation';
import { page } from '$app/state';
import { SettingsChat } from '$lib/components/app/settings';
import { SETTINGS_SECTION_SLUGS } from '$lib/constants';
import { RouterService } from '$lib/services';
afterNavigate(() => {
if (!page.params.section) {
replaceState(RouterService.settings(SETTINGS_SECTION_SLUGS.GENERAL), {});
}
});
</script>
<SettingsChat initialSection={(page.params as Record<string, string | undefined>).section} />
@@ -1,151 +0,0 @@
import { CONFIG_LOCALSTORAGE_KEY, SETTINGS_KEYS } from '$lib/constants';
import type { DatabaseConversation } from '$lib/types/database';
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
// node env unit project has no DOM, install a minimal localStorage backed by a Map
beforeAll(() => {
const store = new Map<string, string>();
const polyfill: Storage = {
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? store.get(k)! : null),
key: (i) => Array.from(store.keys())[i] ?? null,
get length() {
return store.size;
},
removeItem: (k) => {
store.delete(k);
},
setItem: (k, v) => {
store.set(k, String(v));
}
};
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
});
/**
* Regression coverage for the bug where MCP servers flipped to "disabled"
* after sending the first message on a fresh chat (see comment in
* `MCPStore.createConversation`: empty `mcpServerOverrides` should inherit
* `mcpServers[i].enabled`, not be treated as all-off).
*/
describe('conversationsStore MCP override resolution', () => {
beforeEach(async () => {
localStorage.clear();
// Two configured servers: alpha is globally disabled, bravo enabled.
localStorage.setItem(
CONFIG_LOCALSTORAGE_KEY,
JSON.stringify({
[SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([
{ enabled: false, id: 'alpha', url: 'https://alpha.example.com/mcp' },
{ enabled: true, id: 'bravo', url: 'https://bravo.example.com/mcp' }
])
})
);
// The settings store constructor bails in node env (no `browser`),
// so seed the config directly. The shape mirrors what `loadConfig`
// would build from localStorage.
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}';
const saved = JSON.parse(raw) as Record<string, unknown>;
settingsStore.config = {
...settingsStore.config,
[SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS]
};
});
afterEach(() => {
localStorage.clear();
});
function makeConversation(
overrides?: { serverId: string; enabled: boolean }[]
): DatabaseConversation {
return {
currNode: null,
id: 'conv-1',
lastModified: 0,
mcpServerOverrides: overrides,
name: 'Test chat'
};
}
it('inherits server.enabled when no conversation is active', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
conversationsStore.activeConversation = null;
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true);
});
it('inherits server.enabled on a newly created chat with no overrides', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
conversationsStore.activeConversation = makeConversation();
// Empty override list: must fall back to global server.enabled, not all-off.
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true);
});
it('inherits server.enabled on a newly created chat when overrides is undefined', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
conversationsStore.activeConversation = makeConversation(undefined);
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(true);
});
it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
// Override flips bravo off for this chat, alpha keeps its global default.
conversationsStore.activeConversation = makeConversation([
{ enabled: false, serverId: 'bravo' }
]);
expect(conversationsStore.preferences.isMcpServerEnabledForChat('alpha')).toBe(false);
expect(conversationsStore.preferences.isMcpServerEnabledForChat('bravo')).toBe(false);
});
it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
conversationsStore.activeConversation = makeConversation([
{ enabled: true, serverId: 'alpha' }
]);
expect(conversationsStore.preferences.getAllMcpServerOverrides()).toEqual([
{ enabled: true, serverId: 'alpha' },
{ enabled: true, serverId: 'bravo' }
]);
});
it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
conversationsStore.activeConversation = makeConversation();
expect(conversationsStore.preferences.getAllMcpServerOverrides()).toEqual([
{ enabled: false, serverId: 'alpha' },
{ enabled: true, serverId: 'bravo' }
]);
});
it('getMcpServerOverride returns the global default when the server has no explicit override', async () => {
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
conversationsStore.activeConversation = makeConversation([
{ enabled: true, serverId: 'alpha' }
]);
expect(conversationsStore.preferences.getMcpServerOverride('bravo')).toEqual({
enabled: true,
serverId: 'bravo'
});
});
});
@@ -97,6 +97,38 @@ describe('parseModelId', () => {
});
});
it('strips trailing container format segments from model names', () => {
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
activatedParams: null,
modelName: 'DeepSeek-V4-Flash-0731',
orgName: 'unsloth',
params: null,
quantization: 'Q2_K_XL',
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
tags: []
});
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
activatedParams: null,
modelName: 'Laguna-S-2.1',
orgName: 'unsloth',
params: null,
quantization: 'Q4_K_XL',
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
tags: []
});
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
activatedParams: null,
modelName: 'Model-Name',
orgName: 'org',
params: null,
quantization: null,
raw: 'org/Model-Name-GGUF',
tags: []
});
});
it('handles real-world examples correctly', () => {
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
activatedParams: null,