diff --git a/docs/floating-ai-integration-guide.md b/docs/floating-ai-integration-guide.md new file mode 100644 index 0000000..c7eacd5 --- /dev/null +++ b/docs/floating-ai-integration-guide.md @@ -0,0 +1,1230 @@ +# 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: _"Read `docs/floating-ai-integration-guide.md` and implement Step X"_ +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 | [ ] | +| 2 | Create section registry + `FloatingChatContext` | [ ] | +| 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**: [ ] +**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**: [ ] +**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**: [ ] +**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 | diff --git a/src/renderer/hooks/useAIChat.ts b/src/renderer/hooks/useAIChat.ts new file mode 100644 index 0000000..784d5c3 --- /dev/null +++ b/src/renderer/hooks/useAIChat.ts @@ -0,0 +1,130 @@ +import { useState, useCallback, useRef } from 'react'; +import { trpc } from '@/lib/trpc'; + +export interface ChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + error?: boolean; +} + +export interface ChatContext { + type: 'global' | 'project'; + projectId?: string; + uiContext?: string; +} + +export interface UseAIChatReturn { + messages: ChatMessage[]; + input: string; + setInput: (v: string) => void; + isStreaming: boolean; + streamingContent: string; + handleSend: (overrideMessage?: string, overrideContext?: ChatContext) => void; + clearMessages: () => void; +} + +export function useAIChat(defaultContext: ChatContext): UseAIChatReturn { + 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(); + + const clearMessages = useCallback(() => { + setMessages([]); + setStreamingContent(''); + streamingContentRef.current = ''; + }, []); + + const handleSend = useCallback( + (overrideMessage?: string, overrideContext?: ChatContext) => { + const trimmed = (overrideMessage ?? input).trim(); + if (!trimmed || isStreaming) return; + + const userMsg: ChatMessage = { + id: crypto.randomUUID(), + role: 'user', + content: trimmed, + }; + + setMessages((prev) => [...prev, userMsg]); + if (!overrideMessage) setInput(''); + setIsStreaming(true); + setStreamingContent(''); + streamingContentRef.current = ''; + + const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => { + if (done) { + const finalContent = streamingContentRef.current; + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: 'assistant', content: finalContent }, + ]); + setStreamingContent(''); + streamingContentRef.current = ''; + setIsStreaming(false); + unsubscribe(); + return; + } + streamingContentRef.current += token; + setStreamingContent(streamingContentRef.current); + }); + + const ctx = overrideContext ?? defaultContext; + + chatMutation.mutate( + { + message: trimmed, + context: { + type: ctx.type, + ...(ctx.type === 'project' && ctx.projectId ? { projectId: ctx.projectId } : {}), + ...(ctx.uiContext ? { uiContext: ctx.uiContext } : {}), + }, + }, + { + onSuccess: (data) => { + if (data.error) { + unsubscribe(); + setMessages((prev) => [ + ...prev, + { id: crypto.randomUUID(), role: 'assistant', content: data.error!, error: true }, + ]); + setStreamingContent(''); + streamingContentRef.current = ''; + setIsStreaming(false); + } + }, + onError: (err) => { + unsubscribe(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + role: 'assistant', + content: err.message || 'An unexpected error occurred.', + error: true, + }, + ]); + setStreamingContent(''); + streamingContentRef.current = ''; + setIsStreaming(false); + }, + }, + ); + }, + [input, isStreaming, defaultContext, chatMutation], + ); + + return { + messages, + input, + setInput, + isStreaming, + streamingContent, + handleSend, + clearMessages, + }; +}