Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 70 additions & 7 deletions packages/react/src/components/chat/CopilotChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number | null>(null);
const isExpanded = mode === "input" && layout === "expanded";
const [commandQuery, setCommandQuery] = useState<string | null>(null);
const [slashHighlightIndex, setSlashHighlightIndex] = useState(0);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -640,16 +660,43 @@ 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);
observer.observe(addContainer);
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;
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -35,12 +35,12 @@ export const CopilotChatSuggestionPill = React.forwardRef<
{...props}
>
{isLoading ? (
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span className="flex h-3.5 sm:h-4 w-3.5 sm:w-4 items-center justify-center text-muted-foreground">
<Loader2 className="h-3.5 sm:h-4 w-3.5 sm:w-4 animate-spin" aria-hidden="true" />
</span>
) : (
showIcon && (
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">{icon}</span>
<span className="flex h-3.5 sm:h-4 w-3.5 sm:w-4 items-center justify-center text-muted-foreground">{icon}</span>
)
)}
<span className={labelClasses}>{children}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const DefaultContainer = React.forwardRef<
<div
ref={ref}
className={cn(
"flex flex-wrap items-center gap-2 px-4 sm:px-0 pointer-events-none",
"flex flex-wrap items-center gap-1.5 sm:gap-2 pl-0 pr-4 sm:px-0 pointer-events-none",
className,
)}
{...props}
Expand Down
22 changes: 18 additions & 4 deletions packages/react/src/components/chat/CopilotChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { useCopilotChatConfiguration, CopilotChatDefaultLabels } from "@/providers/CopilotChatConfigurationProvider";
import { useKeyboardHeight } from "@/hooks/use-keyboard-height";

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

// Track keyboard state for mobile
const { isKeyboardOpen, keyboardHeight, availableHeight } = useKeyboardHeight();

