From 2d950a72fbbcaf2a29138afac0386457a1ac5169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 24 Jul 2026 22:17:55 +0100 Subject: [PATCH] fix(web): deduplicate model options and restore reasoning scroll (#1933) --- .../components/model-config-selector.test.tsx | 47 ++++++++++-- apps/web/components/model-config-selector.tsx | 23 ++++-- .../tests/chat/mobile-model-selector.spec.ts | 75 ++++++++++++++++++- 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/apps/web/components/model-config-selector.test.tsx b/apps/web/components/model-config-selector.test.tsx index c1a9f6451a..cbc0b5f328 100644 --- a/apps/web/components/model-config-selector.test.tsx +++ b/apps/web/components/model-config-selector.test.tsx @@ -2,7 +2,11 @@ import { cleanup, fireEvent, render, screen, within } from "@testing-library/rea import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ModelConfigSelector } from "@/components/model-config-selector"; +import { + configOptionToModelOptions, + ModelConfigSelector, + type SelectConfigOption, +} from "@/components/model-config-selector"; afterEach(() => { cleanup(); @@ -12,6 +16,7 @@ const effortSectionTestId = "config-option-section-effort"; const effortTriggerTestId = "config-option-trigger-effort"; const modelSettingsButtonName = "Model settings"; const providerModelId = "gpt-5.6-sol"; +const providerModelName = "GPT-5.6-Sol"; const optionDescription = "Controls how much reasoning the model performs."; const effortOptionName = "Reasoning Effort"; const makeModelOptions = (count: number) => @@ -98,6 +103,38 @@ describe("ModelConfigSelector", () => { }); describe("ModelConfigSelector filtering", () => { + it("deduplicates repeated model ids from provider config", () => { + const modelConfig: SelectConfigOption = { + type: "select", + id: "model", + name: "Model", + currentValue: providerModelId, + category: "model", + options: [ + { + value: providerModelId, + name: providerModelName, + description: "Latest frontier agentic coding model.", + }, + { + value: "gpt-5.6-terra", + name: "GPT-5.6-Terra", + description: "Balanced agentic coding model for everyday work.", + }, + { + value: "gpt-5.6-terra", + name: "GPT-5.6-Terra", + description: "Balanced agentic coding model for everyday work.", + }, + ], + }; + + expect(configOptionToModelOptions(modelConfig).map((model) => model.id)).toEqual([ + providerModelId, + "gpt-5.6-terra", + ]); + }); + it("hides the model filter when there are five or fewer models", () => { render( { it("shows provider descriptions only inside the selected option submenu", () => { render( {}} onConfigChange={() => {}} @@ -166,7 +203,7 @@ describe("ModelConfigSelector provider descriptions", () => { render( {}} triggerSummary="changed" @@ -201,7 +238,7 @@ describe("ModelConfigSelector provider descriptions", () => { fireEvent.focus(screen.getByRole("button", { name: modelSettingsButtonName })); const tooltip = screen.getByRole("tooltip"); - expect(tooltip.textContent).toContain("Model: GPT-5.6-Sol"); + expect(tooltip.textContent).toContain(`Model: ${providerModelName}`); expect(tooltip.textContent).toContain("Reasoning Effort: Low"); expect(tooltip.textContent).toContain("Fast Mode: Off"); expect(tooltip.textContent).not.toContain(optionDescription); @@ -211,7 +248,7 @@ describe("ModelConfigSelector provider descriptions", () => { render( {}} configOptions={[ diff --git a/apps/web/components/model-config-selector.tsx b/apps/web/components/model-config-selector.tsx index a35f934d2b..d88ba7cbc3 100644 --- a/apps/web/components/model-config-selector.tsx +++ b/apps/web/components/model-config-selector.tsx @@ -77,11 +77,18 @@ export function configOptionToModelOptions( option: SelectConfigOption | undefined, ): ModelSelectorOption[] { if (!option) return []; - return option.options.map((item) => ({ - id: item.value, - name: item.name, - description: item.description ?? (item.value !== item.name ? item.value : undefined), - })); + const seen = new Set(); + return option.options.flatMap((item) => { + if (seen.has(item.value)) return []; + seen.add(item.value); + return [ + { + id: item.value, + name: item.name, + description: item.description ?? (item.value !== item.name ? item.value : undefined), + }, + ]; + }); } function currentOptionValue(option: DynamicConfigOption) { @@ -258,8 +265,8 @@ function ConfigOptionSubSelector({ )} -
@@ -303,7 +310,7 @@ function ConfigOptionSubSelector({ ); })}
-
+ ); } diff --git a/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts b/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts index b84cb33570..b8518a918f 100644 --- a/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts +++ b/apps/web/e2e/tests/chat/mobile-model-selector.spec.ts @@ -4,6 +4,28 @@ import type { ApiClient } from "../../helpers/api-client"; import type { SeedData } from "../../fixtures/test-base"; import { SessionPage } from "../../pages/session-page"; +type E2EModelStoreWindow = Window & { + __KANDEV_E2E_STORE__?: { + setState: ( + updater: (state: { + sessionModels: { + bySessionId: Record< + string, + { + currentModelId: string; + models: unknown[]; + configOptions: { + id: string; + options?: { value: string; name: string; description?: string }[]; + }[]; + } + >; + }; + }) => void, + ) => void; + }; +}; + async function seedAndOpenTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, @@ -24,6 +46,32 @@ async function seedAndOpenTask(testPage: Page, apiClient: ApiClient, seedData: S return { session, task }; } +async function seedLongReasoningMenu(testPage: Page, sessionId: string) { + await testPage.evaluate((sid) => { + const store = (window as E2EModelStoreWindow).__KANDEV_E2E_STORE__; + if (!store) throw new Error("E2E store bridge is unavailable"); + store.setState((state) => { + const sessionModels = state.sessionModels.bySessionId[sid]; + if (!sessionModels) throw new Error(`Session ${sid} has no model state`); + sessionModels.configOptions = sessionModels.configOptions.map((option) => + option.id === "effort" + ? { + ...option, + options: Array.from({ length: 10 }, (_, index) => ({ + value: index === 0 ? "low" : `effort-${index + 1}`, + name: index === 0 ? "Low" : `Reasoning level ${index + 1}`, + description: + index === 0 + ? "Faster responses with less reasoning" + : `Reasoning depth description for level ${index + 1}`, + })), + } + : option, + ); + }); + }, sessionId); +} + test.describe("Mobile chat model selector", () => { test.describe.configure({ retries: 1, timeout: 60_000 }); @@ -33,6 +81,7 @@ test.describe("Mobile chat model selector", () => { seedData, }, testInfo) => { const { task } = await seedAndOpenTask(testPage, apiClient, seedData); + expect(task.session_id).toBeTruthy(); await expect .poll(async () => { @@ -44,6 +93,8 @@ test.describe("Mobile chat model selector", () => { }) .toBe("medium"); + await seedLongReasoningMenu(testPage, task.session_id!); + const leftActions = testPage.getByTestId("mobile-chat-toolbar-left-actions"); await expect(leftActions).toBeVisible({ timeout: 15_000 }); await expect(testPage.getByTestId("toolbar-overflow-menu")).not.toBeVisible(); @@ -73,7 +124,29 @@ test.describe("Mobile chat model selector", () => { testPage.getByText("Controls how much reasoning the mock model uses", { exact: true }), ).toHaveCount(0); await effortTrigger.tap(); - await expect(testPage.getByTestId("config-option-section-effort")).toBeVisible(); + const effortSection = testPage.getByTestId("config-option-section-effort"); + await expect(effortSection).toBeVisible(); + const overflow = await effortSection.evaluate((element) => ({ + clientHeight: element.clientHeight, + overflowY: getComputedStyle(element).overflowY, + scrollHeight: element.scrollHeight, + })); + expect(overflow.overflowY).toBe("auto"); + expect(overflow.scrollHeight).toBeGreaterThan(overflow.clientHeight); + const effortBox = await effortSection.boundingBox(); + expect(effortBox).not.toBeNull(); + await testPage.mouse.move( + effortBox!.x + effortBox!.width / 2, + effortBox!.y + effortBox!.height / 2, + ); + await testPage.mouse.wheel(0, 800); + await expect + .poll(() => effortSection.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + await testInfo.attach("task-model-reasoning-scroll-mobile", { + body: await testPage.screenshot(), + contentType: "image/png", + }); await expect( testPage.getByText("Controls how much reasoning the mock model uses", { exact: true }), ).toBeVisible();