Skip to content

Commit 930cb79

Browse files
nexxelnRobertWsp
authored andcommitted
fix: restore prompt focus after footer selection (anomalyco#20841)
1 parent 2b9e201 commit 930cb79

3 files changed

Lines changed: 152 additions & 28 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Locator, Page } from "@playwright/test"
2+
import { test, expect } from "../fixtures"
3+
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
4+
5+
type Probe = {
6+
agent?: string
7+
model?: { providerID: string; modelID: string; name?: string }
8+
models?: Array<{ providerID: string; modelID: string; name: string }>
9+
agents?: Array<{ name: string }>
10+
}
11+
12+
async function probe(page: Page): Promise<Probe | null> {
13+
return page.evaluate(() => {
14+
const win = window as Window & {
15+
__opencode_e2e?: {
16+
model?: {
17+
current?: Probe
18+
}
19+
}
20+
}
21+
return win.__opencode_e2e?.model?.current ?? null
22+
})
23+
}
24+
25+
async function state(page: Page) {
26+
const value = await probe(page)
27+
if (!value) throw new Error("Failed to resolve model selection probe")
28+
return value
29+
}
30+
31+
async function ready(page: Page) {
32+
const prompt = page.locator(promptSelector)
33+
await prompt.click()
34+
await expect(prompt).toBeFocused()
35+
await prompt.pressSequentially("focus")
36+
return prompt
37+
}
38+
39+
async function body(prompt: Locator) {
40+
return prompt.evaluate((el) => (el as HTMLElement).innerText)
41+
}
42+
43+
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
44+
await gotoSession()
45+
46+
const prompt = await ready(page)
47+
48+
const info = await state(page)
49+
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
50+
test.skip(!next, "only one agent available")
51+
if (!next) return
52+
53+
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
54+
55+
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
56+
await expect(item).toBeVisible()
57+
await item.click({ force: true })
58+
59+
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
60+
next,
61+
)
62+
await expect(prompt).toBeFocused()
63+
await prompt.pressSequentially(" agent")
64+
await expect.poll(() => body(prompt)).toContain("focus agent")
65+
})
66+
67+
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
68+
await gotoSession()
69+
70+
const prompt = await ready(page)
71+
72+
const info = await state(page)
73+
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
74+
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
75+
test.skip(!next, "only one model available")
76+
if (!next) return
77+
78+
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
79+
80+
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
81+
await expect(item).toBeVisible()
82+
await item.click({ force: true })
83+
84+
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
85+
await expect(prompt).toBeFocused()
86+
await prompt.pressSequentially(" model")
87+
await expect.poll(() => body(prompt)).toContain("focus model")
88+
})

packages/app/src/components/dialog-select-model.tsx

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,30 +85,41 @@ const ModelList: Component<{
8585
}
8686

8787
type ModelSelectorTriggerProps = Omit<ComponentProps<typeof Kobalte.Trigger>, "as" | "ref">
88+
type Dismiss = "escape" | "outside" | "select" | "manage" | "provider"
8889

