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
|
## 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`
|
||||||
|
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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"
|
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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user