fix(vscode): preserve typing focus when questions update

This commit is contained in:
marius-kilocode
2026-08-28 09:18:21 +02:00
parent 6c5f31fc06
commit c1d1e9bcd7
6 changed files with 98 additions and 6 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep focus in active text fields when questions appear or refresh.
@@ -2,6 +2,8 @@ import { Window } from "happy-dom"
import type { QuestionRequest } from "../../webview-ui/src/types/messages"
const window = new Window()
const frames: FrameRequestCallback[] = []
window.document.hasFocus = () => true
Object.assign(globalThis, {
window,
document: window.document,
@@ -9,7 +11,7 @@ Object.assign(globalThis, {
Element: window.Element,
HTMLElement: window.HTMLElement,
SVGElement: window.SVGElement,
requestAnimationFrame: () => 0,
requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback),
})
const { Show, createSignal } = await import("solid-js")
@@ -49,7 +51,10 @@ const language = {
t: (key: string) => key,
}
const root = document.createElement("div")
document.body.append(root)
const prompt = document.createElement("textarea")
prompt.className = "prompt-input"
document.body.append(prompt, root)
prompt.focus()
const dispose = render(
() => (
<SessionContext.Provider value={session as never}>
@@ -61,9 +66,34 @@ const dispose = render(
root,
)
const flush = () => {
while (frames.length) frames.shift()?.(0)
}
flush()
if (document.activeElement !== prompt) throw new Error("New question stole composer focus")
setActive(structuredClone(request))
flush()
if (document.activeElement !== prompt) throw new Error("Repeated question stole composer focus")
setActive(undefined)
prompt.blur()
setActive(structuredClone(request))
prompt.focus()
flush()
if (document.activeElement !== prompt) throw new Error("Scheduled question focus interrupted typing")
setActive(undefined)
prompt.blur()
setActive(structuredClone(request))
flush()
const option = root.querySelector<HTMLButtonElement>('[data-slot="question-option"]')
const submit = root.querySelector<HTMLButtonElement>('[data-slot="question-footer-actions"] button')
if (!option || !submit) throw new Error("Question controls did not render")
if (document.activeElement !== option) throw new Error("Question did not focus when no text field was active")
option.dispatchEvent(new window.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }))
if (document.activeElement !== root.querySelector('[data-custom="true"]')) {
throw new Error("Question keyboard navigation did not move to the next option")
}
option.click()
if (submit.disabled) throw new Error("Submit did not enable after selecting an answer")
submit.click()
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import {
agentManagerFocusTarget,
createChatFocus,
focusQuestionOption,
hasQuestionOption,
preservesTextFocus,
@@ -9,6 +10,61 @@ import {
import { isTextControl } from "../../webview-ui/src/utils/focus"
describe("Agent Manager focus", () => {
it("preserves composer focus through retries unless focus is explicitly requested", async () => {
const window = new Window()
const document = window.document
const frames: FrameRequestCallback[] = []
const original = {
document: Object.getOwnPropertyDescriptor(globalThis, "document"),
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
}
document.hasFocus = () => true
Object.assign(globalThis, {
document,
requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback),
})
const prompt = document.createElement("textarea")
prompt.className = "prompt-input"
const dock = document.createElement("div")
dock.setAttribute("data-component", "question-dock")
const option = document.createElement("button")
option.setAttribute("data-slot", "question-option")
dock.append(option)
document.body.append(prompt, dock)
const focus = createChatFocus({ term: () => undefined, history: () => false, review: () => false })
const flush = () => {
while (frames.length) frames.shift()?.(0)
}
try {
prompt.focus()
focus()
await Promise.resolve()
expect(document.activeElement).toBe(prompt)
flush()
expect(document.activeElement).toBe(prompt)
prompt.blur()
focus()
await Promise.resolve()
expect(document.activeElement).toBe(option)
prompt.focus()
flush()
expect(document.activeElement).toBe(prompt)
focus(true)
await Promise.resolve()
flush()
expect(document.activeElement).toBe(option)
} finally {
for (const [key, descriptor] of Object.entries(original)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor)
else Reflect.deleteProperty(globalThis, key)
}
await window.happyDOM.close()
}
})
it("focuses the first enabled question option", () => {
const window = new Window()
const root = window.document.createElement("div")
@@ -8,8 +8,8 @@ const ROOT = path.resolve(import.meta.dir, "../..")
const WEBVIEW = path.join(ROOT, "webview-ui")
const FIXTURE = path.join(ROOT, "tests/fixtures/question-dock-disposal.tsx")
describe("QuestionDock disposal", () => {
it("does not read a stale callback-form Show accessor", async () => {
describe("QuestionDock lifecycle", () => {
it("preserves text focus and disposes without reading a stale Show accessor", async () => {
const solid = path.dirname(Bun.resolveSync("solid-js/package.json", WEBVIEW))
const aliases: Record<string, string> = {
"solid-js": path.join(solid, "dist/solid.js"),
@@ -55,7 +55,7 @@ export function createChatFocus(deps: {
}) {
const focus = (force: boolean) => {
if ((!force && (!document.hasFocus() || deps.term())) || deps.history() || deps.review()) return
if (preservesTextFocus(document.activeElement)) return
if (preservesTextFocus(document.activeElement) || (!force && isTextControl(document.activeElement))) return
if (!force && document.activeElement?.matches('[role="tab"]')) return
if (!force && document.activeElement?.closest('[data-component="question-dock"]')) return
if (focusQuestionOption()) return
@@ -22,6 +22,7 @@ import {
tr,
} from "./question-dock-utils"
import { isEnterKeyCommitNotIme } from "../../utils/ime-enter"
import { isTextControl } from "../../utils/focus"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const session = useSession()
@@ -328,7 +329,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
void store.tab
if (store.collapsed || store.editing || confirm()) return
requestAnimationFrame(() => {
if (!document.hasFocus()) return
if (!document.hasFocus() || isTextControl(document.activeElement)) return
const btn = root?.querySelector<HTMLButtonElement>("button[data-slot='question-option']:not(:disabled)")
btn?.focus({ preventScroll: true })
})