This commit is contained in:
Roberto Musso
2026-02-25 07:31:50 +01:00
parent 5445bb0eec
commit 50b7fa784c
8 changed files with 335 additions and 86 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Sparkles, KeyRound, ArrowUp } from 'lucide-react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { trpc } from '@/lib/trpc';
@@ -16,12 +16,20 @@ interface ChatMessage {
error?: boolean;
}
const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' },
{ icon: AlertCircle, label: 'Any overdue tasks?' },
{ icon: Lightbulb, label: 'Suggest next actions' },
] as const;
interface AIChatPanelProps {
onOpenSettings?: () => void;
contextType: 'global' | 'project';
projectId?: string;
projectName?: string;
curtainOpen: boolean;
isHomePage?: boolean;
}
export function AIChatPanel({
@@ -30,14 +38,25 @@ export function AIChatPanel({
projectId,
projectName,
curtainOpen,
isHomePage,
}: AIChatPanelProps) {
const hasTokenQuery = trpc.ai.hasToken.useQuery();
// Home-specific queries
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
// Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
const [briefLoading, setBriefLoading] = useState(false);
const briefContentRef = useRef('');
const hasFiredBrief = useRef(false);
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
@@ -62,9 +81,49 @@ export function AIChatPanel({
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
// Auto-fire daily brief on home page
useEffect(() => {
if (!isHomePage || hasFiredBrief.current || hasTokenQuery.data !== true) return;
hasFiredBrief.current = true;
setBriefLoading(true);
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
setDailyBrief(briefContentRef.current);
setBriefLoading(false);
unsubscribe();
return;
}
briefContentRef.current += token;
setDailyBrief(briefContentRef.current);
});
chatMutation.mutate(
{
message:
'Give me a concise daily brief for today. Highlight tasks due today and this week, any overdue items, and recent project activity. Use **bold** for key phrases. Keep it to 3-5 sentences.',
context: { type: 'global' },
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
}
},
onError: () => {
unsubscribe();
setDailyBrief(null);
setBriefLoading(false);
},
},
);
}, [isHomePage, hasTokenQuery.data]); // chatMutation excluded — only fire once
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming) return;
if (!trimmed || isStreaming || briefLoading) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
@@ -128,7 +187,7 @@ export function AIChatPanel({
},
},
);
}, [input, isStreaming, contextType, projectId, chatMutation]);
}, [input, isStreaming, briefLoading, contextType, projectId, chatMutation]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -149,7 +208,7 @@ export function AIChatPanel({
}, []);
// No token configured — show settings prompt
if (hasTokenQuery.data === false) {
if (hasTokenQuery.data === false && !isHomePage) {
return (
<div className="absolute inset-0 z-0 flex flex-col items-center justify-center bg-background">
<Card className="max-w-sm">
@@ -178,22 +237,149 @@ export function AIChatPanel({
? `Chatting about: ${projectName}`
: 'Global workspace';
// Derived values for home page
const dueCount = dueTodayQuery.data?.length ?? 0;
const userName = userNameQuery.data ?? 'there';
return (
<div className="absolute inset-0 z-0 flex flex-col bg-background">
{/* Context header */}
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
<Badge variant="outline">{contextLabel}</Badge>
</div>
{/* Context header (non-home) */}
{!isHomePage && (
<div className="flex items-center gap-2 px-6 pt-4 pb-2">
<Badge variant="outline">{contextLabel}</Badge>
</div>
)}
{/* Scrollable messages area */}
<ScrollArea
className="flex-1 min-h-0"
viewportRef={messagesContainerRef}
viewportClassName="[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end"
viewportClassName={
isHomePage && !hasMessages
? undefined
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
}
onWheel={handleWheel}
>
{/* Messages */}
{hasMessages && (
{/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44">
<div className="flex flex-col gap-6">
{/* Greeting + badge */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
Hello, {userName}
</h1>
<Badge variant="secondary">
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
</Badge>
</div>
{/* Daily brief card */}
<Card>
<CardContent className="pt-6">
{hasTokenQuery.data === false ? (
<div className="flex flex-col items-center gap-3 py-2">
<KeyRound size={24} className="text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center">
Configure your AI provider in Settings to enable the daily brief.
</p>
<Button variant="outline" size="sm" onClick={onOpenSettings}>
Open Settings
</Button>
</div>
) : briefLoading && !dailyBrief ? (
<div className="space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} />
) : (
<p className="text-sm text-muted-foreground">
Your daily brief will appear here.
</p>
)}
</CardContent>
</Card>
</div>
</div>
)}
{/* Home page with messages: brief stays, then messages */}
{isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-44">
<div className="flex flex-col gap-4">
{/* Brief card persists */}
{dailyBrief && (
<Card className="mb-2">
<CardContent className="pt-6">
<ChatMarkdown content={dailyBrief} />
</CardContent>
</Card>
)}
{/* Chat messages */}
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="ml-auto max-w-[75%] rounded-2xl bg-muted px-4 py-2">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[75%]">
<p className="text-sm text-destructive whitespace-pre-wrap">
{msg.content}
</p>
</div>
);
}
return (
<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>
</div>
<div className="pl-[22px]">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
})}
{/* Streaming AI response */}
{isStreaming && (
<div 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>
</div>
{streamingContent ? (
<div className="pl-[22px]">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-2 pl-[22px]">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-32" />
</div>
)}
</div>
)}
</div>
</div>
)}
{/* Non-home messages */}
{!isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-[1088px] px-6 pt-4 pb-44">
<div className="flex flex-col gap-4">
{messages.map((msg) => {
@@ -257,14 +443,32 @@ export function AIChatPanel({
{/* Fixed input — pinned to the bottom */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-6 pb-4 pt-12 pointer-events-none">
<div className="absolute inset-x-0 top-0 h-full bg-gradient-to-b from-transparent via-background/80 to-background" />
<div className="relative pointer-events-auto mx-auto max-w-[1088px]">
<div className={`relative pointer-events-auto mx-auto ${isHomePage ? 'max-w-3xl' : 'max-w-[1088px]'}`}>
<ChatInput
input={input}
isStreaming={isStreaming}
isStreaming={isStreaming || briefLoading}
onInputChange={setInput}
onKeyDown={handleKeyDown}
onSend={handleSend}
isHomePage={isHomePage}
/>
{/* Suggestion chips (home page only, before first message) */}
{isHomePage && !hasMessages && (
<div className="flex flex-wrap gap-2 mt-3">
{SUGGESTION_CHIPS.map((chip) => (
<Button
key={chip.label}
variant="outline"
className="gap-2 h-auto py-2.5 px-4 text-left"
onClick={() => setInput(chip.label)}
>
<chip.icon size={16} className="shrink-0" />
<span className="text-sm">{chip.label}</span>
</Button>
))}
</div>
)}
</div>
</div>
</div>
@@ -279,6 +483,7 @@ interface ChatInputProps {
onInputChange: (value: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
isHomePage?: boolean;
}
function ChatInput({
@@ -287,22 +492,34 @@ function ChatInput({
onInputChange,
onKeyDown,
onSend,
isHomePage,
}: ChatInputProps) {
return (
<div className="relative rounded-2xl bg-muted/60 backdrop-blur-xl border border-border shadow-[0_2px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.3)] overflow-hidden">
<div
className={
isHomePage
? 'relative rounded-2xl bg-white dark:bg-neutral-900 border border-[#d4d4d4] shadow-lg overflow-hidden'
: 'relative rounded-2xl bg-muted/60 backdrop-blur-xl border border-border shadow-[0_2px_20px_rgba(0,0,0,0.08)] dark:shadow-[0_2px_20px_rgba(0,0,0,0.3)] overflow-hidden'
}
>
<textarea
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask me anything..."
rows={3}
rows={isHomePage ? 4 : 3}
style={isHomePage ? { minHeight: 109 } : undefined}
className="w-full resize-none bg-transparent px-4 pt-4 pb-12 text-sm placeholder:text-muted-foreground outline-none"
/>
<div className="absolute bottom-3 right-3">
<button
onClick={onSend}
disabled={!input.trim() || isStreaming}
className="flex h-8 w-8 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
className={`flex h-8 w-8 items-center justify-center rounded-xl shadow-sm transition-opacity hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed ${
isHomePage
? 'bg-foreground text-background'
: 'bg-primary text-primary-foreground'
}`}
>
<ArrowUp size={16} />
</button>