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:
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
@@ -16,6 +16,7 @@ import { KanbanBoard } from './KanbanBoard';
|
||||
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
|
||||
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
|
||||
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -26,6 +27,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
|
||||
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// AI section refs
|
||||
const summaryRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const tasksRef = useRef<HTMLDivElement>(null);
|
||||
const notesRef = useRef<HTMLDivElement>(null);
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
registerSection({ id: 'project-summary', label: 'Project Summary', ref: summaryRef, projectId });
|
||||
registerSection({ id: 'project-timeline', label: 'Project Timeline', ref: timelineRef, projectId });
|
||||
registerSection({ id: 'project-tasks', label: 'Tasks', ref: tasksRef, projectId });
|
||||
registerSection({ id: 'project-notes', label: 'Notes', ref: notesRef, projectId });
|
||||
return () => {
|
||||
unregisterSection('project-summary');
|
||||
unregisterSection('project-timeline');
|
||||
unregisterSection('project-tasks');
|
||||
unregisterSection('project-notes');
|
||||
};
|
||||
}, [projectId, registerSection, unregisterSection]);
|
||||
const utils = trpc.useUtils();
|
||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
const { data: clientsList } = trpc.clients.list.useQuery();
|
||||
@@ -181,54 +202,57 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
|
||||
</div>
|
||||
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<FileText />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{notesCount}</ItemTitle>
|
||||
<ItemDescription>Notes</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
{/* Project Summary Section */}
|
||||
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
|
||||
{/* Stat Cards */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<FileText />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{notesCount}</ItemTitle>
|
||||
<ItemDescription>Notes</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
||||
<ItemDescription>Tasks Complete</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<CheckCircle2 />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{taskStats.done}/{taskStats.total}</ItemTitle>
|
||||
<ItemDescription>Tasks Complete</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
<Item variant="muted">
|
||||
<Item variant="muted">
|
||||
<ItemMedia variant="icon">
|
||||
<Milestone />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
|
||||
<ItemDescription>Checkpoints</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* AI Project Summary */}
|
||||
<Item variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<Milestone />
|
||||
<Sparkles />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{checkpointStats.approved}/{checkpointStats.total}</ItemTitle>
|
||||
<ItemDescription>Checkpoints</ItemDescription>
|
||||
<ItemTitle>AI Project Summary</ItemTitle>
|
||||
<ItemDescription>
|
||||
{project.aiSummary || 'AI summary will appear here'}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* AI Project Summary */}
|
||||
<Item variant="outline">
|
||||
<ItemMedia variant="icon">
|
||||
<Sparkles />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>AI Project Summary</ItemTitle>
|
||||
<ItemDescription>
|
||||
{project.aiSummary || 'AI summary will appear here'}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
|
||||
{/* Project Timeline */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div ref={timelineRef} data-ai-section="project-timeline" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Project Timeline</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -306,7 +330,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* Tasks Kanban */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div ref={tasksRef} data-ai-section="project-tasks" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Tasks</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -372,7 +396,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div ref={notesRef} data-ai-section="project-notes" className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">Notes</h2>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user