9bfc9e2f96
continuous-integration/drone/push Build is passing
- Add separate isWaitingForResponse state to track loading - Set to true before mutation, false only in onSuccess/onError - Combine with mutation.isPending for reliable isLoading state - Typing indicator now stays visible throughout entire AI request
154 lines
6.9 KiB
TypeScript
154 lines
6.9 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Card, CardFooter, CardHeader } from '@/components/ui/card';
|
|
import { Bot, X, Send, Sparkles } from 'lucide-react';
|
|
import { apiRequest } from '@/lib/queryClient';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface Message {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
// Typing indicator with animated dots - v2
|
|
function TypingIndicator() {
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0ms', animationDuration: '0.6s' }} />
|
|
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '150ms', animationDuration: '0.6s' }} />
|
|
<div className="w-2.5 h-2.5 bg-primary rounded-full animate-bounce" style={{ animationDelay: '300ms', animationDuration: '0.6s' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AiChat() {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [input, setInput] = useState('');
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: async (msgs: Message[]) => {
|
|
const res = await apiRequest("POST", "/api/ai/chat", { messages: msgs });
|
|
if (!res.ok) {
|
|
const err = await res.json();
|
|
throw new Error(err.error || "Failed");
|
|
}
|
|
return res.json();
|
|
},
|
|
onSuccess: (data) => {
|
|
setMessages(prev => [...prev, data]);
|
|
setIsWaitingForResponse(false);
|
|
// Refresh tasks and labels in case AI created/modified them
|
|
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
|
|
queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
|
|
queryClient.invalidateQueries({ queryKey: ['/api/user'] });
|
|
},
|
|
onError: (err: Error) => {
|
|
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.chat.error', 'Something went wrong') + ": " + err.message }]);
|
|
setIsWaitingForResponse(false);
|
|
}
|
|
});
|
|
|
|
// Use our own loading state OR mutation.isPending for the indicator
|
|
const isLoading = isWaitingForResponse || mutation.isPending;
|
|
|
|
useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
|
}
|
|
}, [messages, isOpen, isLoading]);
|
|
|
|
const handleSubmit = (e?: React.FormEvent) => {
|
|
e?.preventDefault();
|
|
if (!input.trim() || isLoading) return;
|
|
|
|
const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
|
|
setMessages(newMsgs);
|
|
setInput('');
|
|
setIsWaitingForResponse(true);
|
|
mutation.mutate(newMsgs);
|
|
};
|
|
|
|
if (!isOpen) {
|
|
return (
|
|
<Button
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed bottom-4 right-8 z-50 rounded-full h-14 w-14 shadow-xl bg-gradient-to-r from-pink-500 to-purple-600 hover:scale-110 transition-transform duration-200"
|
|
size="icon"
|
|
>
|
|
<Sparkles className="h-6 w-6 text-white animate-pulse" />
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Card className="fixed bottom-4 right-8 z-50 w-80 md:w-96 h-[500px] flex flex-col shadow-2xl border-primary/20 animate-in slide-in-from-bottom-10 fade-in duration-200">
|
|
<CardHeader className="p-4 border-b bg-primary/5 flex flex-row items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-2 font-semibold">
|
|
<Bot className="w-5 h-5 text-primary" />
|
|
{t('ai.chat.title', 'AI Assistant')}
|
|
{isLoading && (
|
|
<span className="ml-2 text-xs font-normal text-muted-foreground animate-pulse">
|
|
{t('ai.chat.thinking', 'Thinking')}...
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => setIsOpen(false)}>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</CardHeader>
|
|
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4" ref={scrollRef}>
|
|
{messages.length === 0 && (
|
|
<div className="text-center text-muted-foreground mt-10">
|
|
<Bot className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
|
<p className="text-sm px-4">{t('ai.chat.welcome', 'Hi! I can help you manage tasks. Try: "Create a task to buy groceries tomorrow"')}</p>
|
|
</div>
|
|
)}
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={cn("flex w-full", msg.role === 'user' ? "justify-end" : "justify-start")}>
|
|
<div className={cn(
|
|
"max-w-[80%] rounded-2xl px-4 py-2 text-sm shadow-sm whitespace-pre-wrap",
|
|
msg.role === 'user'
|
|
? "bg-primary text-primary-foreground rounded-br-none"
|
|
: "bg-muted text-foreground rounded-bl-none"
|
|
)}>
|
|
{msg.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="flex w-full justify-start">
|
|
<div className="bg-muted rounded-2xl rounded-bl-none px-4 py-3 flex items-center gap-3">
|
|
<TypingIndicator />
|
|
<span className="text-sm text-muted-foreground">{t('ai.chat.thinking', 'Thinking')}...</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<CardFooter className="p-3 border-t bg-background shrink-0">
|
|
<form onSubmit={handleSubmit} className="flex w-full gap-2">
|
|
<Input
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
placeholder={t('ai.chat.placeholder', 'Ask me anything...')}
|
|
className="bg-muted/50 focus-visible:ring-primary/50"
|
|
disabled={isLoading}
|
|
/>
|
|
<Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
|
|
<Send className="w-4 h-4" />
|
|
</Button>
|
|
</form>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|