From b8e801364aec9dcf5d4a9b102d507f5ab9c1f419 Mon Sep 17 00:00:00 2001 From: JeroenBolhuis Date: Sun, 1 Nov 2026 23:23:20 +0100 Subject: [PATCH 1/7] fix(web): prevent docked model picker height regression - Fit the docked model list to its rows and anchor height changes at the bottom - Animate the picker as its content changes while respecting available height --- apps/web/src/components/AnimatedHeight.tsx | 8 +++- .../components/chat/ModelPickerContent.tsx | 18 ++++++-- .../components/chat/ProviderModelPicker.tsx | 45 ++++++++++--------- 3 files changed, 47 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index a098851233a72..6558e22d17d1f 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -1,14 +1,17 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { observeResize } from "../lib/observeResize"; +import { cn } from "../lib/utils"; const HEIGHT_TRANSITION_FALLBACK_MS = 250; export function AnimatedHeight({ children, holdHeight = false, + anchor = "top", }: { readonly children: ReactNode; readonly holdHeight?: boolean; + readonly anchor?: "top" | "bottom"; }) { const contentRef = useRef(null); const [heightState, setHeightState] = useState<{ @@ -83,7 +86,10 @@ export function AnimatedHeight({ return (
; itemKeys: ReadonlyArray; extraData: unknown; renderItem: (props: { item: string; index: number }) => ReactNode; + fitRows: boolean | undefined; }) { const [showTopScrollFade, setShowTopScrollFade] = useState(false); const [showBottomScrollFade, setShowBottomScrollFade] = useState(false); @@ -301,7 +304,15 @@ function ModelPickerList({ className={cn( "relative min-h-0 overflow-hidden pr-px", itemKeys.length > 0 ? "flex-1" : "h-0", + fitRows && "[--row-units:9] sm:[--row-units:8]", )} + style={ + fitRows && itemKeys.length > 0 + ? { + flexBasis: `calc(var(--spacing) * (${itemKeys.length} * var(--row-units) + ${(itemKeys.length - 1) * ROW_SEPARATOR_UNITS} + ${LIST_PADDING_UNITS}))`, + } + : undefined + } > @@ -366,7 +377,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { docked?: boolean; /** Right at the end of the search text moves into the popup's effort column. */ onFocusEffort?: () => void; - /** Closes the list column, inside the popup's fixed height. */ + /** Closes the list column. */ listFooter?: ReactNode; }) { const { @@ -840,9 +851,9 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { ref={pickerContentRef} className={cn( "relative flex w-screen max-w-90 flex-row overflow-hidden", - props.docked ? "max-sm:h-56 max-sm:w-full max-sm:max-w-none" : "max-h-86.5", + props.docked ? "max-sm:max-h-56 max-sm:w-full max-sm:max-w-none" : "max-h-86.5", )} - // Docked, the popup sets the height; a minimum here would overflow short windows. + // Docked, the list sets the height; a minimum here would overflow short windows. style={ props.docked ? undefined @@ -1009,6 +1020,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> void focusEffort("any"), - // Inside the fixed height, so switching to or from a ChatGPT plan never resizes it. ...(props.selectedModels === undefined ? { listFooter: } : {}), @@ -422,27 +422,32 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { > {props.effortColumn ? ( -
- {/* One fixed height, so a model with more options never resizes the popup. Short - windows shrink it and keep the footer. */} -
- {picker} -
{ - if (event.key !== "ArrowLeft" || event.altKey || event.ctrlKey || event.metaKey) - return; - event.preventDefault(); - event.stopPropagation(); - searchInputRef.current?.focus(); - }} - > - {props.effortColumn} + +
+
+ {picker} +
{ + if ( + event.key !== "ArrowLeft" || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + event.preventDefault(); + event.stopPropagation(); + searchInputRef.current?.focus(); + }} + > + {props.effortColumn} +
+ {props.footer}
- {props.footer} -
+ ) : ( picker From 6d644e6598db198646213d251782239073db3dd9 Mon Sep 17 00:00:00 2001 From: JeroenBolhuis Date: Mon, 2 Nov 2026 00:14:41 +0100 Subject: [PATCH 2/7] fix(web): keep the docked model picker footer fixed while it resizes Co-authored-by: Cursor --- apps/web/src/components/AnimatedHeight.tsx | 8 +-- .../components/chat/ProviderModelPicker.tsx | 50 ++++++++++--------- 2 files changed, 27 insertions(+), 31 deletions(-) diff --git a/apps/web/src/components/AnimatedHeight.tsx b/apps/web/src/components/AnimatedHeight.tsx index 6558e22d17d1f..a098851233a72 100644 --- a/apps/web/src/components/AnimatedHeight.tsx +++ b/apps/web/src/components/AnimatedHeight.tsx @@ -1,17 +1,14 @@ import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react"; import { observeResize } from "../lib/observeResize"; -import { cn } from "../lib/utils"; const HEIGHT_TRANSITION_FALLBACK_MS = 250; export function AnimatedHeight({ children, holdHeight = false, - anchor = "top", }: { readonly children: ReactNode; readonly holdHeight?: boolean; - readonly anchor?: "top" | "bottom"; }) { const contentRef = useRef(null); const [heightState, setHeightState] = useState<{ @@ -86,10 +83,7 @@ export function AnimatedHeight({ return (
{props.effortColumn ? ( - -
-
- {picker} -
{ - if ( - event.key !== "ArrowLeft" || - event.altKey || - event.ctrlKey || - event.metaKey - ) - return; - event.preventDefault(); - event.stopPropagation(); - searchInputRef.current?.focus(); - }} - > - {props.effortColumn} +
+
+ +
+ {picker} +
{ + if ( + event.key !== "ArrowLeft" || + event.altKey || + event.ctrlKey || + event.metaKey + ) + return; + event.preventDefault(); + event.stopPropagation(); + searchInputRef.current?.focus(); + }} + > + {props.effortColumn} +
-
- {props.footer} +
- + {props.footer} +
) : ( picker From 166ace05b2da37323017994d01bebaf55ba5241e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 01:00:42 +0000 Subject: [PATCH 3/7] fix(web): chain docked model list scrolling to popup --- apps/web/src/components/chat/ModelPickerContent.tsx | 3 ++- apps/web/src/components/chat/ProviderModelPicker.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index fddc2a2ea19e9..4b368b2a3eb04 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -329,7 +329,8 @@ function ModelPickerList({ onLayout={updateScrollFades} onScroll={updateScrollFades} className={cn( - "scrollbar-gutter-stable h-full overflow-x-hidden overscroll-y-contain py-1.5 [&::-webkit-scrollbar-track]:my-2", + "scrollbar-gutter-stable h-full overflow-x-hidden py-1.5 [&::-webkit-scrollbar-track]:my-2", + !fitRows && "overscroll-y-contain", getVirtualizedScrollFadeClassName({ top: showTopScrollFade, bottom: showBottomScrollFade, diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 4842c5a94deb7..89d164eec0aec 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -423,7 +423,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.effortColumn ? (
-
+
{picker} From 432a9024a7414ffdba2354e30e7f471caf9980ce Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 01:05:13 +0000 Subject: [PATCH 4/7] fix(web): chain effort scrolling back to model search --- apps/web/src/components/chat/ComposerModelEffort.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerModelEffort.tsx b/apps/web/src/components/chat/ComposerModelEffort.tsx index 3a863936101b2..7c998df64d972 100644 --- a/apps/web/src/components/chat/ComposerModelEffort.tsx +++ b/apps/web/src/components/chat/ComposerModelEffort.tsx @@ -513,7 +513,7 @@ export function ComposerEffortColumn(props: { traits: TraitsSelectionInput | nul
{/* pt-2.5 puts the first level on the same line as the first model. */}
From a2d741625bad3cbbf0e34a8675c385ee30ae52d3 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 01:09:59 +0000 Subject: [PATCH 5/7] fix(web): chain docked provider rail scrolling to popup --- apps/web/src/components/chat/ModelPickerContent.tsx | 1 + apps/web/src/components/chat/ModelPickerSidebar.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 4b368b2a3eb04..405a6de94afe1 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -868,6 +868,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { > {showSidebar && ( void; instanceEntries: ReadonlyArray; showFavorites?: boolean; + docked?: boolean; disabledInstanceIds?: ReadonlySet; selectableUnavailableInstanceIds?: ReadonlySet; getDisabledInstanceTooltip?: (entry: ProviderInstanceEntry) => string; @@ -82,7 +83,12 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { } }} > -
+
{selectedIndicatorTop !== null ? (
Date: Thu, 12 Nov 2026 11:22:03 +0100 Subject: [PATCH 6/7] fix(web): reveal model search when switching providers --- .../components/chat/ModelPickerContent.tsx | 1 + .../chat/ProviderModelPicker.browser.test.tsx | 64 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index cbd277f419f2e..014788da9c72c 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -448,6 +448,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { const focusSearchInput = () => { searchInputRef.current?.focus({ preventScroll: true }); + searchInputRef.current?.scrollIntoView({ block: "nearest", inline: "nearest" }); }; const handleSelectInstance = (instanceId: ProviderInstanceId | "favorites") => { diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx index b6dd1a38e86cf..bf3a890ae3ffa 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.test.tsx @@ -1,4 +1,5 @@ import { ProviderDriverKind, ProviderInstanceId, type ModelSelection } from "@t3tools/contracts"; +import { compileResolvedKeybindingsConfig } from "@t3tools/shared/keybindings"; import { useState } from "react"; import { expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -169,3 +170,66 @@ it("keeps a docked picker open after a pick and hands focus to that model's effo await userEvent.keyboard("{Escape}"); await expect.element(search).not.toBeInTheDocument(); }); + +it("reveals search when a provider shortcut returns to the scrolled search field", async () => { + const entries = deriveProviderInstanceEntries( + ["codex", "claude"].map((id) => ({ + instanceId: ProviderInstanceId.make(id), + driver: ProviderDriverKind.make(id), + enabled: true, + installed: true, + version: null, + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-01-01T00:00:00.000Z", + models: [{ slug: "original", name: `${id} model`, isCustom: false, capabilities: {} }], + slashCommands: [], + skills: [], + })), + ); + await using screen = await render( + [entry.instanceId, entry.models]))} + keybindings={compileResolvedKeybindingsConfig([ + { key: "alt+p", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, + ])} + triggerAriaLabel="Choose model" + onInstanceModelChange={() => {}} + effortColumn={
Reasoning effort
} + />, + ); + const viewport = { width: window.innerWidth, height: window.innerHeight }; + await page.viewport(550, 330); + await using _viewport = { + [Symbol.asyncDispose]: () => page.viewport(viewport.width, viewport.height), + }; + await screen.getByRole("button", { name: "Choose model" }).click(); + const search = page.getByPlaceholder("Search models..."); + await expect.element(search).toHaveFocus(); + const input = search.element(); + let scroller = input.parentElement!; + while (getComputedStyle(scroller).overflowY !== "auto") { + scroller = scroller.parentElement!; + } + await vi.waitFor(() => expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight)); + scroller.scrollTop = scroller.scrollHeight; + await vi.waitFor(() => + expect(input.getBoundingClientRect().bottom).toBeLessThan(scroller.getBoundingClientRect().top), + ); + + await userEvent.keyboard("{Alt>}p{/Alt}"); + await expect.element(page.getByRole("option", { name: /claude model/ })).toBeVisible(); + await expect.element(search).toHaveFocus(); + await vi.waitFor(() => + expect(input.getBoundingClientRect().top).toBeGreaterThanOrEqual( + scroller.getBoundingClientRect().top, + ), + ); + await userEvent.keyboard("claude"); + await expect.element(search).toHaveValue("claude"); +}); From 571451f06c575d65e40fe140e9969a86ad56142c Mon Sep 17 00:00:00 2001 From: JeroenBolhuis Date: Thu, 12 Nov 2026 12:16:04 +0100 Subject: [PATCH 7/7] refactor(web): clean up docked model picker changes --- .../components/chat/ModelPickerContent.tsx | 3 +- .../components/chat/ModelPickerSidebar.tsx | 2 +- .../chat/ProviderModelPicker.browser.test.tsx | 64 ------------------- .../components/chat/ProviderModelPicker.tsx | 4 +- 4 files changed, 5 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 014788da9c72c..6a088dcd07e3a 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -907,7 +907,8 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { >
diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index 244d5a721fdb7..58f0d8d04980a 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -70,7 +70,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { return ( { - const entries = deriveProviderInstanceEntries( - ["codex", "claude"].map((id) => ({ - instanceId: ProviderInstanceId.make(id), - driver: ProviderDriverKind.make(id), - enabled: true, - installed: true, - version: null, - status: "ready" as const, - auth: { status: "authenticated" as const }, - checkedAt: "2026-01-01T00:00:00.000Z", - models: [{ slug: "original", name: `${id} model`, isCustom: false, capabilities: {} }], - slashCommands: [], - skills: [], - })), - ); - await using screen = await render( - [entry.instanceId, entry.models]))} - keybindings={compileResolvedKeybindingsConfig([ - { key: "alt+p", command: "modelPicker.nextProvider", when: "modelPickerOpen" }, - ])} - triggerAriaLabel="Choose model" - onInstanceModelChange={() => {}} - effortColumn={
Reasoning effort
} - />, - ); - const viewport = { width: window.innerWidth, height: window.innerHeight }; - await page.viewport(550, 330); - await using _viewport = { - [Symbol.asyncDispose]: () => page.viewport(viewport.width, viewport.height), - }; - await screen.getByRole("button", { name: "Choose model" }).click(); - const search = page.getByPlaceholder("Search models..."); - await expect.element(search).toHaveFocus(); - const input = search.element(); - let scroller = input.parentElement!; - while (getComputedStyle(scroller).overflowY !== "auto") { - scroller = scroller.parentElement!; - } - await vi.waitFor(() => expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight)); - scroller.scrollTop = scroller.scrollHeight; - await vi.waitFor(() => - expect(input.getBoundingClientRect().bottom).toBeLessThan(scroller.getBoundingClientRect().top), - ); - - await userEvent.keyboard("{Alt>}p{/Alt}"); - await expect.element(page.getByRole("option", { name: /claude model/ })).toBeVisible(); - await expect.element(search).toHaveFocus(); - await vi.waitFor(() => - expect(input.getBoundingClientRect().top).toBeGreaterThanOrEqual( - scroller.getBoundingClientRect().top, - ), - ); - await userEvent.keyboard("claude"); - await expect.element(search).toHaveValue("claude"); -}); diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index b62247d574b2f..bcd188749337e 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -457,13 +457,13 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { {props.effortColumn ? (
-
+
{picker}
{ if ( event.key !== "ArrowLeft" ||