Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Window } from "happy-dom"
import { act, type ComponentProps } from "react"
import type { Root } from "react-dom/client"
import type { QuestionAnswer, QuestionRequest } from "../../lib/types"

const dom = new Window({ url: "http://localhost" })
Object.assign(globalThis, {
window: dom,
document: dom.document,
navigator: dom.navigator,
Node: dom.Node,
Element: dom.Element,
HTMLElement: dom.HTMLElement,
HTMLInputElement: dom.HTMLInputElement,
KeyboardEvent: dom.KeyboardEvent,
requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom),
IS_REACT_ACT_ENVIRONMENT: true,
})
const { createRoot } = await import("react-dom/client")
const { ChatQuestionFlow } = await import("./chat-question")
const { handleSessionNavigationKeyDown } = await import("../root-layout-keyboard")
const request: QuestionRequest = {
id: "request-1",
sessionId: "session-1",
questions: [
{
id: "question-1",
header: "Plan",
question: "Choose a path",
isOther: true,
isSecret: false,
options: [{ label: "Option A", description: "First path" }],
},
],
}
let root: Root | undefined
let mount: HTMLElement
let replies: [string, QuestionAnswer[]][] = []
let rejections: string[] = []

async function renderCard(overrides: Partial<ComponentProps<typeof ChatQuestionFlow>> = {}) {
if (!root) {
mount = document.createElement("div")
document.body.append(mount)
root = createRoot(mount)
}
await act(async () =>
root!.render(
<ChatQuestionFlow
questions={[request]}
onReply={async (id, answers) => {
replies.push([id, answers])
}}
onReject={async (id) => {
rejections.push(id)
}}
{...overrides}
/>,
),
)
return mount.querySelector<HTMLElement>('section[aria-label="Agent question"]')!
}

async function key(
target: EventTarget,
key: string,
options: Pick<
KeyboardEventInit,
"key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey" | "isComposing" | "keyCode"
> = {},
) {
const event = new dom.KeyboardEvent("keydown", {
key,
bubbles: true,
cancelable: true,
...options,
})
await act(async () => {
target.dispatchEvent(event as unknown as Event)
})
return event
}

async function answer(card: HTMLElement) {
await act(async () => {
card.querySelector<HTMLButtonElement>('[role="radio"]')!.click()
})
}

afterEach(async () => {
await act(async () => root?.unmount())
root = undefined
document.body.replaceChildren()
replies = []
rejections = []
})

