feat(tasks): register section for floating AI chat in TasksPage
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } 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 { useRouterState } from '@tanstack/react-router';
|
||||||
import { X, ArrowUp, Sparkles, GripHorizontal } from 'lucide-react';
|
import { X, ArrowUp } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useFloatingChat,
|
useFloatingChat,
|
||||||
CHAT_WIDTH,
|
CHAT_WIDTH,
|
||||||
@@ -11,17 +11,9 @@ import {
|
|||||||
} from '@/context/FloatingChatContext';
|
} from '@/context/FloatingChatContext';
|
||||||
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
import { useAIChat, type ChatContext } from '@/hooks/useAIChat';
|
||||||
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
import { ChatMarkdown } from '@/components/ai/AIChatPanel';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
|
||||||
interface DragState {
|
|
||||||
startX: number;
|
|
||||||
startY: number;
|
|
||||||
originX: number;
|
|
||||||
originY: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function FloatingChatInner() {
|
function FloatingChatInner() {
|
||||||
const { state, sections, close } = useFloatingChat();
|
const { state, sections, close } = useFloatingChat();
|
||||||
const routerState = useRouterState();
|
const routerState = useRouterState();
|
||||||
@@ -50,69 +42,7 @@ function FloatingChatInner() {
|
|||||||
clearMessages,
|
clearMessages,
|
||||||
} = useAIChat(chatContext);
|
} = useAIChat(chatContext);
|
||||||
|
|
||||||
// ---- Position & drag state ----
|
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const dragRef = useRef<DragState | null>(null);
|
|
||||||
const posRef = useRef({ x: state.position.x, y: state.position.y });
|
|
||||||
const [posState, setPosState] = useState({
|
|
||||||
x: state.position.x,
|
|
||||||
y: state.position.y,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync from context when position changes externally (new section opened)
|
|
||||||
useEffect(() => {
|
|
||||||
posRef.current = { x: state.position.x, y: state.position.y };
|
|
||||||
setPosState({ x: state.position.x, y: state.position.y });
|
|
||||||
}, [state.position.x, state.position.y]);
|
|
||||||
|
|
||||||
// ---- Drag handlers ----
|
|
||||||
|
|
||||||
const onPointerDown = useCallback((e: React.PointerEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
headerRef.current?.setPointerCapture(e.pointerId);
|
|
||||||
dragRef.current = {
|
|
||||||
startX: e.clientX,
|
|
||||||
startY: e.clientY,
|
|
||||||
originX: posRef.current.x,
|
|
||||||
originY: posRef.current.y,
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerMove = useCallback((e: React.PointerEvent) => {
|
|
||||||
const d = dragRef.current;
|
|
||||||
if (!d) return;
|
|
||||||
|
|
||||||
const dx = e.clientX - d.startX;
|
|
||||||
const dy = e.clientY - d.startY;
|
|
||||||
|
|
||||||
let newX = d.originX + dx;
|
|
||||||
let newY = d.originY + dy;
|
|
||||||
|
|
||||||
newX = Math.max(
|
|
||||||
PADDING,
|
|
||||||
Math.min(newX, window.innerWidth - CHAT_WIDTH - PADDING),
|
|
||||||
);
|
|
||||||
newY = Math.max(
|
|
||||||
PADDING,
|
|
||||||
Math.min(newY, window.innerHeight - CHAT_HEIGHT - PADDING),
|
|
||||||
);
|
|
||||||
|
|
||||||
posRef.current = { x: newX, y: newY };
|
|
||||||
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (el) {
|
|
||||||
el.style.left = `${newX}px`;
|
|
||||||
el.style.top = `${newY}px`;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onPointerUp = useCallback(() => {
|
|
||||||
if (!dragRef.current) return;
|
|
||||||
dragRef.current = null;
|
|
||||||
setPosState({ ...posRef.current });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ---- Close on Escape ----
|
// ---- Close on Escape ----
|
||||||
|
|
||||||
@@ -153,21 +83,19 @@ function FloatingChatInner() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isOpen) return;
|
if (!state.isOpen) return;
|
||||||
const handler = () => {
|
const handler = () => {
|
||||||
const pos = posRef.current;
|
// Re-anchor if the container would go offscreen
|
||||||
const clampedX = Math.max(
|
const el = containerRef.current;
|
||||||
PADDING,
|
if (el) {
|
||||||
Math.min(pos.x, window.innerWidth - CHAT_WIDTH - PADDING),
|
const rect = el.getBoundingClientRect();
|
||||||
);
|
if (rect.right > window.innerWidth || rect.bottom > window.innerHeight) {
|
||||||
const clampedY = Math.max(
|
el.style.left = `${Math.max(PADDING, Math.min(state.position.x, window.innerWidth - CHAT_WIDTH - PADDING))}px`;
|
||||||
PADDING,
|
el.style.top = `${Math.max(PADDING, Math.min(state.position.y, window.innerHeight - CHAT_HEIGHT - PADDING))}px`;
|
||||||
Math.min(pos.y, window.innerHeight - CHAT_HEIGHT - PADDING),
|
}
|
||||||
);
|
}
|
||||||
posRef.current = { x: clampedX, y: clampedY };
|
|
||||||
setPosState({ x: clampedX, y: clampedY });
|
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', handler);
|
window.addEventListener('resize', handler);
|
||||||
return () => window.removeEventListener('resize', handler);
|
return () => window.removeEventListener('resize', handler);
|
||||||
}, [state.isOpen]);
|
}, [state.isOpen, state.position.x, state.position.y]);
|
||||||
|
|
||||||
// ---- Auto-scroll messages ----
|
// ---- Auto-scroll messages ----
|
||||||
|
|
||||||
@@ -200,6 +128,8 @@ function FloatingChatInner() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const hasMessages = messages.length > 0 || isStreaming;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
@@ -208,123 +138,121 @@ function FloatingChatInner() {
|
|||||||
key="floating-chat"
|
key="floating-chat"
|
||||||
layout
|
layout
|
||||||
layoutId={state.morphTargetId ?? undefined}
|
layoutId={state.morphTargetId ?? undefined}
|
||||||
initial={{ opacity: 0, scale: 0.92, y: 8 }}
|
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.92, y: 8 }}
|
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
||||||
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
left: posState.x,
|
left: state.position.x,
|
||||||
top: posState.y,
|
top: state.position.y,
|
||||||
width: state.position.width,
|
width: state.position.width,
|
||||||
height: CHAT_HEIGHT,
|
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
className="rounded-xl border border-border bg-background/95 backdrop-blur-xl shadow-2xl flex flex-col overflow-hidden"
|
className="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
{/* ---- Header ---- */}
|
{/* ---- Messages panel (appears when chat has content) ---- */}
|
||||||
<div
|
<AnimatePresence>
|
||||||
ref={headerRef}
|
{hasMessages && (
|
||||||
className="flex items-center gap-2 px-3 py-2 border-b border-border/50 cursor-grab active:cursor-grabbing select-none"
|
<motion.div
|
||||||
onPointerDown={onPointerDown}
|
key="messages-panel"
|
||||||
onPointerMove={onPointerMove}
|
initial={{ opacity: 0, height: 0, scale: 0.97 }}
|
||||||
onPointerUp={onPointerUp}
|
animate={{ opacity: 1, height: 'auto', scale: 1 }}
|
||||||
>
|
exit={{ opacity: 0, height: 0, scale: 0.97 }}
|
||||||
<GripHorizontal
|
transition={{ type: 'spring', stiffness: 400, damping: 30 }}
|
||||||
size={14}
|
className="rounded-2xl overflow-hidden"
|
||||||
className="text-muted-foreground shrink-0"
|
>
|
||||||
/>
|
<ScrollArea
|
||||||
<Badge variant="outline" className="text-[10px] truncate">
|
className="max-h-[300px]"
|
||||||
{activeSection?.label ?? 'Chat'}
|
viewportRef={scrollRef}
|
||||||
</Badge>
|
>
|
||||||
|
<div className="flex flex-col gap-2.5 p-3">
|
||||||
|
{messages.map((msg) => {
|
||||||
|
if (msg.role === 'user') {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="flex justify-end">
|
||||||
|
<div className="max-w-[80%] rounded-2xl rounded-br-md bg-primary text-primary-foreground px-3.5 py-2 shadow-sm">
|
||||||
|
<p className="text-xs whitespace-pre-wrap leading-relaxed">
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.error) {
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="flex justify-start">
|
||||||
|
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-destructive/10 px-3.5 py-2">
|
||||||
|
<p className="text-xs text-destructive whitespace-pre-wrap leading-relaxed">
|
||||||
|
{msg.content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={msg.id} className="flex justify-start">
|
||||||
|
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2 shadow-xl/30">
|
||||||
|
<div className="text-xs">
|
||||||
|
<ChatMarkdown content={msg.content} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Streaming */}
|
||||||
|
{isStreaming && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="max-w-[80%] rounded-2xl rounded-bl-md bg-primary text-primary-foreground px-3.5 py-2 shadow-xl/30">
|
||||||
|
{streamingContent ? (
|
||||||
|
<div className="text-xs">
|
||||||
|
<ChatMarkdown content={streamingContent} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 py-0.5">
|
||||||
|
<Skeleton className="h-3 w-36" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* ---- Floating input bar ---- */}
|
||||||
|
<div className="relative rounded-2xl bg-background/80 backdrop-blur-2xl shadow-[0_8px_60px_-12px_rgba(0,0,0,0.25)] border border-border/30 ring-1 ring-white/5 transition-shadow focus-within:shadow-[0_8px_60px_-8px_rgba(0,0,0,0.3)] focus-within:ring-ring/20">
|
||||||
|
{/* Close button */}
|
||||||
<button
|
<button
|
||||||
onClick={close}
|
onClick={close}
|
||||||
className="ml-auto flex h-6 w-6 items-center justify-center rounded-md hover:bg-muted transition-colors"
|
className="absolute -top-1.5 -right-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted/90 backdrop-blur-sm border border-border/50 shadow-sm hover:bg-muted transition-colors z-10"
|
||||||
>
|
>
|
||||||
<X size={14} />
|
<X size={10} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---- Messages ---- */}
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
<ScrollArea className="flex-1 min-h-0" viewportRef={scrollRef}>
|
|
||||||
<div className="flex flex-col gap-3 p-3">
|
|
||||||
{messages.map((msg) => {
|
|
||||||
if (msg.role === 'user') {
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="flex justify-end">
|
|
||||||
<div className="ml-auto max-w-[85%] rounded-xl bg-muted px-3 py-1.5">
|
|
||||||
<p className="text-xs whitespace-pre-wrap">
|
|
||||||
{msg.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.error) {
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="mr-auto max-w-[85%]">
|
|
||||||
<p className="text-xs text-destructive whitespace-pre-wrap">
|
|
||||||
{msg.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={msg.id} className="mr-auto max-w-[85%]">
|
|
||||||
<div className="flex items-center gap-1 mb-0.5">
|
|
||||||
<Sparkles size={12} className="text-foreground" />
|
|
||||||
<span className="text-[10px] font-semibold">Adiuva</span>
|
|
||||||
</div>
|
|
||||||
<div className="pl-4 text-xs">
|
|
||||||
<ChatMarkdown content={msg.content} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Streaming indicator */}
|
|
||||||
{isStreaming && (
|
|
||||||
<div className="mr-auto max-w-[85%]">
|
|
||||||
<div className="flex items-center gap-1 mb-0.5">
|
|
||||||
<Sparkles size={12} className="text-foreground" />
|
|
||||||
<span className="text-[10px] font-semibold">Adiuva</span>
|
|
||||||
</div>
|
|
||||||
{streamingContent ? (
|
|
||||||
<div className="pl-4 text-xs">
|
|
||||||
<ChatMarkdown content={streamingContent} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5 pl-4">
|
|
||||||
<Skeleton className="h-3 w-36" />
|
|
||||||
<Skeleton className="h-3 w-24" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* ---- Input bar ---- */}
|
|
||||||
<div className="border-t border-border/50 p-2">
|
|
||||||
<div className="flex items-center gap-1.5 rounded-lg bg-muted/50 px-2.5 py-1.5">
|
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="Ask about this section..."
|
placeholder={`Ask about ${activeSection?.label ?? 'this section'}...`}
|
||||||
rows={1}
|
rows={1}
|
||||||
className="flex-1 resize-none bg-transparent text-xs placeholder:text-muted-foreground outline-none max-h-16 overflow-y-auto"
|
className="flex-1 resize-none bg-transparent text-sm placeholder:text-muted-foreground/60 outline-none max-h-20 overflow-y-auto"
|
||||||
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
style={{ fieldSizing: 'content' } as React.CSSProperties}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSend()}
|
onClick={() => handleSend()}
|
||||||
disabled={!input.trim() || isStreaming}
|
disabled={!input.trim() || isStreaming}
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm transition-all hover:bg-primary/90 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<ArrowUp size={12} />
|
<ArrowUp size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||||
|
import { useFloatingChat } from '@/context/FloatingChatContext';
|
||||||
import {
|
import {
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
@@ -40,6 +41,14 @@ const ORDER_LABELS: Record<OrderBy, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function TasksPage() {
|
function TasksPage() {
|
||||||
|
// Temporary test: register section for floating AI chat
|
||||||
|
const testRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { registerSection, unregisterSection } = useFloatingChat();
|
||||||
|
useEffect(() => {
|
||||||
|
registerSection({ id: 'test', label: 'Tasks', ref: testRef });
|
||||||
|
return () => unregisterSection('test');
|
||||||
|
}, [registerSection, unregisterSection]);
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
@@ -110,7 +119,7 @@ function TasksPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-6 pe-8 w-full">
|
<div ref={testRef} data-ai-section="test" className="flex flex-col gap-6 p-6 pe-8 w-full">
|
||||||
{/* Stat Cards */}
|
{/* Stat Cards */}
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
|
|||||||
Reference in New Issue
Block a user