Skip to content

Commit b1d6421

Browse files
authored
feat(ui): focus messages after prompt submission (NeuralNomadsAI#577)
## Summary Move keyboard focus from the prompt to the active message stream immediately after a desktop prompt submission. This makes conversation navigation keys such as Page Up and Page Down available without requiring a click. Starting to type printable text returns focus to the prompt for a follow-up message. ## Changes - Focus the current session's message stream before waiting for the message, command, or shell request. - Carry an explicit focus intent through first-prompt session creation so the newly mounted session focuses its message stream instead of the detached draft view. - Do not overwrite focus if the user changes controls or opens a modal during first-session activation. - Restore the active prompt when a submission fails. - Make the message stream programmatically focusable without adding it to the normal Tab order. - Keep type-to-focus active on hybrid touch/mouse devices and preserve `!` shell-mode activation from the message stream. ## Unchanged behavior - Touch-only submissions keep their existing focus behavior. - Existing global Escape handling is unchanged. ## Validation - Focused prompt and scrolling tests pass: `24/24`. - UI typecheck passes. - UI production build passes. - `git diff --check` passes. - Independent gatekeeper re-review found no remaining blockers.
1 parent 07e8ce6 commit b1d6421

6 files changed

Lines changed: 126 additions & 15 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import assert from "node:assert/strict"
2+
import { describe, it } from "node:test"
3+
4+
import { focusConversationStream } from "./focus-conversation.ts"
5+
6+
describe("focusConversationStream", () => {
7+
it("focuses the message stream without scrolling", () => {
8+
const calls: Array<FocusOptions | undefined> = []
9+
const stream = {
10+
focus(options?: FocusOptions) {
11+
calls.push(options)
12+
},
13+
} as unknown as HTMLElement
14+
const root = { querySelector: () => stream } as unknown as ParentNode
15+
16+
assert.equal(focusConversationStream(root), true)
17+
assert.deepEqual(calls, [{ preventScroll: true }])
18+
})
19+
20+
it("falls back when focus options are unsupported", () => {
21+
const calls: Array<FocusOptions | undefined> = []
22+
const stream = {
23+
focus(options?: FocusOptions) {
24+
calls.push(options)
25+
if (options) throw new Error("unsupported")
26+
},
27+
} as unknown as HTMLElement
28+
const root = { querySelector: () => stream } as unknown as ParentNode
29+
30+
assert.equal(focusConversationStream(root), true)
31+
assert.deepEqual(calls, [{ preventScroll: true }, undefined])
32+
})
33+
34+
it("returns false when focus is unavailable", () => {
35+
assert.equal(focusConversationStream(null), false)
36+
const root = { querySelector: () => null } as unknown as ParentNode
37+
assert.equal(focusConversationStream(root), false)
38+
})
39+
})
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export function focusConversationStream(root: ParentNode | null | undefined): boolean {
2+
const stream = root?.querySelector<HTMLElement>(".message-stream")
3+
if (!stream) return false
4+
5+
try {
6+
stream.focus({ preventScroll: true })
7+
} catch {
8+
try {
9+
stream.focus()
10+
} catch {
11+
return false
12+
}
13+
}
14+
return true
15+
}

packages/ui/src/components/instance/instance-shell2.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
127127
const [draftModel, setDraftModel] = createSignal({ providerId: "", modelId: "" })
128128
const [draftModelManuallySelected, setDraftModelManuallySelected] = createSignal(false)
129129
const [draftPromptInputApi, setDraftPromptInputApi] = createSignal<PromptInputApi | null>(null)
130+
const [focusConversationSessionId, setFocusConversationSessionId] = createSignal<string | null>(null)
130131

131132
// Worktree selector manages its own dialogs.
132133
const [showSessionSearch, setShowSessionSearch] = createSignal(false)
@@ -952,6 +953,22 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
952953
}
953954
}
954955

956+
function getActiveCreatedSessionPane(sessionId: string) {
957+
if (activeSessionIdForInstance() !== sessionId) return null
958+
const pane = sessionCenterEl()?.querySelector<HTMLElement>('.session-cache-pane[data-session-active="true"]')
959+
return pane?.dataset.sessionId === sessionId ? pane : null
960+
}
961+
962+
function focusCreatedSessionPrompt(sessionId: string) {
963+
const textarea = getActiveCreatedSessionPane(sessionId)?.querySelector<HTMLTextAreaElement>(".prompt-input")
964+
if (!textarea || textarea.disabled) return
965+
try {
966+
textarea.focus({ preventScroll: true })
967+
} catch {
968+
textarea.focus()
969+
}
970+
}
971+
955972
async function createAndActivateDraftSession() {
956973
const agent = draftAgent()
957974
const model = draftModel()
@@ -962,23 +979,33 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
962979
if (model.providerId && model.modelId) {
963980
await updateSessionModel(props.instance.id, session.id, model)
964981
}
982+
if (!window.matchMedia?.("(pointer: coarse)")?.matches || window.matchMedia?.("(any-pointer: fine)")?.matches) {
983+
setFocusConversationSessionId(session.id)
984+
}
965985
setActiveParentSession(props.instance.id, session.id)
966986
return session
967987
}
968988

