diff --git a/packages/react/src/components/chat/CopilotChatInput.tsx b/packages/react/src/components/chat/CopilotChatInput.tsx index a80d8d02..56b6d79a 100644 --- a/packages/react/src/components/chat/CopilotChatInput.tsx +++ b/packages/react/src/components/chat/CopilotChatInput.tsx @@ -121,6 +121,8 @@ export function CopilotChatInput({ const resolvedValue = isControlled ? (value ?? "") : internalValue; const [layout, setLayout] = useState<"compact" | "expanded">("compact"); + const ignoreResizeRef = useRef(false); + const resizeEvaluationRafRef = useRef(null); const isExpanded = mode === "input" && layout === "expanded"; const [commandQuery, setCommandQuery] = useState(null); const [slashHighlightIndex, setSlashHighlightIndex] = useState(0); @@ -543,12 +545,32 @@ export function CopilotChatInput({ return scrollHeight; }, [ensureMeasurements]); + const updateLayout = useCallback((nextLayout: "compact" | "expanded") => { + setLayout((prev) => { + if (prev === nextLayout) { + return prev; + } + ignoreResizeRef.current = true; + return nextLayout; + }); + }, []); + const evaluateLayout = useCallback(() => { if (mode !== "input") { - setLayout("compact"); + updateLayout("compact"); return; } + if (typeof window !== "undefined" && typeof window.matchMedia === "function") { + const isMobileViewport = window.matchMedia("(max-width: 767px)").matches; + if (isMobileViewport) { + ensureMeasurements(); + adjustTextareaHeight(); + updateLayout("expanded"); + return; + } + } + const textarea = inputRef.current; const grid = gridRef.current; const addContainer = addButtonContainerRef.current; @@ -617,10 +639,8 @@ export function CopilotChatInput({ } const nextLayout = shouldExpand ? "expanded" : "compact"; - if (nextLayout !== layout) { - setLayout(nextLayout); - } - }, [adjustTextareaHeight, ensureMeasurements, layout, mode, resolvedValue]); + updateLayout(nextLayout); + }, [adjustTextareaHeight, ensureMeasurements, mode, resolvedValue, updateLayout]); useLayoutEffect(() => { evaluateLayout(); @@ -640,8 +660,29 @@ export function CopilotChatInput({ return; } + const scheduleEvaluation = () => { + if (ignoreResizeRef.current) { + ignoreResizeRef.current = false; + return; + } + + if (typeof window === "undefined") { + evaluateLayout(); + return; + } + + if (resizeEvaluationRafRef.current !== null) { + cancelAnimationFrame(resizeEvaluationRafRef.current); + } + + resizeEvaluationRafRef.current = window.requestAnimationFrame(() => { + resizeEvaluationRafRef.current = null; + evaluateLayout(); + }); + }; + const observer = new ResizeObserver(() => { - evaluateLayout(); + scheduleEvaluation(); }); observer.observe(grid); @@ -649,7 +690,13 @@ export function CopilotChatInput({ observer.observe(actionsContainer); observer.observe(textarea); - return () => observer.disconnect(); + return () => { + observer.disconnect(); + if (typeof window !== "undefined" && resizeEvaluationRafRef.current !== null) { + cancelAnimationFrame(resizeEvaluationRafRef.current); + resizeEvaluationRafRef.current = null; + } + }; }, [evaluateLayout]); const slashMenuVisible = commandQuery !== null && commandItems.length > 0; @@ -972,6 +1019,22 @@ export namespace CopilotChatInput { useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement); + // Auto-scroll input into view on mobile when focused + useEffect(() => { + const textarea = internalTextareaRef.current; + if (!textarea) return; + + const handleFocus = () => { + // Small delay to let the keyboard start appearing + setTimeout(() => { + textarea.scrollIntoView({ behavior: "smooth", block: "nearest" }); + }, 300); + }; + + textarea.addEventListener("focus", handleFocus); + return () => textarea.removeEventListener("focus", handleFocus); + }, []); + useEffect(() => { if (autoFocus) { internalTextareaRef.current?.focus(); diff --git a/packages/react/src/components/chat/CopilotChatSuggestionPill.tsx b/packages/react/src/components/chat/CopilotChatSuggestionPill.tsx index 81b1503b..af4a69d0 100644 --- a/packages/react/src/components/chat/CopilotChatSuggestionPill.tsx +++ b/packages/react/src/components/chat/CopilotChatSuggestionPill.tsx @@ -11,7 +11,7 @@ export interface CopilotChatSuggestionPillProps } const baseClasses = - "group inline-flex h-8 items-center gap-1.5 rounded-full border border-border/60 bg-background px-3 text-xs leading-none text-foreground transition-colors cursor-pointer hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-background disabled:hover:text-muted-foreground pointer-events-auto"; + "group inline-flex h-7 sm:h-8 items-center gap-1 sm:gap-1.5 rounded-full border border-border/60 bg-background px-2.5 sm:px-3 text-[11px] sm:text-xs leading-none text-foreground transition-colors cursor-pointer hover:bg-accent/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:text-muted-foreground disabled:hover:bg-background disabled:hover:text-muted-foreground pointer-events-auto"; const labelClasses = "whitespace-nowrap font-medium leading-none"; @@ -35,12 +35,12 @@ export const CopilotChatSuggestionPill = React.forwardRef< {...props} > {isLoading ? ( - -