Files
koboldcpp/tools/ui/src/lib/components/app/dialogs/DialogMcpServerAddNew.svelte
T
Aleksander Grygier 539f24529b ui: Move Settings and MCP Servers routes to dialog-based views (#27744)
* ui : open MCP servers in a dialog from the chat form

Replace the MCP servers submenu with a single "MCP Servers" item that opens
a new DialogMcpServers dialog instead of navigating to the /mcp-servers route.

Assisted-by: pi

* ui : browse MCP resources from the server card

Make the Resources capability badge clickable so it opens the MCP resources
browser dialog, and drop the page-only chrome from SettingsMcpServers.

Assisted-by: pi

* ui : remove mcp-servers route and sidebar entry

MCP servers are now managed in a dialog, so drop the dedicated route and the
sidebar icon that navigated to it.

Assisted-by: pi

* ui : remove unused MCP servers submenu component

The submenu was replaced by the MCP servers dialog, so delete the component
and its export.

Assisted-by: pi

* feat(ui): add DialogSettingsChat dialog

* refactor(ui): switch SettingsChat to in-app section navigation

* feat(ui): open settings as dialog from sidebar

* refactor(ui): remove settings route and URL-based settings navigation

* fix(ui): adjust MCP dialogs for new base sizing

* chore: Formatting & linting
2026-08-26 21:07:24 +02:00

307 lines
8.5 KiB
Svelte

<script lang="ts">
import { browser } from '$app/environment';
import { McpServerCardCompact, McpServerForm } from '$lib/components/app/mcp';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import {
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
HEADERS,
MCP_SERVER_ID_PREFIX,
RECOMMENDED_MCP_SERVERS
} from '$lib/constants';
import { BooleanString, HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore } from '$lib/stores';
import { canonicalizeServerUrl, parseHeadersToArray, uuid } from '$lib/utils';
interface Props {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
let { onOpenChange, open = $bindable() }: Props = $props();
let newServerUrl = $state('');
let newServerName = $state('');
let nameAutoFilled = $state('');
let nameTouched = $state(false);
let previewRun = 0;
function handleNameChange(value: string) {
newServerName = value;
nameTouched = true;
}
let newServerHeaders = $state('');
let newServerUseProxy = $state(false);
let newServerWantsAuthorization = $state(false);
let selectedRecommendationId = $derived.by(() => {
const url = newServerUrl.trim();
if (!url) return null;
const targetCanonical = canonicalizeServerUrl(url);
return (
RECOMMENDED_MCP_SERVERS.find((rec) => canonicalizeServerUrl(rec.url) === targetCanonical)
?.id ?? null
);
});
let selectedRecommendation = $derived(
selectedRecommendationId
? (RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === selectedRecommendationId) ?? null)
: null
);
let authRequired = $derived(selectedRecommendation?.needsAuthorization ?? false);
let bearerTokenFilled = $derived.by(() => {
const pairs = parseHeadersToArray(newServerHeaders);
const bearerPrefix = HEADERS.BEARER.toLowerCase();
const bearer = pairs.find(
(p) =>
HEADERS.REDACTED.has(p.key.trim().toLowerCase()) &&
p.value.trim().toLowerCase().startsWith(bearerPrefix)
);
if (!bearer) return false;
return bearer.value.trim().slice(bearerPrefix.length).trim().length > 0;
});
let newServerUrlError = $derived.by(() => {
if (!newServerUrl.trim()) return 'URL is required';
try {
new URL(newServerUrl);
return null;
} catch {
return 'Invalid URL format';
}
});
let newServerHeaderPairsValid = $derived(
parseHeadersToArray(newServerHeaders).every((p) => p.key.trim() && p.value.trim())
);
let canSave = $derived(
!newServerUrlError && newServerHeaderPairsValid && (!authRequired || bearerTokenFilled)
);
// Backward-compatible read: older versions stored a JSON array of dismissed ids.
function readRecommendationsDismissed(): boolean {
if (!browser) return false;
const raw = localStorage.getItem(DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY);
if (!raw) return false;
if (raw === BooleanString.TRUE) return true;
if (raw === BooleanString.FALSE) return false;
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) && parsed.length > 0;
} catch {
return false;
}
}
function writeRecommendationsDismissed(dismissed: boolean) {
recommendationsDismissed = dismissed;
if (browser) {
localStorage.setItem(
DISMISSED_RECOMMENDED_MCP_SERVERS_LOCALSTORAGE_KEY,
dismissed ? BooleanString.TRUE : BooleanString.FALSE
);
}
}
let recommendationsDismissed = $state<boolean>(readRecommendationsDismissed());
// Read-only once a recommendation is picked: switch is disabled, so we keep
// the Authorization field in sync with the requirement.
$effect(() => {
if (authRequired) {
newServerWantsAuthorization = true;
}
});
// Debounced preview handshake: once the URL is valid and stable, fetch the
// server-reported name to prefill the display name field. A manual edit
// freezes the autofill for good, and failures stay silent.
$effect(() => {
const url = newServerUrl.trim();
const headers = newServerHeaders.trim();
const useProxy = newServerUseProxy;
if (!open || newServerUrlError || !url) return;
const run = ++previewRun;
// One throwaway id per run: concurrent previews (URL typed, then the
// bearer token pasted) would poison each other's shared health state.
const previewId = `${MCP_SERVER_ID_PREFIX}-preview-${run}`;
const timer = setTimeout(async () => {
await mcpStore.runHealthCheck({
enabled: false,
headers: headers || undefined,
id: previewId,
url,
useProxy
});
const state = mcpStore.getHealthCheckState(previewId);
mcpStore.clearHealthCheck(previewId);
if (run !== previewRun) return;
if (state.status !== HealthCheckStatus.SUCCESS) return;
const autoName = state.serverInfo?.title || state.serverInfo?.name || '';
if (autoName && !nameTouched) {
newServerName = autoName;
nameAutoFilled = autoName;
}
}, 600);
return () => clearTimeout(timer);
});
let hasSelection = $derived(selectedRecommendationId !== null);
let unconfiguredRecommendations = $derived.by(() => {
const configuredCanonicals = new Set(
mcpStore.getServers().map((s) => canonicalizeServerUrl(s.url))
);
return RECOMMENDED_MCP_SERVERS.filter(
(rec) => !configuredCanonicals.has(canonicalizeServerUrl(rec.url))
);
});
let recommendationsToShow = $derived(recommendationsDismissed ? [] : unconfiguredRecommendations);
function handleRecommendationClick(recommendedId: string) {
const recommendation = RECOMMENDED_MCP_SERVERS.find((rec) => rec.id === recommendedId);
if (!recommendation) return;
newServerUrl = recommendation.url;
newServerHeaders = '';
newServerWantsAuthorization = recommendation.needsAuthorization ?? false;
}
function handleDismissAll() {
writeRecommendationsDismissed(true);
}
function handleOpenChange(value: boolean) {
if (!value) {
newServerUrl = '';
newServerName = '';
nameAutoFilled = '';
nameTouched = false;
previewRun++;
newServerHeaders = '';
newServerUseProxy = false;
newServerWantsAuthorization = false;
}
open = value;
onOpenChange?.(value);
}
function saveNewServer() {
if (!canSave) return;
const newServerId = uuid() ?? `${MCP_SERVER_ID_PREFIX}-${Date.now()}`;
mcpStore.addServer({
// A name equal to the autofilled server-reported one is not a
// customization: keep following the automatic label.
displayName:
newServerName.trim() && newServerName.trim() !== nameAutoFilled.trim()
? newServerName.trim()
: undefined,
enabled: true,
headers: newServerHeaders.trim() || undefined,
id: newServerId,
url: newServerUrl.trim(),
useProxy: newServerUseProxy
});
conversationsStore.preferences.setMcpServerOverride(newServerId, true);
handleOpenChange(false);
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
saveNewServer();
}
</script>
<Dialog.Root onOpenChange={handleOpenChange} {open}>
<Dialog.Content class="max-w-2xl!">
<Dialog.Header>
<Dialog.Title class="select-none">Add New MCP Server</Dialog.Title>
</Dialog.Header>
{#if recommendationsToShow.length > 0}
<div class="space-y-3 pt-2">
<div class="flex items-center justify-between gap-3">
<h3 class="text-sm font-medium">Recommended Servers</h3>
<Button class="text-muted-foreground" onclick={handleDismissAll} size="sm" variant="ghost"
>Dismiss</Button
>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
{#each recommendationsToShow as recommendation (recommendation.id)}
<McpServerCardCompact
dimmed={hasSelection && selectedRecommendationId !== recommendation.id}
onClick={() => handleRecommendationClick(recommendation.id)}
selected={selectedRecommendationId === recommendation.id}
server={recommendation}
/>
{/each}
</div>
</div>
{/if}
<form class="contents" onsubmit={handleSubmit}>
<div class="space-y-4 py-4">
<McpServerForm
bind:wantsAuthorization={newServerWantsAuthorization}
headers={newServerHeaders}
id="new-server"
name={newServerName}
onHeadersChange={(v) => (newServerHeaders = v)}
onNameChange={handleNameChange}
onUrlChange={(v) => (newServerUrl = v)}
onUseProxyChange={(v) => (newServerUseProxy = v)}
required={authRequired}
url={newServerUrl}
urlError={newServerUrl ? newServerUrlError : null}
useProxy={newServerUseProxy}
/>
</div>
<Dialog.Footer>
<Button onclick={() => handleOpenChange(false)} size="sm" variant="secondary">
Cancel
</Button>
<Button aria-label="Save" disabled={!canSave} size="sm" type="submit" variant="default">
Add
</Button>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog.Root>