feat(floating-ai): step 1 — extract shared useAIChat hook

Refactor AIChatPanel to consume the existing useAIChat hook instead of
managing chat state inline. Removes duplicate ChatMessage interface,
inline state (messages, input, isStreaming, streamingContent), and the
65-line handleSend callback, replacing them with a single useAIChat()
call and a thin briefLoading guard wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-27 22:27:11 +01:00
parent 4b2162505c
commit 96101e4310
2 changed files with 23 additions and 83 deletions

View File

@@ -1,21 +1,15 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback, useMemo } from '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';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Card, CardContent } from '@/components/ui/card';
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';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' },
@@ -46,10 +40,21 @@ export function AIChatPanel({
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('');
const chatContext = useMemo<ChatContext>(
() => ({
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
}),
[contextType, projectId],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
// Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null);
@@ -59,8 +64,6 @@ export function AIChatPanel({
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
const briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => {
@@ -116,72 +119,9 @@ export function AIChatPanel({
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => {
const trimmed = input.trim();
if (!trimmed || isStreaming || briefLoading) return;
const userMsg: ChatMessage = {
id: crypto.randomUUID(),
role: 'user',
content: trimmed,
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsStreaming(true);
setStreamingContent('');
streamingContentRef.current = '';
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) {
const finalContent = streamingContentRef.current;
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
unsubscribe();
return;
}
streamingContentRef.current += token;
setStreamingContent(streamingContentRef.current);
});
chatMutation.mutate(
{
message: trimmed,
context: {
type: contextType,
...(contextType === 'project' && projectId ? { projectId } : {}),
},
},
{
onSuccess: (data) => {
if (data.error) {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
}
},
onError: (err) => {
unsubscribe();
setMessages((prev) => [
...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: err.message || 'An unexpected error occurred.', error: true },
]);
setStreamingContent('');
streamingContentRef.current = '';
setIsStreaming(false);
},
},
);
}, [input, isStreaming, briefLoading, contextType, projectId, chatMutation]);
if (briefLoading) return;
chatHandleSend();
}, [briefLoading, chatHandleSend]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {