Files
adiuva/docs/floating-ai-integration-guide.md
Roberto Musso 96101e4310 feat(floating-ai): step 1 — extract shared useAIChat hook
Refactor AIChatPanel to consume the existing useAIChat hook instead of
managing chat state inline. Removes duplicate ChatMessage interface,
inline state (messages, input, isStreaming, streamingContent), and the
65-line handleSend callback, replacing them with a single useAIChat()
call and a thin briefLoading guard wrapper.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 22:27:11 +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` | [ ] |
| 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<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**: [ ]
**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**: [ ]
**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**: [ ]
**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 |