diff --git a/docs/floating-ai-integration-guide.md b/docs/floating-ai-integration-guide.md index 1463354..5834053 100644 --- a/docs/floating-ai-integration-guide.md +++ b/docs/floating-ai-integration-guide.md @@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat(); ## Step 8a: Page Interactions — Project Detail -**Status**: [ ] +**Status**: [x] (2026-02-28) **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/components/projects/ProjectDetail.tsx` @@ -1022,7 +1022,7 @@ Repeat for all 4 sections. ## Step 8b: Page Interactions — Tasks Page -**Status**: [ ] +**Status**: [x] (2026-02-28) **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/routes/tasks.tsx` @@ -1055,7 +1055,7 @@ Same pattern as 8a — create refs, add `data-ai-section` attributes, register i ## Step 8c: Page Interactions — Timeline Page -**Status**: [ ] +**Status**: [x] (2026-02-28) **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/routes/timeline.tsx` @@ -1082,7 +1082,7 @@ Register 1 section: ## Step 8d: Page Interactions — Notes Page (Milkdown) -**Status**: [ ] +**Status**: [x] (2026-02-28) **Prerequisites**: Steps 1-4 completed **Modifies**: `src/renderer/routes/notes.$noteId.tsx` diff --git a/src/renderer/components/ai/FloatingChat.tsx b/src/renderer/components/ai/FloatingChat.tsx index c360cdf..727a301 100644 --- a/src/renderer/components/ai/FloatingChat.tsx +++ b/src/renderer/components/ai/FloatingChat.tsx @@ -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 = { + '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(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(null); diff --git a/src/renderer/components/projects/ProjectDetail.tsx b/src/renderer/components/projects/ProjectDetail.tsx index 3ad110f..f52a2cb 100644 --- a/src/renderer/components/projects/ProjectDetail.tsx +++ b/src/renderer/components/projects/ProjectDetail.tsx @@ -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(null); const navigate = useNavigate(); + + // AI section refs + const summaryRef = useRef(null); + const timelineRef = useRef(null); + const tasksRef = useRef(null); + const notesRef = useRef(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) {

{project.name}

- {/* Stat Cards */} -
- - - - - - {notesCount} - Notes - - + {/* Project Summary Section */} +
+ {/* Stat Cards */} +
+ + + + + + {notesCount} + Notes + + - - - - - - {taskStats.done}/{taskStats.total} - Tasks Complete - - + + + + + + {taskStats.done}/{taskStats.total} + Tasks Complete + + - + + + + + + {checkpointStats.approved}/{checkpointStats.total} + Checkpoints + + +
+ + {/* AI Project Summary */} + - + - {checkpointStats.approved}/{checkpointStats.total} - Checkpoints + AI Project Summary + + {project.aiSummary || 'AI summary will appear here'} +
- {/* AI Project Summary */} - - - - - - AI Project Summary - - {project.aiSummary || 'AI summary will appear here'} - - - - {/* Project Timeline */} -
+

Project Timeline

@@ -306,7 +330,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
{/* Tasks Kanban */} -
+

Tasks

@@ -372,7 +396,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
{/* Notes */} -
+

Notes

{/* Editor */} - +
= { }; function TasksPage() { - // Temporary test: register section for floating AI chat - const testRef = useRef(null); + // AI section refs + const overviewRef = useRef(null); + const listRef = useRef(null); const { state: floatingState, registerSection, unregisterSection } = useFloatingChat(); useEffect(() => { - registerSection({ id: 'test', label: 'Tasks', ref: testRef }); - return () => unregisterSection('test'); + registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef }); + registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef }); + return () => { + unregisterSection('tasks-overview'); + unregisterSection('tasks-list'); + }; }, [registerSection, unregisterSection]); const [search, setSearch] = useState(''); @@ -121,7 +126,7 @@ function TasksPage() { return (
{/* Stat Cards */} -
+
@@ -160,50 +165,52 @@ function TasksPage() {
- {/* Search + Order By */} -
- - - - - handleSearchChange(e.target.value)} - /> - - -
+ {/* Task List Section */} +
+ {/* Search + Order By */} +
+ + + + + handleSearchChange(e.target.value)} + /> + + +
- {/* Status Filter Tabs + New Task Button */} -
- setStatusFilter(v as StatusFilter)}> - - All - To Do - In Progress - Completed - - - -
+ {/* Status Filter Tabs + New Task Button */} +
+ setStatusFilter(v as StatusFilter)}> + + All + To Do + In Progress + Completed + + + +
- {/* Task List */} -
+ {/* Task List */} +
{tasksList.length === 0 ? ( @@ -233,6 +240,7 @@ function TasksPage() { /> )) )} +
diff --git a/src/renderer/routes/timeline.tsx b/src/renderer/routes/timeline.tsx index 293bfac..d7e4ac1 100644 --- a/src/renderer/routes/timeline.tsx +++ b/src/renderer/routes/timeline.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router'; -import { useState, useMemo } from 'react'; +import { useEffect, useRef, useState, useMemo } from 'react'; import { Plus } from 'lucide-react'; +import { useFloatingChat } from '@/context/FloatingChatContext'; import { trpc } from '@/lib/trpc'; import { Button } from '@/components/ui/button'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; @@ -15,6 +16,14 @@ function TimelinePage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingCheckpoint, setEditingCheckpoint] = useState(null); + // AI section + const timelineRef = useRef(null); + const { registerSection, unregisterSection } = useFloatingChat(); + useEffect(() => { + registerSection({ id: 'timeline-chart', label: 'Timeline', ref: timelineRef }); + return () => unregisterSection('timeline-chart'); + }, [registerSection, unregisterSection]); + const { data: checkpoints } = trpc.checkpoints.list.useQuery({}); const { data: projectsList } = trpc.projects.listAll.useQuery(); const utils = trpc.useUtils(); @@ -70,7 +79,7 @@ function TimelinePage() { }, [ganttCheckpoints]); return ( -
+
{/* Header */}

Timeline