mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix(vscode): preserve typing focus when questions update
This commit is contained in:
@@ -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 })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user