diff --git a/.changeset/preserve-question-typing-focus.md b/.changeset/preserve-question-typing-focus.md new file mode 100644 index 00000000000..656023986dc --- /dev/null +++ b/.changeset/preserve-question-typing-focus.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep focus in active text fields when questions appear or refresh. diff --git a/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx b/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx index b530ab148b9..641d44d59d3 100644 --- a/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx +++ b/packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx @@ -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( () => ( @@ -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('[data-slot="question-option"]') const submit = root.querySelector('[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() diff --git a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts index 4488414e838..5ebac14cdad 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts @@ -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") diff --git a/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts b/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts index cd9c5da3dda..ea589a42c87 100644 --- a/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts +++ b/packages/kilo-vscode/tests/unit/question-dock-disposal.test.ts @@ -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 = { "solid-js": path.join(solid, "dist/solid.js"), diff --git a/packages/kilo-vscode/webview-ui/agent-manager/focus.ts b/packages/kilo-vscode/webview-ui/agent-manager/focus.ts index e5e1175ba67..65eb7ebb7c2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/focus.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/focus.ts @@ -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 diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx index 74fa8f1ba79..da55702e85e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/QuestionDock.tsx @@ -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("button[data-slot='question-option']:not(:disabled)") btn?.focus({ preventScroll: true }) })