import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { ArrowLeftIcon, PlusIcon } from 'lucide-react'; import { cn } from '../components/copilot/cn'; import { CapabilityBadges } from '../components/copilot/home/CapabilityBadges'; import { HomeCanvases } from '../components/copilot/home/HomeCanvases'; import { HomeComposer, StarterPrompts, } from '../components/copilot/home/HomeComposer'; import { loadHomeWeek } from '../components/copilot/home/load-week'; import { RecentActivity } from '../components/copilot/home/RecentActivity'; import { ThreadSwitcher } from '../components/copilot/ThreadSwitcher'; import { CopilotThread } from '../components/copilot/thread/CopilotThread'; import { Button } from '../components/ui/button'; import ArticleModal from '../components/visibility/ArticleModal'; import { buildVisibleNav } from '../components/layout/nav-config'; import { flattenNav } from '../components/layout/nav-search'; import { useCopilot } from '../context/copilot.context'; import { recomaze_ai_personalization_env } from '../env'; import { pageLinkTarget } from '../lib/copilot/reply-links'; import { starterPrompts } from '../lib/copilot/starter-prompts'; import { TURN_CREDITS } from '../service/copilot/pricing'; import { useHomeCanvas } from '../lib/copilot/use-home-canvas'; import { useCopilotTurn } from '../lib/copilot/use-copilot-turn'; import type { ICopilotAction } from '../service/copilot/copilot.interface'; import type { ArticleSummary, ScanHistoryPoint, WeeklyReportResponse, } from '../service/visibility/visibility.interface'; /** The mark beside a reply: the plugin icon WordPress localized. */ const LOGO: string = recomaze_ai_personalization_env?.plugin_icon ?? ''; /** The measure of the lane: about seventy characters, the one width the * reply, the merchant's bubble and the composer all sit on. */ const LANE = 'mx-auto w-full max-w-[40rem]'; /** The row when a canvas is open beside the conversation: the conversation * keeps a column of its own and the canvas takes the rest. Under ``@5xl`` * the two stack, canvas first, since the canvas is what was just asked for. */ const SPLIT_ROW = 'grid gap-5 @5xl:grid-cols-[minmax(22rem,30rem)_minmax(0,1fr)]'; const SPLIT_CONVERSATION = 'order-2 min-w-0 @5xl:order-1'; const SPLIT_LANE = 'mx-auto w-full max-w-[40rem] @5xl:mx-0 @5xl:max-w-none'; /** The dock the composer rides in under a conversation, so in a thread of * any length the box is never a scroll away. Opaque, so the thread * scrolling underneath stays out of the way of what is being typed. */ const COMPOSER_DOCK = 'sticky bottom-0 z-10 mt-6 bg-background pb-4'; /** The fade that lifts the dock off the last turn, in the room above it. */ const COMPOSER_FADE = 'pointer-events-none -mt-6 mb-3 h-6 bg-linear-to-t from-background to-transparent'; /** * AI HQ: the conversation with Recomaze. Before one starts it is the * question, the box to ask it in and the ways in; asking something turns the * page into the thread, which is the same thread the docked panel shows on * every other page, so an answer started here is still there a page later. * The numbers live on their own pages and open beside the conversation when * a reply points at them. */ const Hq = (): JSX.Element => { const copilot = useCopilot(); const navigate = useNavigate(); const messages = copilot.messages; const isStreaming = copilot.isStreaming; const [conversing, setConversing] = useState( () => copilot.getState().isStreaming ); const endRef = useRef(null); // The week, for the questions under the composer and the canvas beside // the conversation. const [report, setReport] = useState(null); const [scans, setScans] = useState([]); const [articles, setArticles] = useState([]); const [openArticleId, setOpenArticleId] = useState(null); const canvas = useHomeCanvas(); const starters: string[] = useMemo(() => starterPrompts(report), [report]); const menuPages = useMemo(() => { const { sections, utilityItems } = buildVisibleNav({}); return flattenNav(sections, utilityItems); }, []); const turn = useCopilotTurn({ token: copilot.token, clientId: copilot.clientId, onTurnStart: () => setConversing(true), }); // The conversations are read when the page opens; a turn refreshes them // itself afterwards. const refreshThreads = turn.refreshThreads; useEffect(() => { void refreshThreads(); }, [refreshThreads]); useEffect(() => { if (isStreaming) setConversing(true); }, [isStreaming]); useEffect(() => { if (!copilot.token || !copilot.clientId) return; void (async () => { try { const week = await loadHomeWeek(copilot.clientId, copilot.token); if (!week) return; setReport(week.report); setScans(week.scans); setArticles(week.articles); } catch { // The agent is unreachable: the generic questions stand in, which is // what they are for. } })(); }, [copilot.token, copilot.clientId]); // Follow the answer as it streams, the way the panel does. useEffect(() => { if (!conversing) return; endRef.current?.scrollIntoView({ block: 'end' }); }, [messages, conversing]); const ask = (text: string): void => void turn.send(text); const startNew = (): void => { turn.startNewThread(); setConversing(false); }; const openThread = (threadId: string): void => { void turn.openThread(threadId).then(() => setConversing(true)); }; const showThread: boolean = conversing && messages.length > 0; const split: boolean = canvas.hasCanvas; const lane: string = split ? SPLIT_LANE : LANE; const hasActivity: boolean = turn.threads.length > 0 || articles.length > 0; return (
{showThread && ( )} void turn.removeThread(threadId)} onRename={(threadId, title) => void turn.renameThread(threadId, title) } />
{showThread ? (
void turn.retry()} onOpenScan={() => canvas.openView('week')} onOpenCatalog={() => canvas.openView('catalog')} onAction={(action: ICopilotAction, index: number) => { copilot.consumeMessageActions(index); if (action.action === 'prompt' && action.prompt) { ask(action.prompt); } else if (action.action === 'navigate' && action.route) { if (!canvas.openInPage(action.route)) navigate(action.route); } }} onLink={(href: string, label: string) => { // The model writes made-up routes for things it should do // rather than pages it can reach, so a path with no canvas // goes back to it as a follow-up, unless its words name // the menu page it points to. if (canvas.openInPage(href)) return; const page = pageLinkTarget(href, label, menuPages); if (page) navigate(page); else ask(label); }} article={{ onOpen: () => undefined, onPublish: () => undefined, publishing: null, published: new Set(), failed: new Set(), }} />
) : (

What should we improve today?

{/* A tap asks at once; the price stood only by the composer. */}

A tap asks Reco right away: {TURN_CREDITS[copilot.thinking]}{' '} credits an answer.

{hasActivity && (
)}
)}
setOpenArticleId(null)} clientId={copilot.clientId} token={copilot.token} />
); }; export default Hq;