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";
|
} from "@paperclipai/adapter-codex-local";
|
||||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||||
|
import { OnboardingChat } from "./OnboardingChat";
|
||||||
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
||||||
import { ChoosePathButton } from "./PathInstructionsModal";
|
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||||
import { HintIcon } from "./agent-config-primitives";
|
import { HintIcon } from "./agent-config-primitives";
|
||||||
|
|
@ -146,6 +147,10 @@ export function OnboardingWizard() {
|
||||||
el.style.height = el.scrollHeight + "px";
|
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
|
// Created entity IDs — pre-populate from existing company when skipping step 1
|
||||||
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
|
const [createdCompanyId, setCreatedCompanyId] = useState<string | null>(
|
||||||
existingCompanyId ?? null
|
existingCompanyId ?? null
|
||||||
|
|
@ -277,6 +282,8 @@ export function OnboardingWizard() {
|
||||||
setQ2("");
|
setQ2("");
|
||||||
setQ3("");
|
setQ3("");
|
||||||
setQ4("");
|
setQ4("");
|
||||||
|
setPlanningTaskId(null);
|
||||||
|
setPlanContent(null);
|
||||||
setAgentName("CEO");
|
setAgentName("CEO");
|
||||||
setAdapterType("claude_local");
|
setAdapterType("claude_local");
|
||||||
setCwd("");
|
setCwd("");
|
||||||
|
|
@ -462,6 +469,20 @@ export function OnboardingWizard() {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.agents.list(createdCompanyId)
|
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);
|
setStep(4);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||||
|
|
@ -1342,9 +1363,9 @@ export function OnboardingWizard() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Chat with CEO — placeholder for OnboardingChat component */}
|
{/* Step 4: Chat with CEO */}
|
||||||
{step === 4 && (
|
{step === 4 && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="bg-muted/50 p-2">
|
<div className="bg-muted/50 p-2">
|
||||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||||
|
|
@ -1357,11 +1378,19 @@ export function OnboardingWizard() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border p-4 min-h-[200px] flex items-center justify-center">
|
{planningTaskId ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<OnboardingChat
|
||||||
Chat component coming soon — skip to review a sample hiring plan.
|
taskId={planningTaskId}
|
||||||
</p>
|
agentName={agentName}
|
||||||
</div>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue