1 Commits

3 changed files with 269 additions and 54 deletions

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; 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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; 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';
const SUGGESTION_CHIPS = [ const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" }, { icon: ListTodo, label: "What's on my plate today?" },
@@ -16,6 +17,28 @@ const SUGGESTION_CHIPS = [
{ icon: Lightbulb, label: 'Suggest next actions' }, { icon: Lightbulb, label: 'Suggest next actions' },
] as const; ] 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 { interface AIChatPanelProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
isHomePage?: boolean; isHomePage?: boolean;
@@ -50,6 +73,9 @@ export function AIChatPanel({
const briefContentRef = useRef(''); const briefContentRef = useRef('');
const hasFiredBrief = useRef(false); const hasFiredBrief = useRef(false);
const [briefExpanded, setBriefExpanded] = useState(false);
const [briefDismissed, setBriefDismissed] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement | null>(null); const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const briefMutation = trpc.ai.dailyBrief.useMutation(); const briefMutation = trpc.ai.dailyBrief.useMutation();
@@ -118,37 +144,126 @@ export function AIChatPanel({
return ( return (
<div className="absolute inset-0 z-0 flex flex-col bg-background"> <div className="absolute inset-0 z-0 flex flex-col bg-background">
{/* Sticky brief toast — anchored at top when chatting */}
<AnimatePresence>
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<motion.div
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>\-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Scrollable messages area */} {/* Scrollable messages area */}
<ScrollArea <div className="relative flex-1 min-h-0">
className="flex-1 min-h-0" {/* Gradual blur at the top of messages */}
viewportRef={messagesContainerRef} {hasMessages && (
viewportClassName={ <GradualBlur
isHomePage && !hasMessages position="top"
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center' strength={2}
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end' height="5rem"
} divCount={6}
> zIndex={20}
/>
)}
<ScrollArea
className="h-full"
viewportRef={messagesContainerRef}
viewportClassName={
isHomePage && !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 */} {/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && ( {isHomePage && !hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8"> <motion.div
<div className="flex flex-col gap-8"> className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
{/* Greeting + brief grouped closely */} variants={stagger}
<div className="flex flex-col gap-1"> initial="hidden"
<div className="flex items-center justify-between gap-4 flex-wrap"> animate="show"
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}> >
Hello, {userName} <div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
</h1> {/* Greeting — editorial hero moment */}
<Badge variant="secondary"> <motion.div variants={fadeUp} className="flex flex-col gap-1">
{dueCount} Task{dueCount !== 1 ? 's' : ''} due <span
</Badge> className="font-light tracking-wide text-muted-foreground"
</div> style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
>
{getTimeGreeting()}
</span>
<h1
className="font-bold leading-[1.05]"
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
>
{userName}
<span className="text-primary ml-3 inline-block"></span>
</h1>
{dueCount > 0 && (
<p
className="text-muted-foreground mt-2"
style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
>
<span className="text-foreground font-medium">{dueCount}</span>
{' '}task{dueCount !== 1 ? 's' : ''} due today
</p>
)}
</motion.div>
{/* Daily brief */} {/* Daily brief */}
<div> <motion.div variants={fadeUp} className="max-w-3xl">
{hasTokenQuery.data === false ? ( {hasTokenQuery.data === false ? (
<div className="flex flex-col items-center gap-3 py-2"> <div className="flex flex-col items-start gap-3 py-2">
<KeyRound size={24} className="text-muted-foreground" /> <KeyRound size={20} className="text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Configure your AI provider in Settings to enable the daily brief. Configure your AI provider in Settings to enable the daily brief.
</p> </p>
<Button variant="outline" size="sm" onClick={onOpenSettings}> <Button variant="outline" size="sm" onClick={onOpenSettings}>
@@ -156,23 +271,22 @@ export function AIChatPanel({
</Button> </Button>
</div> </div>
) : briefLoading && !dailyBrief ? ( ) : briefLoading && !dailyBrief ? (
<div className="space-y-2"> <div className="space-y-3">
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" /> <Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-5 w-2/3" />
</div> </div>
) : dailyBrief ? ( ) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} /> <ChatMarkdown content={dailyBrief} size="lg" />
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Your daily brief will appear here. Your daily brief will appear here.
</p> </p>
)} )}
</div> </motion.div>
</div>
{/* Inline input + suggestion chips */} {/* Input + suggestion links */}
<div> <motion.div variants={fadeUp} className="max-w-3xl">
<ChatInput <ChatInput
input={input} input={input}
isStreaming={isStreaming || briefLoading} isStreaming={isStreaming || briefLoading}
@@ -180,35 +294,32 @@ export function AIChatPanel({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onSend={handleSend} onSend={handleSend}
/> />
<div className="flex flex-wrap items-center justify-center gap-2 mt-4"> <div className="flex flex-col gap-0.5 mt-5">
{SUGGESTION_CHIPS.map((chip) => ( {SUGGESTION_CHIPS.map((chip) => (
<button <button
key={chip.label} key={chip.label}
type="button" type="button"
className="group flex items-center gap-2 rounded-full border border-border/50 bg-background/60 backdrop-blur-lg px-4 py-2 text-sm text-foreground shadow-sm ring-1 ring-border/20 transition-all hover:shadow-md hover:-translate-y-0.5 hover:border-ring/40 cursor-pointer" className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
onClick={() => setInput(chip.label)} onClick={() => setInput(chip.label)}
> >
<chip.icon size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" /> <chip.icon
size={16}
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
/>
<span>{chip.label}</span> <span>{chip.label}</span>
</button> </button>
))} ))}
</div> </div>
</div> </motion.div>
</div> </div>
</div> </motion.div>
)} )}
{/* Home page with messages: brief stays, then messages */} {/* Home page with messages: brief stays, then messages */}
{isHomePage && hasMessages && ( {isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-3xl 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">
{/* Brief persists */}
{dailyBrief && (
<div className="mb-2">
<ChatMarkdown content={dailyBrief} />
</div>
)}
{/* Chat messages */} {/* Chat messages */}
{messages.map((msg) => { {messages.map((msg) => {
if (msg.role === 'user') { if (msg.role === 'user') {
@@ -268,7 +379,8 @@ export function AIChatPanel({
)} )}
{/* Non-home messages */} {/* Non-home messages */}
</ScrollArea> </ScrollArea>
</div>
{/* Fixed input — pinned to the bottom (hidden on initial state) */} {/* Fixed input — pinned to the bottom (hidden on initial state) */}
{hasMessages && ( {hasMessages && (
@@ -332,9 +444,9 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
export function ChatMarkdown({ content }: { content: string }) { export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) {
return ( return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> <div className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{

View File

@@ -0,0 +1,101 @@
import { useMemo } from 'react';
type Position = 'top' | 'bottom';
interface GradualBlurProps {
/** Edge to attach the blur overlay */
position?: Position;
/** Base blur strength multiplier */
strength?: number;
/** Overlay height (CSS value) */
height?: string;
/** Number of stacked blur layers (higher = smoother) */
divCount?: number;
/** Use exponential progression for stronger end blur */
exponential?: boolean;
/** Opacity applied to each blur layer */
opacity?: number;
/** z-index for the overlay */
zIndex?: number;
/** Additional class names */
className?: string;
}
const getGradientDirection = (position: Position) =>
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(
<div
key={i}
style={{
position: 'absolute',
inset: 0,
maskImage,
WebkitMaskImage: maskImage,
backdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
WebkitBackdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
opacity,
}}
/>,
);
}
return divs;
}, [position, strength, divCount, exponential, opacity]);
return (
<div
className={className}
style={{
position: 'absolute',
[position]: 0,
left: 0,
right: 0,
height,
pointerEvents: 'none',
zIndex,
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{blurDivs}
</div>
</div>
);
}

View File

@@ -1,6 +1,8 @@
@import '@fontsource/geist/300.css';
@import '@fontsource/geist/400.css'; @import '@fontsource/geist/400.css';
@import '@fontsource/geist/500.css'; @import '@fontsource/geist/500.css';
@import '@fontsource/geist/600.css'; @import '@fontsource/geist/600.css';
@import '@fontsource/geist/700.css';
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";