Register AI sections across all content pages with dual-anchor scroll tracking, cross-page navigation via [SECTION:xxx] tags, and right-margin positioning for the notes editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1231 lines
41 KiB
Markdown
1231 lines
41 KiB
Markdown
# Floating AI Chatbot — Step-by-Step Integration Guide
|
|
|
|
---
|
|
|
|
## How to Use This Guide (Read This First)
|
|
|
|
This document is designed to be consumed **one step at a time across multiple Claude Code sessions**. Each step is self-contained with all the context needed to implement it.
|
|
|
|
### Workflow Protocol for Each Step
|
|
|
|
1. **Start a new chat** and say: _"Implement Step [X] from `docs/floating-ai-integration-guide.md`. Use a subagent to extract the general rules and only the data relevant to Step [X], completely ignoring the other steps."_
|
|
2. **Before writing any code**, the agent MUST:
|
|
- Read ALL files listed in the step's "Files to Read First" section
|
|
- Read the project's `CLAUDE.md` for build/lint commands and conventions
|
|
- Confirm understanding of the step's requirements with the user if anything is ambiguous
|
|
3. **Implement the step** following the detailed instructions
|
|
4. **Verify** using the step's verification checklist
|
|
5. **Run lint**: `source ~/.nvm/nvm.sh && npm run lint`
|
|
6. **Update this guide**: Change the step's status from `[ ]` to `[x]` and add the date
|
|
7. **Create a git commit** with message: `feat(floating-ai): step X — <step title>`
|
|
8. **Confirm completion** to the user before stopping
|
|
|
|
### Step Dependencies
|
|
|
|
Steps MUST be implemented in order. Each step lists its prerequisites.
|
|
|
|
### Current State
|
|
|
|
> **Last updated**: 2026-02-26
|
|
> **Branch**: `mvp`
|
|
|
|
| Step | Title | Status |
|
|
|------|-------|--------|
|
|
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 |
|
|
| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 |
|
|
| 3 | Create double-click hook | [x] 2026-02-27 |
|
|
| 4 | Build `FloatingChat` component | [x] 2026-02-27 |
|
|
| 5 | Add `ai:action` IPC side-channel | [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`:
|
|
```ts
|
|
import { useMemo } from 'react';
|
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
|
```
|
|
|
|
2. **Replace inline state** (lines 49-63 in the current file). Remove these:
|
|
```ts
|
|
// REMOVE these lines:
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
const [input, setInput] = useState('');
|
|
const [isStreaming, setIsStreaming] = useState(false);
|
|
const [streamingContent, setStreamingContent] = useState('');
|
|
const streamingContentRef = useRef('');
|
|
const chatMutation = trpc.ai.chat.useMutation();
|
|
```
|
|
|
|
Replace with:
|
|
```ts
|
|
const chatContext = useMemo<ChatContext>(
|
|
() => ({
|
|
type: contextType,
|
|
...(contextType === 'project' && projectId ? { projectId } : {}),
|
|
}),
|
|
[contextType, projectId],
|
|
);
|
|
const {
|
|
messages,
|
|
input,
|
|
setInput,
|
|
isStreaming,
|
|
streamingContent,
|
|
handleSend: chatHandleSend,
|
|
} = useAIChat(chatContext);
|
|
```
|
|
|
|
3. **Replace the `handleSend` callback** (lines 118-184). Remove the entire `handleSend = useCallback(...)` block and replace with:
|
|
```ts
|
|
const handleSend = useCallback(() => {
|
|
if (briefLoading) return;
|
|
chatHandleSend();
|
|
}, [briefLoading, chatHandleSend]);
|
|
```
|
|
|
|
4. **Remove the inline `ChatMessage` interface** (lines 12-17) — it's now exported from the hook.
|
|
|
|
5. **Keep everything else** — daily brief logic, scroll logic, wheel handler, all UI rendering stays the same.
|
|
|
|
### Verification
|
|
|
|
- [ ] `npm run lint` passes with no errors
|
|
- [ ] `npm start` — the app launches
|
|
- [ ] Home page: daily brief loads, greeting shows, chat works (type a message, get streaming response)
|
|
- [ ] Navigate to a project with the curtain: Cmd+K opens AI, chat works in project context
|
|
- [ ] No console errors related to streaming or mutations
|
|
|
|
---
|
|
|
|
## Step 2: Create Section Registry + `FloatingChatContext`
|
|
|
|
**Status**: [x] 2026-02-27
|
|
**Prerequisites**: Step 1 completed
|
|
**Creates**: `src/renderer/context/FloatingChatContext.tsx`
|
|
**Modifies**: `src/renderer/components/layout/AppShell.tsx`
|
|
|
|
### Files to Read First
|
|
|
|
- `src/renderer/components/layout/AppShell.tsx` (to understand where to add the provider)
|
|
- `src/renderer/context/` (check if directory exists, create if not)
|
|
|
|
### What to Build
|
|
|
|
Create a React context that manages:
|
|
|
|
1. **Section Registry** — pages register their sections on mount, unregister on unmount
|
|
2. **Floating Chat State** — isOpen, which section is active, position, morph target
|
|
3. **Actions** — open, close, move between sections, set morph target
|
|
|
|
```ts
|
|
// src/renderer/context/FloatingChatContext.tsx
|
|
|
|
import { createContext, useContext, useCallback, useState, useRef, type ReactNode, type RefObject } from 'react';
|
|
|
|
// ---------- Types ----------
|
|
|
|
export interface AISection {
|
|
id: string; // e.g. "project-tasks", "tasks-list", "timeline-chart"
|
|
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
|
ref: RefObject<HTMLElement | null>;
|
|
projectId?: string; // If section is project-scoped
|
|
}
|
|
|
|
interface FloatingChatState {
|
|
isOpen: boolean;
|
|
activeSectionId: string | null;
|
|
position: { x: number; y: number; width: number };
|
|
morphTargetId: string | null;
|
|
projectId?: string;
|
|
}
|
|
|
|
interface FloatingChatContextValue {
|
|
// State
|
|
state: FloatingChatState;
|
|
sections: Map<string, AISection>;
|
|
|
|
// Section registry
|
|
registerSection: (section: AISection) => void;
|
|
unregisterSection: (id: string) => void;
|
|
|
|
// Actions
|
|
openAtSection: (sectionId: string) => void;
|
|
moveToSection: (sectionId: string) => void;
|
|
close: () => void;
|
|
setMorphTarget: (id: string | null) => void;
|
|
}
|
|
|
|
// ---------- Constants ----------
|
|
|
|
const CHAT_WIDTH = 380;
|
|
const CHAT_HEIGHT = 420;
|
|
const PADDING = 16;
|
|
|
|
// ---------- Position computation ----------
|
|
|
|
function computeAnchorPosition(sectionRef: RefObject<HTMLElement | null>): { x: number; y: number; width: number } {
|
|
const el = sectionRef.current;
|
|
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
|
|
|
|
const rect = el.getBoundingClientRect();
|
|
|
|
// Anchor to top-right of section, offset inward
|
|
let x = rect.right - CHAT_WIDTH - PADDING;
|
|
let y = rect.top + PADDING;
|
|
|
|
// Edge-collision clamping
|
|
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
|
|
y = Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING));
|
|
|
|
return { x, y, width: CHAT_WIDTH };
|
|
}
|
|
|
|
// ---------- Context ----------
|
|
|
|
const FloatingChatCtx = createContext<FloatingChatContextValue | null>(null);
|
|
|
|
export function useFloatingChat(): FloatingChatContextValue {
|
|
const ctx = useContext(FloatingChatCtx);
|
|
if (!ctx) throw new Error('useFloatingChat must be used within FloatingChatProvider');
|
|
return ctx;
|
|
}
|
|
|
|
// Convenience hook for pages to register a section
|
|
export function useAISection(section: AISection): void {
|
|
const { registerSection, unregisterSection } = useFloatingChat();
|
|
|
|
// Register on mount, unregister on unmount
|
|
// Use useEffect with the section id as dependency
|
|
// NOTE: the implementing agent should use useEffect here:
|
|
// useEffect(() => {
|
|
// registerSection(section);
|
|
// return () => unregisterSection(section.id);
|
|
// }, [section.id]);
|
|
}
|
|
|
|
// ---------- Provider ----------
|
|
|
|
export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
|
const sectionsRef = useRef<Map<string, AISection>>(new Map());
|
|
const [sections, setSections] = useState<Map<string, AISection>>(new Map());
|
|
const [state, setState] = useState<FloatingChatState>({
|
|
isOpen: false,
|
|
activeSectionId: null,
|
|
position: { x: 0, y: 0, width: CHAT_WIDTH },
|
|
morphTargetId: null,
|
|
});
|
|
|
|
const registerSection = useCallback((section: AISection) => {
|
|
sectionsRef.current.set(section.id, section);
|
|
setSections(new Map(sectionsRef.current));
|
|
}, []);
|
|
|
|
const unregisterSection = useCallback((id: string) => {
|
|
sectionsRef.current.delete(id);
|
|
setSections(new Map(sectionsRef.current));
|
|
}, []);
|
|
|
|
const openAtSection = useCallback((sectionId: string) => {
|
|
const section = sectionsRef.current.get(sectionId);
|
|
if (!section) return;
|
|
|
|
const position = computeAnchorPosition(section.ref);
|
|
|
|
setState({
|
|
isOpen: true,
|
|
activeSectionId: sectionId,
|
|
position,
|
|
morphTargetId: null,
|
|
projectId: section.projectId,
|
|
});
|
|
}, []);
|
|
|
|
const moveToSection = useCallback((sectionId: string) => {
|
|
const section = sectionsRef.current.get(sectionId);
|
|
if (!section) return;
|
|
|
|
const position = computeAnchorPosition(section.ref);
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
activeSectionId: sectionId,
|
|
position,
|
|
projectId: section.projectId,
|
|
}));
|
|
}, []);
|
|
|
|
const close = useCallback(() => {
|
|
setState((prev) => ({ ...prev, isOpen: false, activeSectionId: null, morphTargetId: null }));
|
|
}, []);
|
|
|
|
const setMorphTarget = useCallback((id: string | null) => {
|
|
setState((prev) => ({ ...prev, morphTargetId: id }));
|
|
}, []);
|
|
|
|
return (
|
|
<FloatingChatCtx.Provider
|
|
value={{
|
|
state,
|
|
sections,
|
|
registerSection,
|
|
unregisterSection,
|
|
openAtSection,
|
|
moveToSection,
|
|
close,
|
|
setMorphTarget,
|
|
}}
|
|
>
|
|
{children}
|
|
</FloatingChatCtx.Provider>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Integration into AppShell
|
|
|
|
In `src/renderer/components/layout/AppShell.tsx`:
|
|
|
|
1. Import `FloatingChatProvider` from the new context file
|
|
2. Wrap the outermost JSX (the `<>` fragment) with `<FloatingChatProvider>`:
|
|
|
|
```tsx
|
|
return (
|
|
<FloatingChatProvider>
|
|
<>
|
|
<SidebarProvider ...>
|
|
...
|
|
</SidebarProvider>
|
|
{/* Token dialog */}
|
|
...
|
|
</>
|
|
</FloatingChatProvider>
|
|
);
|
|
```
|
|
|
|
### Verification
|
|
|
|
- [ ] `npm run lint` passes
|
|
- [ ] `npm start` — app launches, no errors in console
|
|
- [ ] All existing functionality unchanged (this step adds no visible UI changes)
|
|
|
|
---
|
|
|
|
## Step 3: Create Double-Click Hook
|
|
|
|
**Status**: [x] 2026-02-27
|
|
**Prerequisites**: Step 2 completed
|
|
**Creates**: `src/renderer/hooks/useDoubleClickAI.ts`
|
|
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (add hook call)
|
|
|
|
### Files to Read First
|
|
|
|
- `src/renderer/context/FloatingChatContext.tsx` (to understand `useFloatingChat` API)
|
|
- `src/renderer/components/layout/AppShell.tsx` (where to call the hook)
|
|
|
|
### What to Build
|
|
|
|
A hook that listens for `dblclick` events on the document and opens the floating chat at the appropriate section.
|
|
|
|
```ts
|
|
// src/renderer/hooks/useDoubleClickAI.ts
|
|
|
|
import { useEffect } from 'react';
|
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
|
|
|
// Elements where double-click should NOT trigger the AI popup
|
|
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
|
|
export function useDoubleClickAI(): void {
|
|
const { openAtSection, state } = useFloatingChat();
|
|
|
|
useEffect(() => {
|
|
const handler = (e: MouseEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
|
|
// Skip interactive elements (preserve text selection behavior)
|
|
if (INTERACTIVE_TAGS.has(target.tagName)) return;
|
|
|
|
// Skip contenteditable elements UNLESS they're inside Milkdown
|
|
if (target.isContentEditable) {
|
|
const inMilkdown = target.closest('.milkdown-container') || target.closest('.crepe-editor');
|
|
if (!inMilkdown) return;
|
|
// For Milkdown: only trigger if no text was selected by the double-click
|
|
const selection = window.getSelection();
|
|
if (selection && selection.toString().trim().length > 0) return;
|
|
}
|
|
|
|
// Walk up DOM to find nearest [data-ai-section]
|
|
const sectionEl = (target as Element).closest('[data-ai-section]');
|
|
if (!sectionEl) return;
|
|
|
|
const sectionId = sectionEl.getAttribute('data-ai-section');
|
|
if (!sectionId) return;
|
|
|
|
// If popup is already open at THIS section, do nothing (or focus input)
|
|
if (state.isOpen && state.activeSectionId === sectionId) return;
|
|
|
|
openAtSection(sectionId);
|
|
};
|
|
|
|
document.addEventListener('dblclick', handler);
|
|
return () => document.removeEventListener('dblclick', handler);
|
|
}, [openAtSection, state.isOpen, state.activeSectionId]);
|
|
}
|
|
```
|
|
|
|
### Integration into AppShell
|
|
|
|
In `src/renderer/components/layout/AppShell.tsx`:
|
|
|
|
1. Import the hook: `import { useDoubleClickAI } from '@/hooks/useDoubleClickAI';`
|
|
2. Call it inside the `AppShell` component body (after the existing hooks): `useDoubleClickAI();`
|
|
|
|
**Important**: The hook must be called INSIDE the `FloatingChatProvider` (which wraps AppShell's JSX). Since hooks are called in the component body (not in JSX), and `FloatingChatProvider` wraps the return value, you need to restructure slightly. The simplest approach: create an inner component `AppShellInner` that is wrapped by the provider:
|
|
|
|
```tsx
|
|
export function AppShell({ children }: AppShellProps) {
|
|
return (
|
|
<FloatingChatProvider>
|
|
<AppShellInner>{children}</AppShellInner>
|
|
</FloatingChatProvider>
|
|
);
|
|
}
|
|
|
|
function AppShellInner({ children }: AppShellProps) {
|
|
useDoubleClickAI();
|
|
// ... all existing AppShell logic moves here ...
|
|
}
|
|
```
|
|
|
|
### Verification
|
|
|
|
- [ ] `npm run lint` passes
|
|
- [ ] `npm start` — app launches
|
|
- [ ] Double-click on a regular area (no `data-ai-section`) — nothing happens
|
|
- [ ] Double-click on an input/textarea — nothing happens (text selection preserved)
|
|
- [ ] No console errors (the floating chat UI doesn't exist yet, so openAtSection will set state but nothing renders — that's expected)
|
|
|
|
---
|
|
|
|
## Step 4: Build `FloatingChat` Component
|
|
|
|
**Status**: [x] 2026-02-27
|
|
**Prerequisites**: Steps 1-3 completed
|
|
**Creates**: `src/renderer/components/ai/FloatingChat.tsx`
|
|
**Modifies**: `src/renderer/components/layout/AppShell.tsx` (render the portal)
|
|
|
|
### Files to Read First
|
|
|
|
- `src/renderer/hooks/useAIChat.ts` (the hook to consume)
|
|
- `src/renderer/context/FloatingChatContext.tsx` (state and actions)
|
|
- `src/renderer/components/ai/AIChatPanel.tsx` (reference for message rendering and ChatMarkdown)
|
|
- Check framer-motion version: `cat package.json | grep framer-motion`
|
|
|
|
### What to Build
|
|
|
|
A floating popup rendered via `createPortal` to `document.body`. It uses:
|
|
- `useAIChat` for chat logic
|
|
- `useFloatingChat` for position, section, open/close state
|
|
- Framer Motion `motion.div` with `layout` for smooth position transitions
|
|
- `AnimatePresence` for enter/exit animations
|
|
- Pointer-event dragging on the header
|
|
|
|
### Component Structure
|
|
|
|
```
|
|
FloatingChat.tsx exports:
|
|
- FloatingChatPortal — portal wrapper (renders into document.body)
|
|
|
|
Internal structure:
|
|
AnimatePresence
|
|
└─ motion.div (when isOpen)
|
|
├─ Header: drag handle + section label Badge + close (X) button
|
|
├─ ScrollArea: messages list (max-h ~300px)
|
|
│ ├─ User messages (right-aligned, bg-muted)
|
|
│ └─ Assistant messages (left-aligned, Sparkles + "Adiuva" label)
|
|
│ └─ Streaming content or skeleton
|
|
└─ Input bar: textarea + send button (ArrowUp icon)
|
|
```
|
|
|
|
### Detailed Implementation Notes
|
|
|
|
**Portal rendering**:
|
|
```tsx
|
|
import { createPortal } from 'react-dom';
|
|
|
|
export function FloatingChatPortal() {
|
|
return createPortal(<FloatingChat />, document.body);
|
|
}
|
|
```
|
|
|
|
**Motion div** (the main container):
|
|
```tsx
|
|
<AnimatePresence>
|
|
{state.isOpen && (
|
|
<motion.div
|
|
key="floating-chat"
|
|
layout
|
|
layoutId={state.morphTargetId ?? undefined}
|
|
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
style={{
|
|
position: 'fixed',
|
|
left: state.position.x,
|
|
top: state.position.y,
|
|
width: state.position.width,
|
|
zIndex: 9999,
|
|
}}
|
|
className="rounded-xl border border-border bg-background/95 backdrop-blur-xl shadow-2xl flex flex-col overflow-hidden"
|
|
>
|
|
...
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
```
|
|
|
|
**Dragging** (pointer events on header):
|
|
```tsx
|
|
const dragState = useRef<{ startX: number; startY: number; originX: number; originY: number } | null>(null);
|
|
|
|
const onPointerDown = (e: React.PointerEvent) => {
|
|
dragState.current = {
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
originX: state.position.x,
|
|
originY: state.position.y,
|
|
};
|
|
(e.target as HTMLElement).setPointerCapture(e.pointerId);
|
|
};
|
|
|
|
const onPointerMove = (e: React.PointerEvent) => {
|
|
if (!dragState.current) return;
|
|
const dx = e.clientX - dragState.current.startX;
|
|
const dy = e.clientY - dragState.current.startY;
|
|
let newX = dragState.current.originX + dx;
|
|
let newY = dragState.current.originY + dy;
|
|
// Clamp
|
|
newX = Math.max(16, Math.min(newX, window.innerWidth - CHAT_WIDTH - 16));
|
|
newY = Math.max(16, Math.min(newY, window.innerHeight - 420 - 16));
|
|
// Update position directly (avoid context re-render on every pixel)
|
|
// Use a local ref for drag position and sync to context on pointerUp
|
|
};
|
|
|
|
const onPointerUp = () => {
|
|
dragState.current = null;
|
|
};
|
|
```
|
|
|
|
**Chat context** passed to `useAIChat`:
|
|
```tsx
|
|
const activeSection = sections.get(state.activeSectionId ?? '');
|
|
const chatContext = useMemo<ChatContext>(() => ({
|
|
type: activeSection?.projectId ? 'project' : 'global',
|
|
projectId: activeSection?.projectId,
|
|
uiContext: activeSection?.label,
|
|
}), [activeSection]);
|
|
|
|
const { messages, input, setInput, isStreaming, streamingContent, handleSend, clearMessages } = useAIChat(chatContext);
|
|
```
|
|
|
|
**Reuse ChatMarkdown**: Export `ChatMarkdown` from `AIChatPanel.tsx` (it's currently a private function). Or duplicate the small component inline in FloatingChat. Exporting is cleaner.
|
|
|
|
**Message rendering**: Same pattern as AIChatPanel but compact (no min-height on last message, smaller text).
|
|
|
|
**Resize/scroll listener**: Add a `useEffect` that listens for `window resize` and recalculates position from the active section ref:
|
|
```tsx
|
|
useEffect(() => {
|
|
if (!state.isOpen || !state.activeSectionId) return;
|
|
const handleResize = () => {
|
|
const section = sections.get(state.activeSectionId!);
|
|
if (section) {
|
|
// Recompute and update position
|
|
}
|
|
};
|
|
window.addEventListener('resize', handleResize);
|
|
return () => window.removeEventListener('resize', handleResize);
|
|
}, [state.isOpen, state.activeSectionId, sections]);
|
|
```
|
|
|
|
**Close on Escape**:
|
|
```tsx
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape' && state.isOpen) close();
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [state.isOpen, close]);
|
|
```
|
|
|
|
**Clear messages on close**: When the popup closes, call `clearMessages()` so it starts fresh next time.
|
|
|
|
**Close on route change**: Watch the TanStack Router location and close the popup when the route changes. Import `useRouterState` from `@tanstack/react-router`.
|
|
|
|
### Integration into AppShell
|
|
|
|
In `AppShellInner` (or wherever the inner component is after Step 3 restructure):
|
|
|
|
1. Import `FloatingChatPortal`
|
|
2. Render it as a sibling to the token dialog:
|
|
```tsx
|
|
<FloatingChatPortal />
|
|
```
|
|
|
|
### Styling Reference
|
|
|
|
Use these existing patterns from the codebase:
|
|
- Glassmorphism: `bg-background/95 backdrop-blur-xl`
|
|
- Border: `border border-border`
|
|
- Shadow: `shadow-2xl`
|
|
- Input: same `ChatInput` pattern from AIChatPanel (rounded-2xl, bg-background/70)
|
|
- Badge: `<Badge variant="outline">{sectionLabel}</Badge>`
|
|
- Icons: `X`, `ArrowUp`, `Sparkles`, `GripHorizontal` from `lucide-react`
|
|
|
|
### Verification
|
|
|
|
- [ ] `npm run lint` passes
|
|
- [ ] `npm start` — app launches
|
|
- [ ] Temporarily add `data-ai-section="test"` to any div on the tasks page
|
|
- [ ] Double-click that div — floating chat appears anchored to top-right of the section
|
|
- [ ] Type a message and press Enter — streaming response appears
|
|
- [ ] Drag the chat by its header — it moves, stays within screen bounds
|
|
- [ ] Press Escape — chat closes
|
|
- [ ] Navigate to another route — chat closes
|
|
- [ ] Remove the temporary `data-ai-section` attribute after testing
|
|
|
|
---
|
|
|
|
## Step 5: Add `ai:action` IPC Side-Channel
|
|
|
|
**Status**: [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:
|
|
|
|
```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**: [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):
|
|
|
|
```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**: [x] (2026-02-28)
|
|
**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**: [x] (2026-02-28)
|
|
**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**: [x] (2026-02-28)
|
|
**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**: [x] (2026-02-28)
|
|
**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 |
|