diff --git a/ui/src/components/OnboardingChat.tsx b/ui/src/components/OnboardingChat.tsx new file mode 100644 index 00000000..045d579d --- /dev/null +++ b/ui/src/components/OnboardingChat.tsx @@ -0,0 +1,211 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { IssueComment } from "@paperclipai/shared"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { MarkdownBody } from "./MarkdownBody"; +import { cn } from "../lib/utils"; +import { Loader2, Send, CheckCircle2 } from "lucide-react"; + +interface OnboardingChatProps { + taskId: string; + agentName: string; + onPlanDetected?: (planMarkdown: string) => 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)); +} + +export function OnboardingChat({ + taskId, + agentName, + onPlanDetected, +}: OnboardingChatProps) { + const queryClient = useQueryClient(); + const [input, setInput] = useState(""); + const [sending, setSending] = useState(false); + const [detectedPlanCommentId, setDetectedPlanCommentId] = useState< + string | null + >(null); + const scrollRef = useRef(null); + const inputRef = useRef(null); + + const { + data: comments, + isLoading, + } = useQuery({ + queryKey: queryKeys.issues.comments(taskId), + queryFn: () => issuesApi.listComments(taskId), + refetchInterval: 4000, + }); + + // 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 + useEffect(() => { + if (!comments || !onPlanDetected || detectedPlanCommentId) return; + // Scan from newest to oldest for a plan-like comment from the agent + for (let i = comments.length - 1; i >= 0; i--) { + const c = comments[i]; + if (c.authorAgentId && detectHiringPlan(c.body)) { + setDetectedPlanCommentId(c.id); + onPlanDetected(c.body); + break; + } + } + }, [comments, onPlanDetected, detectedPlanCommentId]); + + const handleSend = useCallback(async () => { + const body = input.trim(); + if (!body || sending) return; + setSending(true); + try { + await issuesApi.addComment(taskId, body); + setInput(""); + queryClient.invalidateQueries({ + queryKey: queryKeys.issues.comments(taskId), + }); + } finally { + setSending(false); + inputRef.current?.focus(); + } + }, [input, sending, taskId, queryClient]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend], + ); + + const lastComment = comments?.[comments.length - 1]; + const isWaitingForAgent = + lastComment && lastComment.authorUserId && !lastComment.authorAgentId; + + if (isLoading) { + return ( +
+ + Loading conversation... +
+ ); + } + + return ( +
+ {/* Messages */} +
+ {(!comments || comments.length === 0) && ( +

+ Starting conversation with {agentName}... +

+ )} + {comments?.map((comment) => { + const isAgent = Boolean(comment.authorAgentId); + const isPlan = + detectedPlanCommentId === comment.id; + return ( +
+
+ + {isAgent ? agentName : "You"} + + {isPlan && ( + + + Hiring plan detected + + )} +
+
+ {comment.body} +
+
+ ); + })} + + {/* Thinking indicator */} + {isWaitingForAgent && ( +
+ + {agentName} is thinking... +
+ )} +
+ + {/* Input area */} +
+