feat(floating-ai): step 2 — create section registry + FloatingChatContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
176
src/renderer/context/FloatingChatContext.tsx
Normal file
176
src/renderer/context/FloatingChatContext.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user