ui : fix models page navigation and Discover gating

Record the models page's way back after every navigation, so Back,
a typed URL or a reload no longer return to a stale chat. Escape
closes the detail pane, then leaves the page. Gate every Discover
entry point on one shared check, recheck the API key on /models,
skip the duplicate router fetch on a direct load, and share the
route load between pages.

Assisted by: Claude (Opus 5.5 Medium)
This commit is contained in:
Aleksander Grygier committed 2026-10-22 16:09:28 +02:00
1 parent a206ae5706
commit f64baf3a36
15 files changed
+115 -77

No files matched your search

@@ -1,8 +1,8 @@
<script lang="ts">
import ModelsDiscover from '$lib/components/app/models/discover/ModelsDiscover.svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { DISCOVER_ICON, SETTINGS_KEYS } from '$lib/constants';
import { settingsStore } from '$lib/stores';
import { DISCOVER_ICON } from '$lib/constants';
import { uiStore } from '$lib/stores';
import { untrack } from 'svelte';
interface Props {
@@ -12,14 +12,14 @@
let { onOpenChange, open = $bindable(false) }: Props = $props();
// the dialog exists only while its setting is on
let discoverEnabled = $derived(
Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
);
// the setting can be turned off while the dialog is open: it closes and drops the
// repo it was asked to show
$effect(() => {
if (open && !discoverEnabled) {
untrack(() => handleOpenChange(false));
if (open && !uiStore.isDiscoverEnabled) {
untrack(() => {
uiStore.discoverModelFocus = null;
handleOpenChange(false);
});
}
});
@@ -260,6 +260,32 @@
void uiStore.closeModelsManager();
}
/** Overlays that handle their own Escape, so the page leaves them alone. */
const OPEN_OVERLAY_SELECTOR = [
'[role="dialog"][data-state="open"]',
'[role="alertdialog"][data-state="open"]',
'[role="menu"][data-state="open"]',
'[role="listbox"][data-state="open"]'
].join(', ');
// Escape steps back the way the dialog's did: it closes the pane, then the page
function handleKeydown(event: KeyboardEvent): void {
if (event.key !== 'Escape' || event.defaultPrevented) return;
if (document.querySelector(OPEN_OVERLAY_SELECTOR)) return;
// a typed filter is not thrown away by a stray Escape
if (event.target instanceof HTMLInputElement && event.target.value) return;
if (selectedId !== null) {
selectedId = null;
return;
}
returnToChat();
}
/** Load the model when the server can take load requests, without waiting for it. */
function loadInBackground(option: ModelOption): void {
// only the built-in server loads on request, and only in router mode
@@ -292,6 +318,8 @@
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class={['relative flex min-h-0 flex-1', className]}>
<div class="min-h-0 min-w-0 flex-1">
<ModelsManagerModelsTable
@@ -11,8 +11,7 @@
Trash2
} from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { SETTINGS_KEYS } from '$lib/constants';
import { conversationsStore, deviceStore, settingsStore } from '$lib/stores';
import { conversationsStore, deviceStore, uiStore } from '$lib/stores';
interface Props {
/** A provider that cannot load or unload has no button to offer. */
@@ -48,10 +47,7 @@
size = 'sm'
}: Props = $props();
// the discover view exists only while its setting is on
let discoverEnabled = $derived(
Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
);
let discoverEnabled = $derived(uiStore.isDiscoverEnabled);
// a chat with anything in it takes the model, a new chat is not what its user asked for
let hasChat = $derived(
@@ -6,7 +6,7 @@
import { Button } from '$lib/components/ui/button';
import { type ModalityKey } from '$lib/constants';
import { ModelCapability } from '$lib/enums';
import { deviceStore, uiStore } from '$lib/stores';
import { deviceStore } from '$lib/stores';
import type { Snippet } from 'svelte';
interface Props {
@@ -36,8 +36,6 @@
// or two after the page (the toolbar renders below the table in the DOM), so the
// poll retries until the ref lands or gives up.
$effect(() => {
if (!uiStore.isModelsPageOpen) return;
let frames = 0;
let handle = requestAnimationFrame(function focusFilter() {
if (filterInput) {
@@ -13,13 +13,18 @@ export function modelRowActions(
/** Download state, when the row stands for a tracked download. */
download?: ModelRowDownloadState | null
) {
const viewInDiscover = {
icon: Compass,
label: 'See in Discover',
// the details pane is keyed by repo, not by `<repo>:<quant>`
onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
separator: true
};
// the details pane is keyed by repo, not by `<repo>:<quant>`; the entry exists only
// while Discover is on
const viewInDiscover = uiStore.isDiscoverEnabled
? [
{
icon: Compass,
label: 'See in Discover',
onclick: () => uiStore.openModelsDiscover(repoOf(option.model)),
separator: true
}
]
: [];
// a tracked download is paused, resumed or dropped: it is not loaded, hidden or
// drafted. An in-flight row cancels, which stops the download and removes the
@@ -40,7 +45,7 @@ export function modelRowActions(
separator: true,
variant: 'destructive' as const
},
viewInDiscover
...viewInDiscover
];
}
@@ -63,6 +68,6 @@ export function modelRowActions(
onclick: () => modelsStore.toggleHidden(option.id),
separator: true
},
viewInDiscover
...viewInDiscover
];
}
@@ -20,3 +20,6 @@ export const ROUTES = {
/** Root — start of the app. */
START: '#/'
} as const;
/** SvelteKit route id of the models manager, as `page.route.id` reports it. */
export const MODELS_ROUTE_ID = '/models';
+2 -1
View File
@@ -1,4 +1,5 @@
import { MODEL_ICON } from './icons.constants';
import { MODELS_ROUTE_ID } from './routes.constants';
import { Search, Settings, SquarePen } from '@lucide/svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import { SidebarAction, ToolSource } from '$lib/enums';
@@ -80,7 +81,7 @@ export const SIDEBAR_ACTIONS_ITEMS: DesktopIconStripItem[] = [
{ icon: Search, keys: ['cmd', 'k'], tooltip: 'Search' },
{
action: SidebarAction.MANAGE_MODELS,
activeRouteId: '/models',
activeRouteId: MODELS_ROUTE_ID,
icon: MODEL_ICON,
tooltip: 'Models'
},
@@ -114,13 +114,6 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
);
const emptyMessage = $derived(searchTerm ? 'No models found.' : 'No models yet.');
// the manager takes the focus, so the selector closes instead of sitting behind it
$effect(() => {
if (!uiStore.isModelsPageOpen) return;
opts.onOpenChange?.(false);
});
onMount(() => {
modelsStore.fetch().catch((error) => {
console.error('Unable to load models:', error);
+9
View File
@@ -7,6 +7,7 @@ import { toolsStore } from './tools.svelte';
import { versionStore } from './version.svelte';
import { browser } from '$app/environment';
import { MigrationService } from '$lib/services/migration.service';
import { validateApiKey } from '$lib/utils/api-key-validation';
let startup: Promise<void> | null = null;
@@ -30,3 +31,11 @@ export function initStores(): Promise<void> {
return startup;
}
/** Load shared by the app's pages: start the stores, then check the API key. */
export async function loadAppRoute(fetch: typeof globalThis.fetch): Promise<void> {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
}
+19 -5
View File
@@ -8,7 +8,8 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ROUTES } from '$lib/constants';
import { MODELS_ROUTE_ID, ROUTES, SETTINGS_KEYS } from '$lib/constants';
import { settingsStore } from '$lib/stores/settings/index.svelte';
import type { ModelOption } from '$lib/types/models';
class UiStore {
@@ -26,12 +27,17 @@ class UiStore {
manageModelFocus = $state<string | null>(null);
/** Model the information dialog shows; a phone opens it instead of the manager. */
modelInformation = $state<ModelOption | null>(null);
/** Route the models page returns to, the one it was opened from. */
/** Route the models page returns to: the last page visited before it. */
modelsReturnRoute: string = ROUTES.START;
/** Whether Discover is turned on; every Discover entry point hides without it. */
get isDiscoverEnabled(): boolean {
return Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS));
}
/** Whether the models manager route is the current page. */
get isModelsPageOpen(): boolean {
return page.route.id === '/models';
return page.route.id === MODELS_ROUTE_ID;
}
/** Leave the models page for the route it was opened from. */
@@ -46,8 +52,10 @@ class UiStore {
this.modelInformation = option;
}
/** Open the Discover dialog, focused on one repo. */
/** Open the Discover dialog, focused on one repo; a no-op while Discover is off. */
openModelsDiscover(focus?: string): void {
if (!this.isDiscoverEnabled) return;
this.discoverModelFocus = focus ?? null;
this.discoverModelsOpen = true;
}
@@ -58,10 +66,16 @@ class UiStore {
if (this.isModelsPageOpen) return;
this.modelsReturnRoute = page.url.hash || ROUTES.START;
void goto(ROUTES.MODELS);
}
/** Remember the current route as the models page's way back, unless it is that page. */
rememberModelsReturnRoute(): void {
if (this.isModelsPageOpen) return;
this.modelsReturnRoute = page.url.hash || ROUTES.START;
}
/** Ask the composer to take focus, e.g. after a dialog closes onto the chat. */
requestComposerFocus(): void {
this.composerFocusRequested = true;
+2 -8
View File
@@ -1,10 +1,4 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
import { loadAppRoute } from '$lib/stores/init';
export const load: PageLoad = async ({ fetch }) => {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
};
export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
@@ -1,10 +1,4 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
import { loadAppRoute } from '$lib/stores/init';
export const load: PageLoad = async ({ fetch }) => {
// loads run before the root layout script, so the stored API key reaches
// the probe only once the settings store has read localStorage
await initStores();
await validateApiKey(fetch);
};
export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);
+9 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import '../app.css';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { afterNavigate, goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
import { SidebarNavigation } from '$lib/components/app';
@@ -11,6 +11,7 @@
FAVICON_PATHS,
FAVICON_SELECTORS,
HEADERS,
MODELS_ROUTE_ID,
NEW_CHAT_TAB_ID,
ROUTES,
SETTINGS_KEYS,
@@ -28,6 +29,7 @@
serverStore,
settingsStore,
tabsStore,
uiStore,
versionStore
} from '$lib/stores';
import { initStores } from '$lib/stores/init';
@@ -137,6 +139,9 @@
untrack(() => tabsStore.removeTabs([NEW_CHAT_TAB_ID]));
}
});
// the models page returns to wherever it was reached from, by any kind of navigation
afterNavigate(() => uiStore.rememberModelsReturnRoute());
// Global keyboard shortcuts
const { handleKeydown } = useKeyboardShortcuts({
editActiveConversation: () => chatSidebar?.editActiveConversation?.(),
@@ -158,7 +163,9 @@
untrack(() => {
if (
(page.route.id === '/(chat)' || page.route.id === '/(chat)/chat/[id]') &&
(page.route.id === '/(chat)' ||
page.route.id === '/(chat)/chat/[id]' ||
page.route.id === MODELS_ROUTE_ID) &&
page.status !== 401 &&
page.status !== 403
) {
+13 -13
View File
@@ -1,21 +1,21 @@
<script lang="ts">
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
import { Button } from '$lib/components/ui/button';
import { APP_NAME, DISCOVER_ICON, SETTINGS_KEYS } from '$lib/constants';
import { modelsStore, settingsStore, uiStore } from '$lib/stores';
import { APP_NAME, DISCOVER_ICON } from '$lib/constants';
import { modelsStore, uiStore } from '$lib/stores';
import { onMount } from 'svelte';
let discoverEnabled = $derived(
Boolean(settingsStore.getConfig(SETTINGS_KEYS.ENABLE_DISCOVER_MODELS))
);
// a direct load lands here before any chat has listed the models; the router
// fetch is the sync point for an already loaded list and a no-op outside router mode
// a direct load lands here before any chat has listed the models, and the root
// layout fetches the router details once that list arrives. An already loaded
// list only needs the router fetch as a sync point (a no-op outside router mode)
onMount(() => {
modelsStore
.fetch()
.then(() => modelsStore.fetchRouterModels())
.catch((error) => console.error('Unable to load models:', error));
if (modelsStore.models.length > 0) {
void modelsStore.fetchRouterModels();
return;
}
modelsStore.fetch().catch((error) => console.error('Unable to load models:', error));
});
</script>
@@ -33,7 +33,7 @@
</p>
</div>
{#if discoverEnabled}
{#if uiStore.isDiscoverEnabled}
<Button
class="ml-auto shrink-0 gap-1.5"
onclick={() => uiStore.openModelsDiscover()}
+2 -6
View File
@@ -1,8 +1,4 @@
import type { PageLoad } from './$types';
import { initStores } from '$lib/stores/init';
import { validateApiKey } from '$lib/utils';
import { loadAppRoute } from '$lib/stores/init';
export const load: PageLoad = async ({ fetch }) => {
await initStores();
await validateApiKey(fetch);
};
export const load: PageLoad = ({ fetch }) => loadAppRoute(fetch);