From 0c1582ef4773b704a68392ffe34382cd967e08eb Mon Sep 17 00:00:00 2001 From: scotttong Date: Wed, 18 Mar 2026 01:35:55 -0700 Subject: [PATCH] experiment: chat UX fixes, structured role cards, plan parser improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat fixes: - Comment order: sort chronologically (oldest first) - Reopen+interrupt: user messages reassign task to CEO so it always wakes up - Strip markdown links from CEO messages to keep user focused on wizard - Cycling status messages (rotate every 5s) with elapsed timer - "Review plan" CTA properly disappears when user sends follow-up - Fetch plan document (not comment summary) for richer role data Structured role cards: - 7 fields: Summary, Expertise, Priorities, Boundaries, Tools, Communication, Collaboration - Collapsible card view with "Show more" / "Show less" - Full edit mode with labeled textareas per field - Hire tasks include structured role spec in description Plan parser: - Handles "## Role N: Name" format with ### sub-sections - Handles "### N. Name" format with **Label:** bullets - Maps CEO's labels (Why→Summary, Responsibilities→Expertise, etc.) - Skips non-role sections (Summary, Next Steps, Mission, etc.) Other: - localStorage persistence for wizard state (survives page refresh) - Cleaned up step 6 summary (removed redundant company/CEO entries) Co-Authored-By: Claude Opus 4.6 (1M context) --- UX-EXPERIMENTS.md | 36 ++ ui/src/components/OnboardingChat.tsx | 225 +++++++-- ui/src/components/OnboardingWizard.tsx | 613 +++++++++++++++---------- 3 files changed, 617 insertions(+), 257 deletions(-) create mode 100644 UX-EXPERIMENTS.md diff --git a/UX-EXPERIMENTS.md b/UX-EXPERIMENTS.md new file mode 100644 index 00000000..f9bb04e5 --- /dev/null +++ b/UX-EXPERIMENTS.md @@ -0,0 +1,36 @@ +# Onboarding UX Experiments + +Tracking file for UX prototyping on the `sockmonster-UX-experimentation` branch. + +## Ideas & Feedback + + + +| # | Idea | Status | Commit(s) | Notes | +|---|------|--------|-----------|-------| +| 1 | Mission mandatory + two paths | Done | a35fac7 | Questionnaire + direct input, prompt chips | +| 2 | Launch celebration (step 2) | Done | a35fac7 | "Company is live!" moment after mission | +| 3 | CEO creation reframed | Done | a35fac7 | "Bring your CEO to life" / "give it a heartbeat" | +| 4 | Chat with CEO | Done | b4ef061 | OnboardingChat polls comments, detects plans | +| 5 | Hiring plan review | Done | b60fcd8 | Editable role cards, add/edit/remove, revise with CEO | +| 6 | Make your first hires | Done | b60fcd8 | Creates hire tasks per approved role | +| 7 | Chat comment order fix | Done | pending | Sort chronologically (oldest first) | +| 8 | Chat reopen/interrupt fix | Done | pending | User comments reopen + interrupt so CEO wakes up | +| 9 | Rich heartbeat status in chat | Done | — | Cycling status messages with elapsed timer | +| 10 | Merge steps 5+6, add guided tour | Todo | — | Remove redundant confirm step, add post-wizard orientation | +| 11 | Re-invokable guided tour | Todo | — | User can re-trigger tour from settings or help menu | +| 12 | Persistent CEO chat in dashboard | Future | — | Long-term: CEO chat as command center, drives toward tasks/goals/projects | + +## Experiment Log + +### 2026-03-17: Initial 6-step wizard prototype + +Rewrote the onboarding wizard from 4 steps to 6 steps: +1. Define mission (required) — two paths: direct or questionnaire +2. Launch celebration — "Your company is live!" +3. Create CEO — reframed as "bring to life" / heartbeat +4. Chat with CEO — placeholder (needs OnboardingChat) +5. Review hiring plan — placeholder (needs editable cards) +6. Make first hires — placeholder (needs task creation logic) + +Steps 1-3 are functional. Steps 4-6 have placeholder UI. diff --git a/ui/src/components/OnboardingChat.tsx b/ui/src/components/OnboardingChat.tsx index 045d579d..3d9e67e8 100644 --- a/ui/src/components/OnboardingChat.tsx +++ b/ui/src/components/OnboardingChat.tsx @@ -1,17 +1,20 @@ -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import type { IssueComment } from "@paperclipai/shared"; import { issuesApi } from "../api/issues"; +import { heartbeatsApi } from "../api/heartbeats"; 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"; +import { Loader2, Send, CheckCircle2, ArrowRight } from "lucide-react"; interface OnboardingChatProps { taskId: string; + agentId: string; agentName: string; onPlanDetected?: (planMarkdown: string) => void; + onReviewPlan?: () => void; } /** @@ -28,10 +31,58 @@ function detectHiringPlan(body: string): boolean { return planPatterns.some((pattern) => pattern.test(body)); } +const QUEUED_MESSAGES = [ + "Heartbeat triggered, waking up...", + "Initializing...", + "Getting ready...", +]; + +const RUNNING_MESSAGES = [ + "Working on a response...", + "Reading the conversation...", + "Thinking through the plan...", + "Drafting a response...", + "Still working...", + "Almost there...", +]; + +const WAITING_MESSAGES = [ + "Waiting to wake up...", + "Heartbeat pending...", + "Should wake up soon...", +]; + +function getCyclingMessage(messages: string[], elapsed: number, agentName: string): string { + // Cycle through messages every 5 seconds + const idx = Math.floor(elapsed / 5) % messages.length; + return `${agentName} · ${messages[idx]}`; +} + +function getRunStatusMessage(status: string, agentName: string, elapsed: number): string { + switch (status) { + case "queued": + return getCyclingMessage(QUEUED_MESSAGES, elapsed, agentName); + case "running": + return getCyclingMessage(RUNNING_MESSAGES, elapsed, agentName); + case "succeeded": + return `${agentName} finished`; + case "failed": + return `${agentName} encountered an error`; + case "cancelled": + return `${agentName}'s run was cancelled`; + case "timed_out": + return `${agentName}'s run timed out`; + default: + return `${agentName} is thinking...`; + } +} + export function OnboardingChat({ taskId, + agentId, agentName, onPlanDetected, + onReviewPlan, }: OnboardingChatProps) { const queryClient = useQueryClient(); const [input, setInput] = useState(""); @@ -39,11 +90,16 @@ export function OnboardingChat({ const [detectedPlanCommentId, setDetectedPlanCommentId] = useState< string | null >(null); + // Track the comment ID after which we should ignore old plan detections + // (set when user sends a new message to request revisions) + const [ignoreBeforeCommentId, setIgnoreBeforeCommentId] = useState< + string | null + >(null); const scrollRef = useRef(null); const inputRef = useRef(null); const { - data: comments, + data: rawComments, isLoading, } = useQuery({ queryKey: queryKeys.issues.comments(taskId), @@ -51,6 +107,25 @@ export function OnboardingChat({ refetchInterval: 4000, }); + // Poll for active heartbeat run on this task + const { data: activeRun } = useQuery({ + queryKey: queryKeys.issues.activeRun(taskId), + queryFn: () => heartbeatsApi.activeRunForIssue(taskId), + refetchInterval: 3000, + }); + + // Sort comments chronologically (oldest first) for chat-style display + const comments = useMemo( + () => + rawComments + ? [...rawComments].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ) + : undefined, + [rawComments], + ); + // Auto-scroll to bottom when new comments arrive useEffect(() => { if (scrollRef.current) { @@ -58,27 +133,64 @@ export function OnboardingChat({ } }, [comments?.length]); - // Detect hiring plan in agent comments + // Detect hiring plan in agent comments. + // Only considers agent comments newer than the user's last message AND + // newer than any "ignore" marker (set when user asks for revisions). useEffect(() => { if (!comments || !onPlanDetected || detectedPlanCommentId) return; - // Scan from newest to oldest for a plan-like comment from the agent + + // Find the cutoff — the later of the user's last message or the ignore marker + let cutoffIdx = -1; for (let i = comments.length - 1; i >= 0; i--) { + if (comments[i].authorUserId) { cutoffIdx = i; break; } + } + if (ignoreBeforeCommentId) { + const ignoreIdx = comments.findIndex((c) => c.id === ignoreBeforeCommentId); + if (ignoreIdx >= 0) cutoffIdx = Math.max(cutoffIdx, ignoreIdx); + } + + // Only scan agent comments after the cutoff + for (let i = comments.length - 1; i > cutoffIdx; i--) { const c = comments[i]; if (c.authorAgentId && detectHiringPlan(c.body)) { setDetectedPlanCommentId(c.id); - onPlanDetected(c.body); + // Fetch the full plan document — it has richer role descriptions + issuesApi.getDocument(taskId, "plan").then((doc) => { + onPlanDetected(doc.body ?? c.body); + }).catch(() => { + onPlanDetected(c.body); + }); break; } } - }, [comments, onPlanDetected, detectedPlanCommentId]); + }, [comments, onPlanDetected, detectedPlanCommentId, ignoreBeforeCommentId, taskId]); const handleSend = useCallback(async () => { const body = input.trim(); if (!body || sending) return; setSending(true); try { - await issuesApi.addComment(taskId, body); + // 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 */ } + try { + await issuesApi.update(taskId, { + assigneeAgentId: agentId, + status: "in_progress", + }); + } catch { /* may already be assigned */ } + + await issuesApi.addComment(taskId, body, 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. + const latestId = comments?.[comments.length - 1]?.id ?? null; + setIgnoreBeforeCommentId(latestId); + setDetectedPlanCommentId(null); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId), }); @@ -98,9 +210,36 @@ export function OnboardingChat({ [handleSend], ); + // Determine if we should show a status indicator const lastComment = comments?.[comments.length - 1]; const isWaitingForAgent = lastComment && lastComment.authorUserId && !lastComment.authorAgentId; + const hasActiveRun = activeRun && (activeRun.status === "queued" || activeRun.status === "running"); + const showStatus = isWaitingForAgent || hasActiveRun; + + // Elapsed timer — ticks every second while waiting + const [elapsed, setElapsed] = useState(0); + const waitingSince = useMemo(() => { + if (!showStatus || !lastComment) return null; + // Use the user's last message timestamp as the start time + if (lastComment.authorUserId) return new Date(lastComment.createdAt).getTime(); + // If an active run exists, use its creation time + if (hasActiveRun && activeRun.createdAt) return new Date(activeRun.createdAt).getTime(); + return null; + }, [showStatus, lastComment, hasActiveRun, activeRun]); + + useEffect(() => { + if (!waitingSince) { setElapsed(0); return; } + setElapsed(Math.floor((Date.now() - waitingSince) / 1000)); + const interval = setInterval(() => { + setElapsed(Math.floor((Date.now() - waitingSince) / 1000)); + }, 1000); + return () => clearInterval(interval); + }, [waitingSince]); + + const elapsedStr = elapsed >= 60 + ? `${Math.floor(elapsed / 60)}m ${elapsed % 60}s` + : `${elapsed}s`; if (isLoading) { return ( @@ -156,32 +295,76 @@ export function OnboardingChat({ )}
- {comment.body} + + {isAgent + ? comment.body.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + : comment.body} +
); })} - {/* Thinking indicator */} - {isWaitingForAgent && ( -
- - {agentName} is thinking... + {/* Status indicator — shows real heartbeat run status */} + {showStatus && ( +
+
+ {hasActiveRun ? ( + <> + + + + + {getRunStatusMessage(activeRun.status, agentName, elapsed)} + + ) : ( + <> + + {getCyclingMessage(WAITING_MESSAGES, elapsed, agentName)} + + )} +
+ + {elapsedStr} +
)}
+ {/* Plan ready CTA */} + {detectedPlanCommentId && onReviewPlan && ( +
+
+
+ +
+

+ Your CEO has prepared a hiring plan +

+

+ Review it, make edits, then approve. +

+
+
+ +
+
+ )} + {/* Input area */}