969-
async function handleFirstPromptSend(prompt: string, attachments: Attachment[]) {
989+
async function runFirstPromptSubmission(submit: (sessionId: string) => Promise<void>) {
970990
const session = await createAndActivateDraftSession()
971-
await sendMessage(props.instance.id, session.id, prompt, attachments)
991+
try {
992+
await submit(session.id)
993+
} catch (error) {
994+
focusCreatedSessionPrompt(session.id)
995+
throw error
996+
}
997+
}
998+
999+
async function handleFirstPromptSend(prompt: string, attachments: Attachment[]) {
1000+
await runFirstPromptSubmission((sessionId) => sendMessage(props.instance.id, sessionId, prompt, attachments))
9721001
}
9731002

9741003
async function handleFirstPromptCommand(commandName: string, args: string) {
975-
const session = await createAndActivateDraftSession()
976-
await executeCustomCommand(props.instance.id, session.id, commandName, args)
1004+
await runFirstPromptSubmission((sessionId) => executeCustomCommand(props.instance.id, sessionId, commandName, args))
9771005
}
9781006

9791007
async function handleFirstPromptShell(command: string) {
980-
const session = await createAndActivateDraftSession()
981-
await runShellCommand(props.instance.id, session.id, command)
1008+
await runFirstPromptSubmission((sessionId) => runShellCommand(props.instance.id, sessionId, command))
9821009
}
9831010

9841011
/** Return to the last conversation */
@@ -1287,6 +1314,10 @@ const InstanceShell2: Component<InstanceShellProps> = (props) => {
12871314
escapeInDebounce={props.escapeInDebounce}
12881315
isPhoneLayout={isPhoneLayout()}
12891316
compactPromptLayout={compactPromptLayout()}
1317+
focusConversationOnActivate={focusConversationSessionId() === sessionId}
1318+
onConversationFocusHandled={() => {
1319+
if (focusConversationSessionId() === sessionId) setFocusConversationSessionId(null)
1320+
}}
12901321
registerSessionPromptApi={registerSessionPromptApi}
12911322
showSidebarToggle={showEmbeddedSidebarToggle()}
12921323
onSidebarToggle={() => setLeftOpen(true)}

packages/ui/src/components/prompt-input.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ExpandButton from "./expand-button"
44
import { clearAttachments, removeAttachment } from "../stores/attachments"
55
import { createPastedPlaceholderRegex, pastedDisplayCounterRegex } from "./prompt-input/attachmentPlaceholders"
66
import { preparePromptSubmission } from "./prompt-input/submitPrompt"
7+
import { focusConversationStream } from "./focus-conversation"
78
import Kbd from "./kbd"
89
import { getActiveInstance } from "../stores/instances"
910
import { agents, executeCustomCommand } from "../stores/sessions"
@@ -325,11 +326,6 @@ export default function PromptInput(props: PromptInputProps) {
325326
),
326327
)
327328

328-
const isCoarsePointer = () => {
329-
if (typeof window === "undefined") return false
330-
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches)
331-
}
332-
333329
const isTouchOnlyPointer = () => {
334330
if (typeof window === "undefined") return false
335331
return Boolean(window.matchMedia?.("(pointer: coarse)")?.matches && !window.matchMedia?.("(any-pointer: fine)")?.matches)
@@ -338,7 +334,7 @@ export default function PromptInput(props: PromptInputProps) {
338334
createEffect(() => {
339335
// Scope global "type-to-focus" behavior to the active, visible prompt only.
340336
if (typeof document === "undefined") return
341-
if (isCoarsePointer()) return
337+
if (isTouchOnlyPointer()) return
342338
if (props.isActive === false) return
343339
if (props.disabled) return
344340

@@ -374,6 +370,8 @@ export default function PromptInput(props: PromptInputProps) {
374370
const isSpecialKey =
375371
e.key === "Tab" ||
376372
e.key === "Enter" ||
373+
e.key === " " ||
374+
e.key === "Spacebar" ||
377375
e.key.startsWith("Arrow") ||
378376
e.key === "Backspace" ||
379377
e.key === "Delete"
@@ -385,6 +383,13 @@ export default function PromptInput(props: PromptInputProps) {
385383
// In session cache mode inactive panes are display:none; avoid stealing focus.
386384
if (textarea.offsetParent === null) return
387385

386+
if (e.key === "!" && prompt().length === 0) {
387+
e.preventDefault()
388+
setMode("shell")
389+
textarea.focus()
390+
return
391+
}
392+
388393
if (e.key.length === 1) {
389394
textarea.focus()
390395
}
@@ -510,6 +515,10 @@ export default function PromptInput(props: PromptInputProps) {
510515
void refreshHistory()
511516
}
512517

518+
if (!isTouchOnlyPointer()) {
519+
focusConversationStream(wrapperRef?.closest(".session-view"))
520+
}
521+
513522
try {
514523
if (isShellMode) {
515524
if (props.onRunShell) {
@@ -536,10 +545,10 @@ export default function PromptInput(props: PromptInputProps) {
536545
detail: error instanceof Error ? error.message : String(error),
537546
variant: "error",
538547
})
539-
} finally {
540548
if (!isTouchOnlyPointer()) {
541549
textareaRef?.focus()
542550
}
551+
return
543552
}
544553
}
545554

packages/ui/src/components/session/session-view.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { closeSessionPreview, getSessionPreview, showSessionChat } from "../../s
2222
import { SessionPreviewView } from "../session-preview-view"
2323
import { isSnapshotAutoFollowing } from "../virtual-follow-behavior"
2424
import { getSubmitBottomPinTargetCount, resolveSessionBottomPinIntent, shouldClearSessionBottomPinIntent, type SessionBottomPinIntent } from "./session-bottom-pin-intent"
25+
import { focusConversationStream } from "../focus-conversation"
2526

2627
const log = getLogger("session")
2728

@@ -37,6 +38,8 @@ interface SessionViewProps {
3738
escapeInDebounce: boolean
3839
isPhoneLayout?: boolean
3940
compactPromptLayout?: boolean
41+
focusConversationOnActivate?: boolean
42+
onConversationFocusHandled?: () => void
4043
showSidebarToggle?: boolean
4144
onSidebarToggle?: () => void
4245
forceCompactStatusLayout?: boolean
@@ -240,13 +243,13 @@ export const SessionView: Component<SessionViewProps> = (props) => {
240243
() => props.isActive,
241244
(isActive) => {
242245
if (!isActive) {
246+
if (props.focusConversationOnActivate) props.onConversationFocusHandled?.()
243247
clearConversationPlaybackForSession(props.instanceId, props.sessionId)
244248
return
245249
}
246-
if (!isActive) return
247250

248251
// On phones, focusing the prompt on session switch is disruptive (it raises the OSK).
249-
if (props.isPhoneLayout) return
252+
if (props.isPhoneLayout && !props.focusConversationOnActivate) return
250253

251254
// Don't steal focus from other inputs (command palette, dialogs, selectors, etc.)
252255
if (typeof document === "undefined") return
@@ -264,6 +267,19 @@ export const SessionView: Component<SessionViewProps> = (props) => {
264267
// Defer until the session pane is visible and the textarea is mounted.
265268
requestAnimationFrame(() => {
266269
requestAnimationFrame(() => {
270+
if (!props.isActive) return
271+
if (props.focusConversationOnActivate) {
272+
const activeElement = document.activeElement
273+
const focusIsUnclaimed =
274+
!activeElement || activeElement === document.body || activeElement === document.documentElement
275+
const modalIsOpen = Boolean(document.querySelector('[role="dialog"][aria-modal="true"]'))
276+
if (focusIsUnclaimed && !modalIsOpen && focusConversationStream(rootRef)) {
277+
props.onConversationFocusHandled?.()
278+
return
279+
}
280+
props.onConversationFocusHandled?.()
281+
if (!focusIsUnclaimed || modalIsOpen) return
282+
}
267283
if (promptInputApi) {
268284
promptInputApi.focus()
269285
return

packages/ui/src/components/virtual-follow-list.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,7 @@ export default function VirtualFollowList<T>(props: VirtualFollowListProps<T>) {
692692
}}>
693693
<div
694694
class="message-stream"
695+
tabIndex={-1}
695696
ref={el => {
696697
setScrollElement(el)
697698
props.onScrollElementChange?.(el)

0 commit comments

Comments
 (0)