Register AI sections across all content pages with dual-anchor scroll tracking, cross-page navigation via [SECTION:xxx] tags, and right-margin positioning for the notes editor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { useNavigate, useRouterState } from '@tanstack/react-router';
|
|
import { X, ArrowUp } from 'lucide-react';
|
|
import {
|
|
useFloatingChat,
|
|
computeDualAnchor,
|
|
CHAT_WIDTH,
|
|
CHAT_HEIGHT,
|
|
PADDING,
|
|
} from '@/context/FloatingChatContext';
|
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
|
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { trpc } from '@/lib/trpc';
|
|
|
|
/** Map section IDs to their routes for cross-page navigation */
|
|
const SECTION_ROUTES: Record<string, string> = {
|
|
'project-summary': 'project',
|
|
'project-timeline': 'project',
|
|
'project-tasks': 'project',
|
|
'project-notes': 'project',
|
|
'tasks-overview': '/tasks',
|
|
'tasks-list': '/tasks',
|
|
'timeline-chart': '/timeline',
|
|
'note-editor': 'note',
|
|
};
|
|
|
|
function FloatingChatInner() {
|
|
const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
|
|
const utils = trpc.useUtils();
|
|
const navigate = useNavigate();
|
|
const routerState = useRouterState();
|
|
const prevPathRef = useRef(routerState.location.pathname);
|
|
|
|
// Active section lookup
|
|
const activeSection = sections.get(state.activeSectionId ?? '');
|
|
|
|
// Chat context derived from active section
|
|
const chatContext = useMemo<ChatContext>(
|
|
() => ({
|
|
type: activeSection?.projectId ? 'project' : 'global',
|
|
projectId: activeSection?.projectId,
|
|
uiContext: activeSection?.label,
|
|
}),
|
|
[activeSection?.projectId, activeSection?.label],
|
|
);
|
|
|
|
// Handle [SECTION:xxx] tags from AI responses
|
|
const handleSectionTag = useCallback((sectionId: string) => {
|
|
// Same-page: section is already registered
|
|
const targetSection = sections.get(sectionId);
|
|
if (targetSection) {
|
|
moveToSection(sectionId);
|
|
targetSection.ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
return;
|
|
}
|
|
|
|
// Cross-page: section not registered, navigate to its route
|
|
const route = SECTION_ROUTES[sectionId];
|
|
if (!route) return;
|
|
|
|
setPendingSection({ sectionId });
|
|
|
|
if (route === 'project' && state.projectId) {
|
|
// Navigate to the project page (stay on same project)
|
|
// Project sections re-register on mount and pendingSection will auto-open
|
|
void navigate({ to: '/projects/$projectId', params: { projectId: state.projectId } });
|
|
} else if (route.startsWith('/')) {
|
|
void navigate({ to: route });
|
|
}
|
|
// 'note' type requires noteId — skip cross-page for now
|
|
}, [sections, moveToSection, setPendingSection, state.projectId, navigate]);
|
|
|
|
const {
|
|
messages,
|
|
input,
|
|
setInput,
|
|
isStreaming,
|
|
streamingContent,
|
|
handleSend,
|
|
clearMessages,
|
|
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// ---- Close on Escape ----
|
|
|
|
useEffect(() => {
|
|
if (!state.isOpen) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
close();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handler);
|
|
return () => document.removeEventListener('keydown', handler);
|
|
}, [state.isOpen, close]);
|
|
|
|
// ---- Close on route change (unless cross-page navigation pending) ----
|
|
|
|
useEffect(() => {
|
|
const currentPath = routerState.location.pathname;
|
|
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
|
|
close();
|
|
}
|
|
prevPathRef.current = currentPath;
|
|
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
|
|
|
|
// ---- Clear messages on close ----
|
|
|
|
const prevOpenRef = useRef(state.isOpen);
|
|
useEffect(() => {
|
|
if (prevOpenRef.current && !state.isOpen) {
|
|
clearMessages();
|
|
}
|
|
prevOpenRef.current = state.isOpen;
|
|
}, [state.isOpen, clearMessages]);
|
|
|
|
// ---- AI action: morph into newly-created task ----
|
|
|
|
useEffect(() => {
|
|
if (!state.isOpen) return;
|
|
|
|
const unsubscribe = window.electronAI.onAction((action) => {
|
|
if (action.type === 'task_created' && action.taskId) {
|
|
// Invalidate task queries so the new TaskRow renders
|
|
void utils.tasks.list.invalidate();
|
|
|
|
// Set the morph target layoutId
|
|
setMorphTarget(`task-morph-${action.taskId}`);
|
|
|
|
// Wait for the TaskRow to render, then close (triggering FLIP)
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => {
|
|
close();
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
return unsubscribe;
|
|
}, [state.isOpen, utils, setMorphTarget, close]);
|
|
|
|
// ---- Window resize: keep within bounds ----
|
|
|
|
useEffect(() => {
|
|
if (!state.isOpen) return;
|
|
const handler = () => {
|
|
// Re-anchor if the container would go offscreen
|
|
const el = containerRef.current;
|
|
if (el) {
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
|
|
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - CHAT_WIDTH - PADDING))}px`;
|
|
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('resize', handler);
|
|
return () => window.removeEventListener('resize', handler);
|
|
}, [state.isOpen, state.position.x, state.position.y]);
|
|
|
|
// ---- Scroll tracking: dual-anchor repositioning ----
|
|
|
|
useEffect(() => {
|
|
if (!state.isOpen || !state.activeSectionId) return;
|
|
const section = sections.get(state.activeSectionId);
|
|
if (!section || section.anchorMode === 'right-margin') return;
|
|
|
|
const el = section.ref.current;
|
|
if (!el) return;
|
|
|
|
// Find scrollable ancestor
|
|
let scrollParent: HTMLElement | null = el.parentElement;
|
|
while (scrollParent) {
|
|
const style = getComputedStyle(scrollParent);
|
|
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
|
|
style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
|
break;
|
|
}
|
|
// Also check for Radix ScrollArea viewport
|
|
if (scrollParent.hasAttribute('data-radix-scroll-area-viewport')) break;
|
|
scrollParent = scrollParent.parentElement;
|
|
}
|
|
|
|
if (!scrollParent) return;
|
|
|
|
let rafId: number | null = null;
|
|
const handleScroll = () => {
|
|
if (rafId !== null) return;
|
|
rafId = requestAnimationFrame(() => {
|
|
rafId = null;
|
|
const newPos = computeDualAnchor(section);
|
|
if (newPos) {
|
|
updatePosition(newPos);
|
|
}
|
|
// null = fully off-screen → freeze (do nothing)
|
|
});
|
|
};
|
|
|
|
scrollParent.addEventListener('scroll', handleScroll, { passive: true });
|
|
return () => {
|
|
scrollParent.removeEventListener('scroll', handleScroll);
|
|
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
};
|
|
}, [state.isOpen, state.activeSectionId, sections, updatePosition]);
|
|
|
|
// ---- Auto-scroll messages ----
|
|
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const scrollToBottom = useCallback(() => {
|
|
const el = scrollRef.current;
|
|
if (el) el.scrollTo({ top: el.scrollHeight });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
scrollToBottom();
|
|
}, [messages, streamingContent, scrollToBottom]);
|
|
|
|
// ---- Auto-focus input on open ----
|
|
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
useEffect(() => {
|
|
if (state.isOpen) {
|
|
const timer = setTimeout(() => inputRef.current?.focus(), 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [state.isOpen]);
|
|
|
|
// ---- Input handling ----
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSend();
|
|
}
|
|
};
|
|
|
|
const hasMessages = messages.length > 0 || isStreaming;
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{state.isOpen && (
|
|
<motion.div
|
|
ref={containerRef}
|
|
key="floating-chat"
|
|
layout
|
|
layoutId={state.morphTargetId ?? undefined}
|
|
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
style={{
|
|
position: 'fixed',
|
|
left: state.position.x,
|
|
top: state.position.y,
|
|
width: state.position.width,
|
|
zIndex: 9999,
|
|
}}
|
|
className="flex flex-col gap-2"
|
|
>
|
|
{/* ---- Messages panel (appears when chat has content) ---- */}
|
|
<AnimatePresence>
|
|
{hasMessages && (
|
|
<motion.div
|
|
key="messages-panel"
|
|
initial={{ opacity: 0, height: 0, scale: 0.97 }}
|
|
animate={{ opacity: 1, height: 'auto', scale: 1 }}
|
|
exit={{ opacity: 0, height: 0, scale: 0.97 }}
|
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
|
className="rounded-2xl"
|
|
>
|
|
<div
|
|
ref={scrollRef}
|
|
className="max-h-[300px] overflow-y-auto rounded-2xl [&::-webkit-scrollbar]:w-2.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border"
|
|
>
|
|
<div className="flex flex-col gap-2.5 p-3">
|
|
{messages.map((msg) => {
|
|
if (msg.role === 'user') {
|
|
return (
|
|
<div key={msg.id} className="flex justify-end">
|
|
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-accent text-primary-foreground px-3.5 py-2 shadow-sm">
|
|
<p className="text-xs whitespace-pre-wrap leading-relaxed">
|
|
{msg.content}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (msg.error) {
|
|
return (
|
|
<div key={msg.id} className="flex justify-start">
|
|
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-destructive/10 px-3.5 py-2">
|
|
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
|
|
{msg.content}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div key={msg.id} className="flex justify-start">
|
|
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
|
|
<div className="text-xs">
|
|
<ChatMarkdown content={msg.content} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Streaming */}
|
|
{isStreaming && (
|
|
<div className="flex justify-start">
|
|
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2">
|
|
{streamingContent ? (
|
|
<div className="text-xs">
|
|
<ChatMarkdown content={streamingContent} />
|
|
</div>
|
|
) : (
|
|
<div className="space-y-1.5 py-0.5">
|
|
<Skeleton className="h-3 w-36" />
|
|
<Skeleton className="h-3 w-24" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* ---- Floating input bar ---- */}
|
|
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.5)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.7)] focus-within:ring-ring/20">
|
|
{/* Close button */}
|
|
<button
|
|
onClick={close}
|
|
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
|
|
>
|
|
<X size={10} />
|
|
</button>
|
|
|
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
|
|
rows={1}
|
|
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
|
|
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
|
/>
|
|
<button
|
|
onClick={() => handleSend()}
|
|
disabled={!input.trim() || isStreaming}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
|
>
|
|
<ArrowUp size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
export function FloatingChatPortal() {
|
|
return createPortal(<FloatingChatInner />, document.body);
|
|
}
|