summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages/session
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-04-01 15:47:15 +0530
committerGitHub <[email protected]>2026-04-01 15:47:15 +0530
commit47a676111a3532aebed01110494742e536b7e5b4 (patch)
tree0720ee8d01d1075116ce5b0b3cf55ef7b1b28d99 /packages/app/src/pages/session
parent1df5ad470a49163ad623460b4e969da1b51cc404 (diff)
downloadopencode-47a676111a3532aebed01110494742e536b7e5b4.tar.gz
opencode-47a676111a3532aebed01110494742e536b7e5b4.zip
fix(session): add keyboard support to question dock (#20439)
Diffstat (limited to 'packages/app/src/pages/session')
-rw-r--r--packages/app/src/pages/session/composer/session-question-dock.tsx117
1 files changed, 113 insertions, 4 deletions
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 ef1e52d26..38974b246 100644
--- a/packages/app/src/pages/session/composer/session-question-dock.tsx
+++ b/packages/app/src/pages/session/composer/session-question-dock.tsx
@@ -29,16 +29,20 @@ function Option(props: {
label: string
description?: string
disabled: boolean
+ ref?: (el: HTMLButtonElement) => void
+ onFocus?: VoidFunction
onClick: VoidFunction
}) {
return (
<button
type="button"
+ ref={props.ref}
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
+ onFocus={props.onFocus}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
@@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]),
editing: false,
+ focus: 0,
})
let root: HTMLDivElement | undefined
+ let customRef: HTMLButtonElement | undefined
+ let optsRef: HTMLButtonElement[] = []
let replied = false
+ let focusFrame: number | undefined
const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
+ const count = createMemo(() => options().length + 1)
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
root.style.setProperty("--question-prompt-max-height", `${max}px`)
}
+ const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
+
+ const pickFocus = (tab: number = store.tab) => {
+ const list = questions()[tab]?.options ?? []
+ if (store.customOn[tab] === true) return list.length
+ return Math.max(
+ 0,
+ list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
+ )
+ }
+
+ const focus = (i: number) => {
+ const next = clamp(i)
+ setStore("focus", next)
+ if (store.editing) return
+ if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
+ focusFrame = requestAnimationFrame(() => {
+ focusFrame = undefined
+ const el = next === options().length ? customRef : optsRef[next]
+ el?.focus()
+ })
+ }
+
onMount(() => {
let raf: number | undefined
const update = () => {
@@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf)
})
+
+ focus(pickFocus())
})
onCleanup(() => {
+ if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
cache.set(props.request.id, {
tab: store.tab,
@@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const customToggle = () => {
if (sending()) return
+ setStore("focus", options().length)
if (!multi()) {
setStore("customOn", store.tab, true)
@@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false)
+ focus(options().length)
}
const customOpen = () => {
if (sending()) return
+ setStore("focus", options().length)
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
customUpdate(input(), true)
}
+ const move = (step: number) => {
+ if (store.editing || sending()) return
+ focus(store.focus + step)
+ }
+
+ const nav = (event: KeyboardEvent) => {
+ if (event.defaultPrevented) return
+
+ if (event.key === "Escape") {
+ event.preventDefault()
+ void reject()
+ return
+ }
+
+ const mod = (event.metaKey || event.ctrlKey) && !event.altKey
+ if (mod && event.key === "Enter") {
+ if (event.repeat) return
+ event.preventDefault()
+ next()
+ return
+ }
+
+ const target =
+ event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
+ if (store.editing) return
+ if (!(target instanceof HTMLElement)) return
+ if (event.altKey || event.ctrlKey || event.metaKey) return
+
+ if (event.key === "ArrowDown" || event.key === "ArrowRight") {
+ event.preventDefault()
+ move(1)
+ return
+ }
+
+ if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
+ event.preventDefault()
+ move(-1)
+ return
+ }
+
+ if (event.key === "Home") {
+ event.preventDefault()
+ focus(0)
+ return
+ }
+
+ if (event.key !== "End") return
+ event.preventDefault()
+ focus(count() - 1)
+ }
+
const selectOption = (optIndex: number) => {
if (sending()) return
@@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const opt = options()[optIndex]
if (!opt) return
if (multi()) {
+ setStore("editing", false)
toggle(opt.label)
return
}
@@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const commitCustom = () => {
setStore("editing", false)
customUpdate(input())
+ focus(options().length)
}
const resizeInput = (el: HTMLTextAreaElement) => {
@@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return
}
- setStore("tab", store.tab + 1)
+ const tab = store.tab + 1
+ setStore("tab", tab)
setStore("editing", false)
+ focus(pickFocus(tab))
}
const back = () => {
if (sending()) return
if (store.tab <= 0) return
- setStore("tab", store.tab - 1)
+ const tab = store.tab - 1
+ setStore("tab", tab)
setStore("editing", false)
+ focus(pickFocus(tab))
}
const jump = (tab: number) => {
if (sending()) return
setStore("tab", tab)
setStore("editing", false)
+ focus(pickFocus(tab))
}
return (
<DockPrompt
kind="question"
ref={(el) => (root = el)}
+ onKeyDown={nav}
header={
<>
<div data-slot="question-header-title">{summary()}</div>
@@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
footer={
<>
- <Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
+ <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")}
</Button>
<div data-slot="question-footer-actions">
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
{language.t("ui.common.back")}
</Button>
</Show>
- <Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
+ <Button
+ variant={last() ? "primary" : "secondary"}
+ size="large"
+ disabled={sending()}
+ onClick={next}
+ aria-keyshortcuts="Meta+Enter Control+Enter"
+ >
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button>
</div>
@@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
label={opt.label}
description={opt.description}
disabled={sending()}
+ ref={(el) => (optsRef[i()] = el)}
+ onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())}
/>
)}
@@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
fallback={
<button
type="button"
+ ref={customRef}
data-slot="question-option"
data-custom="true"
data-picked={on()}
role={multi() ? "checkbox" : "radio"}
aria-checked={on()}
disabled={sending()}
+ onFocus={() => setStore("focus", options().length)}
onClick={customOpen}
>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
@@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (e.key === "Escape") {
e.preventDefault()
setStore("editing", false)
+ focus(options().length)
return
}
+ if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault()
commitCustom()