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, FileExtensionText,
KeyboardKey, KeyboardKey,
MimeTypeText, MimeTypeText,
SpecialFileType SpecialFileType,
ToolSource
} from '$lib/enums'; } from '$lib/enums';
import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte'; import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte';
import { import {
@@ -73,7 +74,6 @@
disabled?: boolean; disabled?: boolean;
isLoading?: boolean; isLoading?: boolean;
placeholder?: string; placeholder?: string;
showMcpPromptButton?: boolean;
showAddButton?: boolean; showAddButton?: boolean;
showModelSelector?: boolean; showModelSelector?: boolean;
@@ -103,7 +103,6 @@
onValueChange, onValueChange,
placeholder = 'Type a message...', placeholder = 'Type a message...',
showAddButton = true, showAddButton = true,
showMcpPromptButton = false,
showModelSelector = true, showModelSelector = true,
uploadedFiles = $bindable([]), uploadedFiles = $bindable([]),
value = $bindable('') value = $bindable('')
@@ -152,9 +151,18 @@
getServerHome: () => toolsStore.serverHome ?? null, getServerHome: () => toolsStore.serverHome ?? null,
getShowModelSelector: () => showModelSelector, getShowModelSelector: () => showModelSelector,
getValue: () => value, getValue: () => value,
hasCwdTools: () => toolsStore.hasEnabledCwdTools, hasCwdTools: () => conversationsStore.preferences.hasEnabledCwdTools(),
hasPrompts: () => // policy-aware, same rule as the agentic flow: MCP category on and at
mcpStore.hasPromptsCapability(conversationsStore.preferences.getAllMcpServerOverrides()), // 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(), openModelSelector: () => chatFormActionsRef?.openModelSelector(),
setCaretOffset: (offset) => inputRef?.setCaretOffset(offset), setCaretOffset: (offset) => inputRef?.setCaretOffset(offset),
setValue: (v) => { setValue: (v) => {
@@ -620,8 +628,6 @@
isReasoning={chatStore.isReasoning} isReasoning={chatStore.isReasoning}
{isRecording} {isRecording}
onFileUpload={handleFileUpload} onFileUpload={handleFileUpload}
onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined}
onMcpResourcesClick={() => (isResourceDialogOpen = true)}
onMcpSettingsClick={() => (isMcpServersDialogOpen = true)} onMcpSettingsClick={() => (isMcpServersDialogOpen = true)}
onMicClick={handleMicClick} onMicClick={handleMicClick}
{onStop} {onStop}
@@ -635,7 +641,7 @@
<ContextGaugePopup /> <ContextGaugePopup />
{#if toolsStore.hasEnabledCwdTools} {#if conversationsStore.preferences.hasEnabledCwdTools()}
<ChatFormCurrentWorkingDirectory <ChatFormCurrentWorkingDirectory
bind:query={pickers.workingDirectoryQuery} bind:query={pickers.workingDirectoryQuery}
customAnchor={mentionAnchor} customAnchor={mentionAnchor}
@@ -30,15 +30,11 @@
const attachmentMenu = useAttachmentMenu( const attachmentMenu = useAttachmentMenu(
() => ({ () => ({
hasAudioModality: chatFormActions.hasAudioModality, hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality: chatFormActions.hasVideoModality, hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality hasVisionModality: chatFormActions.hasVisionModality
}), }),
() => ({ () => ({
onFileUpload: chatFormActions.onFileUpload, onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick 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"> <script lang="ts">
import { File, FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import { import {
Check, Check,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
File,
Lightbulb, Lightbulb,
LightbulbOff, LightbulbOff,
MessageSquare,
PencilRuler PencilRuler
} from '@lucide/svelte'; } from '@lucide/svelte';
import { McpLogo } from '$lib/components/app'; import { McpLogo } from '$lib/components/app';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
import * as Collapsible from '$lib/components/ui/collapsible'; import * as Collapsible from '$lib/components/ui/collapsible';
import * as Sheet from '$lib/components/ui/sheet'; import * as Sheet from '$lib/components/ui/sheet';
import { Switch } from '$lib/components/ui/switch';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { import {
ATTACHMENT_FILE_ITEMS, ATTACHMENT_FILE_ITEMS,
@@ -20,12 +20,11 @@
TOOLTIP_DELAY_DURATION TOOLTIP_DELAY_DURATION
} from '$lib/constants'; } from '$lib/constants';
import { getChatFormActionsContext } from '$lib/contexts'; import { getChatFormActionsContext } from '$lib/contexts';
import { HealthCheckStatus } from '$lib/enums';
import { AttachmentAction } from '$lib/enums/attachment.enums'; import { AttachmentAction } from '$lib/enums/attachment.enums';
import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte'; import { useAttachmentMenu } from '$lib/hooks/use-attachment-menu.svelte';
import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte'; import { useReasoningMenu } from '$lib/hooks/use-reasoning-menu.svelte';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.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'; import type { Snippet } from 'svelte';
interface Props { interface Props {
@@ -38,23 +37,18 @@
const chatFormActions = getChatFormActionsContext(); const chatFormActions = getChatFormActionsContext();
let sheetOpen = $state(false); let sheetOpen = $state(false);
let reasoningExpanded = $state(false);
let filesExpanded = $state(true); let filesExpanded = $state(true);
let reasoningExpanded = $state(false);
let toolsExpanded = $state(false); let toolsExpanded = $state(false);
let mcpExpanded = $state(false);
const attachmentMenu = useAttachmentMenu( const attachmentMenu = useAttachmentMenu(
() => ({ () => ({
hasAudioModality: chatFormActions.hasAudioModality, hasAudioModality: chatFormActions.hasAudioModality,
hasMcpPromptsSupport: chatFormActions.hasMcpPromptsSupport,
hasMcpResourcesSupport: chatFormActions.hasMcpResourcesSupport,
hasVideoModality: chatFormActions.hasVideoModality, hasVideoModality: chatFormActions.hasVideoModality,
hasVisionModality: chatFormActions.hasVisionModality hasVisionModality: chatFormActions.hasVisionModality
}), }),
() => ({ () => ({
onFileUpload: chatFormActions.onFileUpload, onFileUpload: chatFormActions.onFileUpload,
onMcpPromptClick: chatFormActions.onMcpPromptClick,
onMcpResourcesClick: chatFormActions.onMcpResourcesClick,
onSystemPromptClick: chatFormActions.onSystemPromptClick onSystemPromptClick: chatFormActions.onSystemPromptClick
}), }),
() => { () => {
@@ -70,8 +64,6 @@
const sheetItemRowClass = 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'; '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> </script>
<div class="flex items-center gap-1 {className}"> <div class="flex items-center gap-1 {className}">
@@ -194,80 +186,15 @@
</Collapsible.Content> </Collapsible.Content>
</Collapsible.Root> </Collapsible.Root>
<Collapsible.Root onOpenChange={(open) => (mcpExpanded = open)} open={mcpExpanded}> <button
<Collapsible.Trigger class={sheetItemClass}> class={sheetItemClass}
{#if mcpExpanded} onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()}
<ChevronDown class="{ICON_CLASS_DEFAULT} shrink-0" /> type="button"
{:else} >
<ChevronRight class="{ICON_CLASS_DEFAULT} shrink-0" /> <MessageSquare class="{ICON_CLASS_DEFAULT} shrink-0" />
{/if}
<McpLogo class="inline {ICON_CLASS_DEFAULT} shrink-0" /> <span>System Message</span>
</button>
<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>
{#if toolsPanel.totalToolCount > 0} {#if toolsPanel.totalToolCount > 0}
<Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}> <Collapsible.Root onOpenChange={(open) => (toolsExpanded = open)} open={toolsExpanded}>
@@ -289,40 +216,12 @@
<Collapsible.Content> <Collapsible.Content>
<div class="flex flex-col gap-0.5 pl-4"> <div class="flex flex-col gap-0.5 pl-4">
{#each toolsPanel.activeGroups as group (group.key)} {#each toolsPanel.categoryGroups as group (group.key)}
{@const checked = toolsPanel.isGroupChecked(group)} {@render sheetGroupRow(group)}
{@const enabledCount = toolsPanel.getEnabledToolCount(group)} {/each}
{@const favicon = toolsPanel.getFavicon(group)}
<button {#each toolsPanel.mcpGroups as group (group.key)}
class={sheetItemRowClass} {@render sheetGroupRow(group)}
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} {/each}
</div> </div>
</Collapsible.Content> </Collapsible.Content>
@@ -331,38 +230,55 @@
<button <button
class={sheetItemClass} class={sheetItemClass}
onclick={() => attachmentMenu.callbacks[AttachmentAction.SYSTEM_PROMPT_CLICK]()} onclick={() => {
sheetOpen = false;
chatFormActions.onMcpSettingsClick?.();
}}
type="button" 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> </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> </div>
</Sheet.Content> </Sheet.Content>
</Sheet.Root> </Sheet.Root>
</div> </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 { CLI_FLAGS, ICON_CLASS_DEFAULT } from '$lib/constants';
import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte'; import { useToolsPanel } from '$lib/hooks/use-tools-panel.svelte';
import { mcpStore, toolsStore } from '$lib/stores'; import { mcpStore, toolsStore } from '$lib/stores';
import type { ToolGroup } from '$lib/types';
const toolsPanel = useToolsPanel(); const toolsPanel = useToolsPanel();
const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0); const hasMcpServersAvailable = $derived(mcpStore.getServers().length > 0);
@@ -62,95 +63,108 @@
{/if} {/if}
{:else} {:else}
<div class="max-h-80 overflow-y-auto p-2 pr-1"> <div class="max-h-80 overflow-y-auto p-2 pr-1">
{#each toolsPanel.activeGroups as group (group.key)} {#each toolsPanel.categoryGroups as group (group.key)}
{@const isExpanded = toolsPanel.expandedGroups.has(group.key)} {@render groupRow(group)}
{@const checked = toolsPanel.isGroupChecked(group)} {/each}
{@const favicon = toolsPanel.getFavicon(group)}
<Collapsible.Root {#each toolsPanel.mcpGroups as group (group.key)}
onOpenChange={() => toolsPanel.toggleGroupExpanded(group.key)} {@render groupRow(group)}
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} {/each}
</div> </div>
{/if} {/if}
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </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 { setChatFormActionsContext } from '$lib/contexts';
import { FileTypeCategory, MessageRole } from '$lib/enums'; import { FileTypeCategory, MessageRole } from '$lib/enums';
import { ChatService } from '$lib/services'; 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'; import { getFileTypeCategory } from '$lib/utils';
interface Props { interface Props {
@@ -31,8 +31,6 @@
onMicClick?: () => void; onMicClick?: () => void;
onStop?: () => void; onStop?: () => void;
onSystemPromptClick?: () => void; onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => void; onMcpSettingsClick?: () => void;
} }
@@ -45,8 +43,6 @@
isReasoning = false, isReasoning = false,
isRecording = false, isRecording = false,
onFileUpload, onFileUpload,
onMcpPromptClick,
onMcpResourcesClick,
onMcpSettingsClick, onMcpSettingsClick,
onMicClick, onMicClick,
onStop, onStop,
@@ -58,18 +54,6 @@
let currentConfig = $derived(settingsStore.config); 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 hasAudioModality = $state(false);
let hasVideoModality = $state(false); let hasVideoModality = $state(false);
let hasVisionModality = $state(false); let hasVisionModality = $state(false);
@@ -142,12 +126,6 @@
get hasAudioModality() { get hasAudioModality() {
return hasAudioModality; return hasAudioModality;
}, },
get hasMcpPromptsSupport() {
return hasMcpPromptsSupport;
},
get hasMcpResourcesSupport() {
return hasMcpResourcesSupport;
},
get hasVideoModality() { get hasVideoModality() {
return hasVideoModality; return hasVideoModality;
}, },
@@ -157,12 +135,6 @@
get onFileUpload() { get onFileUpload() {
return onFileUpload; return onFileUpload;
}, },
get onMcpPromptClick() {
return onMcpPromptClick;
},
get onMcpResourcesClick() {
return onMcpResourcesClick;
},
get onMcpSettingsClick() { get onMcpSettingsClick() {
return onMcpSettingsClick; return onMcpSettingsClick;
}, },
@@ -5,12 +5,12 @@
import SearchInput from '$lib/components/app/forms/SearchInput.svelte'; import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { DEFAULT_MOBILE_BREAKPOINT, HOME_TILDE, SEARCH, UI_DATA_ATTRS } from '$lib/constants'; 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 { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte'; import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte'; import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
import { ToolsService } from '$lib/services/tools.service'; 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 type { GlobEntry } from '$lib/types';
import { import {
abbreviateHome, abbreviateHome,
@@ -63,8 +63,11 @@
// unavailable instead of firing searches that would only fail. Browse is // unavailable instead of firing searches that would only fail. Browse is
// hidden too: it resolves the picked folder name through the same tool. // hidden too: it resolves the picked folder name through the same tool.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH)); const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived( const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey) fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
); );
const searchUnavailableMessage = $derived( const searchUnavailableMessage = $derived(
fileSearchKey === null fileSearchKey === null
@@ -9,7 +9,7 @@
} from '$lib/components/app/chat'; } from '$lib/components/app/chat';
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import { KeyboardKey } from '$lib/enums'; 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 type { GetPromptResult, MCPPromptInfo, MCPServerSettingsEntry } from '$lib/types';
import { debounce, uuid } from '$lib/utils'; import { debounce, uuid } from '$lib/utils';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
@@ -87,8 +87,7 @@
isLoading = true; isLoading = true;
try { try {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); const initialized = await mcpStore.ensureInitialized();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (!initialized) { if (!initialized) {
prompts = []; prompts = [];
@@ -5,10 +5,16 @@
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { FILE_GLOB_SEARCH_PICKERS, HOME_TILDE, SEARCH } from '$lib/constants'; 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 { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.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 type { FileMentionEntry, GlobEntryResult } from '$lib/types';
import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils'; import { abbreviateHome, runGlobSearchWithChildren } from '$lib/utils';
@@ -52,8 +58,11 @@
// --tools) or the user disabled it, the picker still opens but explains // --tools) or the user disabled it, the picker still opens but explains
// why instead of firing searches that would only fail. // why instead of firing searches that would only fail.
const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH)); const fileSearchKey = $derived(toolsStore.getPermissionKey(BuiltInTool.SERVER_FILE_GLOB_SEARCH));
// effective policy: the active conversation's tool policy, or global defaults
const fileSearchEnabled = $derived( const fileSearchEnabled = $derived(
fileSearchKey !== null && toolsStore.isToolEnabled(fileSearchKey) fileSearchKey !== null &&
conversationsStore.preferences.isToolEnabled(fileSearchKey) &&
conversationsStore.preferences.isCategoryEnabled(ToolSource.SERVER)
); );
let searchResults = $state<FileMentionEntry[]>([]); let searchResults = $state<FileMentionEntry[]>([]);
@@ -111,7 +111,6 @@
onValueChange={editCtx.setContent} onValueChange={editCtx.setContent}
placeholder="Edit your message..." placeholder="Edit your message..."
showAddButton={editCtx.messageRole === MessageRole.USER} showAddButton={editCtx.messageRole === MessageRole.USER}
showMcpPromptButton
showModelSelector={editCtx.messageRole === MessageRole.USER} showModelSelector={editCtx.messageRole === MessageRole.USER}
value={editCtx.editedContent} value={editCtx.editedContent}
/> />
@@ -160,6 +160,5 @@
onSubmit={handleSubmit} onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick} onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove} onUploadedFileRemove={handleUploadedFileRemove}
showMcpPromptButton
/> />
</div> </div>
@@ -220,19 +220,6 @@ export { default as ChatFormActionModels } from './ChatForm/ChatFormActions/Chat
*/ */
export { default as ChatFormActionAddToolsSubmenu } from './ChatForm/ChatFormActions/ChatFormActionAdd/ChatFormActionAddToolsSubmenu.svelte'; 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. * Dropdown submenu for selecting reasoning effort level.
* *
@@ -8,7 +8,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { ICON_CLASS_DEFAULT } from '$lib/constants'; 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 type { MCPResourceContent, MCPResourceInfo, MCPResourceTemplateInfo } from '$lib/types';
import { getResourceDisplayName } from '$lib/utils'; import { getResourceDisplayName } from '$lib/utils';
import { SvelteSet } from 'svelte/reactivity'; import { SvelteSet } from 'svelte/reactivity';
@@ -48,8 +48,7 @@
}); });
async function loadResources() { async function loadResources() {
const perChatOverrides = conversationsStore.preferences.getAllMcpServerOverrides(); const initialized = await mcpStore.ensureInitialized();
const initialized = await mcpStore.ensureInitialized(perChatOverrides);
if (initialized) { if (initialized) {
await mcpStore.fetchAllResources(); await mcpStore.fetchAllResources();
@@ -10,7 +10,7 @@
RECOMMENDED_MCP_SERVERS RECOMMENDED_MCP_SERVERS
} from '$lib/constants'; } from '$lib/constants';
import { BooleanString, HealthCheckStatus } from '$lib/enums'; import { BooleanString, HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores'; import { mcpStore } from '$lib/stores';
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils'; import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props { interface Props {
@@ -234,8 +234,6 @@
useProxy: newServerUseProxy useProxy: newServerUseProxy
}); });
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
handleOpenChange(false); handleOpenChange(false);
} }
@@ -76,22 +76,19 @@
</script> </script>
<Dialog.Root bind:open {onOpenChange}> <Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="@container z-9999 !max-h-[80dvh] !max-w-[60rem] max-w-full"> <Dialog.Content
<style> 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]!"
@container (max-width: 56rem) { >
.resizable-text-container { <!-- sticky header holds only the close button; the title scrolls with the body -->
max-width: calc(100vw - var(--threshold)); <Dialog.Header />
}
}
</style>
<Dialog.Header> <div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
<Dialog.Title>Model Information</Dialog.Title> <div class="min-w-0 space-y-2">
<Dialog.Title>Model Information</Dialog.Title>
<Dialog.Description>Current model details and capabilities</Dialog.Description> <Dialog.Description>Current model details and capabilities</Dialog.Description>
</Dialog.Header> </div>
<div class="space-y-6 py-4">
{#if isLoadingModels || isLoadingRouterProps} {#if isLoadingModels || isLoadingRouterProps}
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
<div class="text-sm text-muted-foreground">Loading model information...</div> <div class="text-sm text-muted-foreground">Loading model information...</div>
@@ -100,17 +97,15 @@
{@const modelMeta = firstModel.meta} {@const modelMeta = firstModel.meta}
{#if serverProps} {#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.Header>
<Table.Row> <Table.Row>
<Table.Head class="w-[10rem]">Model</Table.Head> <Table.Head class="w-[10rem]">Model</Table.Head>
<Table.Head> <Table.Head>
<div class="inline-flex items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
<span <span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
style:--threshold="12rem"
class="resizable-text-container min-w-0 flex-1 truncate"
>
{modelName} {modelName}
</span> </span>
@@ -129,20 +124,17 @@
<Table.Row> <Table.Row>
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell> <Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
<Table.Cell <Table.Cell class="h-10 align-middle font-mono text-xs">
class="inline-flex h-10 items-center gap-2 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">
<span {serverProps.model_path}
style:--threshold="14rem" </span>
class="resizable-text-container min-w-0 flex-1 truncate"
>
{serverProps.model_path}
</span>
<ActionIconCopyToClipboard <ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard" ariaLabel="Copy model path to clipboard"
text={serverProps.model_path} text={serverProps.model_path}
/> />
</div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
@@ -251,18 +243,113 @@
<!-- Chat Template --> <!-- Chat Template -->
{#if serverProps.chat_template} {#if serverProps.chat_template}
<Table.Row> <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="overflow-x-auto rounded-md bg-muted p-4">
<div class="rounded-md bg-muted p-4"> <pre
<pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
class="font-mono text-xs whitespace-pre-wrap">{serverProps.chat_template}</pre> </div>
</div> </div>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
{/if} {/if}
</Table.Body> </Table.Body>
</Table.Root> </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} {/if}
{:else if !isLoadingModels} {:else if !isLoadingModels}
<div class="flex items-center justify-center py-8"> <div class="flex items-center justify-center py-8">
@@ -272,3 +359,11 @@
</div> </div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </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 McpLogo from './McpLogo.svelte';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { ICON_CLASS_DEFAULT, MAX_DISPLAYED_MCP_AVATARS } from '$lib/constants'; 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'; import { conversationsStore, mcpStore } from '$lib/stores';
interface Props { interface Props {
@@ -13,9 +13,13 @@
let { class: className = '', onclick }: Props = $props(); let { class: className = '', onclick }: Props = $props();
let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled)); let mcpServers = $derived(mcpStore.getServers().filter((s) => s.enabled));
// respect the active conversation's tool policy, not just global enablement
let enabledMcpServersForChat = $derived( let enabledMcpServersForChat = $derived(
mcpServers.filter( 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( let healthyEnabledMcpServers = $derived(
@@ -25,6 +25,10 @@
<div class="py-8 text-center text-sm text-muted-foreground">No tools available</div> <div class="py-8 text-center text-sm text-muted-foreground">No tools available</div>
{:else} {:else}
<div class="space-y-2"> <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)} {#each groups as group (group.key)}
{@const isExpanded = expandedGroups.has(group.key)} {@const isExpanded = expandedGroups.has(group.key)}
<Collapsible.Root onOpenChange={() => toggleExpanded(group.key)} open={isExpanded}> <Collapsible.Root onOpenChange={() => toggleExpanded(group.key)} open={isExpanded}>
@@ -37,6 +41,17 @@
<ChevronRight class="h-3.5 w-3.5 shrink-0" /> <ChevronRight class="h-3.5 w-3.5 shrink-0" />
{/if} {/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} {@const faviconUrl = group.serverId ? mcpStore.getServerFavicon(group.serverId) : null}
<span class="inline-flex min-w-0 items-center gap-1.5 font-medium"> <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 { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty'; import * as Empty from '$lib/components/ui/empty';
import { HealthCheckStatus } from '$lib/enums'; import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores'; import { mcpStore, toolsStore } from '$lib/stores';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -86,15 +86,13 @@
<McpServerCardSkeleton /> <McpServerCardSkeleton />
{:else} {:else}
<McpServerCard <McpServerCard
enabled={conversationsStore.preferences.isMcpServerEnabledForChat(server.id)} enabled={server.enabled}
onBrowseResources={() => (isResourcesDialogOpen = true)} onBrowseResources={() => (isResourcesDialogOpen = true)}
onDelete={() => mcpStore.removeServer(server.id)} onDelete={() => mcpStore.removeServer(server.id)}
onToggle={async () => { onToggle={async () => {
const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat( const wasEnabled = server.enabled;
server.id
);
await conversationsStore.preferences.toggleMcpServerForChat(server.id); mcpStore.updateServer(server.id, { enabled: !wasEnabled });
if (!wasEnabled) { if (!wasEnabled) {
// Promote the connection so tools/prompts/resources become // Promote the connection so tools/prompts/resources become
@@ -26,10 +26,10 @@
> >
{#snippet children({ checked, indeterminate })} {#snippet children({ checked, indeterminate })}
<div class="text-current transition-none" data-slot="checkbox-indicator"> <div class="text-current transition-none" data-slot="checkbox-indicator">
{#if checked} {#if indeterminate}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" /> <MinusIcon class="size-3.5" />
{:else if checked}
<CheckIcon class="size-3.5" />
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
@@ -1,11 +1,5 @@
import { FolderOpen, MessageSquare, Zap } from '@lucide/svelte';
import { FILE_TYPE_ICONS } from '$lib/constants'; import { FILE_TYPE_ICONS } from '$lib/constants';
import { import { AttachmentAction, AttachmentItemEnabledWhen, AttachmentMenuItemId } from '$lib/enums';
AttachmentAction,
AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId
} from '$lib/enums';
import type { AttachmentMenuItem } from '$lib/types'; 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'; 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 */ /** 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`; 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 FAVORITE_MODELS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.favoriteModels`;
export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`; export const REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.reasoningEffortDefault`;
export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`; export const CONVERSATION_TABS_LOCALSTORAGE_KEY = `${STORAGE_APP_NAME}.conversationTabs`;
@@ -19,8 +19,6 @@ export enum AttachmentType {
export enum AttachmentMenuItemId { export enum AttachmentMenuItemId {
AUDIO = 'audio', AUDIO = 'audio',
IMAGES = 'images', IMAGES = 'images',
MCP_PROMPT = 'mcp-prompt',
MCP_RESOURCES = 'mcp-resources',
PDF = 'pdf', PDF = 'pdf',
SYSTEM_MESSAGE = 'system-message', SYSTEM_MESSAGE = 'system-message',
TEXT = 'text', TEXT = 'text',
@@ -42,8 +40,6 @@ export enum AttachmentItemEnabledWhen {
*/ */
export enum AttachmentAction { export enum AttachmentAction {
FILE_UPLOAD = 'onFileUpload', FILE_UPLOAD = 'onFileUpload',
MCP_PROMPT_CLICK = 'onMcpPromptClick',
MCP_RESOURCES_CLICK = 'onMcpResourcesClick',
SYSTEM_PROMPT_CLICK = 'onSystemPromptClick' SYSTEM_PROMPT_CLICK = 'onSystemPromptClick'
} }
@@ -56,11 +52,3 @@ export enum AttachmentLabel {
MCP_RESOURCE = 'MCP Resource', MCP_RESOURCE = 'MCP Resource',
PDF_FILE = 'PDF File' 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, AttachmentType,
AttachmentMenuItemId, AttachmentMenuItemId,
AttachmentItemEnabledWhen, AttachmentItemEnabledWhen,
AttachmentAction, AttachmentAction
AttachmentItemVisibleWhen
} from './attachment.enums'; } from './attachment.enums';
export { export {
@@ -5,21 +5,16 @@ export interface AttachmentModalityFlags {
hasVisionModality: boolean; hasVisionModality: boolean;
hasAudioModality: boolean; hasAudioModality: boolean;
hasVideoModality: boolean; hasVideoModality: boolean;
hasMcpPromptsSupport: boolean;
hasMcpResourcesSupport: boolean;
} }
export interface AttachmentActionCallbacks { export interface AttachmentActionCallbacks {
onFileUpload?: () => void; onFileUpload?: () => void;
onSystemPromptClick?: () => void; onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
} }
export interface UseAttachmentMenuReturn { export interface UseAttachmentMenuReturn {
readonly callbacks: Record<string, () => void>; readonly callbacks: Record<string, () => void>;
isItemEnabled(enabledWhen: string | undefined): boolean; isItemEnabled(enabledWhen: string | undefined): boolean;
isItemVisible(visibleWhen: string | undefined): boolean;
getSystemMessageTooltip(): string; getSystemMessageTooltip(): string;
} }
@@ -49,8 +44,6 @@ export function useAttachmentMenu(
return { return {
[AttachmentAction.FILE_UPLOAD]: wrap(cbs.onFileUpload), [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) [AttachmentAction.SYSTEM_PROMPT_CLICK]: wrap(cbs.onSystemPromptClick)
}; };
}); });
@@ -61,12 +54,6 @@ export function useAttachmentMenu(
return !!modalityFlags[enabledWhen as keyof AttachmentModalityFlags]; 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 { function getSystemMessageTooltip(): string {
return !page.params.id return !page.params.id
? 'Add custom system message for a new conversation' ? 'Add custom system message for a new conversation'
@@ -78,7 +65,6 @@ export function useAttachmentMenu(
return callbacks; return callbacks;
}, },
getSystemMessageTooltip, getSystemMessageTooltip,
isItemEnabled, isItemEnabled
isItemVisible
}; };
} }
@@ -1,19 +1,23 @@
import { CLI_FLAGS } from '$lib/constants'; import { CLI_FLAGS } from '$lib/constants';
import { ToolSource } from '$lib/enums'; import { ToolSource } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores'; 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'; import { SvelteSet } from 'svelte/reactivity';
export interface UseToolsPanelReturn { export interface UseToolsPanelReturn {
readonly expandedGroups: SvelteSet<string>; readonly expandedGroups: SvelteSet<string>;
readonly groups: ToolGroup[]; readonly categoryGroups: ToolGroup[];
readonly activeGroups: ToolGroup[]; readonly mcpGroups: ToolGroup[];
readonly totalToolCount: number; readonly totalToolCount: number;
readonly noToolsInfoMessage: string | null; readonly noToolsInfoMessage: string | null;
isGroupChecked(group: ToolGroup): boolean; isGroupChecked(group: ToolGroup): boolean;
getEnabledToolCount(group: ToolGroup): number; getEnabledToolCount(group: ToolGroup): number;
getGroupCheckState(group: ToolGroup): { checked: boolean; indeterminate: boolean };
getFavicon(group: ToolGroup): string | null; getFavicon(group: ToolGroup): string | null;
isGroupDisabled(group: ToolGroup): boolean; isGroupDisabled(group: ToolGroup): boolean;
isToolEnabled(entry: ToolEntry): boolean;
isToolParentDisabled(entry: ToolEntry): boolean;
toggleTool(entry: ToolEntry): void;
toggleGroupExpanded(key: string): void; toggleGroupExpanded(key: string): void;
/** Toggle all tools in a group by its stable key (avoids stale group object references). */ /** Toggle all tools in a group by its stable key (avoids stale group object references). */
toggleGroupByKey(key: string): void; toggleGroupByKey(key: string): void;
@@ -26,19 +30,18 @@ export interface UseToolsPanelReturn {
* Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`) * Used by both the desktop dropdown (`ChatFormActionAddToolsSubmenu`)
* and the mobile sheet (`ChatFormActionAddSheet`) to avoid * and the mobile sheet (`ChatFormActionAddSheet`) to avoid
* duplicating group filtering, checked-state derivation, and favicon logic. * 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 { export function useToolsPanel(): UseToolsPanelReturn {
const expandedGroups = new SvelteSet<string>(); const expandedGroups = new SvelteSet<string>();
const groups = $derived(toolsStore.toolGroups); const groups = $derived(toolsStore.toolGroups);
const activeGroups = $derived( // non-MCP groups are 1:1 with tool categories; MCP tools group per server
groups.filter( const categoryGroups = $derived(groups.filter((g) => g.source !== ToolSource.MCP));
(g) => const mcpGroups = $derived(groups.filter((g) => g.source === ToolSource.MCP));
g.source !== ToolSource.MCP || const totalToolCount = $derived(groups.reduce((n, g) => n + g.tools.length, 0));
!g.serverId ||
conversationsStore.preferences.isMcpServerEnabledForChat(g.serverId)
)
);
const totalToolCount = $derived(activeGroups.reduce((n, g) => n + g.tools.length, 0));
const noToolsInfoMessage = $derived.by(() => { const noToolsInfoMessage = $derived.by(() => {
if (toolsStore.loading) return null; if (toolsStore.loading) return null;
@@ -56,11 +59,27 @@ export function useToolsPanel(): UseToolsPanelReturn {
}); });
function isGroupChecked(group: ToolGroup): boolean { function isGroupChecked(group: ToolGroup): boolean {
return toolsStore.isGroupFullyEnabled(group); return conversationsStore.preferences.isGroupChecked(group);
} }
function getEnabledToolCount(group: ToolGroup): number { 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 { function getFavicon(group: ToolGroup): string | null {
@@ -70,13 +89,25 @@ export function useToolsPanel(): UseToolsPanelReturn {
} }
function isGroupDisabled(group: ToolGroup): boolean { function isGroupDisabled(group: ToolGroup): boolean {
// MCP server groups gray out while the whole MCP category is off
return ( return (
group.source === ToolSource.MCP && group.source === ToolSource.MCP &&
!!group.serverId && !conversationsStore.preferences.isCategoryEnabled(ToolSource.MCP)
!conversationsStore.preferences.isMcpServerEnabledForChat(group.serverId)
); );
} }
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 { function toggleGroupExpanded(key: string): void {
if (expandedGroups.has(key)) { if (expandedGroups.has(key)) {
expandedGroups.delete(key); expandedGroups.delete(key);
@@ -87,11 +118,11 @@ export function useToolsPanel(): UseToolsPanelReturn {
function toggleGroupByKey(key: string): void { function toggleGroupByKey(key: string): void {
// Find current group by key to get up-to-date tool references // 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; if (!group) return;
toolsStore.toggleGroup(group); void conversationsStore.preferences.toggleGroup(group);
} }
function handleOpen(): void { function handleOpen(): void {
@@ -103,23 +134,27 @@ export function useToolsPanel(): UseToolsPanelReturn {
} }
return { return {
get activeGroups() { get categoryGroups() {
return activeGroups; return categoryGroups;
}, },
expandedGroups, expandedGroups,
getEnabledToolCount, getEnabledToolCount,
getFavicon, getFavicon,
get groups() { getGroupCheckState,
return groups;
},
handleOpen, handleOpen,
isGroupChecked, isGroupChecked,
isGroupDisabled, isGroupDisabled,
isToolEnabled,
isToolParentDisabled,
get mcpGroups() {
return mcpGroups;
},
get noToolsInfoMessage() { get noToolsInfoMessage() {
return noToolsInfoMessage; return noToolsInfoMessage;
}, },
toggleGroupByKey, toggleGroupByKey,
toggleGroupExpanded, toggleGroupExpanded,
toggleTool,
get totalToolCount() { get totalToolCount() {
return totalToolCount; return totalToolCount;
} }
+59 -1
View File
@@ -11,6 +11,7 @@
import { import {
CONFIG_LOCALSTORAGE_KEY, CONFIG_LOCALSTORAGE_KEY,
DB_APP_NAME_DEPRECATED, DB_APP_NAME_DEPRECATED,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
IDXDB_STORES, IDXDB_STORES,
IDXDB_TABLES, IDXDB_TABLES,
LEGACY_AGENTIC_REGEX, LEGACY_AGENTIC_REGEX,
@@ -21,6 +22,7 @@ import {
STORAGE_APP_NAME_DEPRECATED STORAGE_APP_NAME_DEPRECATED
} from '$lib/constants'; } from '$lib/constants';
import { BooleanString, MessageRole } from '$lib/enums'; import { BooleanString, MessageRole } from '$lib/enums';
import type { McpServerOverride } from '$lib/types/database';
import Dexie from 'dexie'; import Dexie from 'dexie';
// Types // 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[] = [ const migrations: Migration[] = [
localStorageMigration, localStorageMigration,
idxdbMigration, idxdbMigration,
@@ -746,7 +803,8 @@ const migrations: Migration[] = [
mcpDefaultEnabledMigration, mcpDefaultEnabledMigration,
mcpDefaultOverridesMergeMigration, mcpDefaultOverridesMergeMigration,
configTypesMigration, configTypesMigration,
renderKeysMigration renderKeysMigration,
mcpServerOverridesToToolPolicyMigration
]; ];
export const MigrationService = { export const MigrationService = {
@@ -44,7 +44,6 @@ import type {
AgenticFlowParams, AgenticFlowParams,
AgenticFlowResult, AgenticFlowResult,
AgenticSession, AgenticSession,
McpServerOverride,
MCPToolCall, MCPToolCall,
SettingsConfigType, SettingsConfigType,
ToolExecutionResult ToolExecutionResult
@@ -201,10 +200,10 @@ class AgenticStore {
return active; return active;
} }
getConfig(settings: SettingsConfigType, perChatOverrides?: McpServerOverride[]): AgenticConfig { getConfig(settings: SettingsConfigType): AgenticConfig {
const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns; const maxTurns = Number(settings.agenticMaxTurns) || DEFAULT_AGENTIC_CONFIG.maxTurns;
const hasTools = const hasTools =
mcpStore.hasEnabledServers(perChatOverrides) || mcpStore.hasEnabledServers() ||
toolsStore.serverTools.length > 0 || toolsStore.serverTools.length > 0 ||
toolsStore.browserTools.length > 0 || toolsStore.browserTools.length > 0 ||
toolsStore.customTools.length > 0; toolsStore.customTools.length > 0;
@@ -309,8 +308,8 @@ class AgenticStore {
flowRootMessageId, flowRootMessageId,
messages, messages,
options = {}, options = {},
perChatOverrides, signal,
signal toolPolicy
} = params; } = params;
// Clear any pending permissions/continue requests for this conversation when starting a new flow // Clear any pending permissions/continue requests for this conversation when starting a new flow
@@ -321,21 +320,28 @@ class AgenticStore {
await toolsStore.fetchServerTools(); await toolsStore.fetchServerTools();
} }
const agenticConfig = this.getConfig(settingsStore.config, perChatOverrides); const agenticConfig = this.getConfig(settingsStore.config);
if (!agenticConfig.enabled) return { handled: false }; 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) { if (hasMcpServers) {
const initialized = await mcpStore.ensureInitialized(perChatOverrides); const initialized = await mcpStore.ensureInitialized();
if (!initialized) { if (!initialized) {
console.log('[AgenticStore] MCP not initialized'); console.log('[AgenticStore] MCP not initialized');
} }
} }
const tools = toolsStore.getEnabledToolsForLLM(); const tools = toolsStore.getEnabledToolsForLLM(disabledTools, disabledToolCategories);
if (tools.length === 0) { if (tools.length === 0) {
return { handled: false }; return { handled: false };
+6 -3
View File
@@ -1132,7 +1132,10 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
await DatabaseService.updateMessage(messageId, updates); 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({ const agenticResult = await agenticStore.runAgenticFlow({
@@ -1144,8 +1147,8 @@ class ChatStore implements ChatStreamHost, ChatFlowsHost {
...this.getApiOptions(), ...this.getApiOptions(),
...(effectiveModel ? { model: effectiveModel } : {}) ...(effectiveModel ? { model: effectiveModel } : {})
}, },
perChatOverrides, signal: abortController.signal,
signal: abortController.signal toolPolicy
}); });
if (agenticResult.handled) { if (agenticResult.handled) {
@@ -251,12 +251,15 @@ class ConversationsStore implements ConversationsPreferencesHost {
*/ */
async createConversation(name?: string): Promise<string> { async createConversation(name?: string): Promise<string> {
const conversationName = name || `Chat ${new Date().toLocaleString()}`; const conversationName = name || `Chat ${new Date().toLocaleString()}`;
// Working directory and reasoning effort picked on the new-chat screen // The tool policy is seeded from the current defaults: edits made inside
// get threaded into the new conversation here, then cleared so they // the conversation afterwards live on its row and do not flow back into
// don't bleed onto subsequent new chats. // 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, { const conversation = await DatabaseService.createConversation(conversationName, {
cwd: this.preferences.pendingCwd ?? undefined, cwd: this.preferences.pendingCwd ?? undefined,
reasoningEffort: this.preferences.pendingReasoningEffort reasoningEffort: this.preferences.pendingReasoningEffort,
...this.preferences.getToolPolicySnapshot()
}); });
this.preferences.pendingCwd = null; this.preferences.pendingCwd = null;
@@ -1,21 +1,23 @@
/** /**
* ConversationPreferences - Per-chat options with global fallback * ConversationPreferences - Per-chat options with global fallback
* *
* Owns the options that resolve per conversation: MCP server overrides, * Owns the options that resolve per conversation: the tool policy (disabled
* reasoning effort, and the working directory. Cwd and reasoning effort are * categories and tool keys), reasoning effort, and the working directory.
* buffered as pending state and threaded into the next created conversation * Tool picks made on the empty new-chat screen edit the global defaults
* by the host; MCP server overrides edit the sparse `mcpServerOverrides` * directly (they seed every newly created conversation); cwd and reasoning
* list on the active row (new-chat toggles edit the server's global flag). * 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 * Created and owned by conversationsStore; the host owns the conversation
* rows these options persist onto. * rows these options persist onto.
*/ */
import { REASONING_EFFORT_DEFAULT_LOCALSTORAGE_KEY } from '$lib/constants'; 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'; import { DatabaseService } from '$lib/services/database.service';
// direct imports between stores, not via the barrel, to avoid circular deps // direct imports between stores, not via the barrel, to avoid circular deps
import { mcpStore } from '$lib/stores/mcp/index.svelte'; 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 */ /** Load reasoning effort default from localStorage, DEFAULT defers to the server */
function loadReasoningEffortDefault(): ReasoningEffort { function loadReasoningEffortDefault(): ReasoningEffort {
@@ -48,6 +50,26 @@ export interface ConversationsPreferencesHost {
applyConversationUpdate(id: string, updates: Partial<DatabaseConversation>): void; 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 { export class ConversationPreferences {
/** /**
* Working directory picked on the empty new-chat screen, before any * 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 */ /** Global (non-conversation-specific) reasoning effort default */
pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault()); pendingReasoningEffort = $state<ReasoningEffort>(loadReasoningEffortDefault());
constructor(private host: ConversationsPreferencesHost) {} private get _disabledToolCategories(): Set<ToolSource> {
return buildDisabledToolCategories(this.host.activeConversation);
/**
* Gets the effective override list for the current conversation:
* one entry per configured server, resolved per server. The stored
* per-conversation list is sparse and only holds explicit toggles.
*/
getAllMcpServerOverrides(): McpServerOverride[] {
const overrides = this.host.activeConversation?.mcpServerOverrides;
return mcpStore.getServers().map((s) => {
const override = overrides?.find((o: McpServerOverride) => o.serverId === s.id);
return { enabled: override?.enabled ?? s.enabled, serverId: s.id };
});
} }
/** // Tool Policy
* 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
);
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; return this.pendingReasoningEffort;
} }
/** Checks if an MCP server is enabled for the active conversation. */ /** Defaults snapshot for seeding a newly created conversation. */
isMcpServerEnabledForChat(serverId: string): boolean { getToolPolicySnapshot(): { disabledTools?: string[]; disabledToolCategories?: ToolSource[] } {
const override = this.getMcpServerOverride(serverId); 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. */ hasEnabledCwdTools(): boolean {
async removeMcpServerOverride(serverId: string): Promise<void> { return toolsStore.hasEnabledCwdTools(this._disabledTools, this._disabledToolCategories);
await this.setMcpServerOverride(serverId, undefined); }
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. */ /** Reload persisted defaults, e.g. when the active conversation is cleared. */
@@ -132,6 +202,8 @@ export class ConversationPreferences {
this.pendingCwd = null; this.pendingCwd = null;
} }
// Working Directory
/** /**
* Sets the working directory for the active conversation. Pass `null` or * Sets the working directory for the active conversation. Pass `null` or
* an empty string to clear it, which restores the picker's empty state. * an empty string to clear it, which restores the picker's empty state.
@@ -165,56 +237,7 @@ export class ConversationPreferences {
this.pendingCwd = null; this.pendingCwd = null;
} }
/** // Reasoning Effort
* Sets or removes MCP server override for the active conversation.
* If no conversation exists, persists `enabled` onto `mcpServers[i].enabled`
* (the single source of truth for new-chat defaults).
*/
async setMcpServerOverride(serverId: string, enabled: boolean | undefined): Promise<void> {
if (!this.host.activeConversation) {
if (enabled !== undefined) {
mcpStore.updateServer(serverId, { enabled });
}
return;
}
// Clone to plain objects to avoid Proxy serialization issues with IndexedDB
const currentOverrides = (this.host.activeConversation.mcpServerOverrides || []).map(
(o: McpServerOverride) => ({
enabled: o.enabled,
serverId: o.serverId
})
);
let newOverrides: McpServerOverride[];
if (enabled === undefined) {
newOverrides = currentOverrides.filter((o: McpServerOverride) => o.serverId !== serverId);
} else {
const existingIndex = currentOverrides.findIndex(
(o: McpServerOverride) => o.serverId === serverId
);
if (existingIndex >= 0) {
newOverrides = [...currentOverrides];
newOverrides[existingIndex] = { enabled, serverId };
} else {
newOverrides = [...currentOverrides, { enabled, serverId }];
}
}
const overrides = newOverrides.length > 0 ? newOverrides : undefined;
const id = this.host.activeConversation.id;
this.host.applyConversationUpdate(id, {
mcpServerOverrides: overrides
});
await DatabaseService.updateConversation(id, {
mcpServerOverrides: overrides
});
}
/** /**
* Sets the reasoning effort for the active conversation. * Sets the reasoning effort for the active conversation.
@@ -229,33 +252,82 @@ export class ConversationPreferences {
return; return;
} }
const id = this.host.activeConversation.id; this.host.applyConversationUpdate(this.host.activeConversation.id, {
this.host.applyConversationUpdate(id, {
reasoningEffort: effort reasoningEffort: effort
}); });
await DatabaseService.updateConversation(id, { await DatabaseService.updateConversation(this.host.activeConversation.id, {
reasoningEffort: effort reasoningEffort: effort
}); });
} }
/** Toggles MCP server enabled state for the active conversation. */ async toggleCategory(source: ToolSource): Promise<void> {
async toggleMcpServerForChat(serverId: string): Promise<void> { const conv: DatabaseConversation | null = this.host.activeConversation;
const currentEnabled = this.isMcpServerEnabledForChat(serverId);
await this.setMcpServerOverride(serverId, !currentEnabled); if (!conv) {
toolsStore.toggleCategory(source);
return;
}
const next = buildDisabledToolCategories(conv);
if (next.has(source)) next.delete(source);
else next.add(source);
await this.persistDisabledToolCategories(next);
} }
/** async toggleGroup(group: ToolGroup): Promise<void> {
* Resolve the default enabled value for a server: its own `enabled` if (group.source === ToolSource.MCP && group.serverId) {
* flag in `mcpServers`, so the global on/off state lives in one place. await this.toggleServerTools(group.serverId);
*/ } else {
private getDefaultOverride(serverId: string): McpServerOverride | undefined { await this.toggleCategory(group.source);
const server = mcpStore.getServers().find((s) => s.id === serverId); }
}
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, Tool,
ToolExecutionResult ToolExecutionResult
} from '$lib/types'; } 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 type { SettingsConfigType } from '$lib/types/settings';
import { import {
detectMcpTransportFromUrl, detectMcpTransportFromUrl,
@@ -306,12 +306,16 @@ class MCPStore implements McpHealthHost {
return extras; 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) { if (!browser) {
return false; return false;
} }
const mcpConfig = this.buildMcpClientConfig(settingsStore.config, perChatOverrides); const mcpConfig = this.buildMcpClientConfig(settingsStore.config);
const signature = mcpConfig ? JSON.stringify(mcpConfig) : null; const signature = mcpConfig ? JSON.stringify(mcpConfig) : null;
if (!signature) { if (!signature) {
@@ -512,14 +516,6 @@ class MCPStore implements McpHealthHost {
return this.connections; 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. * Check if a server already has an active connection that can be reused.
* Returns the existing connection if available. * Returns the existing connection if available.
@@ -811,106 +807,8 @@ class MCPStore implements McpHealthHost {
); );
} }
hasEnabledServers(perChatOverrides?: McpServerOverride[]): boolean { hasEnabledServers(): boolean {
return Boolean(this.buildMcpClientConfig(settingsStore.config, perChatOverrides)); return Boolean(this.buildMcpClientConfig(settingsStore.config));
}
/**
* 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;
} }
/** /**
@@ -1185,10 +1083,7 @@ class MCPStore implements McpHealthHost {
/** /**
* Builds MCP client configuration from settings. * Builds MCP client configuration from settings.
*/ */
private buildMcpClientConfig( private buildMcpClientConfig(cfg: SettingsConfigType): MCPClientConfig | undefined {
cfg: SettingsConfigType,
perChatOverrides?: McpServerOverride[]
): MCPClientConfig | undefined {
const rawServers = parseMcpServerSettings(cfg.mcpServers); const rawServers = parseMcpServerSettings(cfg.mcpServers);
if (!rawServers.length) { if (!rawServers.length) {
@@ -1198,7 +1093,7 @@ class MCPStore implements McpHealthHost {
const servers: Record<string, MCPServerConfig> = {}; const servers: Record<string, MCPServerConfig> = {};
for (const [index, entry] of rawServers.entries()) { for (const [index, entry] of rawServers.entries()) {
if (!this.checkServerEnabled(entry, perChatOverrides)) continue; if (!entry.enabled) continue;
const normalized = this.buildServerConfig(entry); 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 { private createListChangedHandlers(serverName: string): ListChangedHandlers {
return { return {
prompts: { prompts: {
@@ -1378,6 +1259,15 @@ class MCPStore implements McpHealthHost {
return `${MCP_SERVER_ID_PREFIX}-${index + 1}`; 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 { private handleToolsListChanged(serverName: string, tools: Tool[]): void {
const connection = this.connections.get(serverName); const connection = this.connections.get(serverName);
+110 -42
View File
@@ -12,6 +12,7 @@ import {
buildBrowserInfoToolDefinition, buildBrowserInfoToolDefinition,
buildGetDatetimeToolDefinition, buildGetDatetimeToolDefinition,
buildReadMediaToolDefinition, buildReadMediaToolDefinition,
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY, DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY,
HOME_TILDE, HOME_TILDE,
TOOL_GROUP_LABELS, 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 */ /** Stable selection identity for a tool, shared by the disabled set and the permission store */
class ToolsStore { 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 _disabledTools = $state(new SvelteSet<string>());
private _error = $state<string | null>(null); private _error = $state<string | null>(null);
private _loading = $state(false); private _loading = $state(false);
@@ -150,6 +154,10 @@ class ToolsStore {
} }
} }
get disabledToolCategories(): ReadonlySet<ToolSource> {
return this._disabledToolCategories;
}
get disabledTools(): SvelteSet<string> { get disabledTools(): SvelteSet<string> {
return this._disabledTools; return this._disabledTools;
} }
@@ -158,26 +166,6 @@ class ToolsStore {
return this._error; 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 { get isToolsEndpointUnreachable(): boolean {
return this._toolsEndpointUnreachable; return this._toolsEndpointUnreachable;
} }
@@ -233,9 +221,13 @@ class ToolsStore {
if (!connection) return; 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) { for (const tool of connection.tools) {
this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId)); this._disabledTools.delete(this.toolKey(ToolSource.MCP, tool.name, serverId));
} }
this.persistDisabledTools(); 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 * MCP tool schemas are normalized here so the wire payload is consistent
* across all four sources (server, browser/sandbox, MCP, custom JSON). * across all four sources (server, browser/sandbox, MCP, custom JSON).
* The API identifies tools by name, so a name is sent at most once. * The 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>(); const enabledNames = new SvelteSet<string>();
for (const entry of this.allTools) { for (const entry of this.allTools) {
if (!this._disabledTools.has(entry.key)) { if (this.isEntryEnabled(entry, disabledTools, disabledCategories)) {
enabledNames.add(entry.definition.function.name); enabledNames.add(entry.definition.function.name);
} }
} }
@@ -306,6 +303,11 @@ class ToolsStore {
return result; 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 */ /** Permission key for a tool name, identical to the selection key */
getPermissionKey(toolName: string): string | null { getPermissionKey(toolName: string): string | null {
return this.findEntryByName(toolName)?.key ?? null; return this.findEntryByName(toolName)?.key ?? null;
@@ -333,6 +335,26 @@ class ToolsStore {
return this.findEntryByName(toolName)?.source ?? null; 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. * Load persisted disabled tools and fetch the builtin tool list.
* Called by initStores() after migrations have run. * Called by initStores() after migrations have run.
@@ -357,11 +379,45 @@ class ToolsStore {
console.error('[ToolsStore] Failed to load disabled tools from localStorage:', err); 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(); this.fetchServerTools();
} }
isGroupFullyEnabled(group: ToolGroup): boolean { isCategoryEnabled(source: ToolSource): boolean {
return group.tools.length > 0 && group.tools.every((t) => this.isToolEnabled(t.key)); 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 { isToolEnabled(key: string): boolean {
@@ -394,33 +450,32 @@ class ToolsStore {
return this._serverHome; 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 { setToolEnabled(key: string, enabled: boolean): void {
if (enabled) { if (enabled) {
this._disabledTools.delete(key); this._disabledTools.delete(key);
} else { } else {
this._disabledTools.add(key); this._disabledTools.add(key);
} }
this.persistDisabledTools();
} }
toggleGroup(group: ToolGroup): void { toggleCategory(source: ToolSource): void {
const allEnabled = group.tools.every((t) => this.isToolEnabled(t.key)); this.setCategoryEnabled(source, !this.isCategoryEnabled(source));
const target = !allEnabled;
for (const tool of group.tools) {
if (target) this._disabledTools.delete(tool.key);
else this._disabledTools.add(tool.key);
}
this.persistDisabledTools();
} }
toggleTool(key: string): void { toggleTool(key: string): void {
if (this._disabledTools.has(key)) { this.setToolEnabled(key, !this.isToolEnabled(key));
this._disabledTools.delete(key);
} else {
this._disabledTools.add(key);
}
this.persistDisabledTools();
} }
/** First canonical entry matching a tool name, runtime tool calls resolve by name */ /** First canonical entry matching a tool name, runtime tool calls resolve by name */
@@ -602,6 +657,17 @@ class ToolsStore {
return normalized; return normalized;
} }
private persistDisabledToolCategories(): void {
try {
localStorage.setItem(
DISABLED_TOOL_CATEGORIES_LOCALSTORAGE_KEY,
JSON.stringify([...this._disabledToolCategories])
);
} catch {
// ignore storage errors
}
}
private persistDisabledTools(): void { private persistDisabledTools(): void {
try { try {
localStorage.setItem( localStorage.setItem(
@@ -637,7 +703,9 @@ class ToolsStore {
private toolKey(source: ToolSource, name: string, serverId?: string): string { private toolKey(source: ToolSource, name: string, serverId?: string): string {
switch (source) { switch (source) {
case ToolSource.MCP: 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: case ToolSource.CUSTOM:
return `custom:${name}`; return `custom:${name}`;
case ToolSource.BROWSER: case ToolSource.BROWSER:
+8 -2
View File
@@ -15,7 +15,7 @@ import type {
DatabaseMessageExtraAudioFile, DatabaseMessageExtraAudioFile,
DatabaseMessageExtraImageFile DatabaseMessageExtraImageFile
} from './database'; } from './database';
import type { MessageRole } from '$lib/enums'; import type { MessageRole, ToolSource } from '$lib/enums';
import { AgenticSectionType, ContinueIntentKind, ToolCallType } from '$lib/enums'; import { AgenticSectionType, ContinueIntentKind, ToolCallType } from '$lib/enums';
/** /**
@@ -162,6 +162,12 @@ export interface AgenticFlowOptions {
/** /**
* Parameters for starting an agentic flow * Parameters for starting an agentic flow
*/ */
/** Per-conversation tool policy, captured at flow start */
export interface AgenticToolPolicy {
disabledToolCategories: ToolSource[];
disabledTools: string[];
}
export interface AgenticFlowParams { export interface AgenticFlowParams {
conversationId: string; conversationId: string;
/** ID of the flow's first assistant message, used to keep its stats live */ /** ID of the flow's first assistant message, used to keep its stats live */
@@ -170,7 +176,7 @@ export interface AgenticFlowParams {
options?: AgenticFlowOptions; options?: AgenticFlowOptions;
callbacks: AgenticFlowCallbacks; callbacks: AgenticFlowCallbacks;
signal?: AbortSignal; signal?: AbortSignal;
perChatOverrides?: McpServerOverride[]; toolPolicy?: AgenticToolPolicy;
} }
/** /**
-7
View File
@@ -3,7 +3,6 @@ import type { DatabaseMessage, DatabaseMessageExtra } from './database';
import type { import type {
AttachmentAction, AttachmentAction,
AttachmentItemEnabledWhen, AttachmentItemEnabledWhen,
AttachmentItemVisibleWhen,
AttachmentMenuItemId, AttachmentMenuItemId,
ChatFormCommandAction, ChatFormCommandAction,
ErrorDialogType, ErrorDialogType,
@@ -30,8 +29,6 @@ export interface AttachmentMenuItem {
disabledTooltip?: string; disabledTooltip?: string;
/** Callback key on the Props interface to invoke when clicked */ /** Callback key on the Props interface to invoke when clicked */
action: AttachmentAction; 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) */ /** Whether this item has a tooltip even when enabled (uses dynamic text) */
hasEnabledTooltip?: boolean; hasEnabledTooltip?: boolean;
} }
@@ -336,11 +333,7 @@ export interface ChatFormActionsContext {
readonly hasAudioModality: boolean; readonly hasAudioModality: boolean;
readonly hasVideoModality: boolean; readonly hasVideoModality: boolean;
readonly hasVisionModality: boolean; readonly hasVisionModality: boolean;
readonly hasMcpPromptsSupport: boolean;
readonly hasMcpResourcesSupport: boolean;
onFileUpload?: () => void; onFileUpload?: () => void;
onSystemPromptClick?: () => void; onSystemPromptClick?: () => void;
onMcpPromptClick?: () => void;
onMcpResourcesClick?: () => void;
onMcpSettingsClick?: () => 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'; 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 { export interface McpServerOverride {
serverId: string; serverId: string;
enabled: boolean; enabled: boolean;
@@ -11,10 +16,15 @@ export interface DatabaseConversation {
id: string; id: string;
lastModified: number; lastModified: number;
name: string; name: string;
/** @deprecated See {@link McpServerOverride}. Kept on rows for downgrade compatibility. */
mcpServerOverrides?: McpServerOverride[]; mcpServerOverrides?: McpServerOverride[];
thinkingEnabled?: boolean; thinkingEnabled?: boolean;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
cwd?: string; 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; forkedFromConversationId?: string;
pinned?: boolean; 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'
});
});
});