describe("question card keyboard", () => {
test("unrelated buttons and inputs cannot advance, submit, skip, or navigate questions", async () => {
const card = await renderCard()
await answer(card)
for (const tag of ["button", "input"] as const) {
const outside = document.createElement(tag)
document.body.append(outside)
outside.focus()
for (const name of ["Enter", "Enter", "Escape", "ArrowRight", "ArrowLeft"]) {
expect((await key(outside, name)).defaultPrevented).toBe(false)
}
}
expect(card.textContent).toContain("Choose a path")
expect({ replies, rejections }).toEqual({ replies: [], rejections: [] })
})

test("card Enter reviews then sends exactly one answer; arrows can return to editing", async () => {
const card = await renderCard()
await answer(card)
card.focus()
expect((await key(card, "Enter")).defaultPrevented).toBe(true)
expect(card.textContent).toContain("Your answers")
expect((await key(card, "ArrowLeft")).defaultPrevented).toBe(true)
expect(card.textContent).toContain("Choose a path")
await key(card, "ArrowRight")
await key(card, "Enter")
expect(replies).toEqual([["request-1", [["Option A"]]]])
})

test("focused buttons retain native activation instead of triggering the card's Enter action", async () => {
const card = await renderCard()
await answer(card)
const skip = card.querySelector<HTMLButtonElement>('[aria-label="Skip question"]')!
skip.focus()
expect((await key(skip, "Enter")).defaultPrevented).toBe(false)
expect(card.textContent).toContain("Choose a path")
// Happy DOM does not synthesize native button activation from keyboard events.
await act(async () => skip.click())
expect({ replies, rejections }).toEqual({ replies: [], rejections: ["request-1"] })
})

test("IME confirmation does not advance a custom answer, including legacy keyCode 229", async () => {
const card = await renderCard()
const input = card.querySelector("input")!
input.focus()
await act(async () => {
Object.getOwnPropertyDescriptor(dom.HTMLInputElement.prototype, "value")!.set!.call(
input,
"中文",
)
input.dispatchEvent(new dom.Event("input", { bubbles: true }) as unknown as Event)
})
for (const options of [{ isComposing: true }, { keyCode: 229 }]) {
expect((await key(input, "Enter", options)).defaultPrevented).toBe(false)
expect(card.textContent).toContain("Choose a path")
}
expect((await key(input, "Enter")).defaultPrevented).toBe(true)
expect(card.textContent).toContain("Your answers")
await key(card, "Enter")
expect(replies).toEqual([["request-1", [["中文"]]]])
})

test("card Escape wins over document capture session navigation but ignores composition", async () => {
const card = await renderCard()
const navigations: string[] = []
const onCapture = (event: KeyboardEvent) => {
handleSessionNavigationKeyDown(event, {
agents: [{ id: "session-1", projectSlug: "project" }],
sessionId: "session-1",
navigate: (target) => navigations.push(target.to),
})
}
document.addEventListener("keydown", onCapture, { capture: true })
try {
card.focus()
expect((await key(card, "Escape", { isComposing: true })).defaultPrevented).toBe(false)
expect((await key(card, "Escape")).defaultPrevented).toBe(true)
expect({ rejections, navigations }).toEqual({ rejections: ["request-1"], navigations: [] })
} finally {
document.removeEventListener("keydown", onCapture, { capture: true })
}
})

test("disabled and pending cards cannot be submitted or rejected twice", async () => {
const card = await renderCard({ disabled: true })
expect((await key(card, "Escape")).defaultPrevented).toBe(false)
expect(rejections).toEqual([])
let finish: (() => void) | undefined
await renderCard({
onReply: async (id, answers) => {
replies.push([id, answers])
await new Promise<void>((resolve) => {
finish = resolve
})
},
})
await answer(card)
await key(card, "Enter")
await key(card, "Enter")
await key(card, "Enter")
await key(card, "Escape")
expect({ replies, rejections }).toEqual({
replies: [["request-1", [["Option A"]]]],
rejections: [],
})
await act(async () => finish?.())
})
})
74 changes: 37 additions & 37 deletions apps/desktop/src/renderer/components/chat/chat-question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,12 @@ function QuestionSection({
value={customText}
onChange={(e) => onCustomChange(index, e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
if (
e.key === "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing &&
e.nativeEvent.keyCode !== 229
) {
e.preventDefault()
onSubmitCustom?.()
}
Expand Down Expand Up @@ -391,48 +396,15 @@ const QuestionRequestStepper = memo(function QuestionRequestStepper({
}
}, [disabled, submitting, onReject, request.id])

useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement && e.target.id?.startsWith("question-custom-")) {
return
}
if (e.key === "Enter" && !e.shiftKey) {
if (isReview ? allAnswered : currentAnswered) {
e.preventDefault()
handleAdvance()
}
} else if (e.key === "Escape") {
e.preventDefault()
handleSkip()
} else if (e.key === "ArrowLeft") {
e.preventDefault()
handlePrevPage()
} else if (e.key === "ArrowRight") {
e.preventDefault()
handleNextPage()
}
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [
isReview,
allAnswered,
currentAnswered,
handleAdvance,
handleSkip,
handlePrevPage,
handleNextPage,
])

useEffect(() => {
const timer = requestAnimationFrame(() => {
if (isReview) {
cardRef.current?.focus()
return
}
const customInput = document.getElementById(
`question-custom-${currentStep}`,
) as HTMLInputElement | null
const customInput = cardRef.current?.querySelector<HTMLInputElement>(
`#question-custom-${currentStep}`,
)
if (customInput) {
customInput.focus()
} else {
Expand All @@ -452,6 +424,34 @@ const QuestionRequestStepper = memo(function QuestionRequestStepper({
ref={cardRef}
tabIndex={-1}
aria-label="Agent question"
onKeyDown={(e) => {
if (
e.defaultPrevented ||
e.nativeEvent.isComposing ||
e.nativeEvent.keyCode === 229 ||
disabled ||
submitting ||
e.target instanceof HTMLInputElement
) return

if (e.key === "Enter" && !e.shiftKey) {
// Focused buttons keep their native activation (select, edit, skip, or send).
if (e.target !== e.currentTarget) return
if (isReview ? allAnswered : currentAnswered) {
e.preventDefault()
handleAdvance()
}
} else if (e.key === "Escape") {
e.preventDefault()
handleSkip()
} else if (e.key === "ArrowLeft") {
e.preventDefault()
handlePrevPage()
} else if (e.key === "ArrowRight") {
e.preventDefault()
handleNextPage()
}
}}
className="devo-composer animate-in fade-in slide-in-from-bottom-2 bg-background/95 shadow-[0_8px_32px_rgba(0,0,0,0.05)] outline-none duration-200 dark:shadow-[0_10px_36px_rgba(0,0,0,0.28)]"
>
<div className="px-3 pt-3">
Expand Down
33 changes: 2 additions & 31 deletions apps/desktop/src/renderer/components/chat/chat-view.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useSessionHistoryShortcuts } from "../../hooks/use-session-history-shortcuts"
import { useTurnRecovery } from "../../hooks/use-turn-recovery"
import { TurnRecoveryPanel } from "./turn-recovery-panel"
import {
Expand Down Expand Up @@ -1000,37 +1001,7 @@ export function ChatView({
[onDeny],
)

// Keyboard shortcuts for undo/redo
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't intercept Cmd/Ctrl+Z in any text input — let the browser
// handle native undo/redo. Session undo/redo is still available via
// /undo, /redo slash commands and the command palette.
const target = e.target as HTMLElement
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return

// Cmd+Z / Ctrl+Z — Undo
if ((e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
if (canUndo && onUndo) {
e.preventDefault()
onUndo()
}
return
}

// Cmd+Shift+Z / Ctrl+Shift+Z — Redo
if ((e.metaKey || e.ctrlKey) && e.key === "z" && e.shiftKey) {
if (canRedo && onRedo) {
e.preventDefault()
onRedo()
}
return
}
}

document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [canUndo, canRedo, onUndo, onRedo])
useSessionHistoryShortcuts({ isActive, canUndo, canRedo, onUndo, onRedo })

// Width constraint class: remove max-w when review panel is open
const contentWidthClass = reviewPanelOpen
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/** Session shortcuts must not override controls or an open portal overlay. */
const INTERACTIVE_TARGET =
'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="slider"], [role="combobox"], [role="menuitem"], [role="option"], [aria-label="Tool permission request"]'
'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"]), [role="button"], [role="link"], [role="checkbox"], [role="switch"], [role="tab"], [role="slider"], [role="combobox"], [role="menuitem"], [role="option"], [aria-label="Tool permission request"], [aria-label="Agent question"]'

const OPEN_OVERLAY =
'dialog[open], [role="dialog"][data-open], [role="alertdialog"][data-open], [role="dialog"][aria-modal="true"], [role="alertdialog"][aria-modal="true"], [data-slot="popover-content"][data-open], [data-slot="dropdown-menu-content"][data-open], [data-slot="context-menu-content"][data-open], [data-slot="select-content"][data-open], [data-slot="combobox-content"][data-open], [role="menu"][data-open]'
Expand Down
Loading
Loading