Files
adiuva/docs/floating-ai-integration-guide.md
Roberto Musso 6cd121fa80 feat(floating-ai): step 4 — build FloatingChat component
Create the floating AI chat popup rendered via portal to document.body.
Uses useAIChat for chat logic, useFloatingChat for position/state,
Framer Motion for enter/exit animations, and pointer-event dragging.

Includes: close on Escape, close on route change, auto-scroll,
auto-focus, window resize clamping, and compact message rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 23:05:25 +01:00

1231 lines
40 KiB
Markdown

# 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 — <step title>`
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 | [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) | [ ] |
| 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<ChatMessage[]>([]);
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<ChatContext>(
() => ({
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<HTMLElement | null>;
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<string, AISection>;
// 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<HTMLElement | null>): { 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<FloatingChatContextValue | null>(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<Map<string, AISection>>(new Map());
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
const [state, setState] = useState<FloatingChatState>({
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 (
<FloatingChatCtx.Provider
value={{
state,
sections,
registerSection,
unregisterSection,
openAtSection,
moveToSection,
close,
setMorphTarget,
}}
>
{children}
</FloatingChatCtx.Provider>
);
}
```
### 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 `<FloatingChatProvider>`:
```tsx
return (
<FloatingChatProvider>
<>
<SidebarProvider ...>
...
</SidebarProvider>
{/* Token dialog */}
...
</>
</FloatingChatProvider>
);
```
### 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 (
<FloatingChatProvider>
<AppShellInner>{children}</AppShellInner>
</FloatingChatProvider>
);
}
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**: [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)
### 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(<FloatingChat />, document.body);
}
```
**Motion div** (the main container):
```tsx
<AnimatePresence>
{state.isOpen && (
<motion.div
key="floating-chat"
layout
layoutId={state.morphTargetId ?? undefined}
initial={{ opacity: 0, scale: 0.92, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.92, y: 8 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
style={{
position: 'fixed',
left: state.position.x,
top: state.position.y,
width: state.position.width,
zIndex: 9999,
}}
className="rounded-xl border border-border bg-background/95 backdrop-blur-xl shadow-2xl flex flex-col overflow-hidden"
>
...
</motion.div>
)}
</AnimatePresence>
```
**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<ChatContext>(() => ({
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
<FloatingChatPortal />
```
### 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: `<Badge variant="outline">{sectionLabel}</Badge>`
- 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:<section-id>] 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 `<motion.div layoutId={layoutId} ...>` instead of `<div ...>`
- If not provided, keep the plain `<div>` (avoid motion overhead on all rows)
```tsx
const Wrapper = layoutId ? motion.div : 'div';
const wrapperProps = layoutId ? { layoutId, layout: true } : {};
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<Wrapper
{...wrapperProps}
className={`flex flex-col gap-1.5 px-4 py-3 rounded-md border ...`}
>
...
</Wrapper>
</ContextMenuTrigger>
...
</ContextMenu>
);
```
#### 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:
<LayoutGroup>
<SidebarProvider ...>
...
</SidebarProvider>
<FloatingChatPortal />
</LayoutGroup>
```
**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();
<TaskRow
key={task.id}
task={task}
layoutId={
floatingState.morphTargetId === `task-morph-${task.id}`
? floatingState.morphTargetId
: undefined
}
...
/>
```
### 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 `<div className="flex flex-col gap-3">` containing the h2 "Project Timeline", GanttChart, and pending checkpoints |
| `project-tasks` | Tasks | The tasks `<div className="flex flex-col gap-3">` containing h2 "Tasks", pending tasks, and KanbanBoard |
| `project-notes` | Notes | The notes `<div className="flex flex-col gap-3">` containing h2 "Notes" and note cards |
For each section:
1. Create a `useRef<HTMLDivElement>(null)`
2. Attach the ref to the wrapping div
3. Add `data-ai-section="<section-id>"` to the div
4. Call `useAISection` or use the register/unregister pattern in a `useEffect`:
```tsx
const { registerSection, unregisterSection } = useFloatingChat();
const tasksSectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
registerSection({
id: 'project-tasks',
label: 'Tasks',
ref: tasksSectionRef,
projectId,
});
return () => unregisterSection('project-tasks');
}, [projectId, registerSection, unregisterSection]);
// In JSX:
<div ref={tasksSectionRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
...
</div>
```
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 `<div className="grid grid-cols-4 gap-4">` |
| `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 `<ScrollArea>` 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 `<ScrollArea>` 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 |