Skip to content

Commit 200a7c8

Browse files
roomote[bot]roomotedaniel-lxs
authored
fix: keep pinned models fixed at top of scrollable list (#8813)
* fix: keep pinned models fixed at top of scrollable list - Separated pinned and unpinned configs into different containers - Pinned configs now stay fixed at the top - Only unpinned configs are scrollable - Added tests to verify the fixed behavior Fixes #8812 * fix: resolve sticky header visual artifact in ApiConfigSelector - Changed sticky header background from bg-vscode-editorWidget-background to bg-vscode-dropdown-background to match popover container - Moved separator logic into sticky container as conditional bottom border to prevent scroll artifacts - Updated tests to match new separator structure - All 21 tests passing --------- Co-authored-by: Roo Code <[email protected]> Co-authored-by: daniel-lxs <[email protected]>
1 parent f1caf3f commit 200a7c8

File tree

2 files changed

+125
-21
lines changed

2 files changed

+125
-21
lines changed

webview-ui/src/components/chat/ApiConfigSelector.tsx

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -193,27 +193,31 @@ export const ApiConfigSelector = ({
193193
</div>
194194
)}
195195

196-
{/* Config list */}
197-
<div className="max-h-[300px] overflow-y-auto">
198-
{filteredConfigs.length === 0 && searchValue ? (
199-
<div className="py-2 px-3 text-sm text-vscode-foreground/70">
200-
{t("common:ui.no_results")}
201-
</div>
202-
) : (
203-
<div className="py-1">
204-
{/* Pinned configs */}
205-
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
206-
207-
{/* Separator between pinned and unpinned */}
208-
{pinnedConfigs.length > 0 && unpinnedConfigs.length > 0 && (
209-
<div className="mx-1 my-1 h-px bg-vscode-dropdown-foreground/10" />
210-
)}
211-
212-
{/* Unpinned configs */}
213-
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
214-
</div>
215-
)}
216-
</div>
196+
{/* Config list - single scroll container */}
197+
{filteredConfigs.length === 0 && searchValue ? (
198+
<div className="py-2 px-3 text-sm text-vscode-foreground/70">{t("common:ui.no_results")}</div>
199+
) : (
200+
<div className="max-h-[300px] overflow-y-auto">
201+
{/* Pinned configs - sticky header */}
202+
{pinnedConfigs.length > 0 && (
203+
<div
204+
className={cn(
205+
"sticky top-0 z-10 bg-vscode-dropdown-background py-1",
206+
unpinnedConfigs.length > 0 && "border-b border-vscode-dropdown-foreground/10",
207+
)}
208+
aria-label="Pinned configurations">
209+
{pinnedConfigs.map((config) => renderConfigItem(config, true))}
210+
</div>
211+
)}
212+
213+
{/* Unpinned configs */}
214+
{unpinnedConfigs.length > 0 && (
215+
<div className="py-1" aria-label="All configurations">
216+
{unpinnedConfigs.map((config) => renderConfigItem(config, false))}
217+
</div>
218+
)}
219+
</div>
220+
)}
217221

218222
{/* Bottom bar with buttons on left and title on right */}
219223
<div className="flex flex-row items-center justify-between px-2 py-2 border-t border-vscode-dropdown-border">

webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,4 +435,104 @@ describe("ApiConfigSelector", () => {
435435
// Search value should be maintained
436436
expect(searchInput.value).toBe("Config")
437437
})
438+
439+
test("pinned configs remain fixed at top while unpinned configs scroll", () => {
440+
// Create a list with many configs to test scrolling
441+
const manyConfigs = Array.from({ length: 15 }, (_, i) => ({
442+
id: `config${i + 1}`,
443+
name: `Config ${i + 1}`,
444+
modelId: `model-${i + 1}`,
445+
}))
446+
447+
const props = {
448+
...defaultProps,
449+
listApiConfigMeta: manyConfigs,
450+
pinnedApiConfigs: {
451+
config1: true,
452+
config2: true,
453+
config3: true,
454+
},
455+
}
456+
457+
render(<ApiConfigSelector {...props} />)
458+
459+
const trigger = screen.getByTestId("dropdown-trigger")
460+
fireEvent.click(trigger)
461+
462+
const popoverContent = screen.getByTestId("popover-content")
463+
464+
// Should have a single scroll container with max-h-[300px] and overflow-y-auto
465+
const scrollContainer = popoverContent.querySelector(".max-h-\\[300px\\].overflow-y-auto")
466+
expect(scrollContainer).toBeInTheDocument()
467+
468+
// Check for pinned configs sticky header
469+
const pinnedStickyHeader = scrollContainer?.querySelector(".sticky.top-0.z-10.bg-vscode-dropdown-background")
470+
expect(pinnedStickyHeader).toBeInTheDocument()
471+
expect(pinnedStickyHeader).toHaveAttribute("aria-label", "Pinned configurations")
472+
473+
// Check for Config 1, 2, 3 being visible in the sticky header (pinned)
474+
expect(screen.getAllByText("Config 1").length).toBeGreaterThan(0)
475+
expect(screen.getAllByText("Config 2").length).toBeGreaterThan(0)
476+
expect(screen.getAllByText("Config 3").length).toBeGreaterThan(0)
477+
478+
// Verify pinned container contains the pinned configs
479+
if (pinnedStickyHeader) {
480+
const elements = pinnedStickyHeader.querySelectorAll(".flex-shrink-0")
481+
const pinnedConfigTexts = Array.from(elements)
482+
.map((el) => (el as Element).textContent)
483+
.filter((text) => text?.startsWith("Config"))
484+
485+
expect(pinnedConfigTexts).toContain("Config 1")
486+
expect(pinnedConfigTexts).toContain("Config 2")
487+
expect(pinnedConfigTexts).toContain("Config 3")
488+
}
489+
490+
// Check for unpinned configs section
491+
const unpinnedSection = scrollContainer?.querySelector('[aria-label="All configurations"]')
492+
expect(unpinnedSection).toBeInTheDocument()
493+
494+
// Verify separator exists as border on pinned section when unpinned configs exist
495+
expect(pinnedStickyHeader).toHaveClass("border-b")
496+
})
497+
498+
test("displays all configs in scrollable container when no configs are pinned", () => {
499+
const manyConfigs = Array.from({ length: 10 }, (_, i) => ({
500+
id: `config${i + 1}`,
501+
name: `Config ${i + 1}`,
502+
modelId: `model-${i + 1}`,
503+
}))
504+
505+
const props = {
506+
...defaultProps,
507+
listApiConfigMeta: manyConfigs,
508+
pinnedApiConfigs: {}, // No pinned configs
509+
}
510+
511+
render(<ApiConfigSelector {...props} />)
512+
513+
const trigger = screen.getByTestId("dropdown-trigger")
514+
fireEvent.click(trigger)
515+
516+
const popoverContent = screen.getByTestId("popover-content")
517+
518+
// Should have a single scroll container with max-h-[300px] and overflow-y-auto
519+
const scrollContainer = popoverContent.querySelector(".max-h-\\[300px\\].overflow-y-auto")
520+
expect(scrollContainer).toBeInTheDocument()
521+
522+
// No pinned section should exist when no configs are pinned
523+
const pinnedSection = scrollContainer?.querySelector(".sticky.top-0")
524+
expect(pinnedSection).not.toBeInTheDocument()
525+
526+
// Should have unpinned configs section with all configs
527+
const unpinnedSection = scrollContainer?.querySelector('[aria-label="All configurations"]')
528+
expect(unpinnedSection).toBeInTheDocument()
529+
530+
// All configs should be in the unpinned section
531+
const allConfigRows = unpinnedSection?.querySelectorAll(".group")
532+
expect(allConfigRows?.length).toBe(10)
533+
534+
// No separator should exist when no pinned configs (no sticky header exists)
535+
const stickyHeader = scrollContainer?.querySelector(".sticky.top-0")
536+
expect(stickyHeader).not.toBeInTheDocument()
537+
})
438538
})

0 commit comments

Comments
 (0)