8990
export function ModelSelectorPopover(props: {
9091
provider?: string
9192
children?: JSX.Element
9293
triggerAs?: ValidComponent
9394
triggerProps?: ModelSelectorTriggerProps
95+
onClose?: (cause: "escape" | "select") => void
9496
}) {
9597
const [store, setStore] = createStore<{
9698
open: boolean
97-
dismiss: "escape" | "outside" | null
99+
dismiss: Dismiss | null
98100
}>({
99101
open: false,
100102
dismiss: null,
101103
})
102104
const dialog = useDialog()
103105

104-
const handleManage = () => {
106+
const close = (dismiss: Dismiss) => {
107+
setStore("dismiss", dismiss)
105108
setStore("open", false)
106-
dialog.show(() => <DialogManageModels />)
109+
}
110+
111+
const handleManage = () => {
112+
close("manage")
113+
void import("./dialog-manage-models").then((x) => {
114+
dialog.show(() => <x.DialogManageModels />)
115+
})
107116
}
108117

109118
const handleConnectProvider = () => {
110-
setStore("open", false)
111-
dialog.show(() => <DialogSelectProvider />)
119+
close("provider")
120+
void import("./dialog-select-provider").then((x) => {
121+
dialog.show(() => <x.DialogSelectProvider />)
122+
})
112123
}
113124
const language = useLanguage()
114125

@@ -130,28 +141,27 @@ export function ModelSelectorPopover(props: {
130141
<Kobalte.Content
131142
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
132143
onEscapeKeyDown={(event) => {
133-
setStore("dismiss", "escape")
134-
setStore("open", false)
144+
close("escape")
135145
event.preventDefault()
136146
event.stopPropagation()
137147
}}
138-
onPointerDownOutside={() => {
139-
setStore("dismiss", "outside")
140-
setStore("open", false)
141-
}}
142-
onFocusOutside={() => {
143-
setStore("dismiss", "outside")
144-
setStore("open", false)
145-
}}
148+
onPointerDownOutside={() => close("outside")}
149+
onFocusOutside={() => close("outside")}
146150
onCloseAutoFocus={(event) => {
147-
if (store.dismiss === "outside") event.preventDefault()
151+
const dismiss = store.dismiss
152+
if (dismiss === "outside") event.preventDefault()
153+
if (dismiss === "escape" || dismiss === "select") {
154+
event.preventDefault()
155+
props.onClose?.(dismiss)
156+
}
148157
setStore("dismiss", null)
149158
}}
150159
>
151160
<Kobalte.Title class="sr-only">{language.t("dialog.model.select.title")}</Kobalte.Title>
152161
<ModelList
153162
provider={props.provider}
154-
onSelect={() => setStore("open", false)}
163+
model={props.model}
164+
onSelect={() => close("select")}
155165
class="p-1"
156166
action={
157167
<div class="flex items-center gap-1">

packages/app/src/components/prompt-input.tsx

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
487487
return getCursorPosition(editorRef)
488488
}
489489

490+
const restoreFocus = () => {
491+
requestAnimationFrame(() => {
492+
const cursor = prompt.cursor() ?? promptLength(prompt.current())
493+
editorRef.focus()
494+
setCursorPosition(editorRef, cursor)
495+
queueScroll()
496+
})
497+
}
498+
490499
const renderEditorWithCursor = (parts: Prompt) => {
491500
const cursor = currentCursor()
492501
renderEditor(parts)
@@ -1438,7 +1447,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
14381447
size="normal"
14391448
options={agentNames()}
14401449
current={local.agent.current()?.name ?? ""}
1441-
onSelect={local.agent.set}
1450+
onSelect={(value) => {
1451+
local.agent.set(value)
1452+
restoreFocus()
1453+
}}
14421454
class="capitalize max-w-[160px] text-text-base"
14431455
valueClass="truncate text-13-regular text-text-base"
14441456
triggerStyle={control()}
@@ -1457,14 +1469,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
14571469
title={language.t("command.model.choose")}
14581470
keybind={command.keybind("model.choose")}
14591471
>
1460-
<Button
1461-
data-action="prompt-model"
1462-
as="div"
1463-
variant="ghost"
1464-
size="normal"
1465-
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
1466-
style={control()}
1467-
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
1472+
<ModelSelectorPopover
1473+
model={local.model}
1474+
triggerAs={Button}
1475+
triggerProps={{
1476+
variant: "ghost",
1477+
size: "normal",
1478+
style: control(),
1479+
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
1480+
"data-action": "prompt-model",
1481+
}}
1482+
onClose={restoreFocus}
14681483
>
14691484
<Show when={local.model.current()?.provider?.id}>
14701485
<ProviderIcon
@@ -1487,8 +1502,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
14871502
title={language.t("command.model.choose")}
14881503
keybind={command.keybind("model.choose")}
14891504
>
1490-
<Button
1491-
as="div"
1505+
<Select
1506+
size="normal"
1507+
options={variants()}
1508+
current={local.model.variant.current() ?? "default"}
1509+
label={(x) => (x === "default" ? language.t("common.default") : x)}
1510+
onSelect={(value) => {
1511+
local.model.variant.set(value === "default" ? undefined : value)
1512+
restoreFocus()
1513+
}}
1514+
class="capitalize max-w-[160px] text-text-base"
1515+
valueClass="truncate text-13-regular text-text-base"
1516+
triggerStyle={control()}
1517+
triggerProps={{ "data-action": "prompt-model-variant" }}
14921518
variant="ghost"
14931519
size="normal"
14941520
class="min-w-0 max-w-[320px] text-13-regular group"

0 commit comments

Comments
 (0)