# Floating AI Chatbot — Step-by-Step Integration Guide --- ## How to Use This Guide (Read This First) This document is designed to be consumed **one step at a time across multiple Claude Code sessions**. Each step is self-contained with all the context needed to implement it. ### Workflow Protocol for Each Step 1. **Start a new chat** and say: _"Implement Step [X] from `docs/floating-ai-integration-guide.md`. Use a subagent to extract the general rules and only the data relevant to Step [X], completely ignoring the other steps."_ 2. **Before writing any code**, the agent MUST: - Read ALL files listed in the step's "Files to Read First" section - Read the project's `CLAUDE.md` for build/lint commands and conventions - Confirm understanding of the step's requirements with the user if anything is ambiguous 3. **Implement the step** following the detailed instructions 4. **Verify** using the step's verification checklist 5. **Run lint**: `source ~/.nvm/nvm.sh && npm run lint` 6. **Update this guide**: Change the step's status from `[ ]` to `[x]` and add the date 7. **Create a git commit** with message: `feat(floating-ai): step X — ` 8. **Confirm completion** to the user before stopping ### Step Dependencies Steps MUST be implemented in order. Each step lists its prerequisites. ### Current State > **Last updated**: 2026-02-26 > **Branch**: `mvp` | Step | Title | Status | |------|-------|--------| | 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 | [ ] | | 5 | Add `ai:action` IPC side-channel | [ ] | | 6 | Pass `uiContext` through to the AI | [ ] | | 7 | Implement morph animation (FLIP) | [ ] | | 8a | Page interactions — Project Detail | [ ] | | 8b | Page interactions — Tasks page | [ ] | | 8c | Page interactions — Timeline page | [ ] | | 8d | Page interactions — Notes page (Milkdown) | [ ] | | 9 | Section auto-scroll on jump | [ ] | --- ## Existing State A partial implementation of Step 1 already exists: - **`src/renderer/hooks/useAIChat.ts`** — The hook file was created but is NOT wired into `AIChatPanel.tsx` yet. The hook is complete and ready to use. `AIChatPanel.tsx` still uses its own inline state management. --- ## Step 1: Extract Shared `useAIChat` Hook **Status**: [x] 2026-02-27 **Prerequisites**: None **Creates**: Nothing new (hook file already exists at `src/renderer/hooks/useAIChat.ts`) **Modifies**: `src/renderer/components/ai/AIChatPanel.tsx` ### Files to Read First - `src/renderer/hooks/useAIChat.ts` (already created — read to understand the interface) - `src/renderer/components/ai/AIChatPanel.tsx` (the file to refactor) ### What Already Exists The file `src/renderer/hooks/useAIChat.ts` is already created with: - `ChatMessage` interface (id, role, content, error?) - `ChatContext` interface (type, projectId?, uiContext?) - `UseAIChatReturn` interface - `useAIChat(defaultContext)` hook that manages messages, input, isStreaming, streamingContent - `handleSend(overrideMessage?, overrideContext?)` — calls `trpc.ai.chat.useMutation()`, subscribes to `window.electronAI.onStreamChunk`, handles success/error - `clearMessages()` **Note**: The hook currently passes `uiContext` in the mutation call, but the tRPC schema doesn't accept it yet. That's fine — it will be ignored until Step 6 adds it to the schema. No errors will occur because tRPC strips unknown fields. ### What to Do Refactor `AIChatPanel.tsx` to consume `useAIChat` instead of managing chat state inline: 1. **Add imports** at the top of `AIChatPanel.tsx`: ```ts import { useMemo } from 'react'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; ``` 2. **Replace inline state** (lines 49-63 in the current file). Remove these: ```ts // REMOVE these lines: const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [isStreaming, setIsStreaming] = useState(false); const [streamingContent, setStreamingContent] = useState(''); const streamingContentRef = useRef(''); const chatMutation = trpc.ai.chat.useMutation(); ``` Replace with: ```ts const chatContext = useMemo( () => ({ type: contextType, ...(contextType === 'project' && projectId ? { projectId } : {}), }), [contextType, projectId], ); const { messages, input, setInput, isStreaming, streamingContent, handleSend: chatHandleSend, } = useAIChat(chatContext); ``` 3. **Replace the `handleSend` callback** (lines 118-184). Remove the entire `handleSend = useCallback(...)` block and replace with: ```ts const handleSend = useCallback(() => { if (briefLoading) return; chatHandleSend(); }, [briefLoading, chatHandleSend]); ``` 4. **Remove the inline `ChatMessage` interface** (lines 12-17) — it's now exported from the hook. 5. **Keep everything else** — daily brief logic, scroll logic, wheel handler, all UI rendering stays the same. ### Verification - [ ] `npm run lint` passes with no errors - [ ] `npm start` — the app launches - [ ] Home page: daily brief loads, greeting shows, chat works (type a message, get streaming response) - [ ] Navigate to a project with the curtain: Cmd+K opens AI, chat works in project context - [ ] No console errors related to streaming or mutations --- ## Step 2: Create Section Registry + `FloatingChatContext` **Status**: [x] 2026-02-27 **Prerequisites**: Step 1 completed **Creates**: `src/renderer/context/FloatingChatContext.tsx` **Modifies**: `src/renderer/components/layout/AppShell.tsx` ### Files to Read First - `src/renderer/components/layout/AppShell.tsx` (to understand where to add the provider) - `src/renderer/context/` (check if directory exists, create if not) ### What to Build Create a React context that manages: 1. **Section Registry** — pages register their sections on mount, unregister on unmount 2. **Floating Chat State** — isOpen, which section is active, position, morph target 3. **Actions** — open, close, move between sections, set morph target ```ts // src/renderer/context/FloatingChatContext.tsx import { createContext, useContext, useCallback, useState, useRef, type ReactNode, type RefObject } from 'react'; // ---------- Types ---------- export interface AISection { id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart" label: string; // Human-readable, e.g. "Tasks", "Project Timeline" ref: RefObject; projectId?: string; // If section is project-scoped } interface FloatingChatState { isOpen: boolean; activeSectionId: string | null; position: { x: number; y: number; width: number }; morphTargetId: string | null; projectId?: string; } interface FloatingChatContextValue { // State state: FloatingChatState; sections: Map; // Section registry registerSection: (section: AISection) => void; unregisterSection: (id: string) => void; // Actions openAtSection: (sectionId: string) => void; moveToSection: (sectionId: string) => void; close: () => void; setMorphTarget: (id: string | null) => void; } // ---------- Constants ---------- const CHAT_WIDTH = 380; const CHAT_HEIGHT = 420; const PADDING = 16; // ---------- Position computation ---------- function computeAnchorPosition(sectionRef: RefObject): { x: number; y: number; width: number } { const el = sectionRef.current; if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH }; const rect = el.getBoundingClientRect(); // Anchor to top-right of section, offset inward let x = rect.right - CHAT_WIDTH - PADDING; let y = rect.top + PADDING; // Edge-collision clamping x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)); y = Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)); return { x, y, width: CHAT_WIDTH }; } // ---------- Context ---------- const FloatingChatCtx = createContext(null); export function useFloatingChat(): FloatingChatContextValue { const ctx = useContext(FloatingChatCtx); if (!ctx) throw new Error('useFloatingChat must be used within FloatingChatProvider'); return ctx; } // Convenience hook for pages to register a section export function useAISection(section: AISection): void { const { registerSection, unregisterSection } = useFloatingChat(); // Register on mount, unregister on unmount // Use useEffect with the section id as dependency // NOTE: the implementing agent should use useEffect here: // useEffect(() => { // registerSection(section); // return () => unregisterSection(section.id); // }, [section.id]); } // ---------- Provider ---------- export function FloatingChatProvider({ children }: { children: ReactNode }) { const sectionsRef = useRef>(new Map()); const [sections, setSections] = useState>(new Map()); const [state, setState] = useState({ isOpen: false, activeSectionId: null, position: { x: 0, y: 0, width: CHAT_WIDTH }, morphTargetId: null, }); const registerSection = useCallback((section: AISection) => { sectionsRef.current.set(section.id, section); setSections(new Map(sectionsRef.current)); }, []); const unregisterSection = useCallback((id: string) => { sectionsRef.current.delete(id); setSections(new Map(sectionsRef.current)); }, []); const openAtSection = useCallback((sectionId: string) => { const section = sectionsRef.current.get(sectionId); if (!section) return; const position = computeAnchorPosition(section.ref); setState({ isOpen: true, activeSectionId: sectionId, position, morphTargetId: null, projectId: section.projectId, }); }, []); const moveToSection = useCallback((sectionId: string) => { const section = sectionsRef.current.get(sectionId); if (!section) return; const position = computeAnchorPosition(section.ref); setState((prev) => ({ ...prev, activeSectionId: sectionId, position, projectId: section.projectId, })); }, []); const close = useCallback(() => { setState((prev) => ({ ...prev, isOpen: false, activeSectionId: null, morphTargetId: null })); }, []); const setMorphTarget = useCallback((id: string | null) => { setState((prev) => ({ ...prev, morphTargetId: id })); }, []); return ( {children} ); } ``` ### Integration into AppShell In `src/renderer/components/layout/AppShell.tsx`: 1. Import `FloatingChatProvider` from the new context file 2. Wrap the outermost JSX (the `<>` fragment) with ``: ```tsx return ( <> ... {/* Token dialog */} ... ); ``` ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — app launches, no errors in console - [ ] All existing functionality unchanged (this step adds no visible UI changes) --- ## Step 3: Create Double-Click Hook **Status**: [x] 2026-02-27 **Prerequisites**: Step 2 completed **Creates**: `src/renderer/hooks/useDoubleClickAI.ts` **Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call) ### Files to Read First - `src/renderer/context/FloatingChatContext.tsx` (to understand `useFloatingChat` API) - `src/renderer/components/layout/AppShell.tsx` (where to call the hook) ### What to Build A hook that listens for `dblclick` events on the document and opens the floating chat at the appropriate section. ```ts // src/renderer/hooks/useDoubleClickAI.ts import { useEffect } from 'react'; import { useFloatingChat } from '@/context/FloatingChatContext'; // Elements where double-click should NOT trigger the AI popup const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); export function useDoubleClickAI(): void { const { openAtSection, state } = useFloatingChat(); useEffect(() => { const handler = (e: MouseEvent) => { const target = e.target as HTMLElement; // Skip interactive elements (preserve text selection behavior) if (INTERACTIVE_TAGS.has(target.tagName)) return; // Skip contenteditable elements UNLESS they're inside Milkdown if (target.isContentEditable) { const inMilkdown = target.closest('.milkdown-container') || target.closest('.crepe-editor'); if (!inMilkdown) return; // For Milkdown: only trigger if no text was selected by the double-click const selection = window.getSelection(); if (selection && selection.toString().trim().length > 0) return; } // Walk up DOM to find nearest [data-ai-section] const sectionEl = (target as Element).closest('[data-ai-section]'); if (!sectionEl) return; const sectionId = sectionEl.getAttribute('data-ai-section'); if (!sectionId) return; // If popup is already open at THIS section, do nothing (or focus input) if (state.isOpen && state.activeSectionId === sectionId) return; openAtSection(sectionId); }; document.addEventListener('dblclick', handler); return () => document.removeEventListener('dblclick', handler); }, [openAtSection, state.isOpen, state.activeSectionId]); } ``` ### Integration into AppShell In `src/renderer/components/layout/AppShell.tsx`: 1. Import the hook: `import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';` 2. Call it inside the `AppShell` component body (after the existing hooks): `useDoubleClickAI();` **Important**: The hook must be called INSIDE the `FloatingChatProvider` (which wraps AppShell's JSX). Since hooks are called in the component body (not in JSX), and `FloatingChatProvider` wraps the return value, you need to restructure slightly. The simplest approach: create an inner component `AppShellInner` that is wrapped by the provider: ```tsx export function AppShell({ children }: AppShellProps) { return ( {children} ); } function AppShellInner({ children }: AppShellProps) { useDoubleClickAI(); // ... all existing AppShell logic moves here ... } ``` ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — app launches - [ ] Double-click on a regular area (no `data-ai-section`) — nothing happens - [ ] Double-click on an input/textarea — nothing happens (text selection preserved) - [ ] No console errors (the floating chat UI doesn't exist yet, so openAtSection will set state but nothing renders — that's expected) --- ## Step 4: Build `FloatingChat` Component **Status**: [ ] **Prerequisites**: Steps 1-3 completed **Creates**: `src/renderer/components/ai/FloatingChat.tsx` **Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal) ### Files to Read First - `src/renderer/hooks/useAIChat.ts` (the hook to consume) - `src/renderer/context/FloatingChatContext.tsx` (state and actions) - `src/renderer/components/ai/AIChatPanel.tsx` (reference for message rendering and ChatMarkdown) - Check framer-motion version: `cat package.json | grep framer-motion` ### What to Build A floating popup rendered via `createPortal` to `document.body`. It uses: - `useAIChat` for chat logic - `useFloatingChat` for position, section, open/close state - Framer Motion `motion.div` with `layout` for smooth position transitions - `AnimatePresence` for enter/exit animations - Pointer-event dragging on the header ### Component Structure ``` FloatingChat.tsx exports: - FloatingChatPortal — portal wrapper (renders into document.body) Internal structure: AnimatePresence └─ motion.div (when isOpen) ├─ Header: drag handle + section label Badge + close (X) button ├─ ScrollArea: messages list (max-h ~300px) │ ├─ User messages (right-aligned, bg-muted) │ └─ Assistant messages (left-aligned, Sparkles + "Adiuva" label) │ └─ Streaming content or skeleton └─ Input bar: textarea + send button (ArrowUp icon) ``` ### Detailed Implementation Notes **Portal rendering**: ```tsx import { createPortal } from 'react-dom'; export function FloatingChatPortal() { return createPortal(, document.body); } ``` **Motion div** (the main container): ```tsx {state.isOpen && ( ... )} ``` **Dragging** (pointer events on header): ```tsx const dragState = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(null); const onPointerDown = (e: React.PointerEvent) => { dragState.current = { startX: e.clientX, startY: e.clientY, originX: state.position.x, originY: state.position.y, }; (e.target as HTMLElement).setPointerCapture(e.pointerId); }; const onPointerMove = (e: React.PointerEvent) => { if (!dragState.current) return; const dx = e.clientX - dragState.current.startX; const dy = e.clientY - dragState.current.startY; let newX = dragState.current.originX + dx; let newY = dragState.current.originY + dy; // Clamp newX = Math.max(16, Math.min(newX, window.innerWidth - CHAT_WIDTH - 16)); newY = Math.max(16, Math.min(newY, window.innerHeight - 420 - 16)); // Update position directly (avoid context re-render on every pixel) // Use a local ref for drag position and sync to context on pointerUp }; const onPointerUp = () => { dragState.current = null; }; ``` **Chat context** passed to `useAIChat`: ```tsx const activeSection = sections.get(state.activeSectionId ?? ''); const chatContext = useMemo(() => ({ type: activeSection?.projectId ? 'project' : 'global', projectId: activeSection?.projectId, uiContext: activeSection?.label, }), [activeSection]); const { messages, input, setInput, isStreaming, streamingContent, handleSend, clearMessages } = useAIChat(chatContext); ``` **Reuse ChatMarkdown**: Export `ChatMarkdown` from `AIChatPanel.tsx` (it's currently a private function). Or duplicate the small component inline in FloatingChat. Exporting is cleaner. **Message rendering**: Same pattern as AIChatPanel but compact (no min-height on last message, smaller text). **Resize/scroll listener**: Add a `useEffect` that listens for `window resize` and recalculates position from the active section ref: ```tsx useEffect(() => { if (!state.isOpen || !state.activeSectionId) return; const handleResize = () => { const section = sections.get(state.activeSectionId!); if (section) { // Recompute and update position } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [state.isOpen, state.activeSectionId, sections]); ``` **Close on Escape**: ```tsx useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape' && state.isOpen) close(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [state.isOpen, close]); ``` **Clear messages on close**: When the popup closes, call `clearMessages()` so it starts fresh next time. **Close on route change**: Watch the TanStack Router location and close the popup when the route changes. Import `useRouterState` from `@tanstack/react-router`. ### Integration into AppShell In `AppShellInner` (or wherever the inner component is after Step 3 restructure): 1. Import `FloatingChatPortal` 2. Render it as a sibling to the token dialog: ```tsx ``` ### Styling Reference Use these existing patterns from the codebase: - Glassmorphism: `bg-background/95 backdrop-blur-xl` - Border: `border border-border` - Shadow: `shadow-2xl` - Input: same `ChatInput` pattern from AIChatPanel (rounded-2xl, bg-background/70) - Badge: `{sectionLabel}` - Icons: `X`, `ArrowUp`, `Sparkles`, `GripHorizontal` from `lucide-react` ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — app launches - [ ] Temporarily add `data-ai-section="test"` to any div on the tasks page - [ ] Double-click that div — floating chat appears anchored to top-right of the section - [ ] Type a message and press Enter — streaming response appears - [ ] Drag the chat by its header — it moves, stays within screen bounds - [ ] Press Escape — chat closes - [ ] Navigate to another route — chat closes - [ ] Remove the temporary `data-ai-section` attribute after testing --- ## Step 5: Add `ai:action` IPC Side-Channel **Status**: [ ] **Prerequisites**: Step 4 completed **Modifies**: - `src/preload/trpc.ts` - `src/main/ai/orchestrator.ts` ### Files to Read First - `src/preload/trpc.ts` (see existing `ai:stream` pattern to replicate) - `src/main/ai/orchestrator.ts` (lines 176-204 for `addTaskTool`, lines 231-269 for `suggestCheckpointsTool`, lines 279-327 for `suggestTasksTool`) ### What to Do #### 5a. Preload — add `ai:action` channel In `src/preload/trpc.ts`, add a new channel after the existing `electronAI` block: ```ts const AI_ACTION_CHANNEL = 'ai:action'; ``` Extend the `electronAI` contextBridge object to include: ```ts contextBridge.exposeInMainWorld('electronAI', { // ... existing onStreamChunk ... /** Subscribe to AI action events (task created, suggestions, etc.). Returns unsubscribe. */ onAction: (cb: (data: { type: string; taskId?: string; count?: number }) => void) => { const handler = (_event: Electron.IpcRendererEvent, data: { type: string; taskId?: string; count?: number }) => cb(data); ipcRenderer.on(AI_ACTION_CHANNEL, handler); return () => { ipcRenderer.removeListener(AI_ACTION_CHANNEL, handler); }; }, }); ``` **Also update the TypeScript global type declaration** if one exists. Search for `interface Window` or `electronAI` type declarations in the renderer and add the `onAction` signature. #### 5b. Orchestrator — emit action events after tool execution In `src/main/ai/orchestrator.ts`: 1. Add a constant: `const AI_ACTION_CHANNEL = 'ai:action';` 2. Add a helper near `sendStreamChunk`: ```ts function sendAction(sender: Electron.WebContents | undefined, action: { type: string; taskId?: string; count?: number }): void { if (!sender || sender.isDestroyed()) return; sender.send(AI_ACTION_CHANNEL, action); } ``` 3. The `OrchestrateInput` type already has `sender`. But tools are built BEFORE `orchestrate()` is called, so the sender isn't available inside tool closures. **Solution**: store sender in a module-level variable that gets set at the start of `orchestrate()`: ```ts let currentSender: Electron.WebContents | undefined; ``` Set it at the start of `orchestrate()`: ```ts currentSender = sender; ``` 4. Modify the **project `add_task` tool** (line 176-204): - After `db.insert(tasks).values({...}).run();` add: ```ts sendAction(currentSender, { type: 'task_created', taskId: id }); ``` - Also fix: add `isAiSuggested: 1` to the values (currently missing — tasks created by AI should be marked) 5. Modify the **global `add_task` tool** (line 339-369): - Same: add `sendAction(currentSender, { type: 'task_created', taskId: id });` - Add `isAiSuggested: 1` to values 6. Modify `suggest_tasks` tool (after the insert loop, line ~313): ```ts sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length }); ``` 7. Modify `suggest_checkpoints` tool (after the insert loop, line ~264): ```ts sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length }); ``` ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — app launches - [ ] Ask the AI to create a task (in the existing curtain chat) — check dev console for the `ai:action` event being sent (add a `console.log` in the helper temporarily) - [ ] Ask the AI to suggest tasks — verify event fires - [ ] Remove any temporary console.logs --- ## Step 6: Pass `uiContext` Through to the AI **Status**: [ ] **Prerequisites**: Step 5 completed **Modifies**: - `src/main/router/index.ts` (line ~550-556) - `src/main/ai/orchestrator.ts` (OrchestrateInput type + system prompts) ### Files to Read First - `src/main/router/index.ts` (search for `chat: publicProcedure` — lines 549-568) - `src/main/ai/orchestrator.ts` (lines 32-36 for `OrchestrateInput`, lines 438-498 for system prompts) ### What to Do #### 6a. Router — extend input schema In `src/main/router/index.ts`, find the `chat` procedure's input schema (line ~550): ```ts // BEFORE: context: z.object({ type: z.enum(['global', 'project']), projectId: z.string().optional(), }), // AFTER: context: z.object({ type: z.enum(['global', 'project']), projectId: z.string().optional(), uiContext: z.string().optional(), }), ``` Pass it through to orchestrate (line ~559): ```ts // BEFORE: return await orchestrate({ message: input.message, context: input.context, sender: ctx.sender, }); // AFTER: return await orchestrate({ message: input.message, context: input.context, sender: ctx.sender, }); // No change needed here — context already passes through entirely ``` #### 6b. Orchestrator — update types and prompts Update `OrchestrateInput` (line ~32): ```ts export interface OrchestrateInput { message: string; context: { type: 'global' | 'project'; projectId?: string; uiContext?: string }; sender?: Electron.WebContents; } ``` Update `OrchestratorState` (line ~508): ```ts chatContext: Annotation<{ type: 'global' | 'project'; projectId?: string; uiContext?: string }>(), ``` Add a UI context instruction to each agent prompt function. At the **end** of each system prompt (before the closing backtick), append: ```ts // In makeProjectAgentPrompt, makeGeneralAgentPrompt, makeKnowledgeAgentPrompt: // Add a parameter: uiContext?: string // Then at the end of the prompt string, conditionally append: ${uiContext ? `\nThe user is currently viewing the "${uiContext}" section of the UI.\nIf your response relates to a different section (e.g., user asks about checkpoints while viewing Tasks), prefix your response with [SECTION:] where section-id matches one of: project-summary, project-timeline, project-tasks, project-notes, tasks-overview, tasks-list, timeline-chart, note-editor.\nOnly use this prefix when the answer clearly belongs in a different section than where the user currently is.` : ''} ``` Pass `uiContext` from the state into the agent functions. In `projectAgent`, `generalAgent`, and `knowledgeAgent`, read `state.chatContext.uiContext` and pass to the prompt builder. ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — app launches - [ ] Use the floating chat (from Step 4), ask a question — verify in main process console that the `uiContext` is present in the context - [ ] Ask a cross-section question (e.g., "what are the checkpoints?" while in tasks section) — verify the response includes `[SECTION:project-timeline]` prefix (it may or may not, depending on the LLM's response — this is probabilistic) --- ## Step 7: Implement Morph Animation (FLIP) **Status**: [ ] **Prerequisites**: Steps 4-5 completed **Modifies**: - `src/renderer/components/tasks/TaskRow.tsx` - `src/renderer/components/ai/FloatingChat.tsx` - `src/renderer/components/layout/AppShell.tsx` ### Files to Read First - `src/renderer/components/tasks/TaskRow.tsx` (the morph target) - `src/renderer/components/ai/FloatingChat.tsx` (the morph source) - `src/renderer/components/layout/AppShell.tsx` (LayoutGroup wrapper) - Framer Motion docs on `layoutId` and `LayoutGroup` ### What to Do #### 7a. TaskRow — accept optional `layoutId` In `src/renderer/components/tasks/TaskRow.tsx`: 1. Add `layoutId?: string` to the props interface 2. Import `motion` from `framer-motion` 3. Conditionally wrap the root div: - If `layoutId` is provided, use `` instead of `
` - If not provided, keep the plain `
` (avoid motion overhead on all rows) ```tsx const Wrapper = layoutId ? motion.div : 'div'; const wrapperProps = layoutId ? { layoutId, layout: true } : {}; return ( ... ... ); ``` #### 7b. FloatingChat — subscribe to `ai:action` and trigger morph In `FloatingChat.tsx`, add an effect that subscribes to `window.electronAI.onAction`: ```tsx const utils = trpc.useUtils(); useEffect(() => { if (!state.isOpen) return; const unsubscribe = window.electronAI.onAction((action) => { if (action.type === 'task_created' && action.taskId) { // 1. Invalidate task queries so the new task renders void utils.tasks.list.invalidate(); // 2. Set the morph target setMorphTarget(`task-morph-${action.taskId}`); // 3. Wait for the TaskRow to render, then close (triggering FLIP) requestAnimationFrame(() => { requestAnimationFrame(() => { close(); // AnimatePresence exit + shared layoutId → FLIP }); }); } }); return unsubscribe; }, [state.isOpen, utils, setMorphTarget, close]); ``` The `layoutId` on the `motion.div` in FloatingChat should be: ```tsx layoutId={state.morphTargetId ?? undefined} ``` #### 7c. AppShell — add LayoutGroup In `AppShellInner` (the inner component from Step 3), wrap the content area and `FloatingChatPortal` in a `LayoutGroup`: ```tsx import { LayoutGroup } from 'framer-motion'; // In the JSX: ... ``` **Note on portals + LayoutGroup**: Framer Motion's `LayoutGroup` may not propagate through React portals. If the FLIP animation doesn't work cross-portal, change `FloatingChatPortal` to render inside the `SidebarInset` at `z-[9999]` instead of using `createPortal`. Test this during implementation. #### 7d. Pass layoutId to the correct TaskRow In whatever component renders `TaskRow` (e.g., `tasks.tsx` or `KanbanBoard.tsx`), read `morphTargetId` from `useFloatingChat` and pass it: ```tsx const { state: floatingState } = useFloatingChat(); ``` ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — app launches - [ ] Open floating chat on the tasks section, ask "add a task: Test FLIP animation" - [ ] After the AI creates the task, the floating chat should morph/animate into the new TaskRow position - [ ] The task list should show the new task after the animation --- ## Step 8a: Page Interactions — Project Detail **Status**: [ ] **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/components/projects/ProjectDetail.tsx` ### Files to Read First - `src/renderer/components/projects/ProjectDetail.tsx` (full file) - `src/renderer/context/FloatingChatContext.tsx` (for `useAISection` pattern) ### What to Do Add `data-ai-section` attributes and register 4 sections: | Section ID | Label | Wrap around | |---|---|---| | `project-summary` | Project Summary | Stat cards grid + AI summary card | | `project-timeline` | Project Timeline | The timeline `
` containing the h2 "Project Timeline", GanttChart, and pending checkpoints | | `project-tasks` | Tasks | The tasks `
` containing h2 "Tasks", pending tasks, and KanbanBoard | | `project-notes` | Notes | The notes `
` containing h2 "Notes" and note cards | For each section: 1. Create a `useRef(null)` 2. Attach the ref to the wrapping div 3. Add `data-ai-section=""` to the div 4. Call `useAISection` or use the register/unregister pattern in a `useEffect`: ```tsx const { registerSection, unregisterSection } = useFloatingChat(); const tasksSectionRef = useRef(null); useEffect(() => { registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksSectionRef, projectId, }); return () => unregisterSection('project-tasks'); }, [projectId, registerSection, unregisterSection]); // In JSX:
...
``` Repeat for all 4 sections. ### Verification - [ ] `npm run lint` passes - [ ] `npm start` — navigate to a project - [ ] Double-click on the Tasks section → floating chat opens anchored to top-right of Tasks - [ ] Double-click on Timeline section → floating chat opens at Timeline (or moves if already open) - [ ] Double-click on Notes section → floating chat opens at Notes - [ ] Double-click on Summary/stat cards → floating chat opens there - [ ] Chat works in each section — messages send and stream correctly --- ## Step 8b: Page Interactions — Tasks Page **Status**: [ ] **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/routes/tasks.tsx` ### Files to Read First - `src/renderer/routes/tasks.tsx` (full file) ### What to Do Register 2 sections: | Section ID | Label | Wrap around | |---|---|---| | `tasks-overview` | Tasks Overview | The stat cards `
` | | `tasks-list` | Task List | Everything below the stat cards (search, filters, task rows) | Same pattern as 8a — create refs, add `data-ai-section` attributes, register in `useEffect`. **Note**: The tasks page is a route component, not inside a project. Do NOT pass `projectId` to `registerSection`. ### Verification - [ ] `npm run lint` passes - [ ] Navigate to `/tasks` - [ ] Double-click on stat cards area → floating chat opens at Tasks Overview - [ ] Double-click on task list area → floating chat opens at Task List - [ ] Chat works — can ask questions about tasks --- ## Step 8c: Page Interactions — Timeline Page **Status**: [ ] **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/routes/timeline.tsx` ### Files to Read First - `src/renderer/routes/timeline.tsx` (full file) ### What to Do Register 1 section: | Section ID | Label | Wrap around | |---|---|---| | `timeline-chart` | Timeline | The entire page content div | ### Verification - [ ] `npm run lint` passes - [ ] Navigate to `/timeline` - [ ] Double-click anywhere on the page → floating chat opens - [ ] Chat works — can ask about timeline/checkpoints --- ## Step 8d: Page Interactions — Notes Page (Milkdown) **Status**: [ ] **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/routes/notes.$noteId.tsx` ### Files to Read First - `src/renderer/routes/notes.$noteId.tsx` (full file) - `src/renderer/components/notes/MilkdownEditor.tsx` (understand the editor DOM) ### What to Do Register 1 section: | Section ID | Label | Wrap around | |---|---|---| | `note-editor` | Note Editor | The `` that contains the Milkdown editor | **Milkdown special handling**: The `useDoubleClickAI` hook (from Step 3) already handles Milkdown — it checks for `.milkdown-container` / `.crepe-editor` and only triggers if no text was selected. The `data-ai-section="note-editor"` attribute goes on the parent `` wrapper, not on the editor itself. **Positioning**: For the notes page, the popup should anchor to the **right side** of the editor area (not top-right), to avoid covering content. Adjust the section ref positioning or override with a CSS class hint. ### Verification - [ ] `npm run lint` passes - [ ] Navigate to a note - [ ] Double-click on empty space in the editor → floating chat opens on the right side - [ ] Double-click to select a word → text selection works, NO popup appears - [ ] Chat works — can ask "summarize this note" etc. --- ## Step 9: Section Auto-Scroll on Jump **Status**: [ ] **Prerequisites**: Steps 4, 6, and 8a completed **Modifies**: `src/renderer/components/ai/FloatingChat.tsx` ### Files to Read First - `src/renderer/components/ai/FloatingChat.tsx` (the component to modify) - `src/renderer/context/FloatingChatContext.tsx` (the `moveToSection` function) ### What to Do When the AI response contains a `[SECTION:xxx]` prefix, the floating chat should: 1. **Parse** the prefix from streamed content 2. **Strip** it from the displayed message 3. **Scroll** the page to the target section 4. **Move** the popup to the target section #### Implementation In `FloatingChat.tsx`, add an effect that watches `streamingContent` and `messages` for the `[SECTION:xxx]` pattern: ```tsx const SECTION_PREFIX_RE = /^\[SECTION:([\w-]+)\]\s*/; // Watch for section jumps in the latest assistant message useEffect(() => { const lastMsg = messages[messages.length - 1]; if (!lastMsg || lastMsg.role !== 'assistant') return; const match = lastMsg.content.match(SECTION_PREFIX_RE); if (!match) return; const targetSectionId = match[1]; if (targetSectionId === state.activeSectionId) return; const section = sections.get(targetSectionId); if (!section?.ref.current) return; // Scroll the section into view section.ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); // After scroll animation completes (~400ms), move the popup setTimeout(() => { moveToSection(targetSectionId); }, 400); }, [messages, state.activeSectionId, sections, moveToSection]); ``` **Strip the prefix** from displayed messages. In the message rendering loop, strip `[SECTION:xxx]` from the beginning of assistant messages: ```tsx const displayContent = msg.role === 'assistant' ? msg.content.replace(SECTION_PREFIX_RE, '') : msg.content; ``` Also strip from `streamingContent` before rendering. ### Verification - [ ] `npm run lint` passes - [ ] Open floating chat in the Tasks section of a project detail page - [ ] Ask "what are the upcoming checkpoints?" - [ ] If the AI prefixes with `[SECTION:project-timeline]`: - Page scrolls to the timeline section - Popup animates from tasks to timeline - The `[SECTION:...]` prefix is NOT visible in the message - [ ] If the AI doesn't use the prefix (probabilistic), the popup stays in place — that's correct behavior --- ## Architecture Reference ``` FloatingChatProvider (React Context) | +--------------------+--------------------+ | | | useDoubleClickAI FloatingChat Section Registry (document dblclick) (portal to body) (page-registered refs) | | | | useAIChat hook useAISection hook | (shared with (per-section registration) | AIChatPanel) | | +--------------------+--------------------- | tRPC ai.chat mutation + ai:stream IPC (tokens) + ai:action IPC (task_created, etc.) ``` ### IPC Channels | Channel | Direction | Payload | Purpose | |---------|-----------|---------|---------| | `trpc` | Renderer <-> Main | tRPC request/response | All queries and mutations | | `ai:stream` | Main -> Renderer | `{ token: string, done: boolean }` | Streaming AI text tokens | | `ai:action` | Main -> Renderer | `{ type: string, taskId?: string, count?: number }` | AI tool execution events | ### Section ID Map | Section ID | Page | Component | |---|---|---| | `project-summary` | /projects?projectId=X | ProjectDetail.tsx | | `project-timeline` | /projects?projectId=X | ProjectDetail.tsx | | `project-tasks` | /projects?projectId=X | ProjectDetail.tsx | | `project-notes` | /projects?projectId=X | ProjectDetail.tsx | | `tasks-overview` | /tasks | tasks.tsx | | `tasks-list` | /tasks | tasks.tsx | | `timeline-chart` | /timeline | timeline.tsx | | `note-editor` | /notes/:noteId | notes.$noteId.tsx |