diff --git a/packages/app/e2e/session/question-dock-keyboard.spec.ts b/packages/app/e2e/session/question-dock-keyboard.spec.ts new file mode 100644 index 00000000000..6509100fa3a --- /dev/null +++ b/packages/app/e2e/session/question-dock-keyboard.spec.ts @@ -0,0 +1,221 @@ +import { test, expect } from "../fixtures" +import { seedSessionQuestion, withSession } from "../actions" +import { questionDockSelector, promptSelector } from "../selectors" + +test.setTimeout(120_000) + +test.describe("question dock keyboard navigation", () => { + test("arrow up and arrow down navigate between options", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "e2e question dock keyboard arrows", async (session) => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Keyboard test", + question: "Pick an option", + options: [ + { label: "Option A", description: "First option" }, + { label: "Option B", description: "Second option" }, + { label: "Option C", description: "Third option" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + + const options = dock.locator('[data-slot="question-option"]') + await expect(options).toHaveCount(4) + await expect(options.nth(0)).toBeFocused() + + for (const step of [ + { key: "ArrowDown", index: 1 }, + { key: "ArrowDown", index: 2 }, + { key: "ArrowDown", index: 3 }, + { key: "ArrowUp", index: 2 }, + { key: "ArrowUp", index: 1 }, + { key: "ArrowUp", index: 0 }, + { key: "ArrowUp", index: 3 }, + { key: "ArrowDown", index: 0 }, + ]) { + await page.keyboard.press(step.key) + await expect(options.nth(step.index)).toBeFocused() + } + }) + }) + + test("arrow left and arrow right navigate between questions", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "e2e question dock keyboard left right", async (session) => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Question 1", + question: "First question", + options: [{ label: "Answer 1", description: "First answer" }], + }, + { + header: "Question 2", + question: "Second question", + options: [{ label: "Answer 2", description: "Second answer" }], + }, + { + header: "Question 3", + question: "Third question", + options: [{ label: "Answer 3", description: "Third answer" }], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + const options = dock.locator('[data-slot="question-option"]') + + await expect(dock.locator('[data-slot="question-text"]')).toHaveText("First question") + await expect(options.nth(0)).toBeFocused() + await page.keyboard.press("ArrowRight") + await expect(dock.locator('[data-slot="question-text"]')).toHaveText("Second question") + await expect(options.nth(0)).toBeFocused() + await page.keyboard.press("ArrowRight") + await expect(dock.locator('[data-slot="question-text"]')).toHaveText("Third question") + await expect(options.nth(0)).toBeFocused() + await page.keyboard.press("ArrowRight") + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(0) + await expect(page.locator(promptSelector)).toBeVisible() + }) + }) + + test("enter selects an option", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "e2e question dock keyboard enter", async (session) => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Enter test", + question: "Pick one", + options: [ + { label: "Yes", description: "Continue" }, + { label: "No", description: "Stop" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + + const options = dock.locator('[data-slot="question-option"]') + await page.keyboard.press("ArrowDown") + await expect(options.nth(1)).toBeFocused() + await page.keyboard.press("Enter") + await expect(options.nth(1)).toHaveAttribute("data-picked", "true") + await page.keyboard.press("ArrowRight") + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(0) + }) + }) + + test("space selects an option", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "e2e question dock keyboard space", async (session) => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Space test", + question: "Pick one", + options: [ + { label: "Continue", description: "Keep going" }, + { label: "Exit", description: "Stop now" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + + const options = dock.locator('[data-slot="question-option"]') + await expect(options.nth(0)).toBeFocused() + await page.keyboard.press("Space") + await expect(options.nth(0)).toHaveAttribute("data-picked", "true") + }) + }) + + test("home and end keys navigate options", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "e2e question dock keyboard home end", async (session) => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Home End test", + question: "Navigate options", + options: [ + { label: "First", description: "1st" }, + { label: "Second", description: "2nd" }, + { label: "Third", description: "3rd" }, + { label: "Fourth", description: "4th" }, + ], + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + + const options = dock.locator('[data-slot="question-option"]') + await expect(options).toHaveCount(5) + await expect(options.nth(0)).toBeFocused() + await page.keyboard.press("End") + await expect(options.nth(4)).toBeFocused() + await page.keyboard.press("Home") + await expect(options.nth(0)).toBeFocused() + }) + }) + + test("multi-select with space toggles options", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, "e2e question dock keyboard multi", async (session) => { + await gotoSession(session.id) + + await seedSessionQuestion(sdk, { + sessionID: session.id, + questions: [ + { + header: "Multi test", + question: "Pick multiple", + options: [ + { label: "Red", description: "Color red" }, + { label: "Green", description: "Color green" }, + { label: "Blue", description: "Color blue" }, + ], + multiple: true, + }, + ], + }) + + const dock = page.locator(questionDockSelector) + await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) + + const options = dock.locator('[data-slot="question-option"]') + await page.keyboard.press("Space") + await expect(options.nth(0)).toHaveAttribute("data-picked", "true") + await page.keyboard.press("ArrowDown") + await page.keyboard.press("Space") + await expect(options.nth(1)).toHaveAttribute("data-picked", "true") + await expect(options.nth(0)).toHaveAttribute("data-picked", "true") + await expect(options.nth(1)).toHaveAttribute("data-picked", "true") + await page.keyboard.press("Space") + await expect(options.nth(1)).toHaveAttribute("data-picked", "false") + await expect(options.nth(0)).toHaveAttribute("data-picked", "true") + }) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index b22a92eb0af..bbafa781594 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo, onCleanup, onMount, type Component } from "solid-js" +import { For, Show, createEffect, createMemo, onCleanup, onMount, type Component } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" @@ -97,6 +97,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit update() window.addEventListener("resize", update) + document.addEventListener("keydown", handleKeyDown) const dock = root?.closest('[data-component="session-prompt-dock"]') const scroller = document.querySelector(".scroll-view__viewport") @@ -106,6 +107,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit onCleanup(() => { window.removeEventListener("resize", update) + document.removeEventListener("keydown", handleKeyDown) observer.disconnect() if (raf !== undefined) cancelAnimationFrame(raf) }) @@ -121,6 +123,11 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit }) }) + createEffect(() => { + store.tab + if (!store.editing) focusFirstOption() + }) + const fail = (err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) @@ -224,6 +231,15 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const commitCustom = () => { setStore("editing", false) customUpdate(input()) + const trimmed = input().trim() + if (!trimmed) { + setStore("customOn", store.tab, false) + setStore("answers", store.tab, []) + } + requestAnimationFrame(() => { + const customIndex = options().length + focusOption(customIndex) + }) } const next = () => { @@ -252,6 +268,109 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit setStore("editing", false) } + const focusOption = (index: number) => { + const allOptions = root?.querySelectorAll('[data-slot="question-option"]') + if (!allOptions || allOptions.length === 0) return false + + const clamped = Math.max(0, Math.min(index, allOptions.length - 1)) + const el = allOptions[clamped] + + if (el instanceof HTMLElement) { + el.focus() + return true + } + return false + } + + const focusFirstOption = () => { + let retries = 0 + const maxRetries = 10 + + const tryFocus = () => { + const dock = root?.closest('[data-component="session-prompt-dock"]') + if (!dock || dock.hasAttribute("hidden")) { + if (retries++ < maxRetries) { + requestAnimationFrame(tryFocus) + } + return + } + + const opts = root?.querySelectorAll('[data-slot="question-option"]') + if (!opts || opts.length === 0) { + if (retries++ < maxRetries) { + requestAnimationFrame(tryFocus) + } + return + } + + focusOption(0) + } + + requestAnimationFrame(tryFocus) + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (store.sending) return + + const target = e.target as HTMLElement + + if (store.editing && target instanceof HTMLTextAreaElement) return + + const allOptions = root?.querySelectorAll('[data-slot="question-option"]') ?? [] + const currentIndex = Array.from(allOptions).findIndex((el) => el === document.activeElement) + + if (currentIndex === -1) return + + const customOptionIndex = options().length + const totalOptions = customOptionIndex + 1 + + switch (e.key) { + case "ArrowDown": + e.preventDefault() + focusOption((currentIndex + 1) % totalOptions) + break + + case "ArrowUp": + e.preventDefault() + focusOption((currentIndex - 1 + totalOptions) % totalOptions) + break + + case "ArrowLeft": + if (store.tab > 0) { + e.preventDefault() + back() + focusFirstOption() + } + break + + case "ArrowRight": + e.preventDefault() + next() + focusFirstOption() + break + + case "Enter": + case " ": + e.preventDefault() + if (currentIndex === customOptionIndex) { + customOpen() + } else { + selectOption(currentIndex) + } + break + + case "Home": + e.preventDefault() + focusOption(0) + break + + case "End": + e.preventDefault() + focusOption(totalOptions - 1) + break + } + } + return ( store.answers[store.tab]?.includes(opt.label) ?? false return ( } @@ -408,13 +531,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit {language.t("ui.messagePart.option.typeOwnAnswer")}