Files
adiuva/docs/floating-ai-integration-guide.md
Roberto Musso 60b76c6d97 feat(floating-ai): step 7 — implement morph animation (FLIP)
Add FLIP animation so the floating chat visually morphs into a newly-created
TaskRow when the AI creates a task. Uses Framer Motion's shared layoutId
across FloatingChat and TaskRow, with LayoutGroup wrapping the app shell.

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

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

  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 [x] 2026-02-28
6 Pass uiContext through to the AI [x] 2026-02-28
7 Implement morph animation (FLIP) [x] 2026-02-28
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:

    import { useMemo } from 'react';
    import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
    
  2. 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);
    
  3. Replace the handleSend callback (lines 118-184). Remove the entire handleSend = useCallback(...) block and replace with:

    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
// 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>:
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.

// 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:

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:

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):

  1. Import FloatingChatPortal
  2. 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 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: [x] (2026-02-28) 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:

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:

  1. Add a constant: const AI_ACTION_CHANNEL = 'ai:action';

  2. 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);
}
  1. 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():
let currentSender: Electron.WebContents | undefined;

Set it at the start of orchestrate():

currentSender = sender;
  1. Modify the project add_task tool (line 176-204):

    • After db.insert(tasks).values({...}).run(); add:
      sendAction(currentSender, { type: 'task_created', taskId: id });
      
    • Also fix: add isAiSuggested: 1 to the values (currently missing — tasks created by AI should be marked)
  2. Modify the global add_task tool (line 339-369):

    • Same: add sendAction(currentSender, { type: 'task_created', taskId: id });
    • Add isAiSuggested: 1 to values
  3. Modify suggest_tasks tool (after the insert loop, line ~313):

    sendAction(currentSender, { type: 'tasks_suggested', count: parsed.length });
    
  4. Modify suggest_checkpoints tool (after the insert loop, line ~264):

    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: [x] (2026-02-28) 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):

// 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 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)
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 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:
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:

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 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