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
This commit is contained in:
Aleksander Grygier
2026-08-26 21:07:24 +02:00
committed by GitHub
parent 0379a19f09
commit 539f24529b
33 changed files with 305 additions and 539 deletions
@@ -1,7 +1,5 @@
<script lang="ts">
import { RefreshCw } from '@lucide/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import {
SettingsChatDesktopSidebar,
SettingsChatFields,
@@ -18,21 +16,29 @@
SETTINGS_SECTION_SLUGS
} from '$lib/constants';
import { ColorMode } from '$lib/enums/ui.enums';
import { RouterService } from '$lib/services/router.service';
import { modelsStore, serverStore, settingsReferrer, settingsStore } from '$lib/stores';
import type { SettingsSection } from '$lib/types';
import { modelsStore, serverStore, settingsStore } from '$lib/stores';
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
import { setMode } from 'mode-watcher';
import { fade } from 'svelte/transition';
interface Props {
initialSection?: string;
getSectionHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
onClose?: () => void;
}
let { getSectionHref, initialSection }: Props = $props();
let { initialSection, onClose, onSectionChange }: Props = $props();
let activeSlug = $derived(
initialSection ?? (page.params as Record<string, string | undefined>).section ?? 'general'
);
let activeSlug = $derived(initialSection ?? 'general');
function handleSectionChange(section: SettingsSectionTitle) {
const found = SETTINGS_CHAT_SECTIONS.find((s) => s.title === section);
if (found) {
activeSlug = found.slug;
}
onSectionChange?.(section);
}
let currentSection = $derived(
SETTINGS_CHAT_SECTIONS.find((section) => section.slug === activeSlug) ||
@@ -115,7 +121,7 @@
}
settingsStore.updateMultipleConfig(processedConfig);
goto(settingsReferrer.url);
onClose?.();
}
export function reset() {
@@ -123,32 +129,24 @@
}
</script>
<div in:fade={{ duration: 150 }} class="mx-auto flex h-full w-full flex-col md:pl-8">
<div class="flex flex-1 flex-col gap-4 md:flex-row">
<div in:fade={{ duration: 150 }} class="mx-auto flex h-full w-full flex-col">
<div class="flex flex-1 flex-col md:flex-row md:gap-4">
<SettingsChatDesktopSidebar
getHref={getSectionHref ??
((section: SettingsSection) => RouterService.settings(section.slug))}
isActive={(section: SettingsSection) => section.slug === activeSlug}
onSectionChange={handleSectionChange}
sections={SETTINGS_CHAT_SECTIONS}
/>
<SettingsChatMobileHeader
bind:this={mobileHeader}
getHref={getSectionHref ??
((section: SettingsSection) => RouterService.settings(section.slug))}
isActive={(section: SettingsSection) => section.slug === activeSlug}
onSectionChange={handleSectionChange}
sections={SETTINGS_CHAT_SECTIONS}
/>
<div class="mx-auto max-w-3xl flex-1">
<div class="space-y-6 p-4 md:p-6 md:pt-28">
<div class="mx-auto max-w-2xl px-4 flex-1 md:mt-4">
<div class="space-y-6 pt-3">
<div class="grid">
<div class="mb-6 flex items-center gap-2 border-b border-border/30 pb-6 md:flex">
<currentSection.icon class="h-5 w-5" />
<h3 class="text-lg font-semibold">{currentSection.title}</h3>
</div>
{#if currentSection.slug === SETTINGS_SECTION_SLUGS.TOOLS}
<SettingsChatToolsTab />
{:else if currentSection.slug === SETTINGS_SECTION_SLUGS.IMPORT_EXPORT}
@@ -1,54 +1,31 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { ICON_CLASS_DEFAULT } from '$lib/constants';
import type { SettingsSection, SettingsSectionTitle } from '$lib/types';
interface Props {
sections: SettingsSection[];
isActive: (section: SettingsSection) => boolean;
getHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
}
let { getHref, isActive, onSectionChange, sections }: Props = $props();
let { isActive, onSectionChange, sections }: Props = $props();
</script>
<div class="sticky top-2 hidden w-64 flex-col self-start bg-background py-4 md:flex gap-6">
<div class="flex items-center gap-2 py-2">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
<div class="sticky top-12 hidden w-64 flex-col self-start bg-background md:flex gap-6">
<nav class="space-y-1">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm no-underline transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
href={getHref(section)}
>
<section.icon class={ICON_CLASS_DEFAULT} />
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => onSectionChange?.(section.title)}
>
<section.icon class={ICON_CLASS_DEFAULT} />
<span class="ml-2">{section.title}</span>
</a>
{:else}
<button
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
onclick={() => onSectionChange?.(section.title)}
>
<section.icon class={ICON_CLASS_DEFAULT} />
<span class="ml-2">{section.title}</span>
</button>
{/if}
<span class="ml-2">{section.title}</span>
</button>
{/each}
</nav>
</div>
@@ -1,5 +1,4 @@
<script lang="ts">
import { Settings } from '@lucide/svelte';
import { ScrollCarousel } from '$lib/components/app';
import { ICON_CLASS_DEFAULT, UI_DATA_ATTRS } from '$lib/constants';
import { BooleanString } from '$lib/enums';
@@ -10,11 +9,10 @@
interface Props {
sections: SettingsSection[];
isActive: (section: SettingsSection) => boolean;
getHref?: (section: SettingsSection) => string;
onSectionChange?: (section: SettingsSectionTitle) => void;
}
let { getHref, isActive, onSectionChange, sections }: Props = $props();
let { isActive, onSectionChange, sections }: Props = $props();
const carousel = useScrollCarousel();
@@ -37,51 +35,26 @@
}
</script>
<div class="sticky top-0 z-10 flex flex-col bg-background md:hidden">
<div class="flex items-center gap-2 px-4 pt-4 pb-2 md:pt-6">
<Settings class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-xl font-semibold md:text-2xl">Settings</h1>
</div>
<div class="border-b border-border/30 py-2">
<div class="flex flex-col bg-background md:hidden sticky top-13 z-50">
<div class="border-b border-border/30">
<ScrollCarousel alwaysShowArrows {carousel} containerClass="py-2" innerClass="gap-2">
{#each sections as section (section.title)}
{#if getHref}
<a
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap no-underline transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
href={getHref(section)}
onclick={(e: MouseEvent) => {
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</a>
{:else}
<button
class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm whitespace-nowrap transition-colors first:ml-4 last:mr-4 hover:bg-accent {isActive(
section
)
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground'}"
{...{ [UI_DATA_ATTRS.ACTIVE]: isActive(section) }}
onclick={(e: MouseEvent) => {
onSectionChange?.(section.title);
carousel.scrollToCenter(e.currentTarget as HTMLElement);
}}
>
<section.icon class="{ICON_CLASS_DEFAULT} flex-shrink-0" />
<span>{section.title}</span>
</button>
{/if}
<span>{section.title}</span>
</button>
{/each}
</ScrollCarousel>
</div>
@@ -29,7 +29,7 @@
}
</script>
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between p-6">
<div class="sticky bottom-0 mx-auto mt-4 flex w-full justify-between pb-4 md:pb-0">
<div class="flex gap-2">
<Button onclick={handleResetClick} variant="outline">
<RotateCcw class="h-3 w-3" />
@@ -1,14 +1,11 @@
<script lang="ts">
import McpLogo from '../mcp/McpLogo.svelte';
import { Plus, X } from '@lucide/svelte';
import { browser } from '$app/environment';
import { goto, replaceState } from '$app/navigation';
import { Plus } from '@lucide/svelte';
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import { ActionIcon, McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { McpServerCard, McpServerCardSkeleton } from '$lib/components/app';
import { DialogMcpResourcesBrowser, DialogMcpServerAddNew } from '$lib/components/app/dialogs';
import { Button } from '$lib/components/ui/button';
import * as Empty from '$lib/components/ui/empty';
import { ROUTES } from '$lib/constants';
import { HealthCheckStatus } from '$lib/enums';
import { conversationsStore, mcpStore, toolsStore } from '$lib/stores';
import { onMount } from 'svelte';
@@ -23,26 +20,7 @@
let servers = $derived(mcpStore.getServers());
let isAddingServer = $state(false);
let previousRouteId = $state<string | null>(null);
$effect(() => {
const currentId = page.route.id;
return () => {
previousRouteId = currentId;
};
});
function handleClose() {
const prevIsMcpServers = previousRouteId === '/mcp-servers';
if (browser && window.history.length > 1 && !prevIsMcpServers) {
history.back();
} else {
goto(ROUTES.START);
}
}
let isResourcesDialogOpen = $state(false);
onMount(() => {
if (page.url.searchParams.has('add')) {
@@ -71,25 +49,13 @@
}
</script>
<div in:fade={{ duration: 150 }} class="flex min-h-[calc(100dvh-4rem)] flex-col">
<div class="fixed top-4.5 right-4 z-50 md:hidden">
<ActionIcon icon={X} onclick={handleClose} tooltip="Close" />
</div>
<div
class="sticky top-0 z-10 mt-4 mb-2 flex items-start gap-4 md:p-4 p-0 px-4 md:justify-between md:px-8"
>
<div class="flex items-center gap-2">
<McpLogo class="h-5 w-5 md:h-6 md:w-6" />
<h1 class="text-lg font-semibold md:text-2xl">MCP Servers</h1>
</div>
</div>
<div in:fade={{ duration: 150 }} class="flex flex-col h-full">
<DialogMcpServerAddNew bind:open={isAddingServer} />
<DialogMcpResourcesBrowser bind:open={isResourcesDialogOpen} />
{#if servers.length === 0}
<div class="flex flex-1 items-center justify-center py-16">
<div class="flex flex-1 items-center justify-center pb-20 pt-10 my-auto">
<Empty.Root class="max-w-md">
<Empty.Header>
<Empty.Media variant="icon">
@@ -112,8 +78,8 @@
</div>
{:else}
<div
class="grid gap-3 {className}"
style="grid-template-columns: repeat(auto-fill, minmax(min(32rem, calc(100dvw - 2rem)), 1fr));"
class="grid gap-4 {className}"
style="grid-template-columns: repeat(auto-fill, minmax(min(25rem, calc(100dvw - 4rem)), 1fr));"
>
{#each servers as server (server.id)}
{#if isServerPending(server.id, server.enabled)}
@@ -121,6 +87,7 @@
{:else}
<McpServerCard
enabled={conversationsStore.preferences.isMcpServerEnabledForChat(server.id)}
onBrowseResources={() => (isResourcesDialogOpen = true)}
onDelete={() => mcpStore.removeServer(server.id)}
onToggle={async () => {
const wasEnabled = conversationsStore.preferences.isMcpServerEnabledForChat(
@@ -1,21 +1,21 @@
/**
* Full chat settings page layout with sidebar, mobile header, and content area.
* Manages local configuration state, section navigation, and context setup.
* Accepts an optional `initialSection` prop to override the URL-based section resolution.
* Accepts an optional `initialSection` prop to set the initial active section.
*/
export { default as SettingsChat } from './SettingsChat/SettingsChat.svelte';
/**
* Desktop sidebar navigation for chat settings.
* Displays a list of settings sections with icons and titles.
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
* Switches sections in-app via `onSectionChange`.
*/
export { default as SettingsChatDesktopSidebar } from './SettingsChatDesktopSidebar.svelte';
/**
* Mobile header with a horizontally scrollable section picker for chat settings.
* Shows chevron buttons for scroll navigation and highlights the active section.
* Supports both hash-link navigation (via `getHref`) and in-app section switching (via `onSectionChange`).
* Switches sections in-app via `onSectionChange`.
*/
export { default as SettingsChatMobileHeader } from './SettingsChatMobileHeader.svelte';