feat(floating-ai): step 2 — create section registry + FloatingChatContext

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Roberto Musso
2026-02-27 22:43:05 +01:00
parent 78b4df1028
commit b4e97e14f3
3 changed files with 181 additions and 2 deletions

View File

@@ -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<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();
useEffect(() => {
registerSection(section);
return () => unregisterSection(section.id);
}, [section.id, registerSection, unregisterSection]);
}
// ---------- 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>
);
}