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

@@ -32,7 +32,7 @@ Steps MUST be implemented in order. Each step lists its prerequisites.
| Step | Title | Status | | Step | Title | Status |
|------|-------|--------| |------|-------|--------|
| 1 | Extract shared `useAIChat` hook | [x] 2026-02-27 | | 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 | [ ] | | 3 | Create double-click hook | [ ] |
| 4 | Build `FloatingChat` component | [ ] | | 4 | Build `FloatingChat` component | [ ] |
| 5 | Add `ai:action` IPC side-channel | [ ] | | 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` ## Step 2: Create Section Registry + `FloatingChatContext`
**Status**: [ ] **Status**: [x] 2026-02-27
**Prerequisites**: Step 1 completed **Prerequisites**: Step 1 completed
**Creates**: `src/renderer/context/FloatingChatContext.tsx` **Creates**: `src/renderer/context/FloatingChatContext.tsx`
**Modifies**: `src/renderer/components/layout/AppShell.tsx` **Modifies**: `src/renderer/components/layout/AppShell.tsx`

View File

@@ -56,6 +56,7 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { AIChatPanel } from '@/components/ai/AIChatPanel'; import { AIChatPanel } from '@/components/ai/AIChatPanel';
import { useTheme } from '@/components/theme-provider'; import { useTheme } from '@/components/theme-provider';
import { FloatingChatProvider } from '@/context/FloatingChatContext';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: '/', icon: House, label: 'Home' }, { to: '/', icon: House, label: 'Home' },
@@ -208,6 +209,7 @@ export function AppShell({ children }: AppShellProps) {
}, [openCurtain, closeCurtain]); }, [openCurtain, closeCurtain]);
return ( return (
<FloatingChatProvider>
<> <>
<SidebarProvider open={open} onOpenChange={handleOpenChange}> <SidebarProvider open={open} onOpenChange={handleOpenChange}>
<AppSidebar <AppSidebar
@@ -299,6 +301,7 @@ export function AppShell({ children }: AppShellProps) {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
</FloatingChatProvider>
); );
} }

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>
);
}