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
5 changes: 5 additions & 0 deletions .changeset/preserve-question-typing-focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Keep focus in active text fields when questions appear or refresh.
34 changes: 32 additions & 2 deletions packages/kilo-vscode/tests/fixtures/question-dock-disposal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ 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,
Node: window.Node,
Element: window.Element,
HTMLElement: window.HTMLElement,
SVGElement: window.SVGElement,
requestAnimationFrame: () => 0,
requestAnimationFrame: (callback: FrameRequestCallback) => frames.push(callback),
})

const { Show, createSignal } = await import("solid-js")
Expand Down Expand Up @@ -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}>
Expand All @@ -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()
Expand Down
56 changes: 56 additions & 0 deletions packages/kilo-vscode/tests/unit/agent-manager-focus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,69 @@ import { describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import {
agentManagerFocusTarget,
createChatFocus,
focusQuestionOption,
hasQuestionOption,
preservesTextFocus,
} from "../../webview-ui/agent-manager/focus"
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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion packages/kilo-vscode/webview-ui/agent-manager/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 })
})
Expand Down
Loading