import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueComment } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; import { heartbeatsApi } from "../api/heartbeats"; import { queryKeys } from "../lib/queryKeys"; import { Button } from "@/components/ui/button"; import { MarkdownBody } from "./MarkdownBody"; import { cn } from "../lib/utils"; import { Loader2, Send, CheckCircle2, ArrowRight } from "lucide-react"; interface OnboardingChatProps { taskId: string; agentId: string; agentName: string; companyName: string; companyGoal: string; onPlanDetected?: (planMarkdown: string) => void; onReviewPlan?: () => void; } /** * Detects whether a comment body contains a structured hiring plan. * Looks for markdown headers or bullet lists that mention roles/positions. */ function detectHiringPlan(body: string): boolean { const planPatterns = [ /##?\s*(hiring|team|org|roles|plan)/i, /##?\s*(proposed|recommended)\s*(roles|hires|team)/i, /\n-\s+\*\*[^*]+\*\*/g, // bullet list with bold items (role names) /\|\s*role\s*\|/i, // markdown table with "Role" header ]; return planPatterns.some((pattern) => pattern.test(body)); } const QUEUED_MESSAGES = [ "Heartbeat triggered, waking up...", "Initializing...", "Getting ready...", ]; const RUNNING_MESSAGES = [ "Working on a response...", "Reading the conversation...", "Thinking through the plan...", "Drafting a response...", "Still working...", "Almost there...", ]; const WAITING_MESSAGES = [ "Waiting to wake up...", "Heartbeat pending...", "Should wake up soon...", ]; function getCyclingMessage(messages: string[], elapsed: number, agentName: string): string { // Cycle through messages every 5 seconds const idx = Math.floor(elapsed / 5) % messages.length; return `${agentName} · ${messages[idx]}`; } function getRunStatusMessage(status: string, agentName: string, elapsed: number): string { switch (status) { case "queued": return getCyclingMessage(QUEUED_MESSAGES, elapsed, agentName); case "running": return getCyclingMessage(RUNNING_MESSAGES, elapsed, agentName); case "succeeded": return `${agentName} finished`; case "failed": return `${agentName} encountered an error`; case "cancelled": return `${agentName}'s run was cancelled`; case "timed_out": return `${agentName}'s run timed out`; default: return `${agentName} is thinking...`; } } export function OnboardingChat({ taskId, agentId, agentName, companyName, companyGoal, onPlanDetected, onReviewPlan, }: OnboardingChatProps) { const queryClient = useQueryClient(); const [input, setInput] = useState(""); const [sending, setSending] = useState(false); const [detectedPlanCommentId, setDetectedPlanCommentId] = useState< string | null >(null); // Track the comment ID after which we should ignore old plan detections // (set when user sends a new message to request revisions) const [ignoreBeforeCommentId, setIgnoreBeforeCommentId] = useState< string | null >(null); const scrollRef = useRef(null); const inputRef = useRef(null); const { data: rawComments, isLoading, } = useQuery({ queryKey: queryKeys.issues.comments(taskId), queryFn: () => issuesApi.listComments(taskId), refetchInterval: 4000, }); // Poll for active heartbeat run on this task const { data: activeRun } = useQuery({ queryKey: queryKeys.issues.activeRun(taskId), queryFn: () => heartbeatsApi.activeRunForIssue(taskId), refetchInterval: 3000, }); // Sort comments chronologically (oldest first) for chat-style display const comments = useMemo( () => rawComments ? [...rawComments].sort( (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), ) : undefined, [rawComments], ); // Auto-scroll to bottom when new comments arrive useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } }, [comments?.length]); // Detect hiring plan in agent comments. // Only considers agent comments newer than the user's last message AND // newer than any "ignore" marker (set when user asks for revisions). useEffect(() => { if (!comments || !onPlanDetected || detectedPlanCommentId) return; // Find the cutoff — the later of the user's last message or the ignore marker let cutoffIdx = -1; for (let i = comments.length - 1; i >= 0; i--) { if (comments[i].authorUserId) { cutoffIdx = i; break; } } if (ignoreBeforeCommentId) { const ignoreIdx = comments.findIndex((c) => c.id === ignoreBeforeCommentId); if (ignoreIdx >= 0) cutoffIdx = Math.max(cutoffIdx, ignoreIdx); } // Only scan agent comments after the cutoff for (let i = comments.length - 1; i > cutoffIdx; i--) { const c = comments[i]; if (c.authorAgentId && detectHiringPlan(c.body)) { setDetectedPlanCommentId(c.id); // Fetch the full plan document — it has richer role descriptions issuesApi.getDocument(taskId, "plan").then((doc) => { onPlanDetected(doc.body ?? c.body); }).catch(() => { onPlanDetected(c.body); }); break; } } }, [comments, onPlanDetected, detectedPlanCommentId, ignoreBeforeCommentId, taskId]); const sendMessage = useCallback(async (body: string) => { const trimmed = body.trim(); if (!trimmed || sending) return; setSending(true); try { // Ensure the task is assigned to the CEO and in_progress before commenting. try { await issuesApi.update(taskId, { assigneeUserId: null }); } catch { /* may already be null */ } try { await issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress", }); } catch { /* may already be assigned */ } await issuesApi.addComment(taskId, trimmed, true, true); setInput(""); // Clear detected plan — user is asking for revisions const latestId = comments?.[comments.length - 1]?.id ?? null; setIgnoreBeforeCommentId(latestId); setDetectedPlanCommentId(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId), }); } finally { setSending(false); inputRef.current?.focus(); } }, [sending, taskId, agentId, queryClient, comments]); const handleSend = useCallback(() => { sendMessage(input); }, [input, sendMessage]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }, [handleSend], ); // Determine if we should show a status indicator const lastComment = comments?.[comments.length - 1]; const isWaitingForAgent = lastComment && lastComment.authorUserId && !lastComment.authorAgentId; const hasActiveRun = activeRun && (activeRun.status === "queued" || activeRun.status === "running"); const showStatus = isWaitingForAgent || hasActiveRun; // Elapsed timer — ticks every second while waiting const [elapsed, setElapsed] = useState(0); const waitingSince = useMemo(() => { if (!showStatus || !lastComment) return null; // Use the user's last message timestamp as the start time if (lastComment.authorUserId) return new Date(lastComment.createdAt).getTime(); // If an active run exists, use its creation time if (hasActiveRun && activeRun.createdAt) return new Date(activeRun.createdAt).getTime(); return null; }, [showStatus, lastComment, hasActiveRun, activeRun]); useEffect(() => { if (!waitingSince) { setElapsed(0); return; } setElapsed(Math.floor((Date.now() - waitingSince) / 1000)); const interval = setInterval(() => { setElapsed(Math.floor((Date.now() - waitingSince) / 1000)); }, 1000); return () => clearInterval(interval); }, [waitingSince]); const elapsedStr = elapsed >= 60 ? `${Math.floor(elapsed / 60)}m ${elapsed % 60}s` : `${elapsed}s`; if (isLoading) { return (
Loading conversation...
); } return (
{/* Messages */}
{/* CEO welcome message + chips — delayed reveal */} { setInput("I want to discuss the plan before you get started."); inputRef.current?.focus(); }} onStart={() => sendMessage("Yes, get started on the hiring plan!")} /> {comments?.map((comment) => { const isAgent = Boolean(comment.authorAgentId); const isPlan = detectedPlanCommentId === comment.id; return (
{isAgent ? agentName : "You"} {isPlan && ( Hiring plan detected )}
{isAgent ? comment.body.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") : comment.body}
); })} {/* Status indicator — shows real heartbeat run status */} {showStatus && (
{hasActiveRun ? ( <> {getRunStatusMessage(activeRun.status, agentName, elapsed)} ) : ( <> {getCyclingMessage(WAITING_MESSAGES, elapsed, agentName)} )}
{elapsedStr}
)}
{/* Plan ready CTA */} {detectedPlanCommentId && onReviewPlan && (

Your CEO has prepared a hiring plan

Review it, make edits, then approve.

)} {/* Input area */}
setInput(e.target.value)} onKeyDown={handleKeyDown} autoFocus={!detectedPlanCommentId} />
); } function WelcomeMessage({ agentName, companyName, companyGoal, hasComments, onDiscuss, onStart, }: { agentName: string; companyName: string; companyGoal: string; hasComments: boolean; onDiscuss: () => void; onStart: () => void; }) { const [phase, setPhase] = useState<"waking" | "composing" | "message" | "chips">("waking"); useEffect(() => { const t1 = setTimeout(() => setPhase("composing"), 2500); const t2 = setTimeout(() => setPhase("message"), 5500); const t3 = setTimeout(() => setPhase("chips"), 6500); return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); }; }, []); const showMessage = phase === "message" || phase === "chips"; const showChips = phase === "chips" && !hasComments; return ( <> {/* Message — appears after typing indicator */} {showMessage && (
{agentName}

Hello board! Thank you for appointing me CEO of {companyName}.

Our mission is: {companyGoal}

I'm ready to build a hiring plan. Shall I get started?

)} {/* Chips — fade in after message */} {showChips && (
)} {/* Typing indicator — anchored at bottom of scroll area, before real status messages */} {!showMessage && (
)} {!showMessage && (
{phase === "waking" ? `${agentName} is waking up...` : `${agentName} is composing a message...`}
)} ); }