From 6cd121fa8081c57b90b21516fa87dd508100b5e5 Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Fri, 27 Feb 2026 23:05:25 +0100 Subject: [PATCH] =?UTF-8?q?feat(floating-ai):=20step=204=20=E2=80=94=20bui?= =?UTF-8?q?ld=20FloatingChat=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/floating-ai-integration-guide.md | 6 +- src/renderer/components/ai/AIChatPanel.tsx | 2 +- src/renderer/components/ai/FloatingChat.tsx | 339 +++++++++++++++++++ src/renderer/components/layout/AppShell.tsx | 4 + src/renderer/context/FloatingChatContext.tsx | 6 +- 5 files changed, 350 insertions(+), 7 deletions(-) create mode 100644 src/renderer/components/ai/FloatingChat.tsx diff --git a/docs/floating-ai-integration-guide.md b/docs/floating-ai-integration-guide.md index 64f3b68..69b14a6 100644 --- a/docs/floating-ai-integration-guide.md +++ b/docs/floating-ai-integration-guide.md @@ -33,8 +33,8 @@ Steps MUST be implemented in order. Each step lists its prerequisites. |------|-------|--------| | 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 | | 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 | -| 3 | Create double-click hook | [ ] | -| 4 | Build `FloatingChat` component | [ ] | +| 3 | Create double-click hook | [x] 2026-02-27 | +| 4 | Build `FloatingChat` component | [x] 2026-02-27 | | 5 | Add `ai:action` IPC side-channel | [ ] | | 6 | Pass `uiContext` through to the AI | [ ] | | 7 | Implement morph animation (FLIP) | [ ] | @@ -452,7 +452,7 @@ function AppShellInner({ children }: AppShellProps) { ## Step 4: Build `FloatingChat` Component -**Status**: [ ] +**Status**: [x] 2026-02-27 **Prerequisites**: Steps 1-3 completed **Creates**: `src/renderer/components/ai/FloatingChat.tsx` **Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal) diff --git a/src/renderer/components/ai/AIChatPanel.tsx b/src/renderer/components/ai/AIChatPanel.tsx index 5059b6a..3443a62 100644 --- a/src/renderer/components/ai/AIChatPanel.tsx +++ b/src/renderer/components/ai/AIChatPanel.tsx @@ -461,7 +461,7 @@ function ChatInput({ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ -function ChatMarkdown({ content }: { content: string }) { +export function ChatMarkdown({ content }: { content: string }) { return (
( + () => ({ + 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(null); + const headerRef = useRef(null); + const dragRef = useRef(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(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(); + } + }; + + return ( + + {state.isOpen && ( + + {/* ---- Header ---- */} +
+ + + {activeSection?.label ?? 'Chat'} + + +
+ + {/* ---- Messages ---- */} + +
+ {messages.map((msg) => { + if (msg.role === 'user') { + return ( +
+
+

+ {msg.content} +

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

+ {msg.content} +

+
+ ); + } + + return ( +
+
+ + Adiuva +
+
+ +
+
+ ); + })} + + {/* Streaming indicator */} + {isStreaming && ( +
+
+ + Adiuva +
+ {streamingContent ? ( +
+ +
+ ) : ( +
+ + +
+ )} +
+ )} +
+
+ + {/* ---- Input bar ---- */} +
+
+