feat(app):

Adds auto scrolling.
Adds syntax highlighting to code blocks.
This commit is contained in:
Willie Zutz
2025-05-03 16:03:04 -06:00
parent c8def1989a
commit 8241c87784
6 changed files with 1383 additions and 171 deletions

View File

@ -5,12 +5,13 @@ import MessageInput from './MessageInput';
import { File, Message } from './ChatWindow';
import MessageBox from './MessageBox';
import MessageBoxLoading from './MessageBoxLoading';
import { check } from 'drizzle-orm/gel-core';
const Chat = ({
loading,
messages,
sendMessage,
messageAppeared,
scrollTrigger,
rewrite,
fileIds,
setFileIds,
@ -29,7 +30,7 @@ const Chat = ({
},
) => void;
loading: boolean;
messageAppeared: boolean;
scrollTrigger: number;
rewrite: (messageId: string) => void;
fileIds: string[];
setFileIds: (fileIds: string[]) => void;
@ -39,8 +40,72 @@ const Chat = ({
setOptimizationMode: (mode: string) => void;
}) => {
const [dividerWidth, setDividerWidth] = useState(0);
const [isAtBottom, setIsAtBottom] = useState(true);
const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false);
const dividerRef = useRef<HTMLDivElement | null>(null);
const messageEnd = useRef<HTMLDivElement | null>(null);
const SCROLL_THRESHOLD = 200; // pixels from bottom to consider "at bottom"
// Check if user is at bottom of page
useEffect(() => {
const checkIsAtBottom = () => {
const position = window.innerHeight + window.scrollY;
const height = document.body.scrollHeight;
const atBottom = position >= height - SCROLL_THRESHOLD;
setIsAtBottom(atBottom);
};
// Initial check
checkIsAtBottom();
// Add scroll event listener
window.addEventListener('scroll', checkIsAtBottom);
return () => {
window.removeEventListener('scroll', checkIsAtBottom);
};
}, []);
// Detect wheel and touch events to identify user's scrolling direction
useEffect(() => {
const checkIsAtBottom = () => {
const position = window.innerHeight + window.scrollY;
const height = document.body.scrollHeight;
const atBottom = position >= height - SCROLL_THRESHOLD;
// If user scrolls to bottom, reset the manuallyScrolledUp flag
if (atBottom) {
setManuallyScrolledUp(false);
}
setIsAtBottom(atBottom);
};
const handleWheel = (e: WheelEvent) => {
// Positive deltaY means scrolling down, negative means scrolling up
if (e.deltaY < 0) {
// User is scrolling up
setManuallyScrolledUp(true);
} else if (e.deltaY > 0) {
checkIsAtBottom();
}
};
const handleTouchStart = (e: TouchEvent) => {
// Immediately stop auto-scrolling on any touch interaction
setManuallyScrolledUp(true);
};
// Add event listeners
window.addEventListener('wheel', handleWheel, { passive: true });
window.addEventListener('touchstart', handleTouchStart, { passive: true });
return () => {
window.removeEventListener('wheel', handleWheel);
window.removeEventListener('touchstart', handleTouchStart);
};
}, [isAtBottom]);
useEffect(() => {
const updateDividerWidth = () => {
@ -58,6 +123,7 @@ const Chat = ({
};
});
// Scroll when user sends a message
useEffect(() => {
const scroll = () => {
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
@ -67,11 +133,27 @@ const Chat = ({
document.title = `${messages[0].content.substring(0, 30)} - Perplexica`;
}
if (messages[messages.length - 1]?.role == 'user') {
// Always scroll when user sends a message
if (messages[messages.length - 1]?.role === 'user') {
scroll();
setIsAtBottom(true); // Reset to true when user sends a message
setManuallyScrolledUp(false); // Reset manually scrolled flag when user sends a message
}
}, [messages]);
// Auto-scroll for assistant responses only if user is at bottom and hasn't manually scrolled up
useEffect(() => {
const position = window.innerHeight + window.scrollY;
const height = document.body.scrollHeight;
const atBottom = position >= height - SCROLL_THRESHOLD;
console.log('scrollTrigger', scrollTrigger);
setIsAtBottom(atBottom);
if (isAtBottom && !manuallyScrolledUp && messages.length > 0) {
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [scrollTrigger, isAtBottom, messages.length, manuallyScrolledUp]);
return (
<div className="flex flex-col space-y-6 pt-8 pb-44 lg:pb-32 sm:mx-4 md:mx-8">
{messages.map((msg, i) => {
@ -96,13 +178,44 @@ const Chat = ({
</Fragment>
);
})}
{loading && !messageAppeared && <MessageBoxLoading />}
{loading && <MessageBoxLoading />}
<div ref={messageEnd} className="h-0" />
{dividerWidth > 0 && (
<div
className="bottom-24 lg:bottom-10 fixed z-40"
style={{ width: dividerWidth }}
>
{/* Scroll to bottom button - appears above the MessageInput when user has scrolled up */}
{manuallyScrolledUp && !isAtBottom && (
<div className="absolute -top-14 right-2 z-10">
<button
onClick={() => {
setManuallyScrolledUp(false);
setIsAtBottom(true);
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
}}
className="bg-[#24A0ED] text-white hover:bg-opacity-85 transition duration-100 rounded-full px-4 py-2 shadow-lg flex items-center justify-center"
aria-label="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 mr-1"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z"
clipRule="evenodd"
transform="rotate(180 10 10)"
/>
</svg>
<span className="text-sm">Scroll to bottom</span>
</button>
</div>
)}
<MessageInput
loading={loading}
sendMessage={sendMessage}

View File

@ -278,7 +278,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
}, []);
const [loading, setLoading] = useState(false);
const [messageAppeared, setMessageAppeared] = useState(false);
const [scrollTrigger, setScrollTrigger] = useState(0);
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
const [messages, setMessages] = useState<Message[]>([]);
@ -351,6 +351,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
suggestions?: string[];
},
) => {
setScrollTrigger((x) => (x === 0 ? -1 : 0));
// Special case: If we're just updating an existing message with suggestions
if (options?.suggestions && options.messageId) {
setMessages((prev) =>
@ -371,7 +372,6 @@ const ChatWindow = ({ id }: { id?: string }) => {
}
setLoading(true);
setMessageAppeared(false);
let sources: Document[] | undefined = undefined;
let recievedMessage = '';
@ -389,6 +389,8 @@ const ChatWindow = ({ id }: { id?: string }) => {
messages.length > 2 ? rewriteIndex - 1 : 0,
);
setChatHistory(messageChatHistory);
setScrollTrigger((prev) => prev + 1);
}
const messageId =
@ -427,8 +429,8 @@ const ChatWindow = ({ id }: { id?: string }) => {
},
]);
added = true;
setScrollTrigger((prev) => prev + 1);
}
setMessageAppeared(true);
}
if (data.type === 'message') {
@ -461,7 +463,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
);
recievedMessage += data.data;
setMessageAppeared(true);
setScrollTrigger((prev) => prev + 1);
}
if (data.type === 'messageEnd') {
@ -486,6 +488,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
);
setLoading(false);
setScrollTrigger((prev) => prev + 1);
const lastMsg = messagesRef.current[messagesRef.current.length - 1];
@ -634,7 +637,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
loading={loading}
messages={messages}
sendMessage={sendMessage}
messageAppeared={messageAppeared}
scrollTrigger={scrollTrigger}
rewrite={rewrite}
fileIds={fileIds}
setFileIds={setFileIds}

View File

@ -13,6 +13,8 @@ import {
Layers3,
Plus,
Sparkles,
Copy as CopyIcon,
CheckCheck,
} from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import Copy from './MessageActions/Copy';
@ -23,11 +25,79 @@ import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos';
import { useSpeech } from 'react-text-to-speech';
import ThinkBox from './ThinkBox';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
return <ThinkBox content={children as string} />;
};
const CodeBlock = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
// Extract language from className (format could be "language-javascript" or "lang-javascript")
let language = '';
if (className) {
if (className.startsWith('language-')) {
language = className.replace('language-', '');
} else if (className.startsWith('lang-')) {
language = className.replace('lang-', '');
}
}
const content = children as string;
const [isCopied, setIsCopied] = useState(false);
const handleCopyCode = () => {
navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
console.log('Code block language:', language, 'Class name:', className); // For debugging
return (
<div className="rounded-md overflow-hidden my-4 relative group border border-dark-secondary">
<div className="flex justify-between items-center px-4 py-2 bg-dark-200 border-b border-dark-secondary text-xs text-white/70 font-mono">
<span>{language}</span>
<button
onClick={handleCopyCode}
className="p-1 rounded-md hover:bg-dark-secondary transition duration-200"
aria-label="Copy code to clipboard"
>
{isCopied ? (
<CheckCheck size={14} className="text-green-500" />
) : (
<CopyIcon size={14} className="text-white/70" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
customStyle={{
margin: 0,
padding: '1rem',
borderRadius: 0,
backgroundColor: '#1c1c1c',
}}
wrapLines={true}
wrapLongLines={true}
showLineNumbers={language !== '' && content.split('\n').length > 1}
useInlineStyles={true}
PreTag="div"
>
{content}
</SyntaxHighlighter>
</div>
);
};
const MessageBox = ({
message,
messageIndex,
@ -157,6 +227,24 @@ const MessageBox = ({
think: {
component: ThinkTagProcessor,
},
code: {
component: ({ className, children }) => {
// Check if it's an inline code block or a fenced code block
if (className) {
// This is a fenced code block (```code```)
return <CodeBlock className={className}>{children}</CodeBlock>;
}
// This is an inline code block (`code`)
return (
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
{children}
</code>
);
},
},
pre: {
component: ({ children }) => children,
},
},
};
@ -212,8 +300,10 @@ const MessageBox = ({
</div>
<Markdown
className={cn(
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] dark:prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'max-w-none break-words text-black dark:text-white',
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
'max-w-none break-words text-white',
)}
options={markdownOverrides}
>