Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/app/e2e/models/model-picker-thinking.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { Page } from "@playwright/test"
import { expect, test } from "../fixtures"

type ModelKey = {
providerID: string
modelID: string
}

type Probe = {
model?: ModelKey
variant?: string | null
selected?: string | null
variants?: string[]
models?: Array<ModelKey & { name: string }>
}

async function probe(page: Page): Promise<Probe | null> {
return page.evaluate(() => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
current?: Probe
controls?: {
setModel?: (value: ModelKey | undefined) => void
setVariant?: (value: string | undefined) => void
}
}
}
}
return win.__opencode_e2e?.model?.current ?? null
})
}

async function setModel(page: Page, value: ModelKey) {
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setModel?: (value: ModelKey | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setModel
if (!fn) throw new Error("Model e2e model control is not enabled")
fn(value)
}, value)
}

async function setVariant(page: Page, value: string | undefined) {
await page.evaluate((value) => {
const win = window as Window & {
__opencode_e2e?: {
model?: {
controls?: {
setVariant?: (value: string | undefined) => void
}
}
}
}
const fn = win.__opencode_e2e?.model?.controls?.setVariant
if (!fn) throw new Error("Model e2e variant control is not enabled")
fn(value)
}, value)
}

async function chooseModelWithVariants(page: Page): Promise<string[] | undefined> {
await expect.poll(() => probe(page).then((state) => state?.models?.length ?? 0), { timeout: 30_000 }).toBeGreaterThan(0)

const candidates = (await probe(page))?.models ?? []
for (const candidate of candidates) {
await setModel(page, candidate)
await expect
.poll(() => probe(page).then((state) => `${state?.model?.providerID}:${state?.model?.modelID}`), {
timeout: 30_000,
})
.toBe(`${candidate.providerID}:${candidate.modelID}`)

const variants = (await probe(page))?.variants ?? []
if (variants.length > 0) return variants
}

return undefined
}

test("@smoke thinking option click updates variant from nested model picker", async ({ page, project }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await project.open()

const variants = await chooseModelWithVariants(page)
test.skip(!variants, "no visible e2e model with thinking variants")
if (!variants) return
const target = variants.includes("xhigh") ? "xhigh" : variants[0]
if (!target) throw new Error("Expected at least one thinking variant")

await setVariant(page, undefined)
await expect.poll(() => probe(page).then((state) => state?.variant ?? null), { timeout: 30_000 }).toBe(null)

await page.locator('[data-action="prompt-model"]').first().click()

const thinkingTrigger = page.locator('[data-action="prompt-model-thinking-trigger"]').first()
await expect(thinkingTrigger).toBeVisible()
await expect(thinkingTrigger).toBeEnabled()
await thinkingTrigger.click()

const option = page.locator(`[data-action="prompt-model-thinking-option"][data-variant="${target}"]`).first()
await expect(option).toBeVisible()
await option.click()

await expect.poll(() => probe(page).then((state) => state?.selected ?? null), { timeout: 30_000 }).toBe(target)
await expect.poll(() => probe(page).then((state) => state?.variant ?? null), { timeout: 30_000 }).toBe(target)
})
18 changes: 13 additions & 5 deletions packages/app/src/components/prompt-input/model-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ const ThinkingLevelSection: Component<{ model?: ModelState }> = (props) => {
<Kobalte modal={false} placement="right-start" gutter={4}>
<Kobalte.Trigger
disabled={!supported()}
data-action="prompt-model-thinking-trigger"
class="group/think w-full h-[30px] px-2 gap-3 flex items-center rounded-[6px] text-body text-fg-base text-left hover:bg-row-hover-overlay hover:text-fg-strong data-[expanded]:bg-row-hover-overlay data-[expanded]:text-fg-strong disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-fg-base"
>
<span>{language.t("dialog.model.variant")}</span>
Expand All @@ -124,6 +125,8 @@ const ThinkingLevelSection: Component<{ model?: ModelState }> = (props) => {
<button
type="button"
data-picker-item=""
data-action="prompt-model-thinking-option"
data-variant={opt}
data-selected={opt === current() ? "" : undefined}
class="w-full"
onClick={() => model.variant.set(opt === "default" ? undefined : opt)}
Expand All @@ -143,6 +146,9 @@ const ThinkingLevelSection: Component<{ model?: ModelState }> = (props) => {
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
type Dismiss = "escape" | "outside" | "select"

const isPickerContentTarget = (target: EventTarget | null) =>
target instanceof Element && !!target.closest("[data-picker-content]")

export function ModelSelectorPopover(props: {
provider?: string
model?: ModelState
Expand Down Expand Up @@ -179,6 +185,10 @@ export function ModelSelectorPopover(props: {
const handleFocusOutside = (
event: Parameters<NonNullable<ComponentProps<typeof Kobalte.Content>["onFocusOutside"]>>[0],
) => {
if (isPickerContentTarget(event.target)) {
event.preventDefault()
return
}
if (ignoreFocusOutsideForPointerInside) {
ignoreFocusOutsideForPointerInside = false
event.preventDefault()
Expand Down Expand Up @@ -216,11 +226,9 @@ export function ModelSelectorPopover(props: {
// The nested ThinkingLevel popover renders into Kobalte.Portal, so
// its Content sits outside this outer Content's DOM subtree. Without
// this guard, clicks inside the inner picker satisfy "outside" for
// the outer popover and dismiss the model picker. Both popovers tag
// their content with data-picker-content, so a closest() hit means
// the click landed inside a nested picker — keep the outer open.
const target = event.target
if (target instanceof Element && target.closest("[data-picker-content]")) {
// the outer popover and dismiss the model picker before the inner
// button click runs.
if (isPickerContentTarget(event.target)) {
event.preventDefault()
return
}
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/test/config/e2e-smoke-tagging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const expectedSmokeTests = [
"packages/app/e2e/app/session.spec.ts:@smoke session composer matches home structure without docktray or agent control",
"packages/app/e2e/app/shell-frame.spec.ts:@smoke shell frame exposes stable desktop hooks",
"packages/app/e2e/files/file-tree.spec.ts:@smoke review tab no longer renders the legacy file-tree sub-panel",
"packages/app/e2e/models/model-picker-thinking.spec.ts:@smoke thinking option click updates variant from nested model picker",
"packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke clicking a suggestion row prefills the composer",
"packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke composer placeholder is the static home string",
"packages/app/e2e/onboarding/home-suggestion-chips.spec.ts:@smoke home shows 3 suggestion rows for a first-time visitor",
Expand Down
Loading