feat(coding-agent): implemented dynamic sidebar reordering for model hubs

- Implemented dynamic reordering of model providers in the sidebar to prioritize matches during active searches.
- Refactored model hub registry synchronization to maintain separate state for fixed, unlocked, and locked provider entries.
- Added a dedicated composition step to reassemble sidebar entries based on current search result counts.
- Added test coverage to verify that provider order reverts to alphabetical upon clearing the search query.
This commit is contained in:
can1357
2026-07-11 15:23:48 +02:00
parent 5d3d1230fd
commit 2081bae6a6
3 changed files with 67 additions and 15 deletions
+1
View File
@@ -15,6 +15,7 @@
### Changed
- Updated Model Hub sidebar to float providers with search matches to the top while searching
- Added spatial navigation between the Model Hub sidebar and model list using left/right arrow keys
- Removed "Chat about this" option from question dialogs
- Redesigned ask dialogs with fixed height, removed explicit Next button, and simplified headers
@@ -163,6 +163,11 @@ export class ModelHubComponent implements Component {
#configError: string | undefined;
#entries: SidebarEntry[] = [];
// Sidebar sections from the last registry sync; #composeEntries assembles
// #entries from these (reordered while searching).
#fixedEntries: SidebarEntry[] = [];
#unlockedProviderEntries: SidebarEntry[] = [];
#lockedProviderEntries: SidebarEntry[] = [];
#activeEntryId = "all";
#sidebarScroll = 0;
#sidebarHover: number | null = null;
@@ -429,7 +434,7 @@ export class ModelHubComponent implements Component {
if (assignment && !assignment.autoSelected) assignedCount++;
}
const entries: SidebarEntry[] = [
const fixed: SidebarEntry[] = [
{
id: "recent",
kind: "recent",
@@ -438,29 +443,49 @@ export class ModelHubComponent implements Component {
},
];
if (this.#mode === "roles") {
entries.push({
fixed.push({
id: "roles",
kind: "roles",
label: "Roles",
annotation: `${assignedCount}/${visibleRoles.length}`,
});
}
entries.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
fixed.push({ id: "all", kind: "all", label: "All models", annotation: String(availableModels.length) });
const sortedUnlocked = [...unlocked].sort((a, b) => a.localeCompare(b));
if (sortedUnlocked.length > 0) {
entries.push({ id: "sep:providers", kind: "separator", label: "" });
for (const provider of sortedUnlocked) {
entries.push(providerEntry(provider, false));
}
this.#fixedEntries = fixed;
this.#unlockedProviderEntries = [...unlocked]
.sort((a, b) => a.localeCompare(b))
.map(provider => providerEntry(provider, false));
this.#lockedProviderEntries = [...locked]
.sort((a, b) => a.localeCompare(b))
.map(provider => providerEntry(provider, true));
this.#composeEntries();
}
/**
* Assemble `#entries` from the stored sections. While a search is active,
* providers with matches float to the top of the provider section (each
* group stays alphabetical) so the hop order, mouse hit-testing, and the
* paint all agree.
*/
#composeEntries(): void {
const counts = this.#searchCounts;
let providers = this.#unlockedProviderEntries;
if (counts) {
providers = [...providers].sort((a, b) => {
const aMatched = (counts.get(a.providerId ?? "") ?? 0) > 0;
const bMatched = (counts.get(b.providerId ?? "") ?? 0) > 0;
if (aMatched !== bMatched) return aMatched ? -1 : 1;
return a.label.localeCompare(b.label);
});
}
const sortedLocked = [...locked].sort((a, b) => a.localeCompare(b));
if (sortedLocked.length > 0) {
entries.push({ id: "sep:locked", kind: "separator", label: "" });
for (const provider of sortedLocked) {
entries.push(providerEntry(provider, true));
}
const entries: SidebarEntry[] = [...this.#fixedEntries];
if (providers.length > 0) {
entries.push({ id: "sep:providers", kind: "separator", label: "" }, ...providers);
}
if (this.#lockedProviderEntries.length > 0) {
entries.push({ id: "sep:locked", kind: "separator", label: "" }, ...this.#lockedProviderEntries);
}
this.#entries = entries;
@@ -533,6 +558,7 @@ export class ModelHubComponent implements Component {
#onQueryChanged(query: string): void {
if (!query.trim()) {
this.#searchCounts = null;
this.#composeEntries();
return;
}
const matches = fuzzyFilter(this.#availableItems, query, ({ provider, id }) => `${provider}/${id}`);
@@ -541,6 +567,7 @@ export class ModelHubComponent implements Component {
counts.set(item.provider, (counts.get(item.provider) ?? 0) + 1);
}
this.#searchCounts = counts;
this.#composeEntries();
const entry = this.#activeEntry();
if (
this.#assigningRole === null &&
@@ -426,6 +426,30 @@ describe("ModelHub", () => {
expect(normalize(hub.render(220))).toContain("openrouter ·");
});
test("providers with matches float to the top of the sidebar while searching", () => {
const noMatch = makeModel("aaa-provider", "different-model");
const withMatch = makeModel("zzz-provider", "target-model");
const { hub } = createHub({ models: [noMatch, withMatch] });
installTestTheme();
// Sidebar cell = the first `│`-delimited column of each split row;
// body rows may also mention provider names, so scope the check.
const sidebarIndexOf = (provider: string): number =>
hub
.render(220)
.map(line => stripVTControlCharacters(line).split("│")[1] ?? "")
.findIndex(cell => cell.includes(provider));
expect(sidebarIndexOf("aaa-provider")).toBeLessThan(sidebarIndexOf("zzz-provider"));
for (const ch of "target") hub.handleInput(ch);
expect(sidebarIndexOf("zzz-provider")).toBeLessThan(sidebarIndexOf("aaa-provider"));
// Clearing the query restores the alphabetical order.
hub.handleInput("\x1b");
expect(sidebarIndexOf("aaa-provider")).toBeLessThan(sidebarIndexOf("zzz-provider"));
});
test("Escape clears an active query before closing the hub", () => {
const model = makeModel("test", "escape-model");
const { hub, onCancel } = createHub({ models: [model] });