@@ -6,7 +6,16 @@ import type { EmbedTools, SimplePDFToolName } from '@simplepdf/react-embed-pdf/a
66import { getRouteApi } from '@tanstack/react-router'
77import { DefaultChatTransport , lastAssistantMessageIsCompleteWithToolCalls , type UIMessage } from 'ai'
88import { ArrowUp , Mic , X } from 'lucide-react'
9- import { type ReactElement , useCallback , useEffect , useLayoutEffect , useMemo , useRef , useState } from 'react'
9+ import {
10+ type ReactElement ,
11+ type MouseEvent as ReactMouseEvent ,
12+ useCallback ,
13+ useEffect ,
14+ useLayoutEffect ,
15+ useMemo ,
16+ useRef ,
17+ useState ,
18+ } from 'react'
1019import { useTranslation } from 'react-i18next'
1120import { useStickToBottom } from 'use-stick-to-bottom'
1221import {
@@ -999,6 +1008,11 @@ export const ChatPane = ({
9991008 return t ( 'chat.subtitleWaiting' )
10001009 } , [ hasActiveModel , requiresUserUpload , t ] )
10011010 const inputPlaceholder = canSend ? t ( 'chat.inputPlaceholderReady' ) : chatStatusMessage
1011+ // The composer only behaves as a text target when the textarea is mounted
1012+ // (idle, not recording) and actually editable. Gates both the text cursor
1013+ // and the click-to-focus redirect so neither signals "type here" while the
1014+ // input is disabled or swapped out for the voice bar.
1015+ const isComposerInteractive = canSend && voice . status === 'idle'
10021016
10031017 useEffect ( ( ) => {
10041018 if ( canSend ) {
@@ -1063,6 +1077,27 @@ export const ChatPane = ({
10631077 [ sendMessage , voice . dismissError ] ,
10641078 )
10651079
1080+ // Make the whole composer act as one big input target: a mousedown landing
1081+ // on padding / empty space (anything that isn't the textarea or a control)
1082+ // redirects focus to the textarea. preventDefault stops the textarea from
1083+ // blurring first, so there is no focus flicker.
1084+ const handleComposerMouseDown = useCallback (
1085+ ( event : ReactMouseEvent < HTMLFormElement > ) : void => {
1086+ if ( ! isComposerInteractive ) {
1087+ return
1088+ }
1089+ if ( ! ( event . target instanceof HTMLElement ) ) {
1090+ return
1091+ }
1092+ if ( event . target . closest ( 'button, textarea' ) !== null ) {
1093+ return
1094+ }
1095+ event . preventDefault ( )
1096+ inputRef . current ?. focus ( )
1097+ } ,
1098+ [ isComposerInteractive ] ,
1099+ )
1100+
10661101 return (
10671102 < div className = "flex h-full flex-col" >
10681103 < ChatPaneHeader
@@ -1190,7 +1225,8 @@ export const ChatPane = ({
11901225 event . preventDefault ( )
11911226 handleSend ( draft )
11921227 } }
1193- className = "flex items-end gap-2"
1228+ onMouseDown = { handleComposerMouseDown }
1229+ className = { `flex items-end gap-2 ${ isComposerInteractive ? 'cursor-text' : '' } ` }
11941230 >
11951231 { /* One composer box (P070-03) shared by every voice state, so
11961232 clicking the mic causes NO layout shift: the textarea ↔
0 commit comments