feat(AIChatPanel): implement dynamic chat message font size and enhance user message scrolling behavior

This commit is contained in:
Roberto Musso
2026-03-01 00:21:57 +01:00
parent af8cbc1c96
commit d3e82a3ebb

View File

@@ -10,6 +10,9 @@ import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
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 = [
{ icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' },
@@ -78,17 +81,46 @@ export function AIChatPanel({
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 scrollToBottom = useCallback(() => {
const el = messagesContainerRef.current;
if (el) el.scrollTo({ top: el.scrollHeight });
}, []);
// Auto-scroll when messages change or streaming content updates
// When the user message appears in the list, set the placeholder and scroll it to the top
useEffect(() => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
if (!pendingScrollRef.current) return;
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
useEffect(() => {
@@ -125,6 +157,7 @@ export function AIChatPanel({
const handleSend = useCallback(() => {
if (briefLoading) return;
pendingScrollRef.current = true;
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="flex flex-col gap-4">
{/* Chat messages */}
{messages.map((msg) => {
{messages.map((msg, idx) => {
const isLastMsg = idx === messages.length - 1;
if (msg.role === 'user') {
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">
<ChatMarkdown content={msg.content} />
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
@@ -338,7 +377,7 @@ export function AIChatPanel({
if (msg.error) {
return (
<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}
</p>
</div>
@@ -349,10 +388,10 @@ export function AIChatPanel({
<div key={msg.id} className="mr-auto max-w-[75%]">
<div className="flex items-center gap-1.5 mb-1">
<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 className="pl-[22px]">
<ChatMarkdown content={msg.content} />
<ChatMarkdown content={msg.content} fontSize={CHAT_FONT} />
</div>
</div>
);
@@ -360,14 +399,14 @@ export function AIChatPanel({
{/* Streaming AI response */}
{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">
<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>
{streamingContent ? (
<div className="pl-[22px]">
<ChatMarkdown content={streamingContent} />
<ChatMarkdown content={streamingContent} fontSize={CHAT_FONT} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
@@ -377,6 +416,18 @@ export function AIChatPanel({
)}
</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>
)}
@@ -446,9 +497,12 @@ function ChatInput({
/* ---------- 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 (
<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
remarkPlugins={[remarkGfm]}
components={{