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:
parent
a35fac7281
commit
b4ef0618e5
2 changed files with 247 additions and 7 deletions
211
ui/src/components/OnboardingChat.tsx
Normal file
211
ui/src/components/OnboardingChat.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue