fix: AI chat loading indicator persists until response received
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
This commit is contained in:
Paul Nothaft
2026-01-17 10:15:58 +01:00
parent 2a98ebdf9f
commit 9bfc9e2f96
+13 -6
View File
@@ -30,6 +30,7 @@ export function AiChat() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [isWaitingForResponse, setIsWaitingForResponse] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const mutation = useMutation({ const mutation = useMutation({
@@ -43,6 +44,7 @@ export function AiChat() {
}, },
onSuccess: (data) => { onSuccess: (data) => {
setMessages(prev => [...prev, data]); setMessages(prev => [...prev, data]);
setIsWaitingForResponse(false);
// Refresh tasks and labels in case AI created/modified them // Refresh tasks and labels in case AI created/modified them
queryClient.invalidateQueries({ queryKey: ['/api/tasks'] }); queryClient.invalidateQueries({ queryKey: ['/api/tasks'] });
queryClient.invalidateQueries({ queryKey: ['/api/labels'] }); queryClient.invalidateQueries({ queryKey: ['/api/labels'] });
@@ -50,22 +52,27 @@ export function AiChat() {
}, },
onError: (err: Error) => { onError: (err: Error) => {
setMessages(prev => [...prev, { role: 'assistant', content: t('ai.chat.error', 'Something went wrong') + ": " + err.message }]); 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(() => { useEffect(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight; scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
} }
}, [messages, isOpen, mutation.isPending]); }, [messages, isOpen, isLoading]);
const handleSubmit = (e?: React.FormEvent) => { const handleSubmit = (e?: React.FormEvent) => {
e?.preventDefault(); e?.preventDefault();
if (!input.trim() || mutation.isPending) return; if (!input.trim() || isLoading) return;
const newMsgs: Message[] = [...messages, { role: 'user', content: input }]; const newMsgs: Message[] = [...messages, { role: 'user', content: input }];
setMessages(newMsgs); setMessages(newMsgs);
setInput(''); setInput('');
setIsWaitingForResponse(true);
mutation.mutate(newMsgs); mutation.mutate(newMsgs);
}; };
@@ -87,7 +94,7 @@ export function AiChat() {
<div className="flex items-center gap-2 font-semibold"> <div className="flex items-center gap-2 font-semibold">
<Bot className="w-5 h-5 text-primary" /> <Bot className="w-5 h-5 text-primary" />
{t('ai.chat.title', 'AI Assistant')} {t('ai.chat.title', 'AI Assistant')}
{mutation.isPending && ( {isLoading && (
<span className="ml-2 text-xs font-normal text-muted-foreground animate-pulse"> <span className="ml-2 text-xs font-normal text-muted-foreground animate-pulse">
{t('ai.chat.thinking', 'Thinking')}... {t('ai.chat.thinking', 'Thinking')}...
</span> </span>
@@ -117,7 +124,7 @@ export function AiChat() {
</div> </div>
</div> </div>
))} ))}
{mutation.isPending && ( {isLoading && (
<div className="flex w-full justify-start"> <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"> <div className="bg-muted rounded-2xl rounded-bl-none px-4 py-3 flex items-center gap-3">
<TypingIndicator /> <TypingIndicator />
@@ -134,9 +141,9 @@ export function AiChat() {
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder={t('ai.chat.placeholder', 'Ask me anything...')} placeholder={t('ai.chat.placeholder', 'Ask me anything...')}
className="bg-muted/50 focus-visible:ring-primary/50" className="bg-muted/50 focus-visible:ring-primary/50"
disabled={mutation.isPending} disabled={isLoading}
/> />
<Button type="submit" size="icon" disabled={mutation.isPending || !input.trim()}> <Button type="submit" size="icon" disabled={isLoading || !input.trim()}>
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
</Button> </Button>
</form> </form>