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:
@@ -33,8 +33,8 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
|
|||||||
|------|-------|--------|
|
|------|-------|--------|
|
||||||
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
|
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
|
||||||
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
||||||
| 3 | Create double-click hook | [ ] |
|
| 3 | Create double-click hook | [x] 2026-02-27 |
|
||||||
| 4 | Build `FloatingChat` component | [ ] |
|
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
||||||
| 5 | Add `ai:action` IPC side-channel | [ ] |
|
| 5 | Add `ai:action` IPC side-channel | [ ] |
|
||||||
| 6 | Pass `uiContext` through to the AI | [ ] |
|
| 6 | Pass `uiContext` through to the AI | [ ] |
|
||||||
| 7 | Implement morph animation (FLIP) | [ ] |
|
| 7 | Implement morph animation (FLIP) | [ ] |
|
||||||
@@ -452,7 +452,7 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
|
|
||||||
## Step 4: Build `FloatingChat` Component
|
## Step 4: Build `FloatingChat` Component
|
||||||
|
|
||||||
**Status**: [ ]
|
**Status**: [x] 2026-02-27
|
||||||
**Prerequisites**: Steps 1-3 completed
|
**Prerequisites**: Steps 1-3 completed
|
||||||
**Creates**: `src/renderer/components/ai/FloatingChat.tsx`
|
**Creates**: `src/renderer/components/ai/FloatingChat.tsx`
|
||||||
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal)
|
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal)
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ function ChatInput({
|
|||||||
|
|
||||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||||
|
|
||||||
function ChatMarkdown({ content }: { content: string }) {
|
export function ChatMarkdown({ content }: { content: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
|
|||||||
339
src/renderer/components/ai/FloatingChat.tsx
Normal file
339
src/renderer/components/ai/FloatingChat.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -56,6 +56,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
import { AIChatPanel } from '@/components/ai/AIChatPanel';
|
||||||
|
import { FloatingChatPortal } from '@/components/ai/FloatingChat';
|
||||||
import { useTheme } from '@/components/theme-provider';
|
import { useTheme } from '@/components/theme-provider';
|
||||||
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
import { FloatingChatProvider } from '@/context/FloatingChatContext';
|
||||||
|
|
||||||
@@ -267,6 +268,9 @@ function AppShellInner({ children }: AppShellProps) {
|
|||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
|
||||||
|
{/* Floating AI Chat — portal to document.body */}
|
||||||
|
<FloatingChatPortal />
|
||||||
|
|
||||||
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}
|
||||||
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
<Dialog open={tokenDialogOpen} onOpenChange={(open) => {
|
||||||
setTokenDialogOpen(open);
|
setTokenDialogOpen(open);
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ interface FloatingChatContextValue {
|
|||||||
|
|
||||||
// ---------- Constants ----------
|
// ---------- Constants ----------
|
||||||
|
|
||||||
const CHAT_WIDTH = 380;
|
export const CHAT_WIDTH = 380;
|
||||||
const CHAT_HEIGHT = 420;
|
export const CHAT_HEIGHT = 420;
|
||||||
const PADDING = 16;
|
export const PADDING = 16;
|
||||||
|
|
||||||
// ---------- Position computation ----------
|
// ---------- Position computation ----------
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user