Compare commits
2 Commits
c5e78311e6
...
feature/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
444aa37be2 | ||
|
|
15051cfa7a |
@@ -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`
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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 remarkGfm from 'remark-gfm';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { GradualBlur } from '@/components/ui/gradual-blur';
|
||||
|
||||
const SUGGESTION_CHIPS = [
|
||||
{ icon: ListTodo, label: "What's on my plate today?" },
|
||||
@@ -16,6 +17,28 @@ const SUGGESTION_CHIPS = [
|
||||
{ icon: Lightbulb, label: 'Suggest next actions' },
|
||||
] 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 {
|
||||
onOpenSettings?: () => void;
|
||||
isHomePage?: boolean;
|
||||
@@ -50,6 +73,9 @@ export function AIChatPanel({
|
||||
const briefContentRef = useRef('');
|
||||
const hasFiredBrief = useRef(false);
|
||||
|
||||
const [briefExpanded, setBriefExpanded] = useState(false);
|
||||
const [briefDismissed, setBriefDismissed] = useState(false);
|
||||
|
||||
const messagesContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const briefMutation = trpc.ai.dailyBrief.useMutation();
|
||||
@@ -118,37 +144,126 @@ export function AIChatPanel({
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<ScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
viewportRef={messagesContainerRef}
|
||||
viewportClassName={
|
||||
isHomePage && !hasMessages
|
||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
||||
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
||||
}
|
||||
>
|
||||
<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
|
||||
className="h-full"
|
||||
viewportRef={messagesContainerRef}
|
||||
viewportClassName={
|
||||
isHomePage && !hasMessages
|
||||
? '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-center'
|
||||
: '[&>div]:!flex [&>div]:!flex-col [&>div]:!min-h-full [&>div]:!justify-end'
|
||||
}
|
||||
>
|
||||
{/* Home page initial state: greeting + brief */}
|
||||
{isHomePage && !hasMessages && (
|
||||
<div className="mx-auto w-full max-w-3xl px-6 pt-8 pb-8">
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Greeting + brief grouped closely */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<h1 className="text-[30px] font-semibold" style={{ letterSpacing: '-1px' }}>
|
||||
✦ Hello, {userName}
|
||||
</h1>
|
||||
<Badge variant="secondary">
|
||||
{dueCount} Task{dueCount !== 1 ? 's' : ''} due
|
||||
</Badge>
|
||||
</div>
|
||||
<motion.div
|
||||
className="mx-auto w-full max-w-4xl px-8 pt-14 pb-8"
|
||||
variants={stagger}
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
>
|
||||
<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>
|
||||
{dueCount > 0 && (
|
||||
<p
|
||||
className="text-muted-foreground mt-2"
|
||||
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 */}
|
||||
<div>
|
||||
{/* Daily brief */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
{hasTokenQuery.data === false ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
<KeyRound size={24} className="text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
<div className="flex flex-col items-start gap-3 py-2">
|
||||
<KeyRound size={20} className="text-muted-foreground" />
|
||||
<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.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onOpenSettings}>
|
||||
@@ -156,23 +271,22 @@ export function AIChatPanel({
|
||||
</Button>
|
||||
</div>
|
||||
) : briefLoading && !dailyBrief ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-5 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
<Skeleton className="h-5 w-2/3" />
|
||||
</div>
|
||||
) : 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.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Inline input + suggestion chips */}
|
||||
<div>
|
||||
{/* Input + suggestion links */}
|
||||
<motion.div variants={fadeUp} className="max-w-3xl">
|
||||
<ChatInput
|
||||
input={input}
|
||||
isStreaming={isStreaming || briefLoading}
|
||||
@@ -180,35 +294,32 @@ export function AIChatPanel({
|
||||
onKeyDown={handleKeyDown}
|
||||
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) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
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)}
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Home page with messages: brief stays, then messages */}
|
||||
{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">
|
||||
{/* Brief persists */}
|
||||
{dailyBrief && (
|
||||
<div className="mb-2">
|
||||
<ChatMarkdown content={dailyBrief} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.map((msg) => {
|
||||
if (msg.role === 'user') {
|
||||
@@ -268,7 +379,8 @@ export function AIChatPanel({
|
||||
)}
|
||||
|
||||
{/* Non-home messages */}
|
||||
</ScrollArea>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Fixed input — pinned to the bottom (hidden on initial state) */}
|
||||
{hasMessages && (
|
||||
@@ -332,9 +444,9 @@ function ChatInput({
|
||||
|
||||
/* ---------- ChatMarkdown: lightweight markdown renderer ---------- */
|
||||
|
||||
export function ChatMarkdown({ content }: { content: string }) {
|
||||
export function ChatMarkdown({ content, size = 'sm' }: { content: string; size?: 'sm' | 'lg' }) {
|
||||
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
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
|
||||
@@ -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
|
||||
|
||||
101
src/renderer/components/ui/gradual-blur.tsx
Normal file
101
src/renderer/components/ui/gradual-blur.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,11 @@ export interface AISection {
|
||||
label: string; // Human-readable, e.g. "Tasks", "Project Timeline"
|
||||
ref: RefObject<HTMLElement | null>;
|
||||
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 {
|
||||
@@ -24,6 +29,7 @@ interface FloatingChatState {
|
||||
position: { x: number; y: number; width: number };
|
||||
morphTargetId: string | null;
|
||||
projectId?: string;
|
||||
pendingSection?: { sectionId: string; clickY?: number }; // For cross-page navigation
|
||||
}
|
||||
|
||||
interface FloatingChatContextValue {
|
||||
@@ -36,10 +42,12 @@ interface FloatingChatContextValue {
|
||||
unregisterSection: (id: string) => void;
|
||||
|
||||
// Actions
|
||||
openAtSection: (sectionId: string) => void;
|
||||
moveToSection: (sectionId: string) => void;
|
||||
openAtSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
moveToSection: (sectionId: string, opts?: SectionOpenOpts) => void;
|
||||
close: () => void;
|
||||
setMorphTarget: (id: string | null) => void;
|
||||
updatePosition: (pos: { x: number; y: number; width: number }) => void;
|
||||
setPendingSection: (pending: { sectionId: string; clickY?: number } | undefined) => void;
|
||||
}
|
||||
|
||||
// ---------- Constants ----------
|
||||
@@ -50,25 +58,79 @@ export const PADDING = 16;
|
||||
|
||||
// ---------- 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(
|
||||
sectionRef: RefObject<HTMLElement | null>,
|
||||
section: AISection,
|
||||
opts?: SectionOpenOpts,
|
||||
): { 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 };
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const mode = section.anchorMode ?? 'top-right';
|
||||
|
||||
// Anchor to top-right of section, offset inward
|
||||
let x = rect.right - CHAT_WIDTH - PADDING;
|
||||
let y = rect.top + PADDING;
|
||||
if (mode === 'right-margin') {
|
||||
// Position to the right of the section at the click Y-coordinate
|
||||
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
|
||||
x = Math.max(PADDING, Math.min(x, window.innerWidth - CHAT_WIDTH - PADDING));
|
||||
y = Math.max(
|
||||
// Default: top-right of section
|
||||
const rawX = rect.right - CHAT_WIDTH - PADDING;
|
||||
const rawY = rect.top + PADDING;
|
||||
const { x, y } = clampPosition(rawX, rawY);
|
||||
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,
|
||||
Math.min(y, window.innerHeight - CHAT_HEIGHT - PADDING),
|
||||
);
|
||||
|
||||
return { x, y, width: CHAT_WIDTH };
|
||||
}
|
||||
|
||||
@@ -108,6 +170,23 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
const registerSection = useCallback((section: AISection) => {
|
||||
sectionsRef.current.set(section.id, section);
|
||||
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) => {
|
||||
@@ -115,11 +194,11 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
setSections(new Map(sectionsRef.current));
|
||||
}, []);
|
||||
|
||||
const openAtSection = useCallback((sectionId: string) => {
|
||||
const openAtSection = useCallback((sectionId: string, opts?: SectionOpenOpts) => {
|
||||
const section = sectionsRef.current.get(sectionId);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section.ref);
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState({
|
||||
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);
|
||||
if (!section) return;
|
||||
|
||||
const position = computeAnchorPosition(section.ref);
|
||||
const position = computeAnchorPosition(section, opts);
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
@@ -157,6 +236,14 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
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 (
|
||||
<FloatingChatCtx.Provider
|
||||
value={{
|
||||
@@ -168,6 +255,8 @@ export function FloatingChatProvider({ children }: { children: ReactNode }) {
|
||||
moveToSection,
|
||||
close,
|
||||
setMorphTarget,
|
||||
updatePosition,
|
||||
setPendingSection,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@import '@fontsource/geist/300.css';
|
||||
@import '@fontsource/geist/400.css';
|
||||
@import '@fontsource/geist/500.css';
|
||||
@import '@fontsource/geist/600.css';
|
||||
@import '@fontsource/geist/700.css';
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@@ -24,7 +24,11 @@ export interface UseAIChatReturn {
|
||||
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 [input, setInput] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
@@ -58,7 +62,15 @@ export function useAIChat(defaultContext: ChatContext): UseAIChatReturn {
|
||||
|
||||
const unsubscribe = window.electronAI.onStreamChunk(({ token, 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) => [
|
||||
...prev,
|
||||
{ id: crypto.randomUUID(), role: 'assistant', content: finalContent },
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
const INTERACTIVE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
||||
|
||||
export function useDoubleClickAI(): void {
|
||||
const { openAtSection, state } = useFloatingChat();
|
||||
const { openAtSection, moveToSection, sections, state } = useFloatingChat();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
@@ -35,10 +35,20 @@ export function useDoubleClickAI(): void {
|
||||
// If popup is already open at THIS section, do nothing
|
||||
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);
|
||||
return () => document.removeEventListener('dblclick', handler);
|
||||
}, [openAtSection, state.isOpen, state.activeSectionId]);
|
||||
}, [openAtSection, moveToSection, sections, state.isOpen, state.activeSectionId]);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { MilkdownEditor } from '@/components/notes/MilkdownEditor';
|
||||
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||
|
||||
export const Route = createFileRoute('/notes/$noteId')({
|
||||
component: NoteDetailPage,
|
||||
@@ -29,6 +30,21 @@ function NoteDetailPage() {
|
||||
const utils = trpc.useUtils();
|
||||
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 [isSaving, setIsSaving] = useState(false);
|
||||
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -188,7 +204,7 @@ function NoteDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<MilkdownEditor
|
||||
key={noteId}
|
||||
|
||||
@@ -41,12 +41,17 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
||||
};
|
||||
|
||||
function TasksPage() {
|
||||
// Temporary test: register section for floating AI chat
|
||||
const testRef = useRef<HTMLDivElement>(null);
|
||||
// AI section refs
|
||||
const overviewRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(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 (
|
||||
<div className="flex flex-col gap-6 p-6 w-full">
|
||||
{/* 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">
|
||||
<ItemMedia variant="icon">
|
||||
<ClipboardCheck />
|
||||
@@ -160,50 +165,52 @@ function TasksPage() {
|
||||
</Item>
|
||||
</div>
|
||||
|
||||
{/* Search + Order By */}
|
||||
<div className="flex items-center gap-3">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tasks or projects..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Order by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Task List Section */}
|
||||
<div ref={listRef} data-ai-section="tasks-list" className="flex flex-col gap-6">
|
||||
{/* Search + Order By */}
|
||||
<div className="flex items-center gap-3">
|
||||
<InputGroup className="flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
placeholder="Search tasks or projects..."
|
||||
value={search}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Select value={orderBy} onValueChange={(v) => setOrderBy(v as OrderBy)}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Order by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(Object.entries(ORDER_LABELS) as [OrderBy, string][]).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter Tabs + New Task Button */}
|
||||
<div ref={testRef} data-ai-section="test" className="flex items-center justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
{/* Status Filter Tabs + New Task Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">All</TabsTrigger>
|
||||
<TabsTrigger value="todo">To Do</TabsTrigger>
|
||||
<TabsTrigger value="in_progress">In Progress</TabsTrigger>
|
||||
<TabsTrigger value="done">Completed</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Task
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Task List */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Task List */}
|
||||
<div className="flex flex-col gap-1">
|
||||
{tasksList.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
@@ -233,6 +240,7 @@ function TasksPage() {
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NewTaskDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||
|
||||
@@ -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<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: projectsList } = trpc.projects.listAll.useQuery();
|
||||
const utils = trpc.useUtils();
|
||||
@@ -70,7 +79,7 @@ function TimelinePage() {
|
||||
}, [ganttCheckpoints]);
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">Timeline</h1>
|
||||
|
||||
Reference in New Issue
Block a user