feat(AIChatPanel): implement dynamic chat message font size and enhance user message scrolling behavior
This commit is contained in:
@@ -10,6 +10,9 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { GradualBlur } from '@/components/ui/gradual-blur';
|
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||||
|
|
||||||
|
/** Fluid font size for chat messages — scales with viewport width */
|
||||||
|
const CHAT_FONT = 'clamp(1.125rem, 1.4vw, 1.375rem)';
|
||||||
|
|
||||||
const SUGGESTION_CHIPS = [
|
const SUGGESTION_CHIPS = [
|
||||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||||
{ icon: TrendingUp, label: 'Summarize this week' },
|
{ icon: TrendingUp, label: 'Summarize this week' },
|
||||||
@@ -78,17 +81,46 @@ export function AIChatPanel({
|
|||||||
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
// --- Scroll-to-user-message + shrinking placeholder ---
|
||||||
|
const lastUserMsgRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [streamingEl, setStreamingEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
const [placeholderHeight, setPlaceholderHeight] = useState<number | null>(null);
|
||||||
|
const initialPlaceholderRef = useRef(0);
|
||||||
|
const pendingScrollRef = useRef(false);
|
||||||
|
|
||||||
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
// When the user message appears in the list, set the placeholder and scroll it to the top
|
||||||
const el = messagesContainerRef.current;
|
|
||||||
if (el) el.scrollTo({ top: el.scrollHeight });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Auto-scroll when messages change or streaming content updates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom();
|
if (!pendingScrollRef.current) return;
|
||||||
}, [messages, streamingContent, scrollToBottom]);
|
const lastMsg = messages[messages.length - 1];
|
||||||
|
if (!lastMsg || lastMsg.role !== 'user') return;
|
||||||
|
|
||||||
|
pendingScrollRef.current = false;
|
||||||
|
const ph = Math.round(window.innerHeight * 0.71);
|
||||||
|
initialPlaceholderRef.current = ph;
|
||||||
|
setPlaceholderHeight(ph);
|
||||||
|
|
||||||
|
// Double-rAF: wait for the placeholder div to actually paint before scrolling
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
lastUserMsgRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Shrink placeholder in real-time as AI streaming content grows
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming || !streamingEl) return;
|
||||||
|
const MIN_PADDING = 80;
|
||||||
|
const observer = new ResizeObserver(() => {
|
||||||
|
const contentHeight = streamingEl.getBoundingClientRect().height;
|
||||||
|
setPlaceholderHeight(Math.max(MIN_PADDING, initialPlaceholderRef.current - contentHeight));
|
||||||
|
});
|
||||||
|
observer.observe(streamingEl);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [isStreaming, streamingEl]);
|
||||||
|
|
||||||
|
|
||||||
// Auto-fire daily brief on home page
|
// Auto-fire daily brief on home page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -125,6 +157,7 @@ export function AIChatPanel({
|
|||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
if (briefLoading) return;
|
if (briefLoading) return;
|
||||||
|
pendingScrollRef.current = true;
|
||||||
chatHandleSend();
|
chatHandleSend();
|
||||||
}, [briefLoading, chatHandleSend]);
|
}, [briefLoading, chatHandleSend]);
|
||||||
|
|
||||||
@@ -324,12 +357,18 @@ export function AIChatPanel({
|
|||||||
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
|
<div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{/* Chat messages */}
|
{/* Chat messages */}
|
||||||
{messages.map((msg) => {
|
{messages.map((msg, idx) => {
|
||||||
|
const isLastMsg = idx === messages.length - 1;
|
||||||
|
|
||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className="flex justify-end">
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
ref={isLastMsg ? lastUserMsgRef : undefined}
|
||||||
|
className="flex justify-end"
|
||||||
|
>
|
||||||
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
|
||||||
<ChatMarkdown content={msg.content} />
|
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -338,7 +377,7 @@ export function AIChatPanel({
|
|||||||
if (msg.error) {
|
if (msg.error) {
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||||
<p className="text-sm text-destructive whitespace-pre-wrap">
|
<p style={{ fontSize: CHAT_FONT }} className="text-destructive whitespace-pre-wrap">
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -349,10 +388,10 @@ export function AIChatPanel({
|
|||||||
<div key={msg.id} className="mr-auto max-w-[75%]">
|
<div key={msg.id} className="mr-auto max-w-[75%]">
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Sparkles size={16} className="text-foreground" />
|
<Sparkles size={16} className="text-foreground" />
|
||||||
<span className="text-sm font-semibold">Adiuva</span>
|
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="pl-[22px]">
|
<div className="pl-[22px]">
|
||||||
<ChatMarkdown content={msg.content} />
|
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -360,14 +399,14 @@ export function AIChatPanel({
|
|||||||
|
|
||||||
{/* Streaming AI response */}
|
{/* Streaming AI response */}
|
||||||
{isStreaming && (
|
{isStreaming && (
|
||||||
<div className="mr-auto max-w-[75%]">
|
<div ref={setStreamingEl} className="mr-auto max-w-[75%]">
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<Sparkles size={16} className="text-foreground" />
|
<Sparkles size={16} className="text-foreground" />
|
||||||
<span className="text-sm font-semibold">Adiuva</span>
|
<span style={{ fontSize: CHAT_FONT }} className="font-semibold">Adiuva</span>
|
||||||
</div>
|
</div>
|
||||||
{streamingContent ? (
|
{streamingContent ? (
|
||||||
<div className="pl-[22px]">
|
<div className="pl-[22px]">
|
||||||
<ChatMarkdown content={streamingContent} />
|
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2 pl-[22px]">
|
<div className="space-y-2 pl-[22px]">
|
||||||
@@ -377,6 +416,18 @@ export function AIChatPanel({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Placeholder: fills viewport after user message, shrinks as AI responds */}
|
||||||
|
{placeholderHeight !== null && (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
height: placeholderHeight,
|
||||||
|
transition: 'height 180ms ease-out',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -446,9 +497,12 @@ function ChatInput({
|
|||||||
|
|
||||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||||
|
|
||||||
export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) {
|
export function ChatMarkdown({ content, size = 'sm', fontSize }: { content: string; size?: 'sm' | 'lg'; fontSize?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}>
|
<div
|
||||||
|
className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}
|
||||||
|
style={fontSize ? { fontSize } : undefined}
|
||||||
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
|
|||||||
Reference in New Issue
Block a user