Skip to content
Open
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
3 changes: 3 additions & 0 deletions apps/web/components/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import GeminiIcon from "@/components/icons/gemini"
import OpenAIIcon from "@/components/icons/openai"
import { Menu } from "@/components/ui/menu"
import { authClient } from "@/lib/auth-client"
import { useAutoFocusOnType } from "@/lib/hooks/use-auto-focus-on-type"
import { useDialogStore } from "@/lib/stores/dialogs"
import { cn } from "@/lib/utils"
import { Button } from "./button"
Expand Down Expand Up @@ -178,6 +179,8 @@ export const Composer = ({
}
}, [autoFocus])

useAutoFocusOnType(textareaRef)

return (
<form
className={cn(
Expand Down
3 changes: 3 additions & 0 deletions apps/web/components/repo-list-with-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TableCellText,
TableColumnTitle,
} from "@/components/typography"
import { useAutoFocusOnType } from "@/lib/hooks/use-auto-focus-on-type"
import { formatCompactNumber, formatRelativeTime } from "@/lib/utils"

type RepoStats = {
Expand Down Expand Up @@ -89,6 +90,8 @@ export function RepoListWithSearch({
inputRef.current?.focus()
}, [])

useAutoFocusOnType(inputRef)

useEffect(() => {
const query = value.trim()

Expand Down
3 changes: 3 additions & 0 deletions apps/web/components/repo-search-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SearchIcon } from "lucide-react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useEffect, useRef, useState } from "react"
import { useAutoFocusOnType } from "@/lib/hooks/use-auto-focus-on-type"

function parseRepoInput(input: string): { owner: string; repo: string } | null {
const trimmed = input.trim()
Expand Down Expand Up @@ -42,6 +43,8 @@ export function RepoSearchInput({ autoFocus }: { autoFocus?: boolean }) {
}
}, [autoFocus])

useAutoFocusOnType(inputRef)

function handleSubmit(e: React.FormEvent) {
e.preventDefault()
const parsed = parseRepoInput(value)
Expand Down
114 changes: 114 additions & 0 deletions apps/web/lib/hooks/use-auto-focus-on-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { type RefObject, useEffect } from "react"

/**
* Auto-focuses the target input/textarea when the user starts typing or pastes,
* but only if no other interactive element is currently focused.
*
* This avoids stealing focus from other inputs, textareas, selects, buttons,
* contenteditable elements, or elements inside dialogs/menus.
*/
export function useAutoFocusOnType(
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | null>
) {
useEffect(() => {
function shouldIgnore(): boolean {
const active = document.activeElement
if (!active || active === document.body) {
return false
}

const tag = active.tagName
if (
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT"
) {
return true
}

if ((active as HTMLElement).isContentEditable) {
return true
}

// Don't steal focus from elements inside dialogs, menus, or popovers
if (
active.closest(
'[role="dialog"], [role="menu"], [role="listbox"], [role="combobox"], [data-popup]'
)
) {
return true
}

return false
}

function handleKeyDown(e: KeyboardEvent) {
// Don't interfere if the target element already has focus
if (document.activeElement === ref.current) {
return
}

// Don't intercept if modifier keys are held (except Shift for uppercase)
if (e.metaKey || e.ctrlKey || e.altKey) {
return
}

// Only intercept printable single characters
if (e.key.length !== 1) {
return
}

if (shouldIgnore()) {
return
}

ref.current?.focus()
// Don't prevent default — the character will be typed into the now-focused element
}

function handlePaste(e: ClipboardEvent) {
if (document.activeElement === ref.current) {
return
}

if (shouldIgnore()) {
return
}

const text = e.clipboardData?.getData("text")
if (!text) {
return
}

e.preventDefault()

const el = ref.current
if (!el) return

el.focus()

// Use native value setter + input event to work with React controlled components.
// React overrides the value property on inputs, so we need to call the native
// HTMLInputElement/HTMLTextAreaElement setter to bypass React's controlled value,
// then dispatch an input event so React's onChange fires.
const proto =
el instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype
const nativeSetter = Object.getOwnPropertyDescriptor(proto, "value")?.set
if (nativeSetter) {
const currentValue = el.value
nativeSetter.call(el, currentValue + text)
el.dispatchEvent(new Event("input", { bubbles: true }))
}
}

document.addEventListener("keydown", handleKeyDown)
document.addEventListener("paste", handlePaste)

return () => {
document.removeEventListener("keydown", handleKeyDown)
document.removeEventListener("paste", handlePaste)
}
}, [ref])
}
Loading