@@ -325,6 +325,21 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
325325 }
326326 } , [ variant , defaultModel , storageKeys . modelKey ] ) ;
327327
328+ // ChatInputSection is always present and is a convenient place to surface focus scheduling
329+ // state for Storybook/tests without adding story-only props.
330+ const chatInputSectionRef = useRef < HTMLDivElement | null > ( null ) ;
331+ const handleChatInputSectionRef = useCallback ( ( el : HTMLDivElement | null ) => {
332+ chatInputSectionRef . current = el ;
333+ // Set an initial value once (avoid overriding later transitions).
334+ if ( el && ! el . hasAttribute ( "data-autofocus-state" ) ) {
335+ el . setAttribute ( "data-autofocus-state" , "pending" ) ;
336+ }
337+ } , [ ] ) ;
338+
339+ const setChatInputAutoFocusState = useCallback ( ( state : "pending" | "done" ) => {
340+ chatInputSectionRef . current ?. setAttribute ( "data-autofocus-state" , state ) ;
341+ } , [ ] ) ;
342+
328343 const focusMessageInput = useCallback ( ( ) => {
329344 const element = inputRef . current ;
330345 if ( ! element || element . disabled ) {
@@ -610,16 +625,50 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
610625 } , [ voiceInput , setToast ] ) ;
611626
612627 // Auto-focus chat input when workspace changes (workspace only)
628+ //
629+ // This is intentionally NOT a setTimeout-based delay. Fixed sleeps are prone to races
630+ // (especially in Storybook) and can still fire after other UI interactions.
613631 const workspaceIdForFocus = variant === "workspace" ? props . workspaceId : null ;
614632 useEffect ( ( ) => {
615633 if ( variant !== "workspace" ) return ;
616634
617- // Small delay to ensure DOM is ready and other components have settled
618- const timer = setTimeout ( ( ) => {
635+ const maxFrames = 10 ;
636+ setChatInputAutoFocusState ( "pending" ) ;
637+
638+ let cancelled = false ;
639+ let rafId : number | null = null ;
640+ let attempts = 0 ;
641+
642+ const step = ( ) => {
643+ if ( cancelled ) return ;
644+
645+ attempts += 1 ;
619646 focusMessageInput ( ) ;
620- } , 100 ) ;
621- return ( ) => clearTimeout ( timer ) ;
622- } , [ variant , workspaceIdForFocus , focusMessageInput ] ) ;
647+
648+ const input = inputRef . current ;
649+ const isFocused = ! ! input && document . activeElement === input ;
650+ const isDone = isFocused || attempts >= maxFrames ;
651+
652+ if ( isDone ) {
653+ setChatInputAutoFocusState ( "done" ) ;
654+ return ;
655+ }
656+
657+ rafId = requestAnimationFrame ( step ) ;
658+ } ;
659+
660+ // Start on the next frame so the textarea is mounted and ready.
661+ rafId = requestAnimationFrame ( step ) ;
662+
663+ return ( ) => {
664+ cancelled = true ;
665+ if ( rafId !== null ) {
666+ cancelAnimationFrame ( rafId ) ;
667+ }
668+ // Ensure we never leave the attribute stuck in "pending".
669+ setChatInputAutoFocusState ( "done" ) ;
670+ } ;
671+ } , [ variant , workspaceIdForFocus , focusMessageInput , setChatInputAutoFocusState ] ) ;
623672
624673 // Handle paste events to extract images
625674 const handlePaste = useCallback ( ( e : React . ClipboardEvent < HTMLTextAreaElement > ) => {
@@ -1371,6 +1420,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
13711420
13721421 { /* Input section - centered card for creation, bottom bar for workspace */ }
13731422 < div
1423+ ref = { handleChatInputSectionRef }
13741424 className = { cn (
13751425 "relative flex flex-col gap-1" ,
13761426 variant === "creation"
0 commit comments