fix: open the chat + menu without waiting on the skills and mcp fetches (#11645)

* fix: resolve dropdown items before showing the menu on first open

* fix: open the chat + menu without waiting on the skills and mcp fetches

* fix: keep mcp submenu rows in place when toggling a server
This commit is contained in:
Guilhem
2026-10-09 18:21:10 +02:00
committed by GitHub
parent 92af345d05
commit 1fea1aa660
3 changed files with 66 additions and 47 deletions
@@ -1107,16 +1107,13 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
</Tooltip>
{:else if canAttachFiles}
<DropdownV2
items={async () => {
// 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)
}
}
]
: [])
@@ -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<Row[]>([])
#rowsWorkspace: string | undefined = undefined
#rowsWorkspace = $state<string | undefined>(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<Item[]> {
/** 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
@@ -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<Row[]>([])
#rowsWorkspace: string | undefined = undefined
#rowsWorkspace = $state<string | undefined>(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<Item[]> {
/** 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?.()