diff --git a/docs/floating-ai-integration-guide.md b/docs/floating-ai-integration-guide.md index e8d2cda..0a2ee73 100644 --- a/docs/floating-ai-integration-guide.md +++ b/docs/floating-ai-integration-guide.md @@ -32,7 +32,7 @@ Steps MUST be implemented in order. Each step lists its prerequisites. | Step | Title | Status | |------|-------|--------| | 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 | -| 2 | Create section registry + `FloatingChatContext` | [ ] | +| 2 | Create section registry + `FloatingChatContext` | [x] 2026-02-27 | | 3 | Create double-click hook | [ ] | | 4 | Build `FloatingChat` component | [ ] | | 5 | Add `ai:action` IPC side-channel | [ ] | @@ -142,7 +142,7 @@ Refactor `AIChatPanel.tsx` to consume `useAIChat` instead of managing chat state ## Step 2: Create Section Registry + `FloatingChatContext` -**Status**: [ ] +**Status**: [x] 2026-02-27 **Prerequisites**: Step 1 completed **Creates**: `src/renderer/context/FloatingChatContext.tsx` **Modifies**: `src/renderer/components/layout/AppShell.tsx` diff --git a/src/renderer/components/layout/AppShell.tsx b/src/renderer/components/layout/AppShell.tsx index ff23edd..9822937 100644 --- a/src/renderer/components/layout/AppShell.tsx +++ b/src/renderer/components/layout/AppShell.tsx @@ -56,6 +56,7 @@ import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; import { AIChatPanel } from '@/components/ai/AIChatPanel'; import { useTheme } from '@/components/theme-provider'; +import { FloatingChatProvider } from '@/context/FloatingChatContext'; const NAV_ITEMS = [ { to: '/', icon: House, label: 'Home' }, @@ -208,6 +209,7 @@ export function AppShell({ children }: AppShellProps) { }, [openCurtain, closeCurtain]); return ( + <> + ); } diff --git a/src/renderer/context/FloatingChatContext.tsx b/src/renderer/context/FloatingChatContext.tsx new file mode 100644 index 0000000..05978be --- /dev/null +++ b/src/renderer/context/FloatingChatContext.tsx @@ -0,0 +1,176 @@ +import { + createContext, + useContext, + useCallback, + useEffect, + 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; + 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; + + // 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, +): { 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(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(); + + useEffect(() => { + registerSection(section); + return () => unregisterSection(section.id); + }, [section.id, registerSection, unregisterSection]); +} + +// ---------- Provider ---------- + +export function FloatingChatProvider({ children }: { children: ReactNode }) { + const sectionsRef = useRef>(new Map()); + const [sections, setSections] = useState>(new Map()); + const [state, setState] = useState({ + 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 ( + + {children} + + ); +}