From b4ef0618e5e81bcef326dd382a2d407ddbba77d8 Mon Sep 17 00:00:00 2001 From: scotttong Date: Tue, 17 Mar 2026 16:38:53 -0700 Subject: [PATCH] experiment: add OnboardingChat component and wire into step 4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates a new OnboardingChat component that provides an embedded chat experience between the user and the CEO agent during onboarding. How it works: - After CEO creation (step 3), a planning task is auto-created and assigned to the CEO with the company mission as context - An initial comment kicks off the conversation asking the CEO to propose a hiring plan - OnboardingChat polls issuesApi.listComments every 4 seconds - Messages render as chat bubbles (user on right, agent on left) - A "thinking" indicator shows when waiting for the agent - Automatically detects hiring plan patterns in agent responses (markdown headers/lists with role names) - Calls onPlanDetected callback when a plan is found The existing issue comment system is the backbone — no new server endpoints needed. The agent wakes up automatically when comments are posted via the existing wakeup-on-comment pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/src/components/OnboardingChat.tsx | 211 +++++++++++++++++++++++++ ui/src/components/OnboardingWizard.tsx | 43 ++++- 2 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 ui/src/components/OnboardingChat.tsx 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 */} +
+