From 444aa37be2ece3f2256f2d33a0e3708b501c05a3 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Sat, 28 Feb 2026 16:19:15 +0100 Subject: [PATCH] feat(AIChatPanel): enhance daily brief with animation and expand/collapse functionality; add GradualBlur component for improved UI --- src/renderer/components/ai/AIChatPanel.tsx | 220 +++++++++++++++----- src/renderer/components/ui/gradual-blur.tsx | 101 +++++++++ src/renderer/globals.css | 2 + 3 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 src/renderer/components/ui/gradual-blur.tsx diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx index 2e3f4de..642a80d 100644 --- a/src/renderer/components/ai/AIChatPanel.tsx +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -1,13 +1,14 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react'; +import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { motion, AnimatePresence } from 'framer-motion'; import { trpc } from '@/lib/trpc'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; import { Button } from '@/components/ui/button'; -import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { ScrollArea } from '@/components/ui/scroll-area'; +import { GradualBlur } from '@/components/ui/gradual-blur'; const SUGGESTION_CHIPS = [ { icon: ListTodo, label: "What's on my plate today?" }, @@ -16,6 +17,28 @@ const SUGGESTION_CHIPS = [ { icon: Lightbulb, label: 'Suggest next actions' }, ] as const; +function getTimeGreeting(): string { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning,'; + if (hour < 17) return 'Good afternoon,'; + return 'Good evening,'; +} + +/* Entrance animation: staggered fade-up */ +const stagger = { + hidden: {}, + show: { transition: { staggerChildren: 0.08 } }, +}; + +const fadeUp = { + hidden: { opacity: 0, y: 16 }, + show: { + opacity: 1, + y: 0, + transition: { duration: 0.45, ease: [0.25, 0.1, 0.25, 1] as const }, + }, +}; + interface AIChatPanelProps { onOpenSettings?: () => void; isHomePage?: boolean; @@ -50,6 +73,9 @@ export function AIChatPanel({ const briefContentRef = useRef(''); const hasFiredBrief = useRef(false); + const [briefExpanded, setBriefExpanded] = useState(false); + const [briefDismissed, setBriefDismissed] = useState(false); + const messagesContainerRef = useRef(null); const briefMutation = trpc.ai.dailyBrief.useMutation(); @@ -118,37 +144,126 @@ export function AIChatPanel({ return (
+ {/* Sticky brief toast — anchored at top when chatting */} + + {isHomePage && hasMessages && dailyBrief && !briefDismissed && ( + +
+ {/* Toast header — always visible */} +
+ + Daily Brief +
+ + +
+ {/* Collapsed: one-line preview */} + {!briefExpanded && ( +
+

+ {dailyBrief.replace(/[#*_~`>\-]/g, '').slice(0, 120)}... +

+
+ )} + {/* Expanded: full brief content */} + + {briefExpanded && ( + +
+ +
+
+ )} +
+
+ + )} + + {/* Scrollable messages area */} - div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center' - : '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end' - } - > +
+ {/* Gradual blur at the top of messages */} + {hasMessages && ( + + )} + div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center' + : '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end' + } + > {/* Home page initial state: greeting + brief */} {isHomePage && !hasMessages && ( -
-
- {/* Greeting + brief grouped closely */} -
-
-

- ✦ Hello, {userName} -

- - {dueCount} Task{dueCount !== 1 ? 's' : ''} due - -
+ +
+ {/* Greeting — editorial hero moment */} + + + {getTimeGreeting()} + +

+ {userName} + +

+ {dueCount > 0 && ( +

+ {dueCount} + {' '}task{dueCount !== 1 ? 's' : ''} due today +

+ )} +
- {/* Daily brief */} -
+ {/* Daily brief */} + {hasTokenQuery.data === false ? ( -
- -

+

+ +

Configure your AI provider in Settings to enable the daily brief.

) : briefLoading && !dailyBrief ? ( -
- - - +
+ + +
) : dailyBrief ? ( - + ) : ( -

+

Your daily brief will appear here.

)} -
-
+
- {/* Inline input + suggestion chips */} -
+ {/* Input + suggestion links */} + -
+
{SUGGESTION_CHIPS.map((chip) => ( ))}
-
+
-
+ )} {/* Home page with messages: brief stays, then messages */} {isHomePage && hasMessages && ( -
+
- {/* Brief persists */} - {dailyBrief && ( -
- -
- )} - {/* Chat messages */} {messages.map((msg) => { if (msg.role === 'user') { @@ -268,7 +379,8 @@ export function AIChatPanel({ )} {/* Non-home messages */} - + +
{/* Fixed input — pinned to the bottom (hidden on initial state) */} {hasMessages && ( @@ -332,9 +444,9 @@ function ChatInput({ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ -export function ChatMarkdown({ content }: { content: string }) { +export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) { return ( -
+
*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}> + position === 'top' ? 'to top' : 'to bottom'; + +export function GradualBlur({ + position = 'top', + strength = 2, + height = '6rem', + divCount = 5, + exponential = false, + opacity = 1, + zIndex = 10, + className = '', +}: GradualBlurProps) { + const blurDivs = useMemo(() => { + const divs: React.ReactNode[] = []; + const increment = 100 / divCount; + const direction = getGradientDirection(position); + + for (let i = 1; i <= divCount; i++) { + const progress = i / divCount; + + let blurValue: number; + if (exponential) { + blurValue = Math.pow(2, progress * 4) * 0.0625 * strength; + } else { + blurValue = 0.0625 * (progress * divCount + 1) * strength; + } + + const p1 = Math.round((increment * i - increment) * 10) / 10; + const p2 = Math.round(increment * i * 10) / 10; + const p3 = Math.round((increment * i + increment) * 10) / 10; + const p4 = Math.round((increment * i + increment * 2) * 10) / 10; + + let gradient = `transparent ${p1}%, black ${p2}%`; + if (p3 <= 100) gradient += `, black ${p3}%`; + if (p4 <= 100) gradient += `, transparent ${p4}%`; + + const maskImage = `linear-gradient(${direction}, ${gradient})`; + + divs.push( +
, + ); + } + + return divs; + }, [position, strength, divCount, exponential, opacity]); + + return ( +
+
+ {blurDivs} +
+
+ ); +} diff --git a/src/renderer/globals.css b/src/renderer/globals.css index ea1ed95..73c1487 100644 --- a/src/renderer/globals.css +++ b/src/renderer/globals.css @@ -1,6 +1,8 @@ +@import '@fontsource/geist/300.css'; @import '@fontsource/geist/400.css'; @import '@fontsource/geist/500.css'; @import '@fontsource/geist/600.css'; +@import '@fontsource/geist/700.css'; @import "tailwindcss"; @import "tw-animate-css";