diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx index e8e2445..9957b2f 100644 --- a/src/renderer/components/ai/AIChatPanel.tsx +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -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(null); + // --- Scroll-to-user-message + shrinking placeholder --- + const lastUserMsgRef = useRef(null); + const [streamingEl, setStreamingEl] = useState(null); + const [placeholderHeight, setPlaceholderHeight] = useState(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({
{/* Chat messages */} - {messages.map((msg) => { + {messages.map((msg, idx) => { + const isLastMsg = idx === messages.length - 1; + if (msg.role === 'user') { return ( -
+
- +
); @@ -338,7 +377,7 @@ export function AIChatPanel({ if (msg.error) { return (
-

+

{msg.content}

@@ -349,10 +388,10 @@ export function AIChatPanel({
- Adiuva + Adiuva
- +
); @@ -360,14 +399,14 @@ export function AIChatPanel({ {/* Streaming AI response */} {isStreaming && ( -
+
- Adiuva + Adiuva
{streamingContent ? (
- +
) : (
@@ -377,6 +416,18 @@ export function AIChatPanel({ )}
)} + + {/* Placeholder: fills viewport after user message, shrinks as AI responds */} + {placeholderHeight !== null && ( +
+ )}
)} @@ -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 ( -
*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}> +
*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`} + style={fontSize ? { fontSize } : undefined} + >