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>
40 KiB
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
- 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." - 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.mdfor build/lint commands and conventions - Confirm understanding of the step's requirements with the user if anything is ambiguous
- Implement the step following the detailed instructions
- Verify using the step's verification checklist
- Run lint:
source ~/.nvm/nvm.sh && npm run lint - Update this guide: Change the step's status from
[ ]to[x]and add the date - Create a git commit with message:
feat(floating-ai): step X — <step title> - 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 intoAIChatPanel.tsxyet. The hook is complete and ready to use.AIChatPanel.tsxstill 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:
ChatMessageinterface (id, role, content, error?)ChatContextinterface (type, projectId?, uiContext?)UseAIChatReturninterfaceuseAIChat(defaultContext)hook that manages messages, input, isStreaming, streamingContenthandleSend(overrideMessage?, overrideContext?)— callstrpc.ai.chat.useMutation(), subscribes towindow.electronAI.onStreamChunk, handles success/errorclearMessages()
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:
-
Add imports at the top of
AIChatPanel.tsx:import { useMemo } from 'react'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; -
Replace inline state (lines 49-63 in the current file). Remove these:
// 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:
const chatContext = useMemo<ChatContext>( () => ({ type: contextType, ...(contextType === 'project' && projectId ? { projectId } : {}), }), [contextType, projectId], ); const { messages, input, setInput, isStreaming, streamingContent, handleSend: chatHandleSend, } = useAIChat(chatContext); -
Replace the
handleSendcallback (lines 118-184). Remove the entirehandleSend = useCallback(...)block and replace with:const handleSend = useCallback(() => { if (briefLoading) return; chatHandleSend(); }, [briefLoading, chatHandleSend]); -
Remove the inline
ChatMessageinterface (lines 12-17) — it's now exported from the hook. -
Keep everything else — daily brief logic, scroll logic, wheel handler, all UI rendering stays the same.
Verification
npm run lintpasses with no errorsnpm 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:
- Section Registry — pages register their sections on mount, unregister on unmount
- Floating Chat State — isOpen, which section is active, position, morph target
- Actions — open, close, move between sections, set morph target
// 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:
- Import
FloatingChatProviderfrom the new context file - Wrap the outermost JSX (the
<>fragment) with<FloatingChatProvider>:
return (
<FloatingChatProvider>
<>
<SidebarProvider ...>
...
</SidebarProvider>
{/* Token dialog */}
...
</>
</FloatingChatProvider>
);
Verification
npm run lintpassesnpm 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 understanduseFloatingChatAPI)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.
// 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:
- Import the hook:
import { useDoubleClickAI } from '@/hooks/useDoubleClickAI'; - Call it inside the
AppShellcomponent 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:
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 lintpassesnpm 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:
useAIChatfor chat logicuseFloatingChatfor position, section, open/close state- Framer Motion
motion.divwithlayoutfor smooth position transitions AnimatePresencefor 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:
import { createPortal } from 'react-dom';
export function FloatingChatPortal() {
return createPortal(<FloatingChat />, document.body);
}
Motion div (the main container):
<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):
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:
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:
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:
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):
- Import
FloatingChatPortal - Render it as a sibling to the token dialog:
<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
ChatInputpattern from AIChatPanel (rounded-2xl, bg-background/70) - Badge:
<Badge variant="outline">{sectionLabel}</Badge> - Icons:
X,ArrowUp,Sparkles,GripHorizontalfromlucide-react
Verification
npm run lintpassesnpm 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-sectionattribute after testing
Step 5: Add ai:action IPC Side-Channel
Status: [ ] Prerequisites: Step 4 completed Modifies:
src/preload/trpc.tssrc/main/ai/orchestrator.ts
Files to Read First
src/preload/trpc.ts(see existingai:streampattern to replicate)src/main/ai/orchestrator.ts(lines 176-204 foraddTaskTool, lines 231-269 forsuggestCheckpointsTool, lines 279-327 forsuggestTasksTool)
What to Do
5a. Preload — add ai:action channel
In src/preload/trpc.ts, add a new channel after the existing electronAI block:
const AI_ACTION_CHANNEL = 'ai:action';
Extend the electronAI contextBridge object to include:
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:
-
Add a constant:
const AI_ACTION_CHANNEL = 'ai:action'; -
Add a helper near
sendStreamChunk:
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);
}
- The
OrchestrateInputtype already hassender. But tools are built BEFOREorchestrate()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 oforchestrate():
let currentSender: Electron.WebContents | undefined;
Set it at the start of orchestrate():
currentSender = sender;
-
Modify the project
add_tasktool (line 176-204):- After
db.insert(tasks).values({...}).run();add:sendAction(currentSender, { type: 'task_created', taskId: id }); - Also fix: add
isAiSuggested: 1to the values (currently missing — tasks created by AI should be marked)
- After
-
Modify the global
add_tasktool (line 339-369):- Same: add
sendAction(currentSender, { type: 'task_created', taskId: id }); - Add
isAiSuggested: 1to values
- Same: add
-
Modify
suggest_taskstool (after the insert loop, line ~313):sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length }); -
Modify
suggest_checkpointstool (after the insert loop, line ~264):sendAction(currentSender, { type: 'checkpoints_suggested', count: parsed.length });
Verification
npm run lintpassesnpm start— app launches- Ask the AI to create a task (in the existing curtain chat) — check dev console for the
ai:actionevent being sent (add aconsole.login 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 forchat: publicProcedure— lines 549-568)src/main/ai/orchestrator.ts(lines 32-36 forOrchestrateInput, 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):
// 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):
// 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):
export interface OrchestrateInput {
message: string;
context: { type: 'global' | 'project'; projectId?: string; uiContext?: string };
sender?: Electron.WebContents;
}
Update OrchestratorState (line ~508):
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:
// 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 lintpassesnpm start— app launches- Use the floating chat (from Step 4), ask a question — verify in main process console that the
uiContextis 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.tsxsrc/renderer/components/ai/FloatingChat.tsxsrc/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
layoutIdandLayoutGroup
What to Do
7a. TaskRow — accept optional layoutId
In src/renderer/components/tasks/TaskRow.tsx:
- Add
layoutId?: stringto the props interface - Import
motionfromframer-motion - Conditionally wrap the root div:
- If
layoutIdis provided, use<motion.div layoutId={layoutId} ...>instead of<div ...> - If not provided, keep the plain
<div>(avoid motion overhead on all rows)
- If
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:
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:
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:
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:
const { state: floatingState } = useFloatingChat();
<TaskRow
key={task.id}
task={task}
layoutId={
floatingState.morphTargetId === `task-morph-${task.id}`
? floatingState.morphTargetId
: undefined
}
...
/>
Verification
npm run lintpassesnpm 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(foruseAISectionpattern)
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:
- Create a
useRef<HTMLDivElement>(null) - Attach the ref to the wrapping div
- Add
data-ai-section="<section-id>"to the div - Call
useAISectionor use the register/unregister pattern in auseEffect:
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 lintpassesnpm 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 lintpasses- 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 lintpasses- 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 lintpasses- 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(themoveToSectionfunction)
What to Do
When the AI response contains a [SECTION:xxx] prefix, the floating chat should:
- Parse the prefix from streamed content
- Strip it from the displayed message
- Scroll the page to the target section
- Move the popup to the target section
Implementation
In FloatingChat.tsx, add an effect that watches streamingContent and messages for the [SECTION:xxx] pattern:
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:
const displayContent = msg.role === 'assistant'
? msg.content.replace(SECTION_PREFIX_RE, '')
: msg.content;
Also strip from streamingContent before rendering.
Verification
npm run lintpasses- 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 |