ui : mount the configuration pane as a drawer in the manager

A row click slides the pane in as a fixed-width drawer: the toolbar's
calls to action leave first, the pane is laid out before its first open,
and a model switch fades one out and the next in. The model information
dialog folds into the pane's information tab, and a chat started from
the pane closes the manager and focuses the composer.

Assisted-by: pi:llama.cpp/DeepSeek-V4.1-Flash
This commit is contained in:
Aleksander Grygier
2026-09-28 17:12:27 +02:00
parent 01d8fa0053
commit 43dc386afa
4 changed files with 229 additions and 408 deletions
@@ -133,7 +133,7 @@
<div class="dialog-view min-h-0 flex-1" data-visible={!isSwapping}>
{#if shownView === 'manage'}
<ModelsManager class="h-full">
<ModelsManager class="h-full" onClose={() => handleOpenChange(false)}>
{#snippet toolbarEnd()}
<Button
class="gap-1.5"
@@ -1,369 +0,0 @@
<script lang="ts">
import { ActionIconCopyToClipboard, BadgesModality } from '$lib/components/app';
import * as Dialog from '$lib/components/ui/dialog';
import * as Table from '$lib/components/ui/table';
import { modelsStore, serverStore } from '$lib/stores';
import type { ApiLlamaCppServerProps } from '$lib/types';
import { formatFileSize, formatNumber, formatParameters } from '$lib/utils';
interface Props {
open?: boolean;
onOpenChange?: (open: boolean) => void;
// when set, fetch props from the child process (router mode)
modelId?: string | null;
}
let { modelId = null, onOpenChange, open = $bindable() }: Props = $props();
let isRouter = $derived(serverStore.isRouterMode);
// per-model props fetched from the child process
let routerModelProps = $state<ApiLlamaCppServerProps | null>(null);
let isLoadingRouterProps = $state(false);
// in router mode use per-model props, otherwise use global props
let serverProps = $derived(isRouter && modelId ? routerModelProps : serverStore.props);
let modelName = $derived(isRouter && modelId ? modelId : modelsStore.singleModelName);
let models = $derived(modelsStore.models);
let isLoadingModels = $derived(modelsStore.loading);
// in router mode, find the model option matching modelId
// in single mode, use the first model as before
let firstModel = $derived.by(() => {
if (isRouter && modelId) {
return models.find((m) => m.model === modelId) ?? null;
}
return models[0] ?? null;
});
// Get modalities from modelStore using the model ID from the first model
let modalities = $derived.by(() => {
if (!firstModel?.id) return [];
return modelsStore.props.getModelModalitiesArray(firstModel.id);
});
// Ensure models are fetched when dialog opens
$effect(() => {
if (open && models.length === 0) {
modelsStore.fetch();
}
});
// fetch per-model props from child process when dialog opens in router mode
$effect(() => {
if (open && isRouter && modelId) {
isLoadingRouterProps = true;
modelsStore.props
.fetchModelProps(modelId)
.then((props) => {
routerModelProps = props;
})
.catch(() => {
routerModelProps = null;
})
.finally(() => {
isLoadingRouterProps = false;
});
}
if (!open) {
routerModelProps = null;
}
});
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content
class="z-9999 max-md:h-[100dvh]! max-md:w-screen! max-md:max-w-none! md:w-[calc(100vw-4rem)]! md:max-w-[60rem]! md:max-h-[80dvh]!"
>
<!-- sticky header holds only the close button; the title scrolls with the body -->
<Dialog.Header />
<div class="min-w-0 space-y-6 md:py-4 -mt-4! md:mt-0 pb-4">
<div class="min-w-0 space-y-2">
<Dialog.Title>Model Information</Dialog.Title>
<Dialog.Description>Current model details and capabilities</Dialog.Description>
</div>
{#if isLoadingModels || isLoadingRouterProps}
<div class="flex items-center justify-center py-8">
<div class="text-sm text-muted-foreground">Loading model information...</div>
</div>
{:else if firstModel}
{@const modelMeta = firstModel.meta}
{#if serverProps}
<!-- Desktop: fixed-layout table, long values scroll inside their cell -->
<Table.Root class="hidden table-fixed md:table">
<Table.Header>
<Table.Row>
<Table.Head class="w-[10rem]">Model</Table.Head>
<Table.Head>
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{modelName}
</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model name to clipboard"
canCopy={!!modelName}
text={modelName || ''}
/>
</div>
</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<!-- Model Path -->
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">File Path</Table.Cell>
<Table.Cell class="h-10 align-middle font-mono text-xs">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 overflow-x-auto whitespace-nowrap">
{serverProps.model_path}
</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
</div>
</Table.Cell>
</Table.Row>
<!-- Context Size -->
{#if serverProps?.default_generation_settings?.n_ctx}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Context Size</Table.Cell>
<Table.Cell
>{formatNumber(serverProps.default_generation_settings.n_ctx)} tokens</Table.Cell
>
</Table.Row>
{:else}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium text-red-500"
>Context Size</Table.Cell
>
<Table.Cell class="text-red-500">Not available</Table.Cell>
</Table.Row>
{/if}
<!-- Training Context -->
{#if modelMeta?.n_ctx_train}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Training Context</Table.Cell>
<Table.Cell>{formatNumber(modelMeta.n_ctx_train)} tokens</Table.Cell>
</Table.Row>
{/if}
<!-- Model Size -->
{#if modelMeta?.size}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Model Size</Table.Cell>
<Table.Cell>{formatFileSize(modelMeta.size)}</Table.Cell>
</Table.Row>
{/if}
<!-- Parameters -->
{#if modelMeta?.n_params}
<Table.Row>
<Table.Cell class="h-10 align-middle font-medium">Parameters</Table.Cell>
<Table.Cell>{formatParameters(modelMeta.n_params)}</Table.Cell>
</Table.Row>
{/if}
<!-- Embedding Size -->
{#if modelMeta?.n_embd}
<Table.Row>
<Table.Cell class="align-middle font-medium">Embedding Size</Table.Cell>
<Table.Cell>{formatNumber(modelMeta.n_embd)}</Table.Cell>
</Table.Row>
{/if}
<!-- Vocabulary Size -->
{#if modelMeta?.n_vocab}
<Table.Row>
<Table.Cell class="align-middle font-medium">Vocabulary Size</Table.Cell>
<Table.Cell>{formatNumber(modelMeta.n_vocab)} tokens</Table.Cell>
</Table.Row>
{/if}
<!-- Vocabulary Type -->
{#if modelMeta?.vocab_type}
<Table.Row>
<Table.Cell class="align-middle font-medium">Vocabulary Type</Table.Cell>
<Table.Cell class="align-middle capitalize">{modelMeta.vocab_type}</Table.Cell>
</Table.Row>
{/if}
<!-- Total Slots -->
<Table.Row>
<Table.Cell class="align-middle font-medium">Parallel Slots</Table.Cell>
<Table.Cell>{serverProps.total_slots}</Table.Cell>
</Table.Row>
<!-- Modalities -->
{#if modalities.length > 0}
<Table.Row>
<Table.Cell class="align-middle font-medium">Modalities</Table.Cell>
<Table.Cell>
<div class="flex flex-wrap gap-1">
<BadgesModality {modalities} />
</div>
</Table.Cell>
</Table.Row>
{/if}
<!-- Build Info -->
<Table.Row>
<Table.Cell class="align-middle font-medium">Build Info</Table.Cell>
<Table.Cell class="align-middle font-mono text-xs"
>{serverProps.build_info}</Table.Cell
>
</Table.Row>
<!-- Chat Template -->
{#if serverProps.chat_template}
<Table.Row>
<Table.Cell class="py-4" colspan={2}>
<div class="flex flex-col gap-2">
<span class="font-medium">Chat Template</span>
<div class="overflow-x-auto rounded-md bg-muted p-4">
<pre
class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
</div>
</div>
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
<!-- Mobile: stacked layout; long values wrap instead of scrolling the page -->
<div class="flex min-w-0 flex-col gap-4 md:hidden">
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Model</div>
<div class="flex min-w-0 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs">{modelName}</span>
<ActionIconCopyToClipboard
ariaLabel="Copy model name to clipboard"
canCopy={!!modelName}
text={modelName || ''}
/>
</div>
</div>
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">File Path</div>
<div class="flex min-w-0 items-start gap-2">
<span class="min-w-0 flex-1 break-all font-mono text-xs"
>{serverProps.model_path}</span
>
<ActionIconCopyToClipboard
ariaLabel="Copy model path to clipboard"
text={serverProps.model_path}
/>
</div>
</div>
{#if serverProps?.default_generation_settings?.n_ctx}
{@render infoRow(
'Context Size',
`${formatNumber(serverProps.default_generation_settings.n_ctx)} tokens`
)}
{:else}
{@render infoRow('Context Size', 'Not available', 'text-red-500')}
{/if}
{#if modelMeta?.n_ctx_train}
{@render infoRow('Training Context', `${formatNumber(modelMeta.n_ctx_train)} tokens`)}
{/if}
{#if modelMeta?.size}
{@render infoRow('Model Size', formatFileSize(modelMeta.size))}
{/if}
{#if modelMeta?.n_params}
{@render infoRow('Parameters', formatParameters(modelMeta.n_params))}
{/if}
{#if modelMeta?.n_embd}
{@render infoRow('Embedding Size', formatNumber(modelMeta.n_embd))}
{/if}
{#if modelMeta?.n_vocab}
{@render infoRow('Vocabulary Size', `${formatNumber(modelMeta.n_vocab)} tokens`)}
{/if}
{#if modelMeta?.vocab_type}
{@render infoRow('Vocabulary Type', modelMeta.vocab_type, 'capitalize')}
{/if}
{@render infoRow('Parallel Slots', `${serverProps.total_slots}`)}
{#if modalities.length > 0}
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Modalities</div>
<div class="flex flex-wrap gap-1">
<BadgesModality {modalities} />
</div>
</div>
{/if}
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium text-muted-foreground">Build Info</div>
<span class="block break-all font-mono text-xs">{serverProps.build_info}</span>
</div>
{#if serverProps.chat_template}
<div class="min-w-0 space-y-2">
<div class="text-xs font-medium text-muted-foreground">Chat Template</div>
<div class="overflow-x-auto rounded-md bg-muted p-4">
<pre class="font-mono text-xs whitespace-pre">{serverProps.chat_template}</pre>
</div>
</div>
{/if}
</div>
{/if}
{:else if !isLoadingModels}
<div class="flex items-center justify-center py-8">
<div class="text-sm text-muted-foreground">No model information available</div>
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
{#snippet infoRow(label: string, value: string, valueClass: string = '')}
<div class="flex items-center justify-between gap-3">
<span class="shrink-0 text-xs font-medium text-muted-foreground {valueClass}">{label}</span>
<span class="text-sm {valueClass}">{value}</span>
</div>
{/snippet}
@@ -19,7 +19,7 @@
export { default as DialogMcpServerAddNew } from './DialogMcpServerAddNew.svelte';
/**
* **DialogMcpServers** - MCP servers dialog shown from the chat form
* **DialogMcpServers** - MCP servers dialog shown from the sidebar
*
* Shows the same MCP server list as the `/mcp-servers` route inside a modal
* dialog.
@@ -106,6 +106,10 @@ export { default as DialogExportSettings } from './DialogExportSettings.svelte';
* />
* ```
*/
/**
* **DialogManageModels** - shell for model management, still to be designed.
*/
export { default as DialogManageModels } from './DialogManageModels.svelte';
export { default as DialogConfirmation } from './DialogConfirmation.svelte';
/**
@@ -450,7 +454,6 @@ export { default as DialogConversationSelection } from './DialogConversationSele
* <DialogModelInformation bind:open={showModelInfo} />
* ```
*/
export { default as DialogModelInformation } from './DialogModelInformation.svelte';
/**
* **DialogMcpResourcesBrowser** - MCP resources browser dialog
@@ -537,38 +540,12 @@ export { default as DialogMcpResourcePreview } from './DialogMcpResourcePreview.
* ```
*/
export { default as DialogMermaidPreview } from './DialogMermaidPreview.svelte';
/**
* **DialogConfirmation** - Generic confirmation dialog
* **DialogModelsDiscover** - full-screen model discovery dialog.
*
* Reusable confirmation dialog with customizable title, description,
* and action buttons. Supports destructive action styling and custom icons.
* Used for delete confirmations, irreversible actions, and important decisions.
* Two-pane layout: searchable model list (Hugging Face + llama.app catalog)
* on the left, model details with download options on the right.
*
* **Architecture:**
* - Uses ShadCN AlertDialog
* - Supports variant styling (default, destructive)
* - Customizable button labels and callbacks
*
* **Features:**
* - Customizable title and description text
* - Destructive variant with red styling for dangerous actions
* - Custom icon support in header
* - Cancel and confirm button callbacks
* - Keyboard accessible (Escape to cancel, Enter to confirm)
*
* @example
* ```svelte
* <DialogConfirmation
* bind:open={showDelete}
* title="Delete conversation?"
* description="This action cannot be undone."
* variant="destructive"
* onConfirm={handleDelete}
* onCancel={() => showDelete = false}
* />
* ```
* @see ModelsDiscover in $lib/components/app/models/discover
*/
/**
* **DialogManageModels** - shell for model management, still to be designed.
*/
export { default as DialogManageModels } from './DialogManageModels.svelte';
@@ -1,7 +1,10 @@
<script lang="ts">
import ModelsManagerModelConfiguration from './ModelsManagerModelConfiguration/ModelsManagerModelConfiguration.svelte';
import ModelsManagerModelsTable from './ModelsManagerModelsTable.svelte';
import {
groupModelQuants,
isCustomized,
loadExtraArgs,
loadOverrides,
type ModalityKey,
modelContextLength,
@@ -14,21 +17,22 @@
} from './utils';
import { LOCAL_BACKEND_ID } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { backendsStore, modelsStore, uiStore } from '$lib/stores';
import { backendsStore, conversationsStore, modelsStore, uiStore } from '$lib/stores';
import type { ModelOption } from '$lib/types/models';
import { getBackend } from '$lib/utils/api-base';
import { getBackendCapabilities } from '$lib/utils/backend';
import { type Snippet } from 'svelte';
import { type Snippet, untrack } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import { toast } from 'svelte-sonner';
interface Props {
class?: string;
onClose?: () => void;
/** Forwarded to the table's toolbar right end. */
toolbarEnd?: Snippet;
}
let { class: className, toolbarEnd }: Props = $props();
let { class: className, onClose, toolbarEnd }: Props = $props();
let filter = $state('');
let providerFilter = $state<string[]>([]);
@@ -93,7 +97,6 @@
return counts;
});
// recently used models lead their section, the rest keep the server's order
const rank = new SvelteMap<string, number>();
@@ -220,6 +223,67 @@
return ordered;
});
let selected = $derived(allModels.find((option) => option.id === selectedId) ?? null);
// The pane is laid out before it is ever opened, so the first open only slides a
// finished panel in. It renders the selection, else the model it last showed, else
// the first model in the list.
let lastPicked = $state<ModelOption | null>(null);
let target = $derived(selected ?? lastPicked ?? allModels[0] ?? null);
let shownId = $state<string | null>(null);
let isSwapping = $state(false);
let fade = $state<'open' | 'swap'>('open');
let shownOption = $derived(allModels.find((option) => option.id === shownId) ?? null);
$effect(() => {
const id = selectedId;
if (!id) return;
// untracked: the effect must not track the state it writes
untrack(() => {
lastPicked = allModels.find((option) => option.id === id) ?? null;
});
});
// Another model fades the panel out, swaps it, then fades it back in. Reopening the
// same one only fades it, so the panel keeps its tab.
$effect(() => {
const next = target?.id ?? null;
const isOpen = selected !== null;
if (!next) return;
if (shownId === null) {
untrack(() => (shownId = next));
return;
}
if (next === shownId) {
if (isOpen) {
untrack(() => {
isSwapping = false;
fade = 'open';
});
}
return;
}
untrack(() => {
isSwapping = true;
fade = 'swap';
});
const timer = setTimeout(() => {
untrack(() => {
shownId = next;
isSwapping = false;
});
}, SWAP_FADE_MS);
return () => clearTimeout(timer);
});
// a caller can ask for one model to be revealed, the download rows do
$effect(() => {
@@ -234,6 +298,64 @@
uiStore.manageModelFocus = null;
});
/** How long the panel takes to fade out before it swaps to another model. */
const SWAP_FADE_MS = 120;
/** How long the calls to action take to leave the toolbar, matching their fade. */
const CTA_LEAVE_MS = 120;
/** How long the panel takes to slide out before the calls to action come back. */
const PANE_LEAVE_MS = 120;
// The calls to action leave first, then the panel takes the space they gave up.
// Closing runs the same order backwards.
let ctasVisible = $state(true);
// once faded the row leaves the flow, so the filters keep the room it was holding
let ctasGone = $state(false);
let paneOpen = $state(false);
$effect(() => {
if (selected !== null) {
untrack(() => (ctasVisible = false));
const timer = setTimeout(
() =>
untrack(() => {
ctasGone = true;
paneOpen = true;
}),
CTA_LEAVE_MS
);
return () => clearTimeout(timer);
}
untrack(() => {
paneOpen = false;
ctasGone = false;
});
const timer = setTimeout(() => untrack(() => (ctasVisible = true)), PANE_LEAVE_MS);
return () => clearTimeout(timer);
});
async function toggleLoad(option: ModelOption): Promise<void> {
if (modelsStore.isModelLoaded(option.model)) {
await modelsStore.status.unload(option.model);
return;
}
await modelsStore.status.load(option.model, loadExtraArgs(overrides[option.id]));
}
async function useInNewChat(option: ModelOption): Promise<void> {
await modelsStore.selectModelById(option.id);
await conversationsStore.openNewChat();
// the chat is behind the dialog, so it takes focus once the dialog is out of the way
uiStore.requestComposerFocus();
onClose?.();
}
/** Point the selected model's load settings at another model as its draft. */
function useAsDraft(draft: ModelOption, targetId: string): void {
const target = modelsStore.models.find((option) => option.id === targetId);
@@ -253,6 +375,17 @@
}
</script>
{#snippet toolbarEndRegion()}
<!-- the calls to action fade in place; the pane waits for them to be gone -->
<div
class="transition-[opacity,visibility] duration-[120ms] ease-[cubic-bezier(0.23,1,0.32,1)] {ctasGone
? 'hidden'
: 'flex items-center gap-2'} {ctasVisible ? 'visible opacity-100' : 'invisible opacity-0'}"
>
{@render toolbarEnd?.()}
</div>
{/snippet}
<div class={['flex min-h-0 flex-1', className]}>
<div class="min-h-0 min-w-0 flex-1">
<ModelsManagerModelsTable
@@ -269,7 +402,87 @@
{overrides}
{providerCounts}
{selectedId}
{toolbarEnd}
toolbarEnd={toolbarEndRegion}
/>
</div>
<div class="pane-drawer shrink-0" data-open={paneOpen}>
<!-- the content box keeps the open width, so it never reflows with the drawer -->
<div
class="pane-content flex h-full min-h-0 w-[30rem] max-w-[30rem] flex-col border-l border-border/40"
data-fade={fade}
data-visible={paneOpen && !isSwapping}
>
{#if shownOption}
{#key shownId}
<ModelsManagerModelConfiguration
isCustomized={isCustomized(overrides[shownOption.id])}
onClose={() => (selectedId = null)}
onSave={(override) => saveOverride(shownOption, override)}
onToggleLoad={() => void toggleLoad(shownOption)}
onUseInNewChat={() => void useInNewChat(shownOption)}
option={shownOption}
override={overrides[shownOption.id]}
/>
{/key}
{/if}
</div>
</div>
</div>
<style>
/*
* The drawer moves by width because the table behind it gets that space back, so
* the content box inside holds the open width and only the container changes.
* Opening takes the iOS-like drawer curve; closing is the system responding, so
* it snaps back on the stronger ease-out.
*/
.pane-drawer {
width: 0;
overflow: clip;
visibility: hidden;
transition:
width 120ms cubic-bezier(0.23, 1, 0.32, 1),
visibility 120ms;
}
.pane-drawer[data-open='true'] {
width: 30rem;
visibility: visible;
transition:
width 200ms cubic-bezier(0.32, 0.72, 0, 1),
visibility 200ms;
}
.pane-content {
opacity: 0;
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
.pane-content[data-visible='true'] {
opacity: 1;
}
/* opening: the fade waits for the drawer to move */
.pane-content[data-visible='true'][data-fade='open'] {
transition: opacity 150ms cubic-bezier(0.23, 1, 0.32, 1) 80ms;
}
/* swapping models: out, then in, with no pause */
.pane-content[data-visible='true'][data-fade='swap'] {
transition: opacity 120ms cubic-bezier(0.23, 1, 0.32, 1);
}
/* reduced motion keeps the fades and drops the slide */
@media (prefers-reduced-motion: reduce) {
.pane-drawer,
.pane-drawer[data-open='true'] {
transition: visibility 120ms;
}
.pane-content,
.pane-content[data-visible='true'][data-fade='open'] {
transition: opacity 100ms;
}
}
</style>