feat(floating-ai): step 2 — create section registry + FloatingChatContext
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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