feat(floating-ai): step 4 — build FloatingChat component

Create the floating AI chat popup rendered via portal to document.body.
Uses useAIChat for chat logic, useFloatingChat for position/state,
Framer Motion for enter/exit animations, and pointer-event dragging.

Includes: close on Escape, close on route change, auto-scroll,
auto-focus, window resize clamping, and compact message rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-27 23:05:25 +01:00
parent 28a5d65f1a
commit 6cd121fa80
5 changed files with 350 additions and 7 deletions

View File

@@ -0,0 +1,339 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouterState } from '@tanstack/react-router';
import { X, ArrowUp, Sparkles, GripHorizontal } from 'lucide-react';
import {
useFloatingChat,
CHAT_WIDTH,
CHAT_HEIGHT,
PADDING,
} from '@/context/FloatingChatContext';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Skeleton } from '@/components/ui/skeleton';
interface DragState {
startX: number;
startY: number;
originX: number;
originY: number;
}
function FloatingChatInner() {
const { state, sections, close } = useFloatingChat();
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<ChatContext>(
() => ({
type: activeSection?.projectId ? 'project' : 'global',
projectId: activeSection?.projectId,
uiContext: activeSection?.label,
}),
[activeSection?.projectId, activeSection?.label],
);
const {
messages,
input,
setInput,
isStreaming,
streamingContent,
handleSend,
clearMessages,
} = useAIChat(chatContext);
// ---- Position & drag state ----
const containerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null);
const posRef = useRef({ x: state.position.x, y: state.position.y });
const [posState, setPosState] = useState({
x: state.position.x,
y: state.position.y,
});
// Sync from context when position changes externally (new section opened)
useEffect(() => {
posRef.current = { x: state.position.x, y: state.position.y };
setPosState({ x: state.position.x, y: state.position.y });
}, [state.position.x, state.position.y]);
// ---- Drag handlers ----
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
headerRef.current?.setPointerCapture(e.pointerId);
dragRef.current = {
startX: e.clientX,
startY: e.clientY,
originX: posRef.current.x,
originY: posRef.current.y,
};
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
let newX = d.originX + dx;
let newY = d.originY + dy;
newX = Math.max(
PADDING,
Math.min(newX, window.innerWidth - CHAT_WIDTH - PADDING),
);
newY = Math.max(
PADDING,
Math.min(newY, window.innerHeight - CHAT_HEIGHT - PADDING),
);
posRef.current = { x: newX, y: newY };
const el = containerRef.current;
if (el) {
el.style.left = `${newX}px`;
el.style.top = `${newY}px`;
}
}, []);
const onPointerUp = useCallback(() => {
if (!dragRef.current) return;
dragRef.current = null;
setPosState({ ...posRef.current });
}, []);
// ---- 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 ----
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) {
close();
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, close]);
// ---- Clear messages on close ----
const prevOpenRef = useRef(state.isOpen);
useEffect(() => {
if (prevOpenRef.current && !state.isOpen) {
clearMessages();
}
prevOpenRef.current = state.isOpen;
}, [state.isOpen, clearMessages]);
// ---- Window resize: keep within bounds ----
useEffect(() => {
if (!state.isOpen) return;
const handler = () => {
const pos = posRef.current;
const clampedX = Math.max(
PADDING,
Math.min(pos.x, window.innerWidth - CHAT_WIDTH - PADDING),
);
const clampedY = Math.max(
PADDING,
Math.min(pos.y, window.innerHeight - CHAT_HEIGHT - PADDING),
);
posRef.current = { x: clampedX, y: clampedY };
setPosState({ x: clampedX, y: clampedY });
};
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
}, [state.isOpen]);
// ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>(null);
useEffect(() => {
if (state.isOpen) {
const timer = setTimeout(() => inputRef.current?.focus(), 100);
return () => clearTimeout(timer);
}
}, [state.isOpen]);
// ---- Input handling ----
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<AnimatePresence>
{state.isOpen && (
<motion.div
ref={containerRef}
key="floating-chat"
layout
layoutId={state.morphTargetId ?? undefined}
initial={{ opacity: 0, scale: 0.92, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'fixed',
left: posState.x,
top: posState.y,
width: state.position.width,
height: CHAT_HEIGHT,
zIndex: 9999,
}}
className="rounded-xl border border-border bg-background/95 backdrop-blur-xl shadow-2xl flex flex-col overflow-hidden"
>
{/* ---- Header ---- */}
<div
ref={headerRef}
className="flex items-center gap-2 px-3 py-2 border-b border-border/50 cursor-grab active:cursor-grabbing select-none"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
>
<GripHorizontal
size={14}
className="text-muted-foreground shrink-0"
/>
<Badge variant="outline" className="text-[10px] truncate">
{activeSection?.label ?? 'Chat'}
</Badge>
<button
onClick={close}
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md hover:bg-muted transition-colors"
>
<X size={14} />
</button>
</div>
{/* ---- Messages ---- */}
<ScrollArea className="flex-1 min-h-0" viewportRef={scrollRef}>
<div className="flex flex-col gap-3 p-3">
{messages.map((msg) => {
if (msg.role === 'user') {
return (
<div key={msg.id} className="flex justify-end">
<div className="ml-auto max-w-[85%] rounded-xl bg-muted px-3 py-1.5">
<p className="text-xs whitespace-pre-wrap">
{msg.content}
</p>
</div>
</div>
);
}
if (msg.error) {
return (
<div key={msg.id} className="mr-auto max-w-[85%]">
<p className="text-xs text-destructive whitespace-pre-wrap">
{msg.content}
</p>
</div>
);
}
return (
<div key={msg.id} className="mr-auto max-w-[85%]">
<div className="flex items-center gap-1 mb-0.5">
<Sparkles size={12} className="text-foreground" />
<span className="text-[10px] font-semibold">Adiuva</span>
</div>
<div className="pl-4 text-xs">
<ChatMarkdown content={msg.content} />
</div>
</div>
);
})}
{/* Streaming indicator */}
{isStreaming && (
<div className="mr-auto max-w-[85%]">
<div className="flex items-center gap-1 mb-0.5">
<Sparkles size={12} className="text-foreground" />
<span className="text-[10px] font-semibold">Adiuva</span>
</div>
{streamingContent ? (
<div className="pl-4 text-xs">
<ChatMarkdown content={streamingContent} />
</div>
) : (
<div className="space-y-1.5 pl-4">
<Skeleton className="h-3 w-36" />
<Skeleton className="h-3 w-24" />
</div>
)}
</div>
)}
</div>
</ScrollArea>
{/* ---- Input bar ---- */}
<div className="border-t border-border/50 p-2">
<div className="flex items-center gap-1.5 rounded-lg bg-muted/50 px-2.5 py-1.5">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask about this section..."
rows={1}
className="flex-1 resize-none bg-transparent text-xs placeholder:text-muted-foreground outline-none max-h-16 overflow-y-auto"
style={{ fieldSizing: 'content' } as React.CSSProperties}
/>
<button
onClick={() => handleSend()}
disabled={!input.trim() || isStreaming}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<ArrowUp size={12} />
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
export function FloatingChatPortal() {
return createPortal(<FloatingChatInner />, document.body);
}