experiment: add OnboardingChat component and wire into step 4

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) <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-03-17 16:38:53 -07:00
parent a35fac7281
commit b4ef0618e5
2 changed files with 247 additions and 7 deletions

View file

@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(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 (
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Loading conversation...
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto space-y-3 mb-3 min-h-[180px] max-h-[320px] pr-1"
>
{(!comments || comments.length === 0) && (
<p className="text-sm text-muted-foreground text-center py-4">
Starting conversation with {agentName}...
</p>
)}
{comments?.map((comment) => {
const isAgent = Boolean(comment.authorAgentId);
const isPlan =
detectedPlanCommentId === comment.id;
return (
<div
key={comment.id}
className={cn(
"rounded-md px-3 py-2 text-sm",
isAgent
? "bg-muted/50 border border-border mr-8"
: "bg-accent/50 border border-accent ml-8",
)}
>
<div className="flex items-center gap-1.5 mb-1">
<span
className={cn(
"text-[10px] font-medium uppercase tracking-wide",
isAgent
? "text-muted-foreground"
: "text-foreground/70",
)}
>
{isAgent ? agentName : "You"}
</span>
{isPlan && (
<span className="inline-flex items-center gap-0.5 text-[10px] text-green-600 dark:text-green-400 font-medium">
<CheckCircle2 className="h-3 w-3" />
Hiring plan detected
</span>
)}
</div>
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{comment.body}</MarkdownBody>
</div>
</div>
);
})}
{/* Thinking indicator */}
{isWaitingForAgent && (
<div className="flex items-center gap-2 text-sm text-muted-foreground px-3 py-2">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{agentName} is thinking...
</div>
)}
</div>
{/* Input area */}
<div className="flex items-end gap-2 border-t border-border pt-3">
<textarea
ref={inputRef}
className="flex-1 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[40px] max-h-[100px]"
placeholder="Message your CEO..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
autoFocus
/>
<Button
size="sm"
disabled={!input.trim() || sending}
onClick={handleSend}
className="shrink-0"
>
{sending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Send className="h-3.5 w-3.5" />
)}
</Button>
</div>
{/* Manual plan selection */}
{!detectedPlanCommentId &&
comments &&
comments.some((c) => c.authorAgentId) && (
<p className="text-[10px] text-muted-foreground mt-2">
If the CEO has proposed a plan, click "Next" once you're satisfied,
or keep chatting to refine it.
</p>
)}
</div>
);
}

View file

@ -30,6 +30,7 @@ import {
} from "@paperclipai/adapter-codex-local";
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { OnboardingChat } from "./OnboardingChat";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { ChoosePathButton } from "./PathInstructionsModal";
import { HintIcon } from "./agent-config-primitives";
@ -146,6 +147,10 @@ export function OnboardingWizard() {
el.style.height = el.scrollHeight + "px";
}, []);
// Planning task + hiring plan
const [planningTaskId, setPlanningTaskId] = useState<string | null>(null);
const [planContent, setPlanContent] = useState<string | null>(null);
// Created entity IDs — pre-populate from existing company when skipping step 1
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
existingCompanyId ?? null
@ -277,6 +282,8 @@ export function OnboardingWizard() {
setQ2("");
setQ3("");
setQ4("");
setPlanningTaskId(null);
setPlanContent(null);
setAgentName("CEO");
setAdapterType("claude_local");
setCwd("");
@ -462,6 +469,20 @@ export function OnboardingWizard() {
queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(createdCompanyId)
});
// Create the planning task and kick off the conversation
const planningIssue = await issuesApi.create(createdCompanyId, {
title: "Build hiring plan with CEO",
description: `Company mission: ${companyGoal}\n\nCollaborate with the board to create a hiring plan for the company.`,
assigneeAgentId: agent.id,
status: "in_progress"
});
setPlanningTaskId(planningIssue.id);
await issuesApi.addComment(
planningIssue.id,
`Our company mission is: ${companyGoal}\n\nLet's build a hiring plan together. What roles do you think we need to accomplish this mission?`
);
setStep(4);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create agent");
@ -1342,9 +1363,9 @@ export function OnboardingWizard() {
</div>
)}
{/* Step 4: Chat with CEO — placeholder for OnboardingChat component */}
{/* Step 4: Chat with CEO */}
{step === 4 && (
<div className="space-y-5">
<div className="space-y-4">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Sparkles className="h-5 w-5 text-muted-foreground" />
@ -1357,11 +1378,19 @@ export function OnboardingWizard() {
</p>
</div>
</div>
<div className="rounded-md border border-border p-4 min-h-[200px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">
Chat component coming soon skip to review a sample hiring plan.
</p>
</div>
{planningTaskId ? (
<OnboardingChat
taskId={planningTaskId}
agentName={agentName}
onPlanDetected={(md) => setPlanContent(md)}
/>
) : (
<div className="rounded-md border border-border p-4 min-h-[200px] flex items-center justify-center">
<p className="text-sm text-muted-foreground">
No planning task found. Go back and create your CEO first.
</p>
</div>
)}
</div>
)}