2 Commits

Author SHA1 Message Date
Roberto Musso
444aa37be2 feat(AIChatPanel): enhance daily brief with animation and expand/collapse functionality; add GradualBlur component for improved UI 2026-02-28 16:19:15 +01:00
Roberto Musso
15051cfa7a 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>
2026-02-28 14:15:27 +01:00
12 changed files with 644 additions and 176 deletions

View File

@@ -959,7 +959,7 @@ const { state: floatingState } = useFloatingChat();
## Step 8a: Page Interactions — Project Detail ## Step 8a: Page Interactions — Project Detail
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/components/projects/ProjectDetail.tsx` **Modifies**: `src/renderer/components/projects/ProjectDetail.tsx`
@@ -1022,7 +1022,7 @@ Repeat for all 4 sections.
## Step 8b: Page Interactions — Tasks Page ## Step 8b: Page Interactions — Tasks Page
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/tasks.tsx` **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 ## Step 8c: Page Interactions — Timeline Page
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/timeline.tsx` **Modifies**: `src/renderer/routes/timeline.tsx`
@@ -1082,7 +1082,7 @@ Register 1 section:
## Step 8d: Page Interactions — Notes Page (Milkdown) ## Step 8d: Page Interactions — Notes Page (Milkdown)
**Status**: [ ] **Status**: [x] (2026-02-28)
**Prerequisites**: Steps 1-4 completed **Prerequisites**: Steps 1-4 completed
**Modifies**: `src/renderer/routes/notes.$noteId.tsx` **Modifies**: `src/renderer/routes/notes.$noteId.tsx`

View File

@@ -1,13 +1,14 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb } from 'lucide-react'; import { Sparkles, KeyRound, ArrowUp, ListTodo, TrendingUp, AlertCircle, Lightbulb, ChevronDown, ChevronUp, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { motion, AnimatePresence } from 'framer-motion';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { useAIChat, type ChatContext } from '@/hooks/useAIChat'; import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { GradualBlur } from '@/components/ui/gradual-blur';
const SUGGESTION_CHIPS = [ const SUGGESTION_CHIPS = [
{ icon: ListTodo, label: "What's on my plate today?" }, { icon: ListTodo, label: "What's on my plate today?" },
@@ -16,6 +17,28 @@ const SUGGESTION_CHIPS = [
{ icon: Lightbulb, label: 'Suggest next actions' }, { icon: Lightbulb, label: 'Suggest next actions' },
] as const; ] as const;
function getTimeGreeting(): string {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning,';
if (hour < 17) return 'Good afternoon,';
return 'Good evening,';
}
/* Entrance animation: staggered fade-up */
const stagger = {
hidden: {},
show: { transition: { staggerChildren: 0.08 } },
};
const fadeUp = {
hidden: { opacity: 0, y: 16 },
show: {
opacity: 1,
y: 0,
transition: { duration: 0.45, ease: [0.25, 0.1, 0.25, 1] as const },
},
};
interface AIChatPanelProps { interface AIChatPanelProps {
onOpenSettings?: () => void; onOpenSettings?: () => void;
isHomePage?: boolean; isHomePage?: boolean;
@@ -50,6 +73,9 @@ export function AIChatPanel({
const briefContentRef = useRef(''); const briefContentRef = useRef('');
const hasFiredBrief = useRef(false); const hasFiredBrief = useRef(false);
const [briefExpanded, setBriefExpanded] = useState(false);
const [briefDismissed, setBriefDismissed] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement | null>(null); const messagesContainerRef = useRef<HTMLDivElement | null>(null);
const briefMutation = trpc.ai.dailyBrief.useMutation(); const briefMutation = trpc.ai.dailyBrief.useMutation();
@@ -118,9 +144,78 @@ export function AIChatPanel({
return ( return (
<div className="absolute inset-0 z-0 flex flex-col bg-background"> <div className="absolute inset-0 z-0 flex flex-col bg-background">
{/* Sticky brief toast — anchored at top when chatting */}
<AnimatePresence>
{isHomePage && hasMessages && dailyBrief && !briefDismissed && (
<motion.div
initial={{ y: -80, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -80, opacity: 0 }}
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
className="sticky top-0 z-30 flex justify-center px-4 pt-3 pb-1"
>
<div className="w-full max-w-2xl rounded-xl border border-border/60 bg-background/80 backdrop-blur-xl shadow-[0_8px_30px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_30px_rgba(0,0,0,0.4)] ring-1 ring-border/10">
{/* Toast header — always visible */}
<div className="flex items-center gap-2 px-4 py-2.5">
<Sparkles size={14} className="text-primary shrink-0" />
<span className="text-xs font-semibold tracking-wide text-foreground">Daily Brief</span>
<div className="flex-1" />
<button
onClick={() => setBriefExpanded((v) => !v)}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
{briefExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
<button
onClick={() => setBriefDismissed(true)}
className="flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
>
<X size={14} />
</button>
</div>
{/* Collapsed: one-line preview */}
{!briefExpanded && (
<div className="px-4 pb-3 -mt-1">
<p className="text-xs text-muted-foreground truncate">
{dailyBrief.replace(/[#*_~`>\-]/g, '').slice(0, 120)}...
</p>
</div>
)}
{/* Expanded: full brief content */}
<AnimatePresence>
{briefExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: [0.25, 0.1, 0.25, 1] }}
className="overflow-hidden"
>
<div className="px-4 pb-3 max-h-64 overflow-y-auto">
<ChatMarkdown content={dailyBrief} />
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Scrollable messages area */} {/* Scrollable messages area */}
<div className="relative flex-1 min-h-0">
{/* Gradual blur at the top of messages */}
{hasMessages && (
<GradualBlur
position="top"
strength={2}
height="5rem"
divCount={6}
zIndex={20}
/>
)}
<ScrollArea <ScrollArea
className="flex-1 min-h-0" className="h-full"
viewportRef={messagesContainerRef} viewportRef={messagesContainerRef}
viewportClassName={ viewportClassName={
isHomePage && !hasMessages isHomePage && !hasMessages
@@ -130,25 +225,45 @@ export function AIChatPanel({
> >
{/* Home page initial state: greeting + brief */} {/* Home page initial state: greeting + brief */}
{isHomePage && !hasMessages && ( {isHomePage && !hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8"> <motion.div
<div className="flex flex-col gap-8"> className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
{/* Greeting + brief grouped closely */} variants={stagger}
<div className="flex flex-col gap-1"> initial="hidden"
<div className="flex items-center justify-between gap-4 flex-wrap"> animate="show"
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}> >
Hello, {userName} <div className="flex flex-col" style={{ gap: 'clamp(2.5rem, 4vh, 4rem)' }}>
{/* Greeting — editorial hero moment */}
<motion.div variants={fadeUp} className="flex flex-col gap-1">
<span
className="font-light tracking-wide text-muted-foreground"
style={{ fontSize: 'clamp(1rem, 1.6vw, 1.25rem)' }}
>
{getTimeGreeting()}
</span>
<h1
className="font-bold leading-[1.05]"
style={{ fontSize: 'clamp(3.25rem, 5.5vw, 5.5rem)', letterSpacing: '-0.035em' }}
>
{userName}
<span className="text-primary ml-3 inline-block"></span>
</h1> </h1>
<Badge variant="secondary"> {dueCount > 0 && (
{dueCount} Task{dueCount !== 1 ? 's' : ''} due <p
</Badge> className="text-muted-foreground mt-2"
</div> style={{ fontSize: 'clamp(0.875rem, 1.2vw, 1.125rem)' }}
>
<span className="text-foreground font-medium">{dueCount}</span>
{' '}task{dueCount !== 1 ? 's' : ''} due today
</p>
)}
</motion.div>
{/* Daily brief */} {/* Daily brief */}
<div> <motion.div variants={fadeUp} className="max-w-3xl">
{hasTokenQuery.data === false ? ( {hasTokenQuery.data === false ? (
<div className="flex flex-col items-center gap-3 py-2"> <div className="flex flex-col items-start gap-3 py-2">
<KeyRound size={24} className="text-muted-foreground" /> <KeyRound size={20} className="text-muted-foreground" />
<p className="text-sm text-muted-foreground text-center"> <p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Configure your AI provider in Settings to enable the daily brief. Configure your AI provider in Settings to enable the daily brief.
</p> </p>
<Button variant="outline" size="sm" onClick={onOpenSettings}> <Button variant="outline" size="sm" onClick={onOpenSettings}>
@@ -156,23 +271,22 @@ export function AIChatPanel({
</Button> </Button>
</div> </div>
) : briefLoading && !dailyBrief ? ( ) : briefLoading && !dailyBrief ? (
<div className="space-y-2"> <div className="space-y-3">
<Skeleton className="h-4 w-3/4" /> <Skeleton className="h-5 w-3/4" />
<Skeleton className="h-4 w-1/2" /> <Skeleton className="h-5 w-1/2" />
<Skeleton className="h-4 w-2/3" /> <Skeleton className="h-5 w-2/3" />
</div> </div>
) : dailyBrief ? ( ) : dailyBrief ? (
<ChatMarkdown content={dailyBrief} /> <ChatMarkdown content={dailyBrief} size="lg" />
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground" style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}>
Your daily brief will appear here. Your daily brief will appear here.
</p> </p>
)} )}
</div> </motion.div>
</div>
{/* Inline input + suggestion chips */} {/* Input + suggestion links */}
<div> <motion.div variants={fadeUp} className="max-w-3xl">
<ChatInput <ChatInput
input={input} input={input}
isStreaming={isStreaming || briefLoading} isStreaming={isStreaming || briefLoading}
@@ -180,35 +294,32 @@ export function AIChatPanel({
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onSend={handleSend} onSend={handleSend}
/> />
<div className="flex flex-wrap items-center justify-center gap-2 mt-4"> <div className="flex flex-col gap-0.5 mt-5">
{SUGGESTION_CHIPS.map((chip) => ( {SUGGESTION_CHIPS.map((chip) => (
<button <button
key={chip.label} key={chip.label}
type="button" type="button"
className="group flex items-center gap-2 rounded-full border border-border/50 bg-background/60 backdrop-blur-lg px-4 py-2 text-sm text-foreground shadow-sm ring-1 ring-border/20 transition-all hover:shadow-md hover:-translate-y-0.5 hover:border-ring/40 cursor-pointer" className="group flex items-center gap-3 py-1.5 text-muted-foreground transition-all duration-200 hover:text-foreground hover:translate-x-1 cursor-pointer text-left"
style={{ fontSize: 'clamp(0.9375rem, 1.2vw, 1.0625rem)' }}
onClick={() => setInput(chip.label)} onClick={() => setInput(chip.label)}
> >
<chip.icon size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" /> <chip.icon
size={16}
className="shrink-0 transition-colors duration-200 group-hover:text-primary"
/>
<span>{chip.label}</span> <span>{chip.label}</span>
</button> </button>
))} ))}
</div> </div>
</motion.div>
</div> </div>
</div> </motion.div>
</div>
)} )}
{/* Home page with messages: brief stays, then messages */} {/* Home page with messages: brief stays, then messages */}
{isHomePage && hasMessages && ( {isHomePage && hasMessages && (
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-32"> <div className="mx-auto w-full max-w-6xl px-6 pt-8 pb-32">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Brief persists */}
{dailyBrief && (
<div className="mb-2">
<ChatMarkdown content={dailyBrief} />
</div>
)}
{/* Chat messages */} {/* Chat messages */}
{messages.map((msg) => { {messages.map((msg) => {
if (msg.role === 'user') { if (msg.role === 'user') {
@@ -269,6 +380,7 @@ export function AIChatPanel({
{/* Non-home messages */} {/* Non-home messages */}
</ScrollArea> </ScrollArea>
</div>
{/* Fixed input — pinned to the bottom (hidden on initial state) */} {/* Fixed input — pinned to the bottom (hidden on initial state) */}
{hasMessages && ( {hasMessages && (
@@ -332,9 +444,9 @@ function ChatInput({
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */ /* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
export function ChatMarkdown({ content }: { content: string }) { export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) {
return ( return (
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"> <div className={`prose dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 ${size === 'lg' ? 'prose-base' : 'prose-sm'}`}>
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { AnimatePresence, motion } from 'framer-motion'; 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 { X, ArrowUp } from 'lucide-react';
import { import {
useFloatingChat, useFloatingChat,
computeDualAnchor,
CHAT_WIDTH, CHAT_WIDTH,
CHAT_HEIGHT, CHAT_HEIGHT,
PADDING, PADDING,
@@ -14,9 +15,22 @@ import { ChatMarkdown } from '@/components/ai/AIChatPanel';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { trpc } from '@/lib/trpc'; 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() { function FloatingChatInner() {
const { state, sections, close, setMorphTarget } = useFloatingChat(); const { state, sections, close, setMorphTarget, moveToSection, updatePosition, setPendingSection } = useFloatingChat();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const navigate = useNavigate();
const routerState = useRouterState(); const routerState = useRouterState();
const prevPathRef = useRef(routerState.location.pathname); const prevPathRef = useRef(routerState.location.pathname);
@@ -33,6 +47,32 @@ function FloatingChatInner() {
[activeSection?.projectId, 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 { const {
messages, messages,
input, input,
@@ -41,7 +81,7 @@ function FloatingChatInner() {
streamingContent, streamingContent,
handleSend, handleSend,
clearMessages, clearMessages,
} = useAIChat(chatContext); } = useAIChat(chatContext, { onSectionTag: handleSectionTag });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -59,15 +99,15 @@ function FloatingChatInner() {
return () => document.removeEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler);
}, [state.isOpen, close]); }, [state.isOpen, close]);
// ---- Close on route change ---- // ---- Close on route change (unless cross-page navigation pending) ----
useEffect(() => { useEffect(() => {
const currentPath = routerState.location.pathname; const currentPath = routerState.location.pathname;
if (prevPathRef.current !== currentPath && state.isOpen) { if (prevPathRef.current !== currentPath && state.isOpen && !state.pendingSection) {
close(); close();
} }
prevPathRef.current = currentPath; prevPathRef.current = currentPath;
}, [routerState.location.pathname, state.isOpen, close]); }, [routerState.location.pathname, state.isOpen, state.pendingSection, close]);
// ---- Clear messages on close ---- // ---- Clear messages on close ----
@@ -123,6 +163,51 @@ function FloatingChatInner() {
return () => window.removeEventListener('resize', handler); return () => window.removeEventListener('resize', handler);
}, [state.isOpen, state.position.x, state.position.y]); }, [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 ---- // ---- Auto-scroll messages ----
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);

View File

@@ -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 { Sparkles, FileText, CheckCircle2, Milestone, Plus } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
@@ -16,6 +16,7 @@ import { KanbanBoard } from './KanbanBoard';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog'; import { AddCheckpointDialog } from '@/components/timeline/AddCheckpointDialog';
import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog'; import { EditCheckpointDialog } from '@/components/timeline/EditCheckpointDialog';
import { useFloatingChat } from '@/context/FloatingChatContext';
type ProjectDetailProps = { type ProjectDetailProps = {
projectId: string; projectId: string;
@@ -26,6 +27,26 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
const [addCheckpointOpen, setAddCheckpointOpen] = useState(false); const [addCheckpointOpen, setAddCheckpointOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null); const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
const navigate = useNavigate(); 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 utils = trpc.useUtils();
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId }); const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
const { data: clientsList } = trpc.clients.list.useQuery(); const { data: clientsList } = trpc.clients.list.useQuery();
@@ -181,6 +202,8 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
<h1 className="text-2xl font-semibold text-foreground">{project.name}</h1> <h1 className="text-2xl font-semibold text-foreground">{project.name}</h1>
</div> </div>
{/* Project Summary Section */}
<div ref={summaryRef} data-ai-section="project-summary" className="flex flex-col gap-6">
{/* Stat Cards */} {/* Stat Cards */}
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
<Item variant="muted"> <Item variant="muted">
@@ -226,9 +249,10 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
</div>
{/* Project Timeline */} {/* 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"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Project Timeline</h2> <h2 className="text-lg font-semibold">Project Timeline</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -306,7 +330,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</div> </div>
{/* Tasks Kanban */} {/* 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"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Tasks</h2> <h2 className="text-lg font-semibold">Tasks</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -372,7 +396,7 @@ export function ProjectDetail({ projectId }: ProjectDetailProps) {
</div> </div>
{/* Notes */} {/* 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"> <div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Notes</h2> <h2 className="text-lg font-semibold">Notes</h2>
<Button <Button

View File

@@ -0,0 +1,101 @@
import { useMemo } from 'react';
type Position = 'top' | 'bottom';
interface GradualBlurProps {
/** Edge to attach the blur overlay */
position?: Position;
/** Base blur strength multiplier */
strength?: number;
/** Overlay height (CSS value) */
height?: string;
/** Number of stacked blur layers (higher = smoother) */
divCount?: number;
/** Use exponential progression for stronger end blur */
exponential?: boolean;
/** Opacity applied to each blur layer */
opacity?: number;
/** z-index for the overlay */
zIndex?: number;
/** Additional class names */
className?: string;
}
const getGradientDirection = (position: Position) =>
position === 'top' ? 'to top' : 'to bottom';
export function GradualBlur({
position = 'top',
strength = 2,
height = '6rem',
divCount = 5,
exponential = false,
opacity = 1,
zIndex = 10,
className = '',
}: GradualBlurProps) {
const blurDivs = useMemo(() => {
const divs: React.ReactNode[] = [];
const increment = 100 / divCount;
const direction = getGradientDirection(position);
for (let i = 1; i <= divCount; i++) {
const progress = i / divCount;
let blurValue: number;
if (exponential) {
blurValue = Math.pow(2, progress * 4) * 0.0625 * strength;
} else {
blurValue = 0.0625 * (progress * divCount + 1) * strength;
}
const p1 = Math.round((increment * i - increment) * 10) / 10;
const p2 = Math.round(increment * i * 10) / 10;
const p3 = Math.round((increment * i + increment) * 10) / 10;
const p4 = Math.round((increment * i + increment * 2) * 10) / 10;
let gradient = `transparent ${p1}%, black ${p2}%`;
if (p3 <= 100) gradient += `, black ${p3}%`;
if (p4 <= 100) gradient += `, transparent ${p4}%`;
const maskImage = `linear-gradient(${direction}, ${gradient})`;
divs.push(
<div
key={i}
style={{
position: 'absolute',
inset: 0,
maskImage,
WebkitMaskImage: maskImage,
backdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
WebkitBackdropFilter: `blur(${blurValue.toFixed(3)}rem)`,
opacity,
}}
/>,
);
}
return divs;
}, [position, strength, divCount, exponential, opacity]);
return (
<div
className={className}
style={{
position: 'absolute',
[position]: 0,
left: 0,
right: 0,
height,
pointerEvents: 'none',
zIndex,
overflow: 'hidden',
}}
>
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
{blurDivs}
</div>
</div>
);
}

View File

@@ -16,6 +16,11 @@ export interface AISection {
label: string; // Human-readable, e.g. "Tasks", "Project Timeline" label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
ref: RefObject<HTMLElement | null>; ref: RefObject<HTMLElement | null>;
projectId?: string; // If section is project-scoped projectId?: string; // If section is project-scoped
anchorMode?: 'top-right' | 'right-margin'; // default: 'top-right'
}
export interface SectionOpenOpts {
clickY?: number; // For right-margin mode: Y-coordinate of the double-click
} }
interface FloatingChatState { interface FloatingChatState {
@@ -24,6 +29,7 @@ interface FloatingChatState {
position: { x: number; y: number; width: number }; position: { x: number; y: number; width: number };
morphTargetId: string | null; morphTargetId: string | null;
projectId?: string; projectId?: string;
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
} }
interface FloatingChatContextValue { interface FloatingChatContextValue {
@@ -36,10 +42,12 @@ interface FloatingChatContextValue {
unregisterSection: (id: string) => void; unregisterSection: (id: string) => void;
// Actions // Actions
openAtSection: (sectionId: string) => void; openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
moveToSection: (sectionId: string) => void; moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
close: () => void; close: () => void;
setMorphTarget: (id: string | null) => void; setMorphTarget: (id: string | null) => void;
updatePosition: (pos: { x: number; y: number; width: number }) => void;
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
} }
// ---------- Constants ---------- // ---------- Constants ----------
@@ -50,25 +58,79 @@ export const PADDING = 16;
// ---------- Position computation ---------- // ---------- Position computation ----------
function clampPosition(x: number, y: number): { x: number; y: number } {
return {
x: Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)),
y: Math.max(PADDING, Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING)),
};
}
function computeAnchorPosition( function computeAnchorPosition(
sectionRef: RefObject<HTMLElement | null>, section: AISection,
opts?: SectionOpenOpts,
): { x: number; y: number; width: number } { ): { x: number; y: number; width: number } {
const el = sectionRef.current; const el = section.ref.current;
if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH }; if (!el) return { x: PADDING, y: PADDING, width: CHAT_WIDTH };
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const mode = section.anchorMode ?? 'top-right';
// Anchor to top-right of section, offset inward if (mode === 'right-margin') {
let x = rect.right - CHAT_WIDTH - PADDING; // Position to the right of the section at the click Y-coordinate
let y = rect.top + PADDING; const rawX = rect.right + PADDING;
const rawY = opts?.clickY ?? rect.top + PADDING;
const { x, y } = clampPosition(rawX, rawY);
return { x, y, width: CHAT_WIDTH };
}
// Edge-collision clamping // Default: top-right of section
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING)); const rawX = rect.right - CHAT_WIDTH - PADDING;
y = Math.max( const rawY = rect.top + PADDING;
PADDING, const { x, y } = clampPosition(rawX, rawY);
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING), return { x, y, width: CHAT_WIDTH };
}
/**
* Dual-anchor recomputation for scroll tracking.
* Returns null when the section is fully off-screen (freeze at last position).
*/
export function computeDualAnchor(
section: AISection,
): { x: number; y: number; width: number } | null {
const el = section.ref.current;
if (!el) return null;
// Skip scroll tracking for right-margin mode (stays at fixed clickY)
if (section.anchorMode === 'right-margin') return null;
const rect = el.getBoundingClientRect();
// Fully off-screen — freeze
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
// Primary anchor: top-right (when section top is visible)
if (rect.top >= PADDING) {
const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING,
rect.top + PADDING,
); );
return { x, y, width: CHAT_WIDTH };
}
// Fallback anchor: bottom-right (when section top scrolled off)
if (rect.bottom > CHAT_HEIGHT) {
const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING,
rect.bottom - CHAT_HEIGHT - PADDING,
);
return { x, y, width: CHAT_WIDTH };
}
// Section visible but too small for fallback — clamp to top
const { x, y } = clampPosition(
rect.right - CHAT_WIDTH - PADDING,
PADDING,
);
return { x, y, width: CHAT_WIDTH }; return { x, y, width: CHAT_WIDTH };
} }
@@ -108,6 +170,23 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
const registerSection = useCallback((section: AISection) => { const registerSection = useCallback((section: AISection) => {
sectionsRef.current.set(section.id, section); sectionsRef.current.set(section.id, section);
setSections(new Map(sectionsRef.current)); setSections(new Map(sectionsRef.current));
// Check if there's a pending section to open after cross-page navigation
setState((prev) => {
if (prev.pendingSection && prev.pendingSection.sectionId === section.id) {
const position = computeAnchorPosition(section, { clickY: prev.pendingSection.clickY });
return {
...prev,
isOpen: true,
activeSectionId: section.id,
position,
morphTargetId: null,
projectId: section.projectId,
pendingSection: undefined,
};
}
return prev;
});
}, []); }, []);
const unregisterSection = useCallback((id: string) => { const unregisterSection = useCallback((id: string) => {
@@ -115,11 +194,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
setSections(new Map(sectionsRef.current)); setSections(new Map(sectionsRef.current));
}, []); }, []);
const openAtSection = useCallback((sectionId: string) => { const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId); const section = sectionsRef.current.get(sectionId);
if (!section) return; if (!section) return;
const position = computeAnchorPosition(section.ref); const position = computeAnchorPosition(section, opts);
setState({ setState({
isOpen: true, isOpen: true,
@@ -130,11 +209,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
}); });
}, []); }, []);
const moveToSection = useCallback((sectionId: string) => { const moveToSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
const section = sectionsRef.current.get(sectionId); const section = sectionsRef.current.get(sectionId);
if (!section) return; if (!section) return;
const position = computeAnchorPosition(section.ref); const position = computeAnchorPosition(section, opts);
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
@@ -157,6 +236,14 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
setState((prev) => ({ ...prev, morphTargetId: id })); setState((prev) => ({ ...prev, morphTargetId: id }));
}, []); }, []);
const updatePosition = useCallback((pos: { x: number; y: number; width: number }) => {
setState((prev) => ({ ...prev, position: pos }));
}, []);
const setPendingSection = useCallback((pending: { sectionId: string; clickY?: number } | undefined) => {
setState((prev) => ({ ...prev, pendingSection: pending }));
}, []);
return ( return (
<FloatingChatCtx.Provider <FloatingChatCtx.Provider
value={{ value={{
@@ -168,6 +255,8 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
moveToSection, moveToSection,
close, close,
setMorphTarget, setMorphTarget,
updatePosition,
setPendingSection,
}} }}
> >
{children} {children}

View File

@@ -1,6 +1,8 @@
@import '@fontsource/geist/300.css';
@import '@fontsource/geist/400.css'; @import '@fontsource/geist/400.css';
@import '@fontsource/geist/500.css'; @import '@fontsource/geist/500.css';
@import '@fontsource/geist/600.css'; @import '@fontsource/geist/600.css';
@import '@fontsource/geist/700.css';
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";

View File

@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
clearMessages: () => void; clearMessages: () => void;
} }
export function useAIChat(defaultContext: ChatContext): UseAIChatReturn { export interface UseAIChatOptions {
onSectionTag?: (sectionId: string) => void;
}
export function useAIChat(defaultContext: ChatContext, options?: UseAIChatOptions): UseAIChatReturn {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false); const [isStreaming, setIsStreaming] = useState(false);
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => { const unsubscribe = window.electronAI.onStreamChunk(({ token, done }) => {
if (done) { if (done) {
const finalContent = streamingContentRef.current; let finalContent = streamingContentRef.current;
// Parse and strip [SECTION:xxx] tag from AI response
const sectionMatch = finalContent.match(/^\[SECTION:([\w-]+)\]\s*/);
if (sectionMatch) {
finalContent = finalContent.slice(sectionMatch[0].length);
options?.onSectionTag?.(sectionMatch[1]);
}
setMessages((prev) => [ setMessages((prev) => [
...prev, ...prev,
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent }, { id: crypto.randomUUID(), role: 'assistant', content: finalContent },

View File

@@ -5,7 +5,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']); const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
export function useDoubleClickAI(): void { export function useDoubleClickAI(): void {
const { openAtSection, state } = useFloatingChat(); const { openAtSection, moveToSection, sections, state } = useFloatingChat();
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@@ -35,10 +35,20 @@ export function useDoubleClickAI(): void {
// If popup is already open at THIS section, do nothing // If popup is already open at THIS section, do nothing
if (state.isOpen && state.activeSectionId === sectionId) return; if (state.isOpen && state.activeSectionId === sectionId) return;
openAtSection(sectionId); // Build opts for right-margin sections
const section = sections.get(sectionId);
const opts = section?.anchorMode === 'right-margin' ? { clickY: e.clientY } : undefined;
// If chat is already open at a different section, move (keep conversation)
if (state.isOpen) {
moveToSection(sectionId, opts);
return;
}
openAtSection(sectionId, opts);
}; };
document.addEventListener('dblclick', handler); document.addEventListener('dblclick', handler);
return () => document.removeEventListener('dblclick', handler); return () => document.removeEventListener('dblclick', handler);
}, [openAtSection, state.isOpen, state.activeSectionId]); }, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
} }

View File

@@ -19,6 +19,7 @@ import {
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { MilkdownEditor } from '@/components/notes/MilkdownEditor'; import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
import { useFloatingChat } from '@/context/FloatingChatContext';
export const Route = createFileRoute('/notes/$noteId')({ export const Route = createFileRoute('/notes/$noteId')({
component: NoteDetailPage, component: NoteDetailPage,
@@ -29,6 +30,21 @@ function NoteDetailPage() {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId }); const { data: note, isLoading } = trpc.notes.get.useQuery({ id: noteId });
// AI section — register with right-margin anchor mode
const editorRef = useRef<HTMLDivElement>(null);
const { registerSection, unregisterSection } = useFloatingChat();
const noteProjectId = note?.projectId ?? undefined;
useEffect(() => {
registerSection({
id: 'note-editor',
label: 'Note Editor',
ref: editorRef,
projectId: noteProjectId,
anchorMode: 'right-margin',
});
return () => unregisterSection('note-editor');
}, [noteId, noteProjectId, registerSection, unregisterSection]);
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -188,7 +204,7 @@ function NoteDetailPage() {
</div> </div>
{/* Editor */} {/* Editor */}
<ScrollArea className="flex-1 min-h-0"> <ScrollArea ref={editorRef} data-ai-section="note-editor" className="flex-1 min-h-0">
<div className="px-4 py-4"> <div className="px-4 py-4">
<MilkdownEditor <MilkdownEditor
key={noteId} key={noteId}

View File

@@ -41,12 +41,17 @@ const ORDER_LABELS: Record<OrderBy, string> = {
}; };
function TasksPage() { function TasksPage() {
// Temporary test: register section for floating AI chat // AI section refs
const testRef = useRef<HTMLDivElement>(null); const overviewRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const { state: floatingState, registerSection, unregisterSection } = useFloatingChat(); const { state: floatingState, registerSection, unregisterSection } = useFloatingChat();
useEffect(() => { useEffect(() => {
registerSection({ id: 'test', label: 'Tasks', ref: testRef }); registerSection({ id: 'tasks-overview', label: 'Tasks Overview', ref: overviewRef });
return () => unregisterSection('test'); registerSection({ id: 'tasks-list', label: 'Task List', ref: listRef });
return () => {
unregisterSection('tasks-overview');
unregisterSection('tasks-list');
};
}, [registerSection, unregisterSection]); }, [registerSection, unregisterSection]);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -121,7 +126,7 @@ function TasksPage() {
return ( return (
<div className="flex flex-col gap-6 p-6 w-full"> <div className="flex flex-col gap-6 p-6 w-full">
{/* Stat Cards */} {/* Stat Cards */}
<div className="grid grid-cols-4 gap-4"> <div ref={overviewRef} data-ai-section="tasks-overview" className="grid grid-cols-4 gap-4">
<Item variant="muted"> <Item variant="muted">
<ItemMedia variant="icon"> <ItemMedia variant="icon">
<ClipboardCheck /> <ClipboardCheck />
@@ -160,6 +165,8 @@ function TasksPage() {
</Item> </Item>
</div> </div>
{/* Task List Section */}
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
{/* Search + Order By */} {/* Search + Order By */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<InputGroup className="flex-1"> <InputGroup className="flex-1">
@@ -187,7 +194,7 @@ function TasksPage() {
</div> </div>
{/* Status Filter Tabs + New Task Button */} {/* Status Filter Tabs + New Task Button */}
<div ref={testRef} data-ai-section="test" className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}> <Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
<TabsList> <TabsList>
<TabsTrigger value="all">All</TabsTrigger> <TabsTrigger value="all">All</TabsTrigger>
@@ -234,6 +241,7 @@ function TasksPage() {
)) ))
)} )}
</div> </div>
</div>
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} /> <NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
<EditTaskDialog <EditTaskDialog

View File

@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
import { useState, useMemo } from 'react'; import { useEffect, useRef, useState, useMemo } from 'react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { useFloatingChat } from '@/context/FloatingChatContext';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart'; import { GanttChart, type GanttCheckpoint } from '@/components/timeline/GanttChart';
@@ -15,6 +16,14 @@ function TimelinePage() {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null); const [editingCheckpoint, setEditingCheckpoint] = useState<GanttCheckpoint | null>(null);
// AI section
const timelineRef = useRef<HTMLDivElement>(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: checkpoints } = trpc.checkpoints.list.useQuery({});
const { data: projectsList } = trpc.projects.listAll.useQuery(); const { data: projectsList } = trpc.projects.listAll.useQuery();
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -70,7 +79,7 @@ function TimelinePage() {
}, [ganttCheckpoints]); }, [ganttCheckpoints]);
return ( return (
<div className="flex flex-col gap-6 p-6 w-full"> <div ref={timelineRef} data-ai-section="timeline-chart" className="flex flex-col gap-6 p-6 w-full">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">Timeline</h1> <h1 className="text-xl font-semibold">Timeline</h1>