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

@@ -8,7 +8,7 @@ This document is designed to be consumed **one step at a time across multiple Cl
### Workflow Protocol for Each Step ### Workflow Protocol for Each Step
1. **Start a new chat** and say: _"Read `docs/floating-ai-integration-guide.md` and implement Step X"_ 1. **Start a new chat** and say: _"Implement Step [X] from `docs/floating-ai-integration-guide.md`. Use a subagent to extract the general rules and only the data relevant to Step [X], completely ignoring the other steps."_
2. **Before writing any code**, the agent MUST: 2. **Before writing any code**, the agent MUST:
- Read ALL files listed in the step's "Files to Read First" section - Read ALL files listed in the step's "Files to Read First" section
- Read the project's `CLAUDE.md` for build/lint commands and conventions - Read the project's `CLAUDE.md` for build/lint commands and conventions
@@ -31,7 +31,7 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
| Step | Title | Status | | Step | Title | Status |
|------|-------|--------| |------|-------|--------|
| 1 | Extract shared `useAIChat` hook | [ ] | | 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
| 2 | Create section registry + `FloatingChatContext` | [ ] | | 2 | Create section registry + `FloatingChatContext` | [ ] |
| 3 | Create double-click hook | [ ] | | 3 | Create double-click hook | [ ] |
| 4 | Build `FloatingChat` component | [ ] | | 4 | Build `FloatingChat` component | [ ] |
@@ -56,7 +56,7 @@ A partial implementation of Step 1 already exists:
## Step 1: Extract Shared `useAIChat` Hook ## Step 1: Extract Shared `useAIChat` Hook
**Status**: [ ] **Status**: [x] 2026-02-27
**Prerequisites**: None **Prerequisites**: None
**Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`) **Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`)
**Modifies**: `src/renderer/components/ai/AIChatPanel.tsx` **Modifies**: `src/renderer/components/ai/AIChatPanel.tsx`

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 { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; 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';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
error?: boolean;
}
const SUGGESTION_CHIPS = [ const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" }, { icon: ListTodo, label: "What's on my plate today?" },
{ icon: TrendingUp, label: 'Summarize this week' }, { icon: TrendingUp, label: 'Summarize this week' },
@@ -46,10 +40,21 @@ export function AIChatPanel({
const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage }); const userNameQuery = trpc.settings.getUserName.useQuery(undefined, { enabled: !!isHomePage });
const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage }); const dueTodayQuery = trpc.tasks.dueToday.useQuery(undefined, { enabled: !!isHomePage });
const [messages, setMessages] = useState<ChatMessage[]>([]); const chatContext = useMemo<ChatContext>(
const [input, setInput] = useState(''); () => ({
const [isStreaming, setIsStreaming] = useState(false); type: contextType,
const [streamingContent, setStreamingContent] = useState(''); ...(contextType === 'project' && projectId ? { projectId } : {}),
}),
[contextType, projectId],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend: chatHandleSend,
} = useAIChat(chatContext);
// Daily brief state (home page only) // Daily brief state (home page only)
const [dailyBrief, setDailyBrief] = useState<string | null>(null); const [dailyBrief, setDailyBrief] = useState<string | null>(null);
@@ -59,8 +64,6 @@ export function AIChatPanel({
const messagesContainerRef = useRef<HTMLDivElement | null>(null); const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const streamingContentRef = useRef('');
const chatMutation = trpc.ai.chat.useMutation();
const briefMutation = trpc.ai.dailyBrief.useMutation(); const briefMutation = trpc.ai.dailyBrief.useMutation();
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
@@ -116,72 +119,9 @@ export function AIChatPanel({
}, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once }, [isHomePage, hasTokenQuery.data]); // briefMutation excluded — only fire once
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
const trimmed = input.trim(); if (briefLoading) return;
if (!trimmed || isStreaming || briefLoading) return; chatHandleSend();
}, [briefLoading, chatHandleSend]);
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]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {