experiment: CEO welcome flow, merged steps, orientation screen, UX polish

CEO welcome & user agency:
- Removed auto-posted user message — CEO greets the board first
- "CEO is waking up..." → "CEO is composing..." → welcome message fades in
- Response chips ("Yes, get started!" / "Let's discuss first") fade in after
- Planning task created unassigned — CEO only wakes when user initiates
- "Yes" chip sends directly; "Discuss" pre-fills input for editing

Merged steps 5+6:
- "Approve & hire" on the Plan step creates hire tasks directly
- No redundant confirmation step
- Step 6 is now a welcome/orientation screen (hidden from nav tabs)

Orientation screen:
- Shows what to expect: Tasks, Agents, Approvals, Dashboard
- "Go to dashboard" button closes wizard and navigates

Nav tabs cleaned up:
- Mission → Launch → CEO → Plan → Review (5 visible tabs)
- Orientation step exists but not in nav (no redundant rockets)

Chat UX polish:
- Single-line input (like a URL bar) instead of multi-line textarea
- CEO welcome message always visible in conversation history
- Structured task description tells CEO exact format for role specs
- "Confirm mission" button hidden until user has chosen a path
- Switching paths preserves previously entered text

Parser improvements:
- Handles N. **Role Name** with indented bullets (fallback format)
- Summary field populated from first expertise line when no explicit summary
- Better skip patterns for non-role sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-03-18 03:18:14 -07:00
parent 0c1582ef47
commit 05a2848b02
2 changed files with 321 additions and 119 deletions

View file