// Track input container height changes
useEffect(() => {
const element = inputContainerRef.current;
Expand Down Expand Up @@ -132,7 +136,7 @@ export function CopilotChatView({
<div style={{ paddingBottom: `${inputContainerHeight + (hasSuggestions ? 4 : 32)}px` }}>
<div className="max-w-3xl mx-auto">
{BoundMessageView}
{hasSuggestions ? <div className="px-4 sm:px-0 mt-4">{BoundSuggestionView}</div> : null}
{hasSuggestions ? <div className="pl-0 pr-4 sm:px-0 mt-4">{BoundSuggestionView}</div> : null}
</div>
</div>
),
Expand All @@ -144,6 +148,7 @@ export function CopilotChatView({

const BoundInputContainer = renderSlot(inputContainer, CopilotChatView.InputContainer, {
ref: inputContainerRef,
keyboardHeight: isKeyboardOpen ? keyboardHeight : 0,
children: (
<>
<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">
Expand Down Expand Up @@ -354,9 +359,18 @@ export namespace CopilotChatView {

export const InputContainer = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { children: React.ReactNode }
>(({ children, className, ...props }, ref) => (
<div ref={ref} className={cn("absolute bottom-0 left-0 right-0 z-20 pointer-events-none", className)} {...props}>
React.HTMLAttributes<HTMLDivElement> & { children: React.ReactNode; keyboardHeight?: number }
>(({ children, className, keyboardHeight = 0, ...props }, ref) => (
<div
ref={ref}
className={cn("absolute bottom-0 left-0 right-0 z-20 pointer-events-none", className)}
style={{
// Adjust position when keyboard is open to keep input visible
transform: keyboardHeight > 0 ? `translateY(-${keyboardHeight}px)` : undefined,
transition: "transform 0.2s ease-out",
}}
{...props}
>
{children}
</div>
));
Expand Down
54 changes: 39 additions & 15 deletions packages/react/src/components/chat/CopilotPopupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,33 +137,57 @@ export function CopilotPopupView({
const resolvedWidth = dimensionToCss(width, DEFAULT_POPUP_WIDTH);
const resolvedHeight = dimensionToCss(height, DEFAULT_POPUP_HEIGHT);

const popupStyle = useMemo(
() =>
({
"--copilot-popup-width": resolvedWidth,
"--copilot-popup-height": resolvedHeight,
"--copilot-popup-max-width": "calc(100vw - 3rem)",
"--copilot-popup-max-height": "calc(100dvh - 7.5rem)",
paddingTop: "env(safe-area-inset-top)",
paddingBottom: "env(safe-area-inset-bottom)",
paddingLeft: "env(safe-area-inset-left)",
paddingRight: "env(safe-area-inset-right)",
}) as React.CSSProperties,
[resolvedHeight, resolvedWidth],
);

const popupAnimationClass =
isPopupOpen && !isAnimatingOut
? "pointer-events-auto translate-y-0 opacity-100 md:scale-100"
: "pointer-events-none translate-y-4 opacity-0 md:translate-y-5 md:scale-[0.95]";

const popupContent = isRendered ? (
<div className="fixed bottom-24 right-6 z-[1200] flex max-w-full flex-col items-end gap-4">
<div
className={cn(
"fixed inset-0 z-[1200] flex max-w-full flex-col items-stretch",
"md:inset-auto md:bottom-24 md:right-6 md:items-end md:gap-4",
)}
>
<div
ref={containerRef}
tabIndex={-1}
role="dialog"
aria-label={labels.modalHeaderTitle}
data-copilot-popup
className={cn(
"relative flex max-w-lg flex-col overflow-hidden rounded-2xl border border-border",
"bg-background text-foreground shadow-xl ring-1 ring-border/40",
"focus:outline-none transition-all duration-200 ease-out",
isPopupOpen && !isAnimatingOut
? "pointer-events-auto translate-y-0 scale-100 opacity-100"
: "pointer-events-none translate-y-5 scale-[0.95] opacity-0",
"relative flex h-full w-full flex-col overflow-hidden bg-background text-foreground",
"origin-bottom focus:outline-none transform-gpu transition-transform transition-opacity duration-200 ease-out",
"md:transition-transform md:transition-opacity",
"rounded-none border border-border/0 shadow-none ring-0",
"md:h-[var(--copilot-popup-height)] md:w-[var(--copilot-popup-width)]",
"md:max-h-[var(--copilot-popup-max-height)] md:max-w-[var(--copilot-popup-max-width)]",
"md:origin-bottom-right md:rounded-2xl md:border-border md:shadow-xl md:ring-1 md:ring-border/40",
popupAnimationClass,
)}
style={{
width: resolvedWidth,
maxWidth: "calc(100vw - 3rem)",
height: resolvedHeight,
maxHeight: "calc(100dvh - 7.5rem)",
transformOrigin: "bottom right",
}}
style={popupStyle}
>
{headerElement}
<div className="flex-1 overflow-hidden" data-popup-chat>
<CopilotChatView {...restProps} className={cn("h-full", className)} />
<CopilotChatView
{...restProps}
className={cn("h-full min-h-0", className)}
/>
</div>
</div>
</div>
Expand Down
25 changes: 19 additions & 6 deletions packages/react/src/components/chat/CopilotSidebarView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ export function CopilotSidebarView({ header, width, ...props }: CopilotSidebarVi
{isSidebarOpen && (
<style
dangerouslySetInnerHTML={{
__html: `body {
margin-inline-end: ${widthToMargin(sidebarWidth)};
transition: margin-inline-end ${SIDEBAR_TRANSITION_MS}ms ease;
}`,
__html: `
@media (min-width: 768px) {
body {
margin-inline-end: ${widthToMargin(sidebarWidth)};
transition: margin-inline-end ${SIDEBAR_TRANSITION_MS}ms ease;
}
}`,
}}
/>
)}
Expand All @@ -90,12 +93,22 @@ export function CopilotSidebarView({ header, width, ...props }: CopilotSidebarVi
ref={sidebarRef}
data-copilot-sidebar
className={cn(
"fixed right-0 top-0 z-[1200] flex h-dvh max-h-screen",
"fixed right-0 top-0 z-[1200] flex",
// Height with dvh fallback and safe area support
"h-[100vh] h-[100dvh] max-h-screen",
// Responsive width: full on mobile, custom on desktop
"w-full",
"border-l border-border bg-background text-foreground shadow-xl",
"transition-transform duration-300 ease-out",
isSidebarOpen ? "translate-x-0" : "translate-x-full pointer-events-none",
)}
style={{ width: widthToCss(sidebarWidth) }}
style={{
// Use CSS custom property for responsive width
["--sidebar-width" as string]: widthToCss(sidebarWidth),
// Safe area insets for iOS
paddingTop: "env(safe-area-inset-top)",
paddingBottom: "env(safe-area-inset-bottom)",
} as React.CSSProperties}
aria-hidden={!isSidebarOpen}
aria-label="Copilot chat sidebar"
role="complementary"
Expand Down
67 changes: 67 additions & 0 deletions packages/react/src/hooks/use-keyboard-height.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { useState, useEffect } from "react";

export interface KeyboardState {
isKeyboardOpen: boolean;
keyboardHeight: number;
availableHeight: number;
viewportHeight: number;
}

/**
* Hook to detect mobile keyboard appearance and calculate available viewport height.
* Uses the Visual Viewport API to track keyboard state on mobile devices.
*
* @returns KeyboardState object with keyboard information
*/
export function useKeyboardHeight(): KeyboardState {
const [keyboardState, setKeyboardState] = useState<KeyboardState>({
isKeyboardOpen: false,
keyboardHeight: 0,
availableHeight: typeof window !== "undefined" ? window.innerHeight : 0,
viewportHeight: typeof window !== "undefined" ? window.innerHeight : 0,
});

useEffect(() => {
if (typeof window === "undefined") {
return;
}

// Check if Visual Viewport API is available
const visualViewport = window.visualViewport;
if (!visualViewport) {
return;
}

const updateKeyboardState = () => {
const layoutHeight = window.innerHeight;
const visualHeight = visualViewport.height;

// Calculate keyboard height (difference between layout and visual viewport)
const keyboardHeight = Math.max(0, layoutHeight - visualHeight);

// Keyboard is considered open if the height difference is significant (> 150px)
const isKeyboardOpen = keyboardHeight > 150;

setKeyboardState({
isKeyboardOpen,
keyboardHeight,
availableHeight: visualHeight,
viewportHeight: layoutHeight,
});
};

// Initial state
updateKeyboardState();

// Listen for viewport changes
visualViewport.addEventListener("resize", updateKeyboardState);
visualViewport.addEventListener("scroll", updateKeyboardState);

return () => {
visualViewport.removeEventListener("resize", updateKeyboardState);
visualViewport.removeEventListener("scroll", updateKeyboardState);
};
}, []);

return keyboardState;
}
Loading
Loading