ui: Replace per-conversation MCP overrides with per-conversation tool policy (#27745)

* 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

* ui: keep tool policy migration running when defaults parse fails

A corrupt disabledToolKeys localStorage entry no longer aborts the
migration; it falls through with empty defaults so legacy MCP server
overrides still get converted.

Assisted-by: pi

* ui: fall back to global defaults when agentic flow has no tool policy

Passing empty disabled sets bypassed the global defaults and could
enable tools for callers that do not pass a policy yet.

Assisted-by: pi

* ui: align preferences section headers with their methods

The Reasoning Effort and Working Directory headers sat above tool
policy methods; move them above setCwd and setReasoningEffort. Also
clarify the disabled tools JSDoc: existing rows with an unset field
have an empty policy, defaults apply only when there is no active
conversation.

Assisted-by: pi

* ui: gate MCP server avatars on conversation tool policy

Servers whose tools are disabled for the current conversation (MCP
category or server-scoped key) no longer show as enabled for the chat.

Assisted-by: pi

* ui: drop unused MCP category toggle from tools panel hook

Per-conversation MCP control is server-granular; no component renders
a whole-category toggle, so remove the dead API.

Assisted-by: pi

* ui: skip MCP init when flow policy disables the MCP category

Resolve the effective tool policy before deciding whether to
initialize MCP so flows that will not send any MCP tools skip the
init work. Callers without a policy keep falling back to global
defaults.

Assisted-by: pi

* chore: format

* ui: restore reasoning section in mobile add sheet

The sheet rewrite dropped it; the desktop dropdown still has it.
MCP Prompts and Resources stay out of the sheet on purpose.

Assisted-by: pi

* ui: clear MCP server group key in enableAllToolsForServer

The group key disables every tool of the server regardless of
per-tool keys, so re-enabling a server from Settings did nothing
while it was set.

Assisted-by: pi

* ui: skip MCP init when no policy-enabled server remains

Extends the category-level check: the flow also skips MCP init when
every globally-enabled server has its server-scoped group key
disabled in the tool policy.

Assisted-by: pi

* ui: make Settings tools tab edit defaults with category toggles

Adds per-category checkboxes and a caption stating the tab applies
to new conversations; tool picks inside a chat only affect that
chat.

Assisted-by: pi

* ui: gate cwd picker and mention picker on effective tool policy

Both checked the global disabled set directly, so a conversation
that disabled file_search still showed search as available.

Assisted-by: pi

* ui: clean up tool key helpers and store docs

Documents getEnabledToolsForLLM properly, unstacks the JSDoc at
isEntryEnabled, makes setToolEnabled persist like setCategoryEnabled
(toggleTool now delegates to it), and routes the serverId-less MCP
branch of toolKey through getMcpServerToolsKey so both key formats
come from one place. Preferences banner comments become plain
comments so they no longer read as class member docs.

Assisted-by: pi

* ui: indeterminate group checkboxes and inert grayed rows

A category that is on with nothing enabled under it now shows the
mixed checkbox state instead of a checked box next to 0/N. Rows
grayed out by a disabled parent no longer stay clickable behind
opacity.

Assisted-by: pi

* ui: gate MCP prompt and resource capabilities on tool policy

hasPromptsCapability and hasResourcesCapability accept an optional
set of usable server ids; ChatFormActions resolves it from global
enablement minus the active conversation's policy. Restores the
per-chat gating the old mcpServerOverrides provided; callers without
arguments keep global behavior.

Assisted-by: pi

* ui: remove unmounted MCP submenu component

Never rendered anywhere; its entries are duplicates (prompts and
resources live in the attachment menu, servers in the add menu and
sheet) that would need capability wiring maintained for nothing.

Assisted-by: pi

* ui: fix model information dialog width on all screen sizes

The dialog sets container-type: inline-size, so auto width ignores
its contents and collapses to padding. Give it an explicit viewport
width on mobile and cap at 60rem on desktop.

Assisted-by: pi

* ui: scroll wide chat template in model information dialog

Long unbreakable Jinja tokens blew out the table and dialog width;
the block now scrolls horizontally instead of stretching.

Assisted-by: pi

* ui: use fixed table layout in model information dialog

Auto table layout sizes columns to content min-content, so the chat
template's long lines kept inflating the dialog despite the scroll
wrapper. Fixed layout pins the first column and gives the value
column a definite width the wrapper can scroll within. min-w-0 on
the grid item guards the same path on the grid side.

Assisted-by: pi

* ui: make model information dialog full-screen on mobile

Matches the settings dialog pattern: full viewport below md,
calc-sized and capped at 60rem on desktop.

Assisted-by: pi

* ui: stack chat template row in model information dialog

Label above the block in a single full-width cell, so the template
gets the whole table width and its horizontal scroll is usable on
narrow screens.

Assisted-by: pi

* ui: scroll model information header with the content

The base dialog header is sticky; this dialog overrides it to
relative so the title and description scroll away with the body.
relative keeps the header as the close button's containing block.

Assisted-by: pi

* ui: replace literal comment text in sheet group snippet

A // line inside the Svelte snippet rendered as visible text; use an
HTML comment.

Assisted-by: pi

* ui: let indeterminate state win over checked in group checkboxes

The checkbox indicator snippet renders the check icon whenever
checked, so the mixed state never showed. Pass the checked prop
as false while indeterminate.

Assisted-by: pi

* ui: initialize only policy-enabled MCP servers for a flow

ensureInitialized accepts an optional server id set; the agentic
flow passes the servers its tool policy leaves usable, so servers
disabled for the conversation no longer get connected. Callers
without arguments keep the global behavior.

Assisted-by: pi

* ui: derive group checkbox state in useToolsPanel

Moves the mixed-state derivation out of the submenu and sheet
snippets into one getGroupCheckState accessor; the snippets just
consume checked and indeterminate.

Assisted-by: pi

* ui: gate /prompt command on the conversation tool policy

The slash command's availability now follows the same rule as the
agentic flow instead of the global capability check, so it disables
itself when the conversation's policy leaves no usable MCP server.

Assisted-by: pi

* ui: remove dead MCP prompt menu trigger chain

The /prompt slash command is the surviving trigger; the menu-button
path (onMcpPromptClick, hasMcpPromptsSupport, showMcpPromptButton,
the MCP_PROMPT attachment item and its unrendered item arrays) has
no consumer left. Message display for inserted prompts is untouched.

Assisted-by: pi

* ui: render dash for mixed-state group checkboxes

The accessor refactor dropped the checked-and-not-indeterminate
guard, so the category-on flag won and the dash never showed. The
tooltip keeps using the raw parent flag since clicking a mixed
group still disables it.

Assisted-by: pi

* ui: fix group checkbox sticking checked after disable

Clicking a mixed-state group box let bits-ui optimistically flip
its internal checked flag; the derived checked prop did not change
across the transition (both mixed and off map to checked=false),
so Svelte never applied the settled value and the check icon stuck
while the count already read 0/7.

Pass the parent flag as checked and the mix as indeterminate, so
every group toggle changes checked; render the dash on top of a
checked box for the mixed state.

Assisted-by: pi

* fix: UI for Model Information dialog

* ui: keep MCP connections stable across policy switches

ensureInitialized folds the policy into its config signature, so
alternating two conversations with different policies tore down and
reconnected every server with health checks included. Tool collection
already filters by the flow policy, so initialize every
settings-enabled server instead and never pass a policy into the MCP
config. The duplicated policy-server check becomes one accessor on
ConversationPreferences.

Assisted-by: pi

* ui: remove dead MCP resources menu trigger chain

Same shape as the earlier prompt trigger cleanup: nothing renders the
MCP resources menu button, and the only live entry into resource
browsing is Settings > MCP Servers plus the attachment resource
picker. Drop onMcpResourcesClick, hasMcpResourcesSupport,
MCP_RESOURCES_CLICK, the AttachmentItemVisibleWhen enum and
hasResourcesCapability; the resources display, browser and picker
components are untouched.

Assisted-by: pi
This commit is contained in:
Aleksander Grygier
2026-08-27 13:08:01 +02:00
committed by GitHub
parent 2bb9bddafa
commit fe235f4343
36 changed files with 838 additions and 949 deletions
@@ -23,7 +23,8 @@
FileExtensionText,
KeyboardKey,
MimeTypeText,
SpecialFileType
SpecialFileType,
ToolSource
} from '$lib/enums';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import {
@@ -73,7 +74,6 @@
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
showAddButton?: boolean;
showModelSelector?: boolean;
@@ -103,7 +103,6 @@
onValueChange,
placeholder = 'Type a message...',
showAddButton = true,
showMcpPromptButton = false,
showModelSelector = true,
uploadedFiles = $bindable([]),
value = $bindable('')
@@ -152,9 +151,18 @@
getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector,
getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools,
hasPrompts: () =>
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()),
hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
// policy-aware, same rule as the agentic flow: MCP category on and at
// least one globally-enabled server whose group key is not disabled
hasPrompts: () => {
const prefs = conversationsStore.preferences;
if (!prefs.isCategoryEnabled(ToolSource.MCP)) return false;
return mcpStore
.getServers()
.some((s) => s.enabled && prefs.isServerToolsEnabled(s.id) && s.url.trim());
},
openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => {
@@ -620,8 +628,6 @@
isReasoning={chatStore.isReasoning}
{isRecording}
onFileUpload={handleFileUpload}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
onMicClick={handleMicClick}
{onStop}
@@ -635,7 +641,7 @@
<ContextGaugePopup />
{#if toolsStore.hasEnabledCwdTools}
{#if conversationsStore.preferences.hasEnabledCwdTools()}
<ChatFormCurrentWorkingDirectory
bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor}
@@ -30,15 +30,11 @@
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}),
() => {
@@ -1,51 +0,0 @@
<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>
@@ -1,18 +1,18 @@
<script lang="ts">
import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import {
Check,
ChevronDown,
ChevronRight,
File,
Lightbulb,
LightbulbOff,
MessageSquare,
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 +20,11 @@
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,23 +37,18 @@
const chatFormActions = getChatFormActionsContext();
let sheetOpen = $state(false);
let reasoningExpanded = $state(false);
let filesExpanded = $state(true);
let reasoningExpanded = $state(false);
let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu(
() => ({
hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality
}),
() => ({
onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick
}),
() => {
@@ -70,8 +64,6 @@
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}">
@@ -194,80 +186,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 +216,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 +230,55 @@
<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 checkState = toolsPanel.getGroupCheckState(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)}
{@const favicon = toolsPanel.getFavicon(group)}
{@const groupDisabled = toolsPanel.isGroupDisabled(group)}
<button
class="{sheetItemRowClass} {groupDisabled ? 'pointer-events-none 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={checkState.checked}
class="{ICON_CLASS_DEFAULT} shrink-0"
indeterminate={checkState.indeterminate}
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,108 @@
{/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 checkState = toolsPanel.getGroupCheckState(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 ? 'pointer-events-none 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={checkState.checked}
class="mr-2 {ICON_CLASS_DEFAULT} shrink-0"
indeterminate={checkState.indeterminate}
onCheckedChange={() => toolsPanel.toggleGroupByKey(group.key)}
/>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
<p>
{checkState.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}
@@ -13,7 +13,7 @@
import { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services';
import { chatStore, conversationsStore, mcpStore, settingsStore } from '$lib/stores';
import { chatStore, conversationsStore, settingsStore } from '$lib/stores';
import { getFileTypeCategory } from '$lib/utils';
interface Props {
@@ -31,8 +31,6 @@
onMicClick?: () => void;
onStop?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
@@ -45,8 +43,6 @@
isReasoning = false,
isRecording = false,
onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick,
onMicClick,
onStop,
@@ -58,18 +54,6 @@
let currentConfig = $derived(settingsStore.config);
let hasMcpPromptsSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasPromptsCapability(perChatOverrides);
});
let hasMcpResourcesSupport = $derived.by(() => {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides();
return mcpStore.hasResourcesCapability(perChatOverrides);
});
let hasAudioModality = $state(false);
let hasVideoModality = $state(false);
let hasVisionModality = $state(false);
@@ -142,12 +126,6 @@
get hasAudioModality() {
return hasAudioModality;
},
get hasMcpPromptsSupport() {
return hasMcpPromptsSupport;
},
get hasMcpResourcesSupport() {
return hasMcpResourcesSupport;
},
get hasVideoModality() {
return hasVideoModality;
},
@@ -157,12 +135,6 @@
get onFileUpload() {
return onFileUpload;
},
get onMcpPromptClick() {
return onMcpPromptClick;
},
get onMcpResourcesClick() {
return onMcpResourcesClick;
},
get onMcpSettingsClick() {
return onMcpSettingsClick;
},
@@ -5,12 +5,12 @@
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants';
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
import { BuiltInTool, GlobSearchType, KeyboardKey, ToolSource } from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service';
import { toolsStore } from '$lib/stores';
import { conversationsStore, toolsStore } from '$lib/stores';
import type { GlobEntry } from '$lib/types';
import {
abbreviateHome,
@@ -63,8 +63,11 @@
// unavailable instead of firing searches that would only fail. Browse is
// hidden too: it resolves the picked folder name through the same tool.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
);
const searchUnavailableMessage = $derived(
fileSearchKey === null
@@ -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,10 +5,16 @@
import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_GLOB_SEARCH_PICKERS, HOME_TILDE, SEARCH } from '$lib/constants';
import { BuiltInTool, FileMentionEntryType, GlobSearchType, KeyboardKey } from '$lib/enums';
import {
BuiltInTool,
FileMentionEntryType,
GlobSearchType,
KeyboardKey,
ToolSource
} from '$lib/enums';
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { deviceStore, settingsStore, toolsStore } from '$lib/stores';
import { conversationsStore, deviceStore, settingsStore, toolsStore } from '$lib/stores';
import type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -52,8 +58,11 @@
// --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey)
fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
);
let searchResults = $state<FileMentionEntry[]>([]);
@@ -111,7 +111,6 @@
onValueChange={editCtx.setContent}
placeholder="Edit your message..."
showAddButton={editCtx.messageRole === MessageRole.USER}
showMcpPromptButton
showModelSelector={editCtx.messageRole === MessageRole.USER}
value={editCtx.editedContent}
/>
@@ -160,6 +160,5 @@
onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove}
showMcpPromptButton
/>
</div>
@@ -220,19 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte';
/**
* Dropdown submenu for MCP prompts and resources in the chat form.
*
* Shows an "MCP" sub-menu item with entries for MCP Prompts and MCP
* Resources. Only visible when the server supports them.
*
* @example
* ```svelte
* <ChatFormActionAddMcpSubmenu />
* ```
*/
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();
@@ -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);
}
@@ -76,22 +76,19 @@
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="@container z-9999 !max-h-[80dvh] !max-w-[60rem] max-w-full">
<style>
@container (max-width: 56rem) {
.resizable-text-container {
max-width: calc(100vw - var(--threshold));
}
}
</style>
<Dialog.Content
class="z-9999 max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! md:w-[calc(100vw-4rem)]! md:max-w-[60rem]! md:max-h-[80dvh]!"
>
<!-- sticky header holds only the close button; the title scrolls with the body -->
<Dialog.Header />
<Dialog.Header>
<Dialog.Title>Model Information</Dialog.Title>
<div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
<div class="min-w-0 space-y-2">
<Dialog.Title>Model Information</Dialog.Title>
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</Dialog.Header>
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</div>
<div class="space-y-6 py-4">
{#if isLoadingModels || isLoadingRouterProps}
<div class="flex items-center justify-center py-8">
<div class="text-sm text-muted-foreground">Loading model information...</div>
@@ -100,17 +97,15 @@
{@const modelMeta = firstModel.meta}
{#if serverProps}
<Table.Root>
<!-- Desktop: fixed-layout table, long values scroll inside their cell -->
<Table.Root class="hidden table-fixed md:table">
<Table.Header>
<Table.Row>
<Table.Head class="w-[10rem]">Model</Table.Head>
<Table.Head>
<div class="inline-flex items-center gap-2">
<span
style:--threshold="12rem"
class="resizable-text-container min-w-0 flex-1 truncate"
>
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{modelName}
</span>
@@ -129,20 +124,17 @@
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
<Table.Cell
class="inline-flex h-10 items-center gap-2 align-middle font-mono text-xs"
>
<span
style:--threshold="14rem"
class="resizable-text-container min-w-0 flex-1 truncate"
>
{serverProps.model_path}
</span>
<Table.Cell class="h-10 align-middle font-mono text-xs">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{serverProps.model_path}
</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
</div>
</Table.Cell>
</Table.Row>
@@ -251,18 +243,113 @@
<!-- Chat Template -->
{#if serverProps.chat_template}
<Table.Row>
<Table.Cell class="align-middle font-medium">Chat Template</Table.Cell>
<Table.Cell class="py-4" colspan={2}>
<div class="flex flex-col gap-2">
<span class="font-medium">Chat Template</span>
<Table.Cell class="py-10">
<div class="rounded-md bg-muted p-4">
<pre
class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre>
<div class="overflow-x-auto rounded-md bg-muted p-4">
<pre
class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
</div>
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
<!-- Mobile: stacked layout; long values wrap instead of scrolling the page -->
<div class="flex min-w-0 flex-col gap-4 md:hidden">
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Model</div>
<div class="flex min-w-0 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs">{modelName}</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model name to clipboard"
canCopy={!!modelName}
text={modelName || ''}
/>
</div>
</div>
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">File Path</div>
<div class="flex min-w-0 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs"
>{serverProps.model_path}</span
>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
</div>
</div>
{#if serverProps?.default_generation_settings?.n_ctx}
{@render infoRow(
'Context Size',
`${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
)}
{:else}
{@render infoRow('Context Size', 'Not available', 'text-red-500')}
{/if}
{#if modelMeta?.n_ctx_train}
{@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
{/if}
{#if modelMeta?.size}
{@render infoRow('Model Size', formatFileSize(modelMeta.size))}
{/if}
{#if modelMeta?.n_params}
{@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
{/if}
{#if modelMeta?.n_embd}
{@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
{/if}
{#if modelMeta?.n_vocab}
{@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
{/if}
{#if modelMeta?.vocab_type}
{@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
{/if}
{@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
{#if modalities.length > 0}
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Modalities</div>
<div class="flex flex-wrap gap-1">
<BadgesModality {modalities} />
</div>
</div>
{/if}
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Build Info</div>
<span class="block break-all font-mono text-xs">{serverProps.build_info}</span>
</div>
{#if serverProps.chat_template}
<div class="min-w-0 space-y-2">
<div class="text-xs font-medium text-muted-foreground">Chat Template</div>
<div class="overflow-x-auto rounded-md bg-muted p-4">
<pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
</div>
</div>
{/if}
</div>
{/if}
{:else if !isLoadingModels}
<div class="flex items-center justify-center py-8">
@@ -272,3 +359,11 @@
</div>
</Dialog.Content>
</Dialog.Root>
{#snippet infoRow(label: string, value: string, valueClass: string = '')}
<div class="flex items-center justify-between gap-3">
<span class="shrink-0 text-xs font-medium text-muted-foreground {valueClass}">{label}</span>
<span class="text-sm {valueClass}">{value}</span>
</div>
{/snippet}
@@ -2,7 +2,7 @@
import McpLogo from './McpLogo.svelte';
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 { HealthCheckStatus, ToolSource } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
interface Props {
@@ -13,9 +13,13 @@
let { class: className = '', onclick }: Props = $props();
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
// respect the active conversation's tool policy, not just global enablement
let enabledMcpServersForChat = $derived(
mcpServers.filter(
(s) => conversationsStore.preferences.isMcpServerEnabledForChat(s.id) && s.url.trim()
(s) =>
s.url.trim() &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP) &&
conversationsStore.preferences.isServerToolsEnabled(s.id)
)
);
let healthyEnabledMcpServers = $derived(
@@ -25,6 +25,10 @@
<div class="py-8 text-center text-sm text-muted-foreground">No tools available</div>
{:else}
<div class="space-y-2">
<p class="text-sm text-muted-foreground">
Applies to new conversations. Tool picks inside a chat only affect that chat.
</p>
{#each groups as group (group.key)}
{@const isExpanded = expandedGroups.has(group.key)}
<Collapsible.Root onOpenChange={() => toggleExpanded(group.key)} open={isExpanded}>
@@ -37,6 +41,17 @@
<ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if}
{@const isCategoryEnabled =
group.source !== ToolSource.MCP && toolsStore.isCategoryEnabled(group.source)}
{#if group.source !== ToolSource.MCP}
<Checkbox
checked={isCategoryEnabled}
onCheckedChange={() => toolsStore.toggleCategory(group.source)}
onclick={(e) => e.stopPropagation()}
/>
{/if}
{@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium">
@@ -7,7 +7,7 @@
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
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';
@@ -86,15 +86,13 @@
<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
@@ -26,10 +26,10 @@
>
{#snippet children({ checked, indeterminate })}
<div class="text-current transition-none" data-slot="checkbox-indicator">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
{#if indeterminate}
<MinusIcon class="size-3.5" />
{:else if checked}
<CheckIcon class="size-3.5" />
{/if}
</div>
{/snippet}
@@ -1,11 +1,5 @@
import { FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import { FILE_TYPE_ICONS } from '$lib/constants';
import {
AttachmentAction,
AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId
} from '$lib/enums';
import { AttachmentAction, AttachmentItemEnabledWhen, AttachmentMenuItemId } from '$lib/enums';
import type { AttachmentMenuItem } from '$lib/types';
/**
@@ -58,36 +52,4 @@ export const ATTACHMENT_FILE_ITEMS: AttachmentMenuItem[] = [
}
];
export const ATTACHMENT_EXTRA_ITEMS: AttachmentMenuItem[] = [];
export const ATTACHMENT_PROMPT_ITEMS: AttachmentMenuItem[] = [
{
action: AttachmentAction.SYSTEM_PROMPT_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
hasEnabledTooltip: true,
icon: MessageSquare,
id: AttachmentMenuItemId.SYSTEM_MESSAGE,
label: 'System Message'
},
{
action: AttachmentAction.MCP_PROMPT_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
icon: Zap,
id: AttachmentMenuItemId.MCP_PROMPT,
label: 'MCP Prompts',
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_PROMPTS_SUPPORT
}
];
export const ATTACHMENT_MCP_ITEMS: AttachmentMenuItem[] = [
{
action: AttachmentAction.MCP_RESOURCES_CLICK,
enabledWhen: AttachmentItemEnabledWhen.ALWAYS,
icon: FolderOpen,
id: AttachmentMenuItemId.MCP_RESOURCES,
label: 'MCP Resources',
visibleWhen: AttachmentItemVisibleWhen.HAS_MCP_RESOURCES_SUPPORT
}
];
export const ATTACHMENT_TOOLTIP_TEXT = 'Add files, prompts, tools or MCP Servers';
@@ -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`;
@@ -19,8 +19,6 @@ export enum AttachmentType {
export enum AttachmentMenuItemId {
AUDIO = 'audio',
IMAGES = 'images',
MCP_PROMPT = 'mcp-prompt',
MCP_RESOURCES = 'mcp-resources',
PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message',
TEXT = 'text',
@@ -42,8 +40,6 @@ export enum AttachmentItemEnabledWhen {
*/
export enum AttachmentAction {
FILE_UPLOAD = 'onFileUpload',
MCP_PROMPT_CLICK = 'onMcpPromptClick',
MCP_RESOURCES_CLICK = 'onMcpResourcesClick',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick'
}
@@ -56,11 +52,3 @@ export enum AttachmentLabel {
MCP_RESOURCE = 'MCP Resource',
PDF_FILE = 'PDF File'
}
/**
* Visibility conditions for attachment menu items.
*/
export enum AttachmentItemVisibleWhen {
HAS_MCP_PROMPTS_SUPPORT = 'hasMcpPromptsSupport',
HAS_MCP_RESOURCES_SUPPORT = 'hasMcpResourcesSupport'
}
+1 -2
View File
@@ -3,8 +3,7 @@ export {
AttachmentType,
AttachmentMenuItemId,
AttachmentItemEnabledWhen,
AttachmentAction,
AttachmentItemVisibleWhen
AttachmentAction
} from './attachment.enums';
export {
@@ -5,21 +5,16 @@ export interface AttachmentModalityFlags {
hasVisionModality: boolean;
hasAudioModality: boolean;
hasVideoModality: boolean;
hasMcpPromptsSupport: boolean;
hasMcpResourcesSupport: boolean;
}
export interface AttachmentActionCallbacks {
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
}
export interface UseAttachmentMenuReturn {
readonly callbacks: Record<string, () => void>;
isItemEnabled(enabledWhen: string | undefined): boolean;
isItemVisible(visibleWhen: string | undefined): boolean;
getSystemMessageTooltip(): string;
}
@@ -49,8 +44,6 @@ export function useAttachmentMenu(
return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload),
[AttachmentAction.MCP_PROMPT_CLICK]: wrap(cbs.onMcpPromptClick),
[AttachmentAction.MCP_RESOURCES_CLICK]: wrap(cbs.onMcpResourcesClick),
[AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick)
};
});
@@ -61,12 +54,6 @@ export function useAttachmentMenu(
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags];
}
function isItemVisible(visibleWhen: string | undefined): boolean {
if (!visibleWhen) return true;
return !!modalityFlags[visibleWhen as keyof AttachmentModalityFlags];
}
function getSystemMessageTooltip(): string {
return !page.params.id
? 'Add custom system message for a new conversation'
@@ -78,7 +65,6 @@ export function useAttachmentMenu(
return callbacks;
},
getSystemMessageTooltip,
isItemEnabled,
isItemVisible
isItemEnabled
};
}
@@ -1,19 +1,23 @@
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;
isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number;
getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean };
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;
@@ -26,19 +30,18 @@ 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)
)
);
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
// 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 noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null;
@@ -56,11 +59,27 @@ 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;
}
/**
* Group checkbox state: checked is the parent flag (category on, or the
* server key on for MCP groups); indeterminate marks the mixed case where
* the parent is on but nothing or only part of the group is enabled.
* isToolActive folds the parent gates into the count, so a disabled parent
* always yields plain unchecked.
*/
function getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean } {
const checked = isGroupChecked(group);
const enabledCount = getEnabledToolCount(group);
const indeterminate =
group.tools.length > 0 && (enabledCount === 0 ? checked : enabledCount < group.tools.length);
return { checked, indeterminate };
}
function getFavicon(group: ToolGroup): string | null {
@@ -70,13 +89,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 +118,11 @@ 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 handleOpen(): void {
@@ -103,23 +134,27 @@ export function useToolsPanel(): UseToolsPanelReturn {
}
return {
get activeGroups() {
return activeGroups;
get categoryGroups() {
return categoryGroups;
},
expandedGroups,
getEnabledToolCount,
getFavicon,
get groups() {
return groups;
},
getGroupCheckState,
handleOpen,
isGroupChecked,
isGroupDisabled,
isToolEnabled,
isToolParentDisabled,
get mcpGroups() {
return mcpGroups;
},
get noToolsInfoMessage() {
return noToolsInfoMessage;
},
toggleGroupByKey,
toggleGroupExpanded,
toggleTool,
get totalToolCount() {
return totalToolCount;
}
+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 {
// fall through with empty defaults so legacy overrides still migrate
}
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 = {
@@ -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,28 @@ 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);
// callers without an explicit policy fall back to the global defaults
const disabledTools = new Set(toolPolicy?.disabledTools ?? toolsStore.disabledTools);
const disabledToolCategories = new Set(
toolPolicy?.disabledToolCategories ?? toolsStore.disabledToolCategories
);
// initialize every settings-enabled server; tool collection filters by this
// flow's policy, so switching policies never re-initializes connections
const hasMcpServers = conversationsStore.preferences.policyEnabledServerIds().length > 0;
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(disabledTools, 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,23 @@
/**
* 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 +50,26 @@ export interface ConversationsPreferencesHost {
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void;
}
/**
* Effective disabled tool keys: the active conversation row, or the global
* defaults when there is no conversation. An existing row with an unset
* field has an empty policy, not a fallback to defaults.
*/
function buildDisabledTools(conv: DatabaseConversation | null): Set<string> {
return new Set(conv ? (conv.disabledTools ?? []) : [...toolsStore.disabledTools]);
}
/**
* Effective disabled tool categories: the active conversation row, or the
* global defaults when there is no conversation. An existing row with an
* unset field has an empty policy, not a fallback 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 +83,29 @@ export class ConversationPreferences {
/** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
constructor(private host: ConversationsPreferencesHost) {}
/**
* 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 };
});
private get _disabledToolCategories(): Set<ToolSource> {
return buildDisabledToolCategories(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.
*/
getMcpServerOverride(serverId: string): McpServerOverride | undefined {
const override = this.host.activeConversation?.mcpServerOverrides?.find(
(o: McpServerOverride) => o.serverId === serverId
);
// Tool Policy
if (override) return override;
// 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);
}
return this.getDefaultOverride(serverId);
constructor(private host: ConversationsPreferencesHost) {}
/** 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 +129,71 @@ 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)
);
}
/**
* MCP servers usable under the effective policy: globally enabled, url set,
* MCP category on and the server-scoped key not disabled.
*/
policyEnabledServerIds(): string[] {
if (!this.isCategoryEnabled(ToolSource.MCP)) return [];
return mcpStore
.getServers()
.filter(
(server) => server.enabled && server.url.trim() && this.isServerToolsEnabled(server.id)
)
.map((server) => server.id);
}
/** Reload persisted defaults, e.g. when the active conversation is cleared. */
@@ -132,6 +202,8 @@ export class ConversationPreferences {
this.pendingCwd = null;
}
// Working Directory
/**
* Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state.
@@ -165,56 +237,7 @@ 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
});
}
// Reasoning Effort
/**
* Sets the reasoning effort for the active conversation.
@@ -229,33 +252,82 @@ 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);
}
/**
* Resolve the default enabled value for a server: its own `enabled`
* flag in `mcpServers`, so the global on/off state lives in one place.
*/
private getDefaultOverride(serverId: string): McpServerOverride | undefined {
const server = mcpStore.getServers().find((s) => s.id === serverId);
async toggleGroup(group: ToolGroup): Promise<void> {
if (group.source === ToolSource.MCP && group.serverId) {
await this.toggleServerTools(group.serverId);
} else {
await this.toggleCategory(group.source);
}
}
if (!server) return undefined;
async toggleServerTools(serverId: string): Promise<void> {
await this.toggleTool(toolsStore.getMcpServerToolsKey(serverId));
}
return { enabled: server.enabled, serverId };
async toggleTool(key: string): Promise<void> {
const conv: DatabaseConversation | null = this.host.activeConversation;
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 });
}
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 });
}
}
+20 -130
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,16 @@ class MCPStore implements McpHealthHost {
return extras;
}
async ensureInitialized(perChatOverrides?: McpServerOverride[]): Promise<boolean> {
/**
* Initialize every settings-enabled server. Policy filtering happens at tool
* collection time, so switching conversation policies never re-initializes.
*/
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 +516,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,106 +807,8 @@ class MCPStore implements McpHealthHost {
);
}
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides));
}
/**
* Check if any enabled server with successful health check supports prompts.
* 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)
);
}
if (enabledServerIds.size === 0) {
return false;
}
for (const [serverId, state] of Object.entries(this.health.checks)) {
if (!enabledServerIds.has(serverId)) continue;
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.prompts !== undefined
) {
return true;
}
}
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
hasPromptsSupport(): boolean {
for (const connection of this.connections.values()) {
if (connection.serverCapabilities?.prompts) {
return true;
}
}
return false;
}
/**
* Check if any enabled server with successful health check supports resources.
* 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)
);
}
if (enabledServerIds.size === 0) {
return false;
}
for (const [serverId, state] of Object.entries(this.health.checks)) {
if (!enabledServerIds.has(serverId)) continue;
if (
state.status === HealthCheckStatus.SUCCESS &&
state.capabilities?.server?.resources !== undefined
) {
return true;
}
}
for (const [serverName, connection] of this.connections) {
if (!enabledServerIds.has(serverName)) continue;
if (MCPService.supportsResources(connection)) {
return true;
}
}
return false;
hasEnabledServers(): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config));
}
/**
@@ -1185,10 +1083,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 +1093,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 +1147,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: {
@@ -1378,6 +1259,15 @@ class MCPStore implements McpHealthHost {
return `${MCP_SERVER_ID_PREFIX}-${index + 1}`;
}
/** Server ids that are usable right now: globally enabled ones. */
private globalEnabledServerIds(): Set<string> {
return new Set(
this.getServers()
.filter((s) => s.enabled)
.map((s) => s.id)
);
}
private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName);
+110 -42
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;
}
@@ -233,9 +221,13 @@ class ToolsStore {
if (!connection) return;
// the server-scoped group key disables every tool regardless of per-tool keys
this._disabledTools.delete(this.getMcpServerToolsKey(serverId));
for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
}
this.persistDisabledTools();
}
@@ -272,16 +264,21 @@ class ToolsStore {
}
/**
* Enabled tool definitions for sending to the LLM.
* Enabled tool definitions for sending to the LLM. Callers pass an
* explicit policy (the active conversation's, resolved with global
* defaults when absent); without arguments the store defaults apply.
* 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 +303,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 +335,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 +379,45 @@ 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);
}
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,33 +450,32 @@ 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);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
}
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 {
if (this._disabledTools.has(key)) {
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
this.setToolEnabled(key, !this.isToolEnabled(key));
}
/** First canonical entry matching a tool name, runtime tool calls resolve by name */
@@ -602,6 +657,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(
@@ -637,7 +703,9 @@ class ToolsStore {
private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) {
case ToolSource.MCP:
return serverId ? `mcp-${serverId}:${name}` : `mcp:${name}`;
// with a serverId this is a per-tool key; without one it hits the
// server group key shape, which no MCP entry ever does
return serverId ? `mcp-${serverId}:${name}` : this.getMcpServerToolsKey(name);
case ToolSource.CUSTOM:
return `custom:${name}`;
case ToolSource.BROWSER:
+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;
}
/**
-7
View File
@@ -3,7 +3,6 @@ import type { DatabaseMessage, DatabaseMessageExtra } from './database';
import type {
AttachmentAction,
AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId,
ChatFormCommandAction,
ErrorDialogType,
@@ -30,8 +29,6 @@ export interface AttachmentMenuItem {
disabledTooltip?: string;
/** Callback key on the Props interface to invoke when clicked */
action: AttachmentAction;
/** Whether the item is only shown when a specific capability is present */
visibleWhen?: AttachmentItemVisibleWhen;
/** Whether this item has a tooltip even when enabled (uses dynamic text) */
hasEnabledTooltip?: boolean;
}
@@ -336,11 +333,7 @@ export interface ChatFormActionsContext {
readonly hasAudioModality: boolean;
readonly hasVideoModality: boolean;
readonly hasVisionModality: boolean;
readonly hasMcpPromptsSupport: boolean;
readonly hasMcpResourcesSupport: boolean;
onFileUpload?: () => void;
onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void;
}
+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,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'
});
});
});