feat(floating-ai): step 8 — page interactions (all variants)

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>
This commit is contained in:
Roberto Musso
2026-02-28 14:15:27 +01:00
parent c5e78311e6
commit 15051cfa7a
9 changed files with 375 additions and 122 deletions

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { useRouterState } from '@tanstack/react-router';
import { useNavigate, useRouterState } from '@tanstack/react-router';
import { X, ArrowUp } from 'lucide-react';
import {
useFloatingChat,
computeDualAnchor,
CHAT_WIDTH,
CHAT_HEIGHT,
PADDING,
@@ -14,9 +15,22 @@ 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 } = useFloatingChat();
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);
@@ -33,6 +47,32 @@ function FloatingChatInner() {
[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,
@@ -41,7 +81,7 @@ function FloatingChatInner() {
streamingContent,
handleSend,
clearMessages,
} = useAIChat(chatContext);
} = useAIChat(chatContext, { onSectionTag: handleSectionTag });
const containerRef = useRef<HTMLDivElement>(null);
@@ -59,15 +99,15 @@ function FloatingChatInner() {
return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]);
// ---- Close on route change ----
// ---- Close on route change (unless cross-page navigation pending) ----
useEffect(() => {
const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) {
if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
close();
}
prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, close]);
}, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
// ---- Clear messages on close ----
@@ -123,6 +163,51 @@ function FloatingChatInner() {
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);