diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index d5406aeca2..b280162f39 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -1107,16 +1107,13 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> {:else if canAttachFiles} { - // Both submenus fetch on the menu's first open, so they start - // together: awaited inline they queue, and the whole menu — - // attachments included — waits out two round trips. + items={() => { const closeMenu = () => (plusMenuOpen = false) const inGlobal = chatHost.mode === AIMode.GLOBAL - const [skillItems, mcpItems] = await Promise.all([ - inGlobal ? skillsMenu.items(closeMenu) : undefined, - inGlobal ? mcpMenu.items(closeMenu) : undefined - ]) + if (inGlobal) { + skillsMenu.refresh() + mcpMenu.refresh() + } return [ { displayName: 'Attach file or image', @@ -1156,23 +1153,24 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> } ] : []), - ...(skillItems + ...(inGlobal ? [ { displayName: 'Skills', icon: BookOpen, separatorTop: true, - submenuItems: skillItems - } - ] - : []), - ...(mcpItems - ? [ + // Getters so the submenus fill in when their fetch lands + // rather than the whole menu waiting on it. + get submenuItems() { + return skillsMenu.items(closeMenu) + } + }, { displayName: 'MCP connections', icon: Plug, - separatorTop: !skillItems, - submenuItems: mcpItems + get submenuItems() { + return mcpMenu.items(closeMenu) + } } ] : []) diff --git a/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts b/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts index dd480c5e97..75d996ebef 100644 --- a/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/skills/skillsMenu.svelte.ts @@ -1,4 +1,4 @@ -import { BookOpen, List, Plus } from 'lucide-svelte' +import { BookOpen, List, Loader2, Plus } from 'lucide-svelte' import { get } from 'svelte/store' import { userStore, workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' @@ -14,6 +14,14 @@ type Row = SkillResource & { enabled: boolean } // scannable, so the rest are reached through the settings modal rather than dropped. const MAX_MENU_SKILLS = 8 +// Not `disabled`: melt stamps that on a row at mount and never clears it, and the +// unkeyed submenu reuses this row for the first skill once the load lands. +const loadingItem: Item = { + displayName: 'Loading…', + icon: Loader2, + iconProps: { class: 'animate-spin shrink-0' } +} + /** * The chat "+" menu's Skills submenu: one row per skill, checked when it is on, * then the way to manage them. Everything a skill is beyond turning it on and off @@ -25,7 +33,7 @@ export class SkillsMenu { #seq = 0 /** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */ #rows = $state([]) - #rowsWorkspace: string | undefined = undefined + #rowsWorkspace = $state(undefined) constructor(manager: AIChatManager, onManage: () => void) { this.#manager = manager @@ -89,19 +97,21 @@ export class SkillsMenu { await this.#manager.refreshGlobalSkills(ws) } - /** Loaded on open so the checks are current. */ - async items(closeMenu?: () => void): Promise { + /** Called on open so the checks are current; never awaited, see `items`. */ + refresh() { + const ws = this.#ws + if (ws) void this.#load(ws) + } + + /** + * Built from the live rows, so call it from a getter: the submenu then fills in + * when a load lands instead of the whole "+" menu waiting on the fetch. Rows for + * another workspace are not shown — same path, different skill. + */ + items(closeMenu?: () => void): Item[] { const ws = this.#ws if (!ws) return [] - // The menu opens on what is already known and refreshes behind it: awaited - // inline it would stall the whole "+" menu, attachments included. Rows for - // another workspace are not "already known" — same path, different skill. - if (this.#rowsWorkspace !== ws) { - this.#rows = [] - await this.#load(ws) - } else { - void this.#load(ws) - } + if (this.#rowsWorkspace !== ws) return [loadingItem] const ambiguous = ambiguousSkillNames(this.#rows) // By path. Ordering the ones that are on first would put every row in the same // bucket now that skills start that way, and drop the one row it did move — a diff --git a/frontend/src/lib/components/mcp/mcpMenu.svelte.ts b/frontend/src/lib/components/mcp/mcpMenu.svelte.ts index a911d71c07..853d994014 100644 --- a/frontend/src/lib/components/mcp/mcpMenu.svelte.ts +++ b/frontend/src/lib/components/mcp/mcpMenu.svelte.ts @@ -1,4 +1,4 @@ -import { List, Plus } from 'lucide-svelte' +import { List, Loader2, Plus } from 'lucide-svelte' import type { Component } from 'svelte' import { get } from 'svelte/store' import { ResourceService } from '$lib/gen' @@ -27,6 +27,14 @@ const MAX_MENU_SERVERS = 8 // menu nobody is reading that far down. const MAX_ICON_LOOKUPS = 20 +// Not `disabled`: melt stamps that on a row at mount and never clears it, and the +// unkeyed submenu reuses this row for the first server once the load lands. +const loadingItem: Item = { + displayName: 'Loading…', + icon: Loader2, + iconProps: { class: 'animate-spin shrink-0' } +} + /** * The chat "+" menu's MCP submenu: one row per connected server, checked when it * is on, then the way to manage them. Connecting and deleting live in the @@ -38,7 +46,7 @@ export class McpMenu { #seq = 0 /** Rows for the workspace named by `#rowsWorkspace`, and meaningless for any other. */ #rows = $state([]) - #rowsWorkspace: string | undefined = undefined + #rowsWorkspace = $state(undefined) constructor(manager: AIChatManager, onManage: () => void) { this.#manager = manager @@ -73,6 +81,10 @@ export class McpMenu { editedAt: r.edited_at, enabled: isMcpEnabled(ws, r.path) })) + // Enabled first: those are the ones a quick visit is most likely about. + // Sorted here, once per load, not in `items()`: the open submenu re-derives + // from `items()`, so a toggle would move rows under the cursor. + .sort((a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path)) this.#rowsWorkspace = ws void this.#loadIcons(ws, seq) } catch { @@ -143,23 +155,22 @@ export class McpMenu { await this.#manager.refreshMcpServers(ws) } - /** Loaded on open so the checks are current. */ - async items(closeMenu?: () => void): Promise { + /** Called on open so the checks are current; never awaited, see `items`. */ + refresh() { + const ws = this.#ws + if (ws) void this.#load(ws) + } + + /** + * Built from the live rows, so call it from a getter: the submenu then fills in + * when a load lands instead of the whole "+" menu waiting on the fetch. Rows for + * another workspace are not shown — same path, different server. + */ + items(closeMenu?: () => void): Item[] { const ws = this.#ws if (!ws) return [] - // The menu opens on what is already known and refreshes behind it: awaited - // inline it would stall the whole "+" menu, attachments included. Rows for - // another workspace are not "already known" — same path, different server. - if (this.#rowsWorkspace !== ws) { - this.#rows = [] - await this.#load(ws) - } else { - void this.#load(ws) - } - // Enabled first: those are the ones a quick visit is most likely about. - const ordered = [...this.#rows].sort( - (a, b) => Number(b.enabled) - Number(a.enabled) || a.path.localeCompare(b.path) - ) + if (this.#rowsWorkspace !== ws) return [loadingItem] + const ordered = this.#rows const shown = ordered.slice(0, MAX_MENU_SERVERS) const manage = () => { closeMenu?.()