@ -13,6 +13,8 @@ interface OnboardingChatProps {
taskId: string;
agentId: string;
agentName: string;
companyName: string;
companyGoal: string;
onPlanDetected?: (planMarkdown: string) => void;
onReviewPlan?: () => void;
}
@ -81,6 +83,8 @@ export function OnboardingChat({
taskId,
agentId,
agentName,
companyName,
companyGoal,
onPlanDetected,
onReviewPlan,
}: OnboardingChatProps) {
@ -96,7 +100,7 @@ export function OnboardingChat({
string | null
>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const {
data: rawComments,
@ -165,14 +169,12 @@ export function OnboardingChat({
}
}, [comments, onPlanDetected, detectedPlanCommentId, ignoreBeforeCommentId, taskId]);
const handleSend = useCallback(async () => {
const body = input.trim();
if (!body || sending) return;
const sendMessage = useCallback(async (body: string) => {
const trimmed = body.trim();
if (!trimmed || sending) return;
setSending(true);
try {
// Ensure the task is assigned to the CEO and in_progress before commenting.
// The CEO tends to unassign itself and set status to in_review after responding,
// which prevents the comment wakeup from working.
try {
await issuesApi.update(taskId, { assigneeUserId: null });
} catch { /* may already be null */ }
@ -183,11 +185,9 @@ export function OnboardingChat({
});
} catch { /* may already be assigned */ }
await issuesApi.addComment(taskId, body, true, true);
await issuesApi.addComment(taskId, trimmed, true, true);
setInput("");
// Clear detected plan — user is asking for revisions, so the old plan
// is stale. A new plan will be detected when the CEO responds again.
// Mark the last known comment so the detector ignores older plans.
// Clear detected plan — user is asking for revisions
const latestId = comments?.[comments.length - 1]?.id ?? null;
setIgnoreBeforeCommentId(latestId);
setDetectedPlanCommentId(null);
@ -198,7 +198,11 @@ export function OnboardingChat({
setSending(false);
inputRef.current?.focus();
}
}, [input, sending, taskId, queryClient]);
}, [sending, taskId, agentId, queryClient, comments]);
const handleSend = useCallback(() => {
sendMessage(input);
}, [input, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@ -257,11 +261,18 @@ export function OnboardingChat({
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>
)}
{/* CEO welcome message + chips — delayed reveal */}
<WelcomeMessage
agentName={agentName}
companyName={companyName}
companyGoal={companyGoal}
hasComments={Boolean(comments?.length)}
onDiscuss={() => {
setInput("I want to discuss the plan before you get started.");
inputRef.current?.focus();
}}
onStart={() => sendMessage("Yes, get started on the hiring plan!")}
/>
{comments?.map((comment) => {
const isAgent = Boolean(comment.authorAgentId);
const isPlan =
@ -355,15 +366,15 @@ export function OnboardingChat({
)}
{/* Input area */}
<div className="flex items-end gap-2 border-t border-border pt-3">
<textarea
<div className="flex items-center gap-2 border-t border-border pt-3">
<input
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={detectedPlanCommentId ? "Ask your CEO to revise the plan, or review it above..." : "Message your CEO..."}
type="text"
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"
placeholder={detectedPlanCommentId ? "Ask your CEO to revise the plan..." : "Message your CEO..."}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
autoFocus={!detectedPlanCommentId}
/>
<Button
@ -382,3 +393,89 @@ export function OnboardingChat({
</div>
);
}
function WelcomeMessage({
agentName,
companyName,
companyGoal,
hasComments,
onDiscuss,
onStart,
}: {
agentName: string;
companyName: string;
companyGoal: string;
hasComments: boolean;
onDiscuss: () => void;
onStart: () => void;
}) {
const [phase, setPhase] = useState<"waking" | "composing" | "message" | "chips">("waking");
useEffect(() => {
const t1 = setTimeout(() => setPhase("composing"), 2500);
const t2 = setTimeout(() => setPhase("message"), 5500);
const t3 = setTimeout(() => setPhase("chips"), 6500);
return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); };
}, []);
const showMessage = phase === "message" || phase === "chips";
const showChips = phase === "chips" && !hasComments;
return (
<>
{/* Message — appears after typing indicator */}
{showMessage && (
<div className="rounded-md px-3 py-2 text-sm bg-muted/50 border border-border mr-8 animate-in fade-in duration-300">
<div className="flex items-center gap-1.5 mb-1">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{agentName}
</span>
</div>
<p>
Hello board! Thank you for appointing me CEO of <strong>{companyName}</strong>.
</p>
<p className="mt-1">
Our mission is: <em>{companyGoal}</em>
</p>
<p className="mt-1">
I'm ready to build a hiring plan. Shall I get started?
</p>
</div>
)}
{/* Chips — fade in after message */}
{showChips && (
<div className="flex gap-2 ml-auto justify-end animate-in fade-in duration-500">
<button
className="rounded-full border border-border px-3 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
onClick={onDiscuss}
>
Let's discuss first
</button>
<button
className="rounded-full border border-foreground bg-foreground text-background px-3 py-1 text-xs hover:opacity-90 transition-opacity"
onClick={onStart}
>
Yes, get started!
</button>
</div>
)}
{/* Typing indicator — anchored at bottom of scroll area, before real status messages */}
{!showMessage && (
<div className="flex-1" />
)}
{!showMessage && (
<div className="flex items-center gap-2 text-sm text-muted-foreground px-3 py-2">
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500" />
</span>
{phase === "waking"
? `${agentName} is waking up...`
: `${agentName} is composing a message...`}
</div>
)}
</>
);
}

View file

@ -144,31 +144,55 @@ function parseHiringPlan(markdown: string): HiringRole[] {
const roles: HiringRole[] = [];
const seen = new Set<string>();
// Split into ## sections (each role is a ## heading)
const roleSections = markdown.split(/^##\s+/m).slice(1).filter(Boolean);
// Find role headings: any ## or ### heading with a numbered prefix
// like "### 1. Content Marketing Officer" or "## Role 2: CTO"
const rolePattern = /^(?:role\s*\d+[:.]\s*|\d+[.)]\s*)/i;
const roleHeadingRegex = /^#{2,3}\s+(.+)$/gm;
let match: RegExpExecArray | null;
for (const section of roleSections) {
const lines = section.split("\n");
const titleLine = lines[0]?.trim() ?? "";
// First pass: find all role heading positions (start of line, end of heading)
const rolePositions: Array<{ title: string; lineStart: number; contentStart: number }> = [];
while ((match = roleHeadingRegex.exec(markdown)) !== null) {
if (rolePattern.test(match[1].trim())) {
rolePositions.push({
title: match[1].trim(),
lineStart: match.index,
contentStart: match.index + match[0].length,
});
}
}
// Extract role name — match "Role N: Name" or just "N. Name" or plain name
let name = titleLine
// Extract body for each role (from heading end to the next role heading start)
const sections: Array<{ title: string; body: string }> = [];
for (let i = 0; i < rolePositions.length; i++) {
const end = i + 1 < rolePositions.length
? rolePositions[i + 1].lineStart
: markdown.length;
sections.push({
title: rolePositions[i].title,
body: markdown.slice(rolePositions[i].contentStart, end),
});
}
for (const section of sections) {
if (!rolePattern.test(section.title)) continue;
let name = section.title
.replace(/^role\s*\d*[:.]\s*/i, "")
.replace(/^\d+[.)]\s*/, "")
.replace(/\*\*/g, "")
.trim();
// Skip non-role sections
const skipPatterns = /^(mission|hiring approach|hiring|roles|approach|open|phase|deferred|timeline|budget|summary|next steps|overview|notes|questions|appendix|---)/i;
if (skipPatterns.test(name) || name.length < 3) continue;
if (name.length < 3) continue;
if (seen.has(name.toLowerCase())) continue;
// Parse content: **Label:** bullets and ### sub-sections
const fields: Record<string, string[]> = {};
let currentField: string | null = null;
const bodyLines = section.body.split("\n");
for (let i = 1; i < lines.length; i++) {
const raw = lines[i];
for (let i = 0; i < bodyLines.length; i++) {
const raw = bodyLines[i];
const trimmed = raw.trim();
if (!trimmed) continue;
@ -210,12 +234,21 @@ function parseHiringPlan(markdown: string): HiringRole[] {
const join = (arr?: string[]) => (arr ?? []).join("\n");
// If no summary, use first line of expertise
let summary = join(fields.summary);
let expertise = join(fields.expertise);
if (!summary && expertise) {
const lines = expertise.split("\n");
summary = lines[0];
expertise = lines.slice(1).join("\n");
}
seen.add(name.toLowerCase());
roles.push({
id: nextRoleId(),
name,
summary: join(fields.summary),
expertise: join(fields.expertise),
summary,
expertise,
priorities: join(fields.priorities),
boundaries: join(fields.boundaries),
tools: join(fields.tools),
@ -226,27 +259,64 @@ function parseHiringPlan(markdown: string): HiringRole[] {
});
}
// Fallback: simple bullet parsing from comment text
// Fallback: parse "N. **Role Name**" with indented bullets
if (roles.length === 0) {
const lines = markdown.split("\n");
for (const line of lines) {
const bulletMatch = line.match(
/^\s*(?:[-*]|\d+[.)]\s*)\s*\*\*([^*]+)\*\*[:\s—-]*(.*)$/
);
if (!bulletMatch) continue;
const name = bulletMatch[1].trim();
const summary = cleanMd(bulletMatch[2]);
if (seen.has(name.toLowerCase())) continue;
const skip = /^(phase|month|step|update|note|question|summary|timeline|priority|plan|total|budget|immediate|hire|\d+ immediate)/i;
if (skip.test(name) || name.length < 3) continue;
let currentRole: HiringRole | null = null;
seen.add(name.toLowerCase());
roles.push({
id: nextRoleId(), name, summary,
expertise: "", priorities: "", boundaries: "",
tools: "", communication: "", collaboration: "",
enabled: true, editing: false,
});
for (const line of lines) {
// Match numbered bold role: "1. **Content Strategist / CMO**"
const roleMatch = line.match(/^\s*(\d+)[.)]\s+\*\*([^*]+)\*\*/);
if (roleMatch) {
const name = roleMatch[2].trim();
const skip = /^(phase|month|step|update|note|question|summary|timeline|priority|plan|total|budget|immediate|hire)/i;
if (skip.test(name) || name.length < 3) continue;
if (seen.has(name.toLowerCase())) continue;
if (currentRole) roles.push(currentRole);
seen.add(name.toLowerCase());
currentRole = {
id: nextRoleId(), name, summary: "", expertise: "",
priorities: "", boundaries: "", tools: "",
communication: "", collaboration: "",
enabled: true, editing: false,
};
continue;
}
// Indented bullets under the current role
if (currentRole && /^\s{2,}[-*]/.test(line)) {
const cleaned = cleanMd(line);
if (!cleaned) continue;
// Check for labeled bullet: "*Why first:*", "**Tools:**", etc.
const labelMatch = cleaned.match(/^\*?([^:*]+)\*?:\s*(.*)/);
if (labelMatch) {
const field = classifyBullet(labelMatch[1].trim());
if (field && typeof currentRole[field] === "string") {
const val = labelMatch[2].trim();
const prev = currentRole[field] as string;
(currentRole as unknown as Record<string, unknown>)[field] = prev
? `${prev}\n${val}` : val;
continue;
}
}
// Default: add to expertise
currentRole.expertise = currentRole.expertise
? `${currentRole.expertise}\n${cleaned}` : cleaned;
}
}
if (currentRole) roles.push(currentRole);
// If summary is empty, use the first line of expertise as the summary
for (const role of roles) {
if (!role.summary && role.expertise) {
const firstLine = role.expertise.split("\n")[0];
if (firstLine) {
role.summary = firstLine;
role.expertise = role.expertise.split("\n").slice(1).join("\n");
}
}
}
}
@ -681,18 +751,36 @@ export function OnboardingWizard() {
queryKey: queryKeys.agents.list(createdCompanyId)
});
// Create the planning task and kick off the conversation
// Create the planning task unassigned — the CEO only gets assigned
// when the user sends their first message (user controls initiation)
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"
description: `Company mission: ${companyGoal}
Collaborate with the board to create a hiring plan for the company.
IMPORTANT: When writing the hiring plan document, use this exact format for EACH role. Use ## headings for each role (e.g. "## 1. Role Name") and ### sub-headings for each section within the role:
## 1. Role Name
### Summary
One-line description of this role.
### Expertise & Responsibilities
What this agent does, detailed responsibilities.
### Priorities
Ordered list of what matters most.
### Boundaries
What this role should NOT do.
### Tools & Permissions
What tools and access this role needs.
### Communication
Tone, style, and interaction guidelines.
### Collaboration & Escalation
Who this role works with, escalation paths.
Follow this structure for every role in the plan.`,
status: "backlog"
});
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) {
@ -789,13 +877,7 @@ export function OnboardingWizard() {
});
setSelectedCompanyId(createdCompanyId);
reset();
closeOnboarding();
navigate(
createdCompanyPrefix
? `/${createdCompanyPrefix}/issues`
: `/issues`
);
setStep(6); // → orientation screen
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create hire tasks");
} finally {
@ -803,6 +885,13 @@ export function OnboardingWizard() {
}
}
function handleFinishOnboarding() {
const prefix = createdCompanyPrefix;
reset(); // clears localStorage
closeOnboarding();
navigate(prefix ? `/${prefix}/dashboard` : `/dashboard`);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
@ -854,9 +943,8 @@ export function OnboardingWizard() {
{ step: 1 as Step, label: "Mission", icon: Building2 },
{ step: 2 as Step, label: "Launch", icon: Rocket },
{ step: 3 as Step, label: "CEO", icon: Bot },
{ step: 4 as Step, label: "Chat", icon: Sparkles },
{ step: 5 as Step, label: "Plan", icon: ListTodo },
{ step: 6 as Step, label: "Hire", icon: Bot }
{ step: 4 as Step, label: "Plan", icon: Sparkles },
{ step: 5 as Step, label: "Review", icon: ListTodo },
] as const
).map(({ step: s, label, icon: Icon }) => (
<button
@ -983,7 +1071,7 @@ export function OnboardingWizard() {
</div>
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setMissionPath(null); setCompanyGoal(""); }}
onClick={() => setMissionPath(null)}
>
Choose a different path
</button>
@ -1052,7 +1140,7 @@ export function OnboardingWizard() {
)}
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors block"
onClick={() => { setMissionPath(null); setQ1(""); setQ2(""); setQ3(""); setQ4(""); }}
onClick={() => setMissionPath(null)}
>
Choose a different path
</button>
@ -1603,6 +1691,8 @@ export function OnboardingWizard() {
taskId={planningTaskId}
agentId={createdAgentId!}
agentName={agentName}
companyName={companyName}
companyGoal={companyGoal}
onPlanDetected={(md) => setPlanContent(md)}
onReviewPlan={async () => {
// Always fetch the latest plan document for the richest content
@ -1736,43 +1826,58 @@ export function OnboardingWizard() {
</div>
)}
{/* Step 6: Make your first hires */}
{/* Step 6: Welcome & orientation */}
{step === 6 && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Rocket className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">Make your first hires</h3>
<p className="text-xs text-muted-foreground">
Your CEO will create these roles for{" "}
<span className="font-medium text-foreground">{companyName}</span>.
</p>
</div>
<div className="space-y-6 py-2">
<div className="text-center">
<div className="text-4xl mb-3">🎉</div>
<h3 className="text-lg font-semibold">
{companyName} is ready to go!
</h3>
<p className="text-sm text-muted-foreground mt-1">
Your CEO is now hiring{" "}
{hiringRoles.filter((r) => r.enabled && r.name.trim()).length} roles.
Here's what to expect on your dashboard:
</p>
</div>
<div className="border border-border divide-y divide-border rounded-md">
{hiringRoles
.filter((r) => r.enabled && r.name.trim())
.map((role) => (
<div
key={role.id}
className="flex items-center gap-3 px-3 py-2.5"
>
<Bot className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{role.name}
</p>
<p className="text-xs text-muted-foreground truncate">
{role.summary || "New hire"}
</p>
</div>
<span className="text-[10px] text-amber-500 font-medium">
To hire
</span>
</div>
))}
<div className="space-y-3">
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<ListTodo className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Tasks</p>
<p className="text-xs text-muted-foreground">
Your CEO has hire tasks queued up. Watch them progress from todo in progress done.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<Bot className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Agents</p>
<p className="text-xs text-muted-foreground">
New agents will appear here as your CEO completes each hire. You may need to approve them.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<Check className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Approvals</p>
<p className="text-xs text-muted-foreground">
Check your inbox for pending approvals. Your CEO may need your sign-off before agents can start working.
</p>
</div>
</div>
<div className="flex items-start gap-3 rounded-md border border-border px-3 py-2.5">
<Building2 className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-sm font-medium">Dashboard</p>
<p className="text-xs text-muted-foreground">
Your command center see agent activity, costs, and overall company health at a glance.
</p>
</div>
</div>
</div>
</div>
)}
@ -1800,7 +1905,7 @@ export function OnboardingWizard() {
)}
</div>
<div className="flex items-center gap-2">
{step === 1 && (
{step === 1 && missionPath && (missionPath !== "questionnaire" || missionConfirmed) && (
<Button
size="sm"
disabled={!companyName.trim() || !companyGoal.trim() || loading}
@ -1851,21 +1956,21 @@ export function OnboardingWizard() {
{step === 5 && (
<Button
size="sm"
disabled={!hiringRoles.some((r) => r.enabled && r.name.trim())}
onClick={() => setStep(6)}
disabled={!hiringRoles.some((r) => r.enabled && r.name.trim()) || loading}
onClick={handleLaunch}
>
<Check className="h-3.5 w-3.5 mr-1" />
Approve hiring plan
</Button>
)}
{step === 6 && (
<Button size="sm" disabled={loading} onClick={handleLaunch}>
{loading ? (
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
) : (
<Rocket className="h-3.5 w-3.5 mr-1" />
<Check className="h-3.5 w-3.5 mr-1" />
)}
{loading ? "Creating tasks..." : "Make your first hires"}
{loading ? "Creating hires..." : "Approve & hire"}
</Button>
)}
{step === 6 && (
<Button size="sm" onClick={handleFinishOnboarding}>
<Rocket className="h-3.5 w-3.5 mr-1" />
Go to dashboard
</Button>
)}
</div>