import { useCallback, useEffect, useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import { AnimatePresence, motion } from 'framer-motion'; import { useNavigate, useRouterState } from '@tanstack/react-router'; import { X, ArrowUp } from 'lucide-react'; import { useFloatingChat, computeDualAnchor, CHAT_WIDTH, CHAT_HEIGHT, PADDING, } from '@/context/FloatingChatContext'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; import { ChatMarkdown } from '@/components/ai/AIChatPanel'; import { Skeleton } from '@/components/ui/skeleton'; import { trpc } from '@/lib/trpc'; /** Map section IDs to their routes for cross-page navigation */ const SECTION_ROUTES: Record = { 'project-summary': 'project', 'project-timeline': 'project', 'project-tasks': 'project', 'project-notes': 'project', 'tasks-overview': '/tasks', 'tasks-list': '/tasks', 'timeline-chart': '/timeline', 'note-editor': 'note', }; function FloatingChatInner() { const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat(); const utils = trpc.useUtils(); const navigate = useNavigate(); const routerState = useRouterState(); const prevPathRef = useRef(routerState.location.pathname); // Active section lookup const activeSection = sections.get(state.activeSectionId ?? ''); // Chat context derived from active section const chatContext = useMemo( () => ({ type: activeSection?.projectId ? 'project' : 'global', projectId: activeSection?.projectId, uiContext: activeSection?.label, }), [activeSection?.projectId, activeSection?.label], ); // Handle [SECTION:xxx] tags from AI responses const handleSectionTag = useCallback((sectionId: string) => { // Same-page: section is already registered const targetSection = sections.get(sectionId); if (targetSection) { moveToSection(sectionId); targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); return; } // Cross-page: section not registered, navigate to its route const route = SECTION_ROUTES[sectionId]; if (!route) return; setPendingSection({ sectionId }); if (route === 'project' && state.projectId) { // Navigate to the project page (stay on same project) // Project sections re-register on mount and pendingSection will auto-open void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } }); } else if (route.startsWith('/')) { void navigate({ to: route }); } // 'note' type requires noteId — skip cross-page for now }, [sections, moveToSection, setPendingSection, state.projectId, navigate]); const { messages, input, setInput, isStreaming, streamingContent, handleSend, clearMessages, } = useAIChat(chatContext, { onSectionTag: handleSectionTag }); const containerRef = useRef(null); // ---- Close on Escape ---- useEffect(() => { if (!state.isOpen) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); close(); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [state.isOpen, close]); // ---- Close on route change (unless cross-page navigation pending) ---- useEffect(() => { const currentPath = routerState.location.pathname; if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) { close(); } prevPathRef.current = currentPath; }, [routerState.location.pathname, state.isOpen, state.pendingSection, close]); // ---- Clear messages on close ---- const prevOpenRef = useRef(state.isOpen); useEffect(() => { if (prevOpenRef.current && !state.isOpen) { clearMessages(); } prevOpenRef.current = state.isOpen; }, [state.isOpen, clearMessages]); // ---- AI action: morph into newly-created task ---- useEffect(() => { if (!state.isOpen) return; const unsubscribe = window.electronAI.onAction((action) => { if (action.type === 'task_created' && action.taskId) { // Invalidate task queries so the new TaskRow renders void utils.tasks.list.invalidate(); // Set the morph target layoutId setMorphTarget(`task-morph-${action.taskId}`); // Wait for the TaskRow to render, then close (triggering FLIP) requestAnimationFrame(() => { requestAnimationFrame(() => { close(); }); }); } }); return unsubscribe; }, [state.isOpen, utils, setMorphTarget, close]); // ---- Window resize: keep within bounds ---- useEffect(() => { if (!state.isOpen) return; const handler = () => { // Re-anchor if the container would go offscreen const el = containerRef.current; if (el) { const rect = el.getBoundingClientRect(); if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) { el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - CHAT_WIDTH - PADDING))}px`; el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`; } } }; window.addEventListener('resize', handler); return () => window.removeEventListener('resize', handler); }, [state.isOpen, state.position.x, state.position.y]); // ---- Scroll tracking: dual-anchor repositioning ---- useEffect(() => { if (!state.isOpen || !state.activeSectionId) return; const section = sections.get(state.activeSectionId); if (!section || section.anchorMode === 'right-margin') return; const el = section.ref.current; if (!el) return; // Find scrollable ancestor let scrollParent: HTMLElement | null = el.parentElement; while (scrollParent) { const style = getComputedStyle(scrollParent); if (style.overflow === 'auto' || style.overflow === 'scroll' || style.overflowY === 'auto' || style.overflowY === 'scroll') { break; } // Also check for Radix ScrollArea viewport if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break; scrollParent = scrollParent.parentElement; } if (!scrollParent) return; let rafId: number | null = null; const handleScroll = () => { if (rafId !== null) return; rafId = requestAnimationFrame(() => { rafId = null; const newPos = computeDualAnchor(section); if (newPos) { updatePosition(newPos); } // null = fully off-screen → freeze (do nothing) }); }; scrollParent.addEventListener('scroll', handleScroll, { passive: true }); return () => { scrollParent.removeEventListener('scroll', handleScroll); if (rafId !== null) cancelAnimationFrame(rafId); }; }, [state.isOpen, state.activeSectionId, sections, updatePosition]); // ---- Auto-scroll messages ---- const scrollRef = useRef(null); const scrollToBottom = useCallback(() => { const el = scrollRef.current; if (el) el.scrollTo({ top: el.scrollHeight }); }, []); useEffect(() => { scrollToBottom(); }, [messages, streamingContent, scrollToBottom]); // ---- Auto-focus input on open ---- const inputRef = useRef(null); useEffect(() => { if (state.isOpen) { const timer = setTimeout(() => inputRef.current?.focus(), 100); return () => clearTimeout(timer); } }, [state.isOpen]); // ---- Input handling ---- const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }; const hasMessages = messages.length > 0 || isStreaming; return ( {state.isOpen && ( {/* ---- Messages panel (appears when chat has content) ---- */} {hasMessages && (
{messages.map((msg) => { if (msg.role === 'user') { return (

{msg.content}

); } if (msg.error) { return (

{msg.content}

); } return (
); })} {/* Streaming */} {isStreaming && (
{streamingContent ? (
) : (
)}
)}
)}
{/* ---- Floating input bar ---- */}
{/* Close button */}