diff --git a/docs/floating-ai-integration-guide.md b/docs/floating-ai-integration-guide.md index c7eacd5..e8d2cda 100644 --- a/docs/floating-ai-integration-guide.md +++ b/docs/floating-ai-integration-guide.md @@ -8,7 +8,7 @@ This document is designed to be consumed **one step at a time across multiple Cl ### 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: - 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 @@ -31,7 +31,7 @@ Steps MUST be implemented in order. Each step lists its prerequisites. | Step | Title | Status | |------|-------|--------| -| 1 | Extract shared `useAIChat` hook | [ ] | +| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 | | 2 | Create section registry + `FloatingChatContext` | [ ] | | 3 | Create double-click hook | [ ] | | 4 | Build `FloatingChat` component | [ ] | @@ -56,7 +56,7 @@ A partial implementation of Step 1 already exists: ## Step 1: Extract Shared `useAIChat` Hook -**Status**: [ ] +**Status**: [x] 2026-02-27 **Prerequisites**: None **Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`) **Modifies**: `src/renderer/components/ai/AIChatPanel.tsx` diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx index c168a03..5059b6a 100644 --- a/src/renderer/components/ai/AIChatPanel.tsx +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -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([]); - const [input, setInput] = useState(''); - const [isStreaming, setIsStreaming] = useState(false); - const [streamingContent, setStreamingContent] = useState(''); + const chatContext = useMemo( + () => ({ + 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(null); @@ -59,8 +64,6 @@ export function AIChatPanel({ const messagesContainerRef = useRef(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) => { if (e.key === 'Enter' && !e.shiftKey) {