Skip to content
Open
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
16 changes: 16 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);

--animate-floating-action: floatingAction 2s ease-in-out infinite;

@keyframes floatingAction {
0% {
transform: translateY(calc(-4px*1));
}

50% {
transform: translateY(calc(4px*1));
}

to {
transform: translateY(calc(-4px*1));
}
}
}

/*
Expand Down
4 changes: 3 additions & 1 deletion components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { MultimodalInput } from "./multimodal-input";
import { getChatHistoryPaginationKey } from "./sidebar-history";
import { toast } from "./toast";
import type { VisibilityType } from "./visibility-selector";
import { ScrollToBottomProvider } from "@/hooks/use-scroll-to-bottom";

export function Chat({
id,
Expand Down Expand Up @@ -156,6 +157,7 @@ export function Chat({

return (
<>
<ScrollToBottomProvider>
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
<ChatHeader
chatId={id}
Expand Down Expand Up @@ -214,7 +216,7 @@ export function Chat({
stop={stop}
votes={votes}
/>

</ScrollToBottomProvider>
<AlertDialog
onOpenChange={setShowCreditCardAlert}
open={showCreditCardAlert}
Expand Down
152 changes: 102 additions & 50 deletions components/elements/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type {
HTMLAttributes,
KeyboardEventHandler,
} from "react";
import { Children } from "react";
import { Children, useCallback, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Select,
Expand All @@ -18,6 +18,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import React from "react";

export type PromptInputProps = HTMLAttributes<HTMLFormElement>;

Expand All @@ -38,60 +39,111 @@ export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
resizeOnNewLinesOnly?: boolean;
};

export const PromptInputTextarea = ({
onChange,
className,
placeholder = "What would you like to know?",
minHeight = 48,
maxHeight = 164,
disableAutoResize = false,
resizeOnNewLinesOnly = false,
...props
}: PromptInputTextareaProps) => {
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
}

if (e.shiftKey) {
// Allow newline
return;
export const PromptInputTextarea = React.forwardRef<
HTMLTextAreaElement,
PromptInputTextareaProps
>(
(
{
onChange,
className,
placeholder = "Hi, there! How can I help you today?",
minHeight = 48,
maxHeight = 164,
disableAutoResize = false,
resizeOnNewLinesOnly = false,
...props
},
forwardedRef
) => {
const internalRef = useRef<HTMLTextAreaElement>(null);
const textareaRef =
(forwardedRef as React.RefObject<HTMLTextAreaElement>) || internalRef;
const prevLineCountRef = useRef<number>(0);

const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (!textarea || disableAutoResize) return;

// Reset height to auto to get the correct scrollHeight
textarea.style.height = "auto";
const scrollHeight = textarea.scrollHeight;

const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
textarea.style.height = `${newHeight}px`;
}, [disableAutoResize, maxHeight, minHeight, textareaRef]);

useEffect(() => {
adjustHeight();
}, [disableAutoResize, minHeight, maxHeight]);

useEffect(() => {
if (disableAutoResize) return;

const currentValue = props.value?.toString() || "";

if (resizeOnNewLinesOnly) {
const currentLineCount = (currentValue.match(/\n/g) || []).length;
if (currentLineCount !== prevLineCountRef.current) {
adjustHeight();
prevLineCountRef.current = currentLineCount;
}
} else {
adjustHeight();
}
}, [props.value, disableAutoResize, resizeOnNewLinesOnly]);

const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange?.(e);
};

// Submit on Enter (without Shift)
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
if (e.key === "Enter") {
// Don't submit if IME composition is in progress
if (e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
// Allow newline
return;
}
e.preventDefault();
const form = e.currentTarget.form;
if (form) {
form.requestSubmit();
}
}
}
};
};

return (
<Textarea
className={cn(
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0",
disableAutoResize
? "field-sizing-fixed"
: resizeOnNewLinesOnly
? "field-sizing-fixed"
: "field-sizing-content max-h-[6lh]",
"bg-transparent dark:bg-transparent",
"focus-visible:ring-0",
className
)}
name="message"
onChange={(e) => {
onChange?.(e);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
};
return (
<Textarea
ref={textareaRef}
className={cn(
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0",
"bg-transparent dark:bg-transparent",
"focus-visible:ring-0",
disableAutoResize && "field-sizing-content max-h-[6lh]",
className
)}
style={
disableAutoResize
? undefined
: {
minHeight: `${minHeight}px`,
maxHeight: `${maxHeight}px`,
}
}
name="message"
onChange={handleChange}
onKeyDown={handleKeyDown}
placeholder={placeholder}
{...props}
/>
);
}
);
PromptInputTextarea.displayName = "PromptInputTextarea";

export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;

Expand Down
14 changes: 1 addition & 13 deletions components/messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,10 @@ function PureMessages({
const {
containerRef: messagesContainerRef,
endRef: messagesEndRef,
isAtBottom,
scrollToBottom,
hasSentMessage,
} = useMessages({
status,
useScrollContext: true,
});

useDataStream();
Expand Down Expand Up @@ -101,17 +100,6 @@ function PureMessages({
/>
</ConversationContent>
</Conversation>

{!isAtBottom && (
<button
aria-label="Scroll to bottom"
className="-translate-x-1/2 absolute bottom-40 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-colors hover:bg-muted"
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
)}
</div>
);
}
Expand Down
38 changes: 16 additions & 22 deletions components/multimodal-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ import { PreviewAttachment } from "./preview-attachment";
import { SuggestedActions } from "./suggested-actions";
import { Button } from "./ui/button";
import type { VisibilityType } from "./visibility-selector";
import { ArrowDownIcon } from "lucide-react";
import { useScrollToBottomPersist } from "@/hooks/use-scroll-to-bottom";

function PureMultimodalInput({
chatId,
Expand Down Expand Up @@ -82,24 +84,7 @@ function PureMultimodalInput({
}) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { width } = useWindowSize();

const adjustHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);

useEffect(() => {
if (textareaRef.current) {
adjustHeight();
}
}, [adjustHeight]);

const resetHeight = useCallback(() => {
if (textareaRef.current) {
textareaRef.current.style.height = "44px";
}
}, []);
const { isAtBottom, scrollToBottom } = useScrollToBottomPersist(true);

const [localStorageInput, setLocalStorageInput] = useLocalStorage(
"input",
Expand All @@ -112,11 +97,10 @@ function PureMultimodalInput({
// Prefer DOM value over localStorage to handle hydration
const finalValue = domValue || localStorageInput || "";
setInput(finalValue);
adjustHeight();
}
// Only run once after hydration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [adjustHeight, localStorageInput, setInput]);
}, [localStorageInput, setInput]);

useEffect(() => {
setLocalStorageInput(input);
Expand Down Expand Up @@ -150,7 +134,6 @@ function PureMultimodalInput({

setAttachments([]);
setLocalStorageInput("");
resetHeight();
setInput("");

if (width && width > 768) {
Expand All @@ -165,7 +148,6 @@ function PureMultimodalInput({
setLocalStorageInput,
width,
chatId,
resetHeight,
]);

const uploadFile = useCallback(async (file: File) => {
Expand Down Expand Up @@ -288,6 +270,18 @@ function PureMultimodalInput({

return (
<div className={cn("relative flex w-full flex-col gap-4", className)}>

{!isAtBottom && (
<button
aria-label="Scroll to bottom"
className="animate-floating-action mx-auto cursor-pointer -translate-x-1/2 -translate-y-14 absolute bottom-[inherit] left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-colors hover:bg-muted"
onClick={() => scrollToBottom("smooth")}
type="button"
>
<ArrowDownIcon className="size-4" />
</button>
)}

{messages.length === 0 &&
attachments.length === 0 &&
uploadQueue.length === 0 && (
Expand Down
6 changes: 4 additions & 2 deletions hooks/use-messages.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { UseChatHelpers } from "@ai-sdk/react";
import { useEffect, useState } from "react";
import type { ChatMessage } from "@/lib/types";
import { useScrollToBottom } from "./use-scroll-to-bottom";
import { useScrollToBottomPersist } from "./use-scroll-to-bottom";

export function useMessages({
status,
useScrollContext = false,
}: {
status: UseChatHelpers<ChatMessage>["status"];
useScrollContext?: boolean;
}) {
const {
containerRef,
Expand All @@ -15,7 +17,7 @@ export function useMessages({
scrollToBottom,
onViewportEnter,
onViewportLeave,
} = useScrollToBottom();
} = useScrollToBottomPersist(useScrollContext);

const [hasSentMessage, setHasSentMessage] = useState(false);

Expand Down
Loading