feat: Built the Oracle Tab
This commit is contained in:
184
app/src/oracle/components/CanvasViewport.tsx
Normal file
184
app/src/oracle/components/CanvasViewport.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* CanvasViewport — Virtualized scrolling canvas.
|
||||
* Renders CanvasComponent objects sorted by layout.orderIndex.
|
||||
* Uses CSS content-visibility for large component lists (virtualization-lite).
|
||||
* Stable scroll anchoring: new components append without scroll jump.
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import type { CanvasComponent, PromptExecution } from '../types/canvas';
|
||||
import { ComponentRegistry, type ComponentRenderContext } from './ComponentRegistry';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface CanvasViewportProps {
|
||||
components: CanvasComponent[];
|
||||
ctx: ComponentRenderContext;
|
||||
inFlightExecution: PromptExecution | null;
|
||||
selectedComponentId?: string | null;
|
||||
onSelectComponent: (id: string | null) => void;
|
||||
}
|
||||
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
sec_pipeline: 'Pipeline',
|
||||
sec_leads: 'Lead Intelligence',
|
||||
sec_team: 'Team Performance',
|
||||
sec_actions: 'Action Queue',
|
||||
sec_prompt_generated: 'Oracle Responses',
|
||||
sec_geography: 'Geographic',
|
||||
sec_forecast: 'Forecasting',
|
||||
};
|
||||
|
||||
/** Groups components by sectionId, preserving orderIndex sort */
|
||||
function groupBySection(components: CanvasComponent[]): Array<{ sectionId: string; components: CanvasComponent[] }> {
|
||||
const sorted = [...components].sort((a, b) => a.layout.orderIndex - b.layout.orderIndex);
|
||||
const sectionMap = new Map<string, CanvasComponent[]>();
|
||||
|
||||
for (const comp of sorted) {
|
||||
const sid = comp.layout.sectionId;
|
||||
if (!sectionMap.has(sid)) sectionMap.set(sid, []);
|
||||
sectionMap.get(sid)!.push(comp);
|
||||
}
|
||||
|
||||
return Array.from(sectionMap.entries()).map(([sectionId, comps]) => ({ sectionId, components: comps }));
|
||||
}
|
||||
|
||||
/** CSS content-visibility wrapper for off-screen components, applying width mode to the flex item */
|
||||
function ComponentFlexWrapper({ comp, children }: { comp: CanvasComponent; children: React.ReactNode }) {
|
||||
const styles: Record<string, string> = {
|
||||
full: 'w-full',
|
||||
half: 'w-full xl:w-[calc(50%-8px)]',
|
||||
third: 'w-full xl:w-[calc(33.333%-11px)]',
|
||||
};
|
||||
return (
|
||||
<div
|
||||
className={styles[comp.layout.widthMode] ?? 'w-full'}
|
||||
style={{
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: `0 ${comp.renderingHints.estimatedHeightPx}px`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasViewport({
|
||||
components,
|
||||
ctx,
|
||||
inFlightExecution,
|
||||
selectedComponentId,
|
||||
onSelectComponent,
|
||||
}: CanvasViewportProps) {
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const sections = groupBySection(components);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar"
|
||||
style={{ scrollBehavior: 'smooth' }}
|
||||
onClick={(e) => {
|
||||
// Click outside component to deselect
|
||||
if (e.currentTarget === e.target) onSelectComponent(null);
|
||||
}}
|
||||
>
|
||||
<div className="px-4 py-5 space-y-8">
|
||||
{sections.length === 0 && !inFlightExecution && (
|
||||
<EmptyCanvasState />
|
||||
)}
|
||||
|
||||
{sections.map(({ sectionId, components: sectionComps }) => (
|
||||
<section key={sectionId} className="space-y-4">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-1 h-4 rounded-full bg-gradient-to-b from-blue-400 to-cyan-500" />
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-zinc-500">
|
||||
{SECTION_LABELS[sectionId] ?? sectionId.replace(/^sec_/, '').replace(/_/g, ' ')}
|
||||
</h2>
|
||||
<div className="flex-1 h-[1px]" style={{ background: 'rgba(255,255,255,0.05)' }} />
|
||||
<span className="text-[10px] text-zinc-700">{sectionComps.length}</span>
|
||||
</div>
|
||||
|
||||
{/* Flex wrap for half/third width components */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{sectionComps.map((comp) => (
|
||||
<ComponentFlexWrapper
|
||||
key={`${comp.componentId}-${comp.version}`}
|
||||
comp={comp}
|
||||
>
|
||||
<ComponentRegistry
|
||||
component={comp}
|
||||
ctx={{
|
||||
...ctx,
|
||||
isSelected: selectedComponentId === comp.componentId,
|
||||
onSelect: onSelectComponent,
|
||||
}}
|
||||
/>
|
||||
</ComponentFlexWrapper>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* In-flight execution placeholder */}
|
||||
<AnimatePresence>
|
||||
{inFlightExecution && (
|
||||
<motion.div
|
||||
key="in-flight-placeholder"
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
className="w-full rounded-2xl p-6 flex items-center gap-4"
|
||||
style={{
|
||||
background: 'rgba(59,130,246,0.06)',
|
||||
border: '1px dashed rgba(59,130,246,0.25)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-300">Oracle is analyzing your prompt…</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 line-clamp-1">
|
||||
"{inFlightExecution.prompt}"
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="ml-auto text-xs font-mono px-2 py-1 rounded-lg"
|
||||
style={{ background: 'rgba(59,130,246,0.12)', color: '#60a5fa' }}
|
||||
>
|
||||
{inFlightExecution.status}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Bottom padding for floating prompt bar */}
|
||||
<div className="h-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyCanvasState() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-6">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center"
|
||||
style={{ background: 'rgba(59,130,246,0.08)', border: '1px solid rgba(59,130,246,0.15)' }}
|
||||
>
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-10 h-10">
|
||||
<rect x="6" y="6" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" />
|
||||
<rect x="27" y="6" width="15" height="15" rx="3" stroke="#22D3EE" strokeWidth="1.5" />
|
||||
<rect x="6" y="27" width="15" height="15" rx="3" stroke="#A78BFA" strokeWidth="1.5" />
|
||||
<rect x="27" y="27" width="15" height="15" rx="3" stroke="#3B82F6" strokeWidth="1.5" opacity="0.5" strokeDasharray="3 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-medium text-zinc-200 mb-2">Canvas is empty</h3>
|
||||
<p className="text-sm text-zinc-500 max-w-sm">
|
||||
Ask Oracle anything — type a prompt below to generate analytical components on your canvas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user