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 (
+
+ );
+ }
+
+ if (msg.error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+ {/* Streaming indicator */}
+ {isStreaming && (
+
+
+
+ Adiuva
+
+ {streamingContent ? (
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+ )}
+
+
+
+ {/* ---- Input bar ---- */}
+
+
+ )}
+
+ );
+}
+
+export function FloatingChatPortal() {
+ return createPortal(, document.body);
+}
diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx
index f8e7512..1641772 100644
--- a/src/renderer/components/layout/AppShell.tsx
+++ b/src/renderer/components/layout/AppShell.tsx
@@ -56,6 +56,7 @@ import {
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel';
+import { FloatingChatPortal } from '@/components/ai/FloatingChat';
import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
@@ -267,6 +268,9 @@ function AppShellInner({ children }: AppShellProps) {
+ {/* Floating AI Chat — portal to document.body */}
+
+
{/* AI Token Dialog — rendered outside Sidebar to avoid layout conflicts */}