313 lines
8.3 KiB
TypeScript
313 lines
8.3 KiB
TypeScript
/**
|
|
* ChatPopover — context-adaptive chat bubble anchored to the FAB.
|
|
*
|
|
* Opens upward from FAB position, ~400x500px desktop, resizable via top-edge drag.
|
|
*/
|
|
|
|
import { useContext, useRef, useCallback, useState, useEffect } from 'react';
|
|
import { XIcon, MinusIcon, EyeIcon } from '@lilith/ui-icons';
|
|
import { AnimatePresence, motion } from '@lilith/ui-motion';
|
|
import styled from '@lilith/ui-styled-components';
|
|
import { AssistantContext } from './AssistantProvider';
|
|
import { AssistantChat } from './components/AssistantChat';
|
|
import { MiniProgress } from './components/MiniProgress';
|
|
|
|
export interface ChatPopoverProps {
|
|
fabX: number;
|
|
fabY: number;
|
|
onOpenPreview: () => void;
|
|
}
|
|
|
|
const POPOVER_WIDTH = 400;
|
|
const POPOVER_MIN_HEIGHT = 300;
|
|
const POPOVER_DEFAULT_HEIGHT = 500;
|
|
const FAB_SIZE = 56;
|
|
|
|
export function ChatPopover({ fabX, fabY, onOpenPreview }: ChatPopoverProps): JSX.Element | null {
|
|
const ctx = useContext(AssistantContext);
|
|
const [height, setHeight] = useState(POPOVER_DEFAULT_HEIGHT);
|
|
const resizeRef = useRef<{ startY: number; startHeight: number } | null>(null);
|
|
const sessionInitRef = useRef(false);
|
|
|
|
// Ensure session is created when popover mounts (opens)
|
|
useEffect(() => {
|
|
if (!sessionInitRef.current && ctx?.session.phase === 'idle') {
|
|
sessionInitRef.current = true;
|
|
ctx.ensureSession(undefined, 'escort');
|
|
}
|
|
}, [ctx]);
|
|
|
|
// Position popover above the FAB
|
|
const popoverLeft = Math.max(8, Math.min(fabX, window.innerWidth - POPOVER_WIDTH - 8));
|
|
const popoverBottom = window.innerHeight - fabY;
|
|
|
|
const handleResizeStart = useCallback((e: React.PointerEvent): void => {
|
|
e.preventDefault();
|
|
resizeRef.current = { startY: e.clientY, startHeight: height };
|
|
|
|
const onMove = (me: PointerEvent): void => {
|
|
if (!resizeRef.current) return;
|
|
const dy = resizeRef.current.startY - me.clientY; // drag up = increase height
|
|
const newHeight = Math.max(POPOVER_MIN_HEIGHT, resizeRef.current.startHeight + dy);
|
|
setHeight(newHeight);
|
|
};
|
|
|
|
const onUp = (): void => {
|
|
window.removeEventListener('pointermove', onMove);
|
|
window.removeEventListener('pointerup', onUp);
|
|
resizeRef.current = null;
|
|
};
|
|
|
|
window.addEventListener('pointermove', onMove);
|
|
window.addEventListener('pointerup', onUp);
|
|
}, [height]);
|
|
|
|
if (!ctx) return null;
|
|
|
|
const { session, page, draft, sendMessage, publishAll, setPopoverState } = ctx;
|
|
const isEditor = page === 'editor';
|
|
const hasProgress = session.progress.length > 0;
|
|
const hasDrafts = (draft.preview?.items.length ?? 0) > 0;
|
|
|
|
return (
|
|
<AnimatePresence onExitComplete={() => {}}>
|
|
<Overlay
|
|
as={motion.div}
|
|
key="popover"
|
|
initial={{ opacity: 0, scale: 0.95, y: 12 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.95, y: 12 }}
|
|
transition={{ duration: 0.18, ease: 'easeOut' }}
|
|
style={{
|
|
left: popoverLeft,
|
|
bottom: popoverBottom + FAB_SIZE + 12,
|
|
width: POPOVER_WIDTH,
|
|
height,
|
|
}}
|
|
role="dialog"
|
|
aria-label="Profile assistant"
|
|
aria-modal="false"
|
|
>
|
|
{/* Top resize handle */}
|
|
<ResizeHandle onPointerDown={handleResizeStart} aria-hidden="true" />
|
|
|
|
{/* Header */}
|
|
<Header>
|
|
<HeaderLeft>
|
|
{isEditor && hasProgress ? (
|
|
<MiniProgress progress={session.progress} />
|
|
) : (
|
|
<HeaderTitle>
|
|
{page === 'manage' ? 'Profile Assistant' : 'Browse Assistant'}
|
|
</HeaderTitle>
|
|
)}
|
|
</HeaderLeft>
|
|
|
|
<HeaderActions>
|
|
{isEditor && hasDrafts && (
|
|
<IconButton onClick={onOpenPreview} title="Preview draft changes" aria-label="Preview draft">
|
|
<EyeIcon size={15} />
|
|
</IconButton>
|
|
)}
|
|
<IconButton
|
|
onClick={() => {
|
|
setPopoverState('minimized');
|
|
}}
|
|
title="Minimize"
|
|
aria-label="Minimize chat"
|
|
>
|
|
<MinusIcon size={15} />
|
|
</IconButton>
|
|
<IconButton
|
|
onClick={() => {
|
|
setPopoverState('closed');
|
|
}}
|
|
title="Close"
|
|
aria-label="Close chat"
|
|
>
|
|
<XIcon size={15} />
|
|
</IconButton>
|
|
</HeaderActions>
|
|
</Header>
|
|
|
|
{/* Chat body */}
|
|
<Body>
|
|
<AssistantChat
|
|
messages={session.messages}
|
|
isLoading={session.phase === 'creating'}
|
|
onSendMessage={sendMessage}
|
|
inputPlaceholder={
|
|
page === 'editor'
|
|
? 'Describe this category...'
|
|
: page === 'manage'
|
|
? 'Create a profile, apply a template...'
|
|
: 'Ask about profiles...'
|
|
}
|
|
/>
|
|
</Body>
|
|
|
|
{/* Context footer */}
|
|
{isEditor && (
|
|
<Footer>
|
|
<FooterButton onClick={onOpenPreview} disabled={!hasDrafts}>
|
|
<EyeIcon size={14} />
|
|
Preview Draft
|
|
</FooterButton>
|
|
<FooterButton
|
|
$primary
|
|
onClick={() => publishAll()}
|
|
disabled={!hasDrafts || draft.isLoading}
|
|
>
|
|
Save Draft
|
|
</FooterButton>
|
|
</Footer>
|
|
)}
|
|
</Overlay>
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
const Overlay = styled.div`
|
|
position: fixed;
|
|
z-index: 10000;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #0f0f1e;
|
|
border: 1px solid rgba(124, 58, 237, 0.2);
|
|
border-radius: 1rem;
|
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(124, 58, 237, 0.1);
|
|
overflow: hidden;
|
|
|
|
@media (max-width: 480px) {
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 80px !important;
|
|
width: 100vw !important;
|
|
border-radius: 1rem 1rem 0 0;
|
|
height: 60vh !important;
|
|
}
|
|
`;
|
|
|
|
const ResizeHandle = styled.div`
|
|
height: 8px;
|
|
cursor: ns-resize;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
|
|
&::after {
|
|
content: '';
|
|
width: 32px;
|
|
height: 3px;
|
|
border-radius: 2px;
|
|
background: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
&:hover::after {
|
|
background: rgba(124, 58, 237, 0.5);
|
|
}
|
|
`;
|
|
|
|
const Header = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.625rem 0.875rem;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const HeaderLeft = styled.div`
|
|
flex: 1;
|
|
min-width: 0;
|
|
margin-right: 0.5rem;
|
|
`;
|
|
|
|
const HeaderTitle = styled.span`
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: rgba(255, 255, 255, 0.8);
|
|
`;
|
|
|
|
const HeaderActions = styled.div`
|
|
display: flex;
|
|
gap: 0.25rem;
|
|
align-items: center;
|
|
`;
|
|
|
|
const IconButton = styled.button`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.75rem;
|
|
height: 1.75rem;
|
|
background: none;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
color: rgba(255, 255, 255, 0.4);
|
|
cursor: pointer;
|
|
transition: background 0.15s, color 0.15s;
|
|
|
|
&:hover {
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: rgba(255, 255, 255, 0.7);
|
|
}
|
|
|
|
&:disabled {
|
|
opacity: 0.3;
|
|
cursor: default;
|
|
}
|
|
`;
|
|
|
|
const Body = styled.div`
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
`;
|
|
|
|
const Footer = styled.div`
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem;
|
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
background: rgba(255, 255, 255, 0.02);
|
|
flex-shrink: 0;
|
|
`;
|
|
|
|
const FooterButton = styled.button<{ $primary?: boolean }>`
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
flex: 1;
|
|
justify-content: center;
|
|
padding: 0.5rem 0.75rem;
|
|
border-radius: 0.5rem;
|
|
font-size: 0.8125rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.15s;
|
|
|
|
${(props) =>
|
|
props.$primary
|
|
? `
|
|
background: rgba(124, 58, 237, 0.2);
|
|
border: 1px solid rgba(124, 58, 237, 0.4);
|
|
color: #a78bfa;
|
|
&:hover:not(:disabled) { background: rgba(124,58,237,0.3); border-color: #7c3aed; }
|
|
`
|
|
: `
|
|
background: rgba(255,255,255,0.04);
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
color: rgba(255,255,255,0.5);
|
|
&:hover:not(:disabled) { background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.7); }
|
|
`}
|
|
|
|
&:disabled {
|
|
opacity: 0.35;
|
|
cursor: default;
|
|
}
|
|
`;
|