Skip to content

Commit 7c4a6cd

Browse files
authored
Mobile support (#20)
1 parent 2632ba6 commit 7c4a6cd

File tree

9 files changed

+253
-41
lines changed

9 files changed

+253
-41
lines changed

packages/react/src/components/chat/CopilotChatInput.tsx

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,8 @@ export function CopilotChatInput({
121121
const resolvedValue = isControlled ? (value ?? "") : internalValue;
122122

123123
const [layout, setLayout] = useState<"compact" | "expanded">("compact");
124+
const ignoreResizeRef = useRef(false);
125+
const resizeEvaluationRafRef = useRef<number | null>(null);
124126
const isExpanded = mode === "input" && layout === "expanded";
125127
const [commandQuery, setCommandQuery] = useState<string | null>(null);
126128
const [slashHighlightIndex, setSlashHighlightIndex] = useState(0);
@@ -543,12 +545,32 @@ export function CopilotChatInput({
543545
return scrollHeight;
544546
}, [ensureMeasurements]);
545547

548+
const updateLayout = useCallback((nextLayout: "compact" | "expanded") => {
549+
setLayout((prev) => {
550+
if (prev === nextLayout) {
551+
return prev;
552+
}
553+
ignoreResizeRef.current = true;
554+
return nextLayout;
555+
});
556+
}, []);
557+
546558
const evaluateLayout = useCallback(() => {
547559
if (mode !== "input") {
548-
setLayout("compact");
560+
updateLayout("compact");
549561
return;
550562
}
551563

564+
if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
565+
const isMobileViewport = window.matchMedia("(max-width: 767px)").matches;
566+
if (isMobileViewport) {
567+
ensureMeasurements();
568+
adjustTextareaHeight();
569+
updateLayout("expanded");
570+
return;
571+
}
572+
}
573+
552574
const textarea = inputRef.current;
553575
const grid = gridRef.current;
554576
const addContainer = addButtonContainerRef.current;
@@ -617,10 +639,8 @@ export function CopilotChatInput({
617639
}
618640

619641
const nextLayout = shouldExpand ? "expanded" : "compact";
620-
if (nextLayout !== layout) {
621-
setLayout(nextLayout);
622-
}
623-
}, [adjustTextareaHeight, ensureMeasurements, layout, mode, resolvedValue]);
642+
updateLayout(nextLayout);
643+
}, [adjustTextareaHeight, ensureMeasurements, mode, resolvedValue, updateLayout]);
624644

625645
useLayoutEffect(() => {
626646
evaluateLayout();
@@ -640,16 +660,43 @@ export function CopilotChatInput({
640660
return;
641661
}
642662

663+
const scheduleEvaluation = () => {
664+
if (ignoreResizeRef.current) {
665+
ignoreResizeRef.current = false;
666+
return;
667+
}
668+
669+
if (typeof window === "undefined") {
670+
evaluateLayout();
671+
return;
672+
}
673+
674+
if (resizeEvaluationRafRef.current !== null) {
675+
cancelAnimationFrame(resizeEvaluationRafRef.current);
676+
}
677+
678+
resizeEvaluationRafRef.current = window.requestAnimationFrame(() => {
679+
resizeEvaluationRafRef.current = null;
680+
evaluateLayout();
681+
});
682+
};
683+
643684
const observer = new ResizeObserver(() => {
644-
evaluateLayout();
685+
scheduleEvaluation();
645686
});
646687

647688
observer.observe(grid);
648689
observer.observe(addContainer);
649690
observer.observe(actionsContainer);
650691
observer.observe(textarea);
651692

652-
return () => observer.disconnect();
693+
return () => {
694+
observer.disconnect();
695+
if (typeof window !== "undefined" && resizeEvaluationRafRef.current !== null) {
696+
cancelAnimationFrame(resizeEvaluationRafRef.current);
697+
resizeEvaluationRafRef.current = null;
698+
}
699+
};
653700
}, [evaluateLayout]);
654701

655702
const slashMenuVisible = commandQuery !== null && commandItems.length > 0;
@@ -972,6 +1019,22 @@ export namespace CopilotChatInput {
9721019

9731020
useImperativeHandle(ref, () => internalTextareaRef.current as HTMLTextAreaElement);
9741021

1022+
// Auto-scroll input into view on mobile when focused
1023+
useEffect(() => {
1024+
const textarea = internalTextareaRef.current;
1025+
if (!textarea) return;
1026+
1027+
const handleFocus = () => {
1028+
// Small delay to let the keyboard start appearing
1029+
setTimeout(() => {
1030+
textarea.scrollIntoView({ behavior: "smooth", block: "nearest" });
1031+
}, 300);
1032+
};
1033+
1034+
textarea.addEventListener("focus", handleFocus);
1035+
return () => textarea.removeEventListener("focus", handleFocus);
1036+
}, []);
1037+
9751038
useEffect(() => {
9761039
if (autoFocus) {
9771040
internalTextareaRef.current?.focus();

packages/react/src/components/chat/CopilotChatSuggestionPill.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export interface CopilotChatSuggestionPillProps
1111
}
1212

1313
const baseClasses =
14-
"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";
14+
"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";
1515

1616
const labelClasses = "whitespace-nowrap font-medium leading-none";
1717

@@ -35,12 +35,12 @@ export const CopilotChatSuggestionPill = React.forwardRef<
3535
{...props}
3636
>
3737
{isLoading ? (
38-
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
39-
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
38+
<span className="flex h-3.5 sm:h-4 w-3.5 sm:w-4 items-center justify-center text-muted-foreground">
39+
<Loader2 className="h-3.5 sm:h-4 w-3.5 sm:w-4 animate-spin" aria-hidden="true" />
4040
</span>
4141
) : (
4242
showIcon && (
43-
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">{icon}</span>
43+
<span className="flex h-3.5 sm:h-4 w-3.5 sm:w-4 items-center justify-center text-muted-foreground">{icon}</span>
4444
)
4545
)}
4646
<span className={labelClasses}>{children}</span>

packages/react/src/components/chat/CopilotChatSuggestionView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const DefaultContainer = React.forwardRef<
1414
<div
1515
ref={ref}
1616
className={cn(
17-
"flex flex-wrap items-center gap-2 px-4 sm:px-0 pointer-events-none",
17+
"flex flex-wrap items-center gap-1.5 sm:gap-2 pl-0 pr-4 sm:px-0 pointer-events-none",
1818
className,
1919
)}
2020
{...props}

packages/react/src/components/chat/CopilotChatView.tsx

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ChevronDown } from "lucide-react";
1111
import { Button } from "@/components/ui/button";
1212
import { cn } from "@/lib/utils";
1313
import { useCopilotChatConfiguration, CopilotChatDefaultLabels } from "@/providers/CopilotChatConfigurationProvider";
14+
import { useKeyboardHeight } from "@/hooks/use-keyboard-height";
1415

1516
export type CopilotChatViewProps = WithSlots<
1617
{
@@ -59,6 +60,9 @@ export function CopilotChatView({
5960
const [isResizing, setIsResizing] = useState(false);
6061
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
6162

63+
// Track keyboard state for mobile
64+
const { isKeyboardOpen, keyboardHeight, availableHeight } = useKeyboardHeight();
65+
6266
// Track input container height changes
6367
useEffect(() => {
6468
const element = inputContainerRef.current;
@@ -132,7 +136,7 @@ export function CopilotChatView({
132136
<div style={{ paddingBottom: `${inputContainerHeight + (hasSuggestions ? 4 : 32)}px` }}>
133137
<div className="max-w-3xl mx-auto">
134138
{BoundMessageView}
135-
{hasSuggestions ? <div className="px-4 sm:px-0 mt-4">{BoundSuggestionView}</div> : null}
139+
{hasSuggestions ? <div className="pl-0 pr-4 sm:px-0 mt-4">{BoundSuggestionView}</div> : null}
136140
</div>
137141
</div>
138142
),
@@ -144,6 +148,7 @@ export function CopilotChatView({
144148

145149
const BoundInputContainer = renderSlot(inputContainer, CopilotChatView.InputContainer, {
146150
ref: inputContainerRef,
151+
keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
147152
children: (
148153
<>
149154
<div className="max-w-3xl mx-auto py-0 px-4 sm:px-0 [div[data-sidebar-chat]_&]:px-8 [div[data-popup-chat]_&]:px-6 pointer-events-auto">
@@ -354,9 +359,18 @@ export namespace CopilotChatView {
354359

355360
export const InputContainer = React.forwardRef<
356361
HTMLDivElement,
357-
React.HTMLAttributes<HTMLDivElement> & { children: React.ReactNode }
358-
>(({ children, className, ...props }, ref) => (
359-
<div ref={ref} className={cn("absolute bottom-0 left-0 right-0 z-20 pointer-events-none", className)} {...props}>
362+
React.HTMLAttributes<HTMLDivElement> & { children: React.ReactNode; keyboardHeight?: number }
363+
>(({ children, className, keyboardHeight = 0, ...props }, ref) => (
364+
<div
365+
ref={ref}
366+
className={cn("absolute bottom-0 left-0 right-0 z-20 pointer-events-none", className)}
367+
style={{
368+
// Adjust position when keyboard is open to keep input visible
369+
transform: keyboardHeight > 0 ? `translateY(-${keyboardHeight}px)` : undefined,
370+
transition: "transform 0.2s ease-out",
371+
}}
372+
{...props}
373+
>
360374
{children}
361375
</div>
362376
));

packages/react/src/components/chat/CopilotPopupView.tsx

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,33 +137,57 @@ export function CopilotPopupView({
137137
const resolvedWidth = dimensionToCss(width, DEFAULT_POPUP_WIDTH);
138138
const resolvedHeight = dimensionToCss(height, DEFAULT_POPUP_HEIGHT);
139139

140+
const popupStyle = useMemo(
141+
() =>
142+
({
143+
"--copilot-popup-width": resolvedWidth,
144+
"--copilot-popup-height": resolvedHeight,
145+
"--copilot-popup-max-width": "calc(100vw - 3rem)",
146+
"--copilot-popup-max-height": "calc(100dvh - 7.5rem)",
147+
paddingTop: "env(safe-area-inset-top)",
148+
paddingBottom: "env(safe-area-inset-bottom)",
149+
paddingLeft: "env(safe-area-inset-left)",
150+
paddingRight: "env(safe-area-inset-right)",
151+
}) as React.CSSProperties,
152+
[resolvedHeight, resolvedWidth],
153+
);
154+
155+
const popupAnimationClass =
156+
isPopupOpen && !isAnimatingOut
157+
? "pointer-events-auto translate-y-0 opacity-100 md:scale-100"
158+
: "pointer-events-none translate-y-4 opacity-0 md:translate-y-5 md:scale-[0.95]";
159+
140160
const popupContent = isRendered ? (
141-
<div className="fixed bottom-24 right-6 z-[1200] flex max-w-full flex-col items-end gap-4">
161+
<div
162+
className={cn(
163+
"fixed inset-0 z-[1200] flex max-w-full flex-col items-stretch",
164+
"md:inset-auto md:bottom-24 md:right-6 md:items-end md:gap-4",
165+
)}
166+
>
142167
<div
143168
ref={containerRef}
144169
tabIndex={-1}
145170
role="dialog"
146171
aria-label={labels.modalHeaderTitle}
147172
data-copilot-popup
148173
className={cn(
149-
"relative flex max-w-lg flex-col overflow-hidden rounded-2xl border border-border",
150-
"bg-background text-foreground shadow-xl ring-1 ring-border/40",
151-
"focus:outline-none transition-all duration-200 ease-out",
152-
isPopupOpen && !isAnimatingOut
153-
? "pointer-events-auto translate-y-0 scale-100 opacity-100"
154-
: "pointer-events-none translate-y-5 scale-[0.95] opacity-0",
174+
"relative flex h-full w-full flex-col overflow-hidden bg-background text-foreground",
175+
"origin-bottom focus:outline-none transform-gpu transition-transform transition-opacity duration-200 ease-out",
176+
"md:transition-transform md:transition-opacity",
177+
"rounded-none border border-border/0 shadow-none ring-0",
178+
"md:h-[var(--copilot-popup-height)] md:w-[var(--copilot-popup-width)]",
179+
"md:max-h-[var(--copilot-popup-max-height)] md:max-w-[var(--copilot-popup-max-width)]",
180+
"md:origin-bottom-right md:rounded-2xl md:border-border md:shadow-xl md:ring-1 md:ring-border/40",
181+
popupAnimationClass,
155182
)}
156-
style={{
157-
width: resolvedWidth,
158-
maxWidth: "calc(100vw - 3rem)",
159-
height: resolvedHeight,
160-
maxHeight: "calc(100dvh - 7.5rem)",
161-
transformOrigin: "bottom right",
162-
}}
183+
style={popupStyle}
163184
>
164185
{headerElement}
165186
<div className="flex-1 overflow-hidden" data-popup-chat>
166-
<CopilotChatView {...restProps} className={cn("h-full", className)} />
187+
<CopilotChatView
188+
{...restProps}
189+
className={cn("h-full min-h-0", className)}
190+
/>
167191
</div>
168192
</div>
169193
</div>

packages/react/src/components/chat/CopilotSidebarView.tsx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,13 @@ export function CopilotSidebarView({ header, width, ...props }: CopilotSidebarVi
7878
{isSidebarOpen && (
7979
<style
8080
dangerouslySetInnerHTML={{
81-
__html: `body {
82-
margin-inline-end: ${widthToMargin(sidebarWidth)};
83-
transition: margin-inline-end ${SIDEBAR_TRANSITION_MS}ms ease;
84-
}`,
81+
__html: `
82+
@media (min-width: 768px) {
83+
body {
84+
margin-inline-end: ${widthToMargin(sidebarWidth)};
85+
transition: margin-inline-end ${SIDEBAR_TRANSITION_MS}ms ease;
86+
}
87+
}`,
8588
}}
8689
/>
8790
)}
@@ -90,12 +93,22 @@ export function CopilotSidebarView({ header, width, ...props }: CopilotSidebarVi
9093
ref={sidebarRef}
9194
data-copilot-sidebar
9295
className={cn(
93-
"fixed right-0 top-0 z-[1200] flex h-dvh max-h-screen",
96+
"fixed right-0 top-0 z-[1200] flex",
97+
// Height with dvh fallback and safe area support
98+
"h-[100vh] h-[100dvh] max-h-screen",
99+
// Responsive width: full on mobile, custom on desktop
100+
"w-full",
94101
"border-l border-border bg-background text-foreground shadow-xl",
95102
"transition-transform duration-300 ease-out",
96103
isSidebarOpen ? "translate-x-0" : "translate-x-full pointer-events-none",
97104
)}
98-
style={{ width: widthToCss(sidebarWidth) }}
105+
style={{
106+
// Use CSS custom property for responsive width
107+
["--sidebar-width" as string]: widthToCss(sidebarWidth),
108+
// Safe area insets for iOS
109+
paddingTop: "env(safe-area-inset-top)",
110+
paddingBottom: "env(safe-area-inset-bottom)",
111+
} as React.CSSProperties}
99112
aria-hidden={!isSidebarOpen}
100113
aria-label="Copilot chat sidebar"
101114
role="complementary"
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useState, useEffect } from "react";
2+
3+
export interface KeyboardState {
4+
isKeyboardOpen: boolean;
5+
keyboardHeight: number;
6+
availableHeight: number;
7+
viewportHeight: number;
8+
}
9+
10+
/**
11+
* Hook to detect mobile keyboard appearance and calculate available viewport height.
12+
* Uses the Visual Viewport API to track keyboard state on mobile devices.
13+
*
14+
* @returns KeyboardState object with keyboard information
15+
*/
16+
export function useKeyboardHeight(): KeyboardState {
17+
const [keyboardState, setKeyboardState] = useState<KeyboardState>({
18+
isKeyboardOpen: false,
19+
keyboardHeight: 0,
20+
availableHeight: typeof window !== "undefined" ? window.innerHeight : 0,
21+
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
22+
});
23+
24+
useEffect(() => {
25+
if (typeof window === "undefined") {
26+
return;
27+
}
28+
29+
// Check if Visual Viewport API is available
30+
const visualViewport = window.visualViewport;
31+
if (!visualViewport) {
32+
return;
33+
}
34+
35+
const updateKeyboardState = () => {
36+
const layoutHeight = window.innerHeight;
37+
const visualHeight = visualViewport.height;
38+
39+
// Calculate keyboard height (difference between layout and visual viewport)
40+
const keyboardHeight = Math.max(0, layoutHeight - visualHeight);
41+
42+
// Keyboard is considered open if the height difference is significant (> 150px)
43+
const isKeyboardOpen = keyboardHeight > 150;
44+
45+
setKeyboardState({
46+
isKeyboardOpen,
47+
keyboardHeight,
48+
availableHeight: visualHeight,
49+
viewportHeight: layoutHeight,
50+
});
51+
};
52+
53+
// Initial state
54+
updateKeyboardState();
55+
56+
// Listen for viewport changes
57+
visualViewport.addEventListener("resize", updateKeyboardState);
58+
visualViewport.addEventListener("scroll", updateKeyboardState);
59+
60+
return () => {
61+
visualViewport.removeEventListener("resize", updateKeyboardState);
62+
visualViewport.removeEventListener("scroll", updateKeyboardState);
63+
};
64+
}, []);
65+
66+
return keyboardState;
67+
}

0 commit comments

Comments
 (0)