@@ -20,6 +20,16 @@ export type ChatInputProps = {
2020 readonly onSend : ( ) => void ;
2121 /** Shown while the agent is replying. Its presence turns send into stop. */
2222 readonly onStop ?: ( ) => void ;
23+ /**
24+ * Holding the send button fires this instead of sending — the heavier
25+ * alternative gesture (send back, send-and-…) that shares the button
26+ * because it is a send, not a separate action. The click that ends the
27+ * hold is swallowed so it never sends on release. There is no keyboard
28+ * hold, so whatever this opens must be reachable another way too.
29+ */
30+ readonly onSendHold ?: ( ) => void ;
31+ /** How long a press counts as a hold. Defaults to 450ms. */
32+ readonly sendHoldMs ?: number ;
2333 readonly working ?: boolean ;
2434 readonly placeholder ?: string ;
2535 readonly disabled ?: boolean ;
@@ -62,6 +72,9 @@ export type ChatInputSubmitProps = Omit<ComponentProps<"button">, "type" | "chil
6272 readonly sendIcon ?: ReactNode ;
6373 /** When false, send is disabled. Omit to leave enablement to `disabled`. */
6474 readonly canSend ?: boolean ;
75+ readonly onSendHold ?: ( ) => void ;
76+ /** How long a press counts as a hold. Defaults to 450ms. */
77+ readonly sendHoldMs ?: number ;
6578} ;
6679
6780export type ChatInputAttachProps = {
@@ -100,12 +113,17 @@ const toolButtonClass =
100113 * Callers that need extra footer controls (dictate, model picker, …) pass
101114 * `leadingTools` / `trailingTools`, or compose the named slots instead of
102115 * this all-in-one. Omit the extras and the composer looks as it did.
116+ *
117+ * Holding send fires `onSendHold` instead of sending. The click that ends the
118+ * hold is swallowed so it never sends on release.
103119 */
104120export function ChatInput ( {
105121 value,
106122 onValueChange,
107123 onSend,
108124 onStop,
125+ onSendHold,
126+ sendHoldMs = 450 ,
109127 working = false ,
110128 placeholder = "Send a message…" ,
111129 disabled = false ,
@@ -120,8 +138,14 @@ export function ChatInput({
120138 className,
121139} : ChatInputProps ) {
122140 const canSend = value . trim ( ) . length > 0 && ! disabled ;
141+ // Set when a hold fires so the click that ends the press does not send.
142+ const heldRef = useRef ( false ) ;
123143
124144 const submit = ( ) => {
145+ if ( heldRef . current ) {
146+ heldRef . current = false ;
147+ return ;
148+ }
125149 if ( ! canSend ) return ;
126150 onSend ( ) ;
127151 } ;
@@ -172,7 +196,21 @@ export function ChatInput({
172196 { leadingTools }
173197 </ ChatInputTools >
174198 { trailingTools }
175- < ChatInputSubmit onStop = { onStop } working = { working } sendIcon = { sendIcon } canSend = { canSend } />
199+ < ChatInputSubmit
200+ onStop = { onStop }
201+ working = { working }
202+ sendIcon = { sendIcon }
203+ canSend = { canSend }
204+ sendHoldMs = { sendHoldMs }
205+ { ...( onSendHold === undefined
206+ ? { }
207+ : {
208+ onSendHold : ( ) => {
209+ heldRef . current = true ;
210+ onSendHold ( ) ;
211+ } ,
212+ } ) }
213+ />
176214 </ ChatInputFooter >
177215 </ ChatInputRoot >
178216 ) ;
@@ -339,18 +377,30 @@ export function ChatInputAttach({
339377 ) ;
340378}
341379
342- /** Send, or stop while the agent is replying. */
380+ /** Send, or stop while the agent is replying. Holding send fires `onSendHold`. */
343381export function ChatInputSubmit ( {
344382 onStop,
345383 working = false ,
346384 sendIcon = defaultSendIcon ,
347385 canSend,
348386 disabled,
349387 className,
388+ onSendHold,
389+ sendHoldMs = 450 ,
350390 ...props
351391} : ChatInputSubmitProps ) {
352392 const showStop = working && onStop !== undefined ;
353- const sendDisabled = disabled === true || canSend === false ;
393+ const sendDisabled = disabled === true || ( canSend === false && onSendHold === undefined ) ;
394+ const holdTimer = useRef < ReturnType < typeof setTimeout > | null > ( null ) ;
395+
396+ const releaseHold = ( ) => {
397+ if ( holdTimer . current !== null ) {
398+ clearTimeout ( holdTimer . current ) ;
399+ holdTimer . current = null ;
400+ }
401+ } ;
402+
403+ useEffect ( ( ) => releaseHold , [ ] ) ;
354404
355405 if ( showStop ) {
356406 return (
@@ -377,6 +427,20 @@ export function ChatInputSubmit({
377427 data-slot = "chat-input-submit"
378428 disabled = { sendDisabled }
379429 aria-label = { props [ "aria-label" ] ?? "Send message" }
430+ { ...( onSendHold === undefined
431+ ? { }
432+ : {
433+ onPointerDown : ( ) => {
434+ releaseHold ( ) ;
435+ holdTimer . current = setTimeout ( ( ) => {
436+ holdTimer . current = null ;
437+ onSendHold ( ) ;
438+ } , sendHoldMs ) ;
439+ } ,
440+ onPointerUp : releaseHold ,
441+ onPointerLeave : releaseHold ,
442+ onPointerCancel : releaseHold ,
443+ } ) }
380444 className = { cn (
381445 "grid size-9 shrink-0 place-items-center rounded-md bg-primary text-primary-foreground transition-colors hover:bg-primary-active disabled:opacity-40" ,
382446 className ,
0 commit comments