platform-codebase/features/platform-assistant/plugin-platform-assistant/src/ChatPopover.tsx

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;
}
`;