diff --git a/scripts/dev-fresh-chat.sh b/scripts/dev-fresh-chat.sh new file mode 100755 index 00000000..6a6c7eaf --- /dev/null +++ b/scripts/dev-fresh-chat.sh @@ -0,0 +1,122 @@ +#!/bin/bash +# Kills the dev server, restarts it, creates a fresh company + CEO + task, opens chat. +# Usage: ./scripts/dev-fresh-chat.sh [company-name] + +set -e +cd "$(dirname "$0")/.." + +BASE="http://127.0.0.1:3000/api" +NAME="${1:-Dev Test $(date +%H%M%S)}" + +# Kill existing server +echo "Killing existing server..." +lsof -ti:3000 | xargs kill -9 2>/dev/null || true +sleep 1 +lsof -ti:3000 | xargs kill -9 2>/dev/null || true +sleep 1 + +# Start dev server in background +echo "Starting dev server..." +npm run dev > /tmp/paperclip-dev.log 2>&1 & +DEV_PID=$! + +# Wait for server to be ready +echo -n "Waiting for server" +for i in $(seq 1 30); do + if curl -s "$BASE/health" > /dev/null 2>&1; then + echo " ready!" + break + fi + echo -n "." + sleep 1 + if [ "$i" -eq 30 ]; then + echo " timed out! Check /tmp/paperclip-dev.log" + exit 1 + fi +done + +# Archive old test companies (keep "strata" and "faceless") +echo "Cleaning up old companies..." +COMPANIES=$(curl -s "$BASE/companies") +echo "$COMPANIES" | python3 -c " +import sys, json +companies = json.load(sys.stdin) +keep = {'strata', 'faceless'} +for c in companies: + name_lower = c.get('name', '').lower() + if not any(k in name_lower for k in keep) and not c.get('archivedAt'): + cid = c['id'] + print(f\" archiving: {c['name']} ({cid})\") +" 2>/dev/null | while read -r line; do echo "$line"; done + +# Actually archive them +echo "$COMPANIES" | python3 -c " +import sys, json +companies = json.load(sys.stdin) +keep = {'strata', 'faceless'} +for c in companies: + name_lower = c.get('name', '').lower() + if not any(k in name_lower for k in keep) and not c.get('archivedAt'): + print(c['id']) +" 2>/dev/null | while read -r CID; do + curl -s -X POST "$BASE/companies/$CID/archive" > /dev/null 2>&1 || true +done + +MISSION="Create educational and news content about AI (technology, use cases, applications, policies) for elderly audiences on a faceless YouTube channel. Goal: \$5k MRR in passive income within 6 months." + +echo "Creating company: $NAME" +COMPANY=$(curl -s -X POST "$BASE/companies" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$NAME\"}") + +COMPANY_ID=$(echo "$COMPANY" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +PREFIX=$(echo "$COMPANY" | python3 -c "import sys,json; print(json.load(sys.stdin)['issuePrefix'])") +echo " id: $COMPANY_ID prefix: $PREFIX" + +echo "Setting company mission..." +curl -s -X POST "$BASE/companies/$COMPANY_ID/goals" \ + -H "Content-Type: application/json" \ + -d "{\"title\": \"$MISSION\", \"level\": \"company\"}" > /dev/null + +echo "Creating CEO agent..." +AGENT=$(curl -s -X POST "$BASE/companies/$COMPANY_ID/agents" \ + -H "Content-Type: application/json" \ + -d '{ + "name": "CEO", + "role": "ceo", + "adapterType": "claude_local", + "adapterConfig": {}, + "runtimeConfig": { + "heartbeat": { "enabled": false, "intervalSec": 3600, "wakeOnDemand": false, "cooldownSec": 10, "maxConcurrentRuns": 1 } + } + }') + +AGENT_ID=$(echo "$AGENT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo " agent id: $AGENT_ID" + +# Create a lightweight chat task (needed for the comment system) +echo "Creating chat task..." +TASK=$(curl -s -X POST "$BASE/companies/$COMPANY_ID/issues" \ + -H "Content-Type: application/json" \ + -d "{ + \"title\": \"Chat with CEO\", + \"description\": \"CEO onboarding conversation. Company mission: $MISSION\", + \"status\": \"in_progress\", + \"assigneeAgentId\": \"$AGENT_ID\" + }") + +TASK_ID=$(echo "$TASK" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") +echo " task id: $TASK_ID" + +URL="http://localhost:3000/$PREFIX/chat?taskId=$TASK_ID" +echo "" +echo "Ready! Open:" +echo " $URL" +echo "" +echo "Server log: /tmp/paperclip-dev.log" +echo "Server PID: $DEV_PID" + +# Try to open in browser +if command -v open &>/dev/null; then + open "$URL" +fi diff --git a/server/src/routes/agent-chat.ts b/server/src/routes/agent-chat.ts index 150136a6..575c6d55 100644 --- a/server/src/routes/agent-chat.ts +++ b/server/src/routes/agent-chat.ts @@ -1,17 +1,49 @@ import { Router } from "express"; import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import fs from "node:fs"; import type { Db } from "@paperclipai/db"; -import { agents as agentsTable } from "@paperclipai/db"; +import { agents as agentsTable, heartbeatRuns, issueWorkProducts } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { getServerAdapter } from "../adapters/index.js"; import { agentService, issueService, + documentService, secretService, } from "../services/index.js"; import { notFound } from "../errors.js"; import { parseObject } from "../adapters/utils.js"; +/** + * Detect if the CEO's response commits to creating an artifact. + * Returns a list of artifacts to create. Simple pattern matching — + * reliable and instant, no AI call needed. + */ +function detectArtifactCommitments(response: string): Array<{ title: string; status: string }> { + const artifacts: Array<{ title: string; status: string }> = []; + const lower = response.toLowerCase(); + + // Hiring plan commitment + if ( + (/(?:i'll|i will|let me|going to)\s+(?:put together|draft|create|build|start|work on)/i.test(response) && + /hiring\s*plan|team\s*plan/i.test(response)) || + (/hiring\s*plan/i.test(response) && /(?:right away|now|started|on it)/i.test(response)) + ) { + artifacts.push({ title: "Hiring Plan", status: "in_progress" }); + } + + // Strategy document commitment + if ( + /(?:i'll|i will|let me|going to)\s+(?:put together|draft|create|build|write)/i.test(response) && + /strateg(?:y|ic)\s*(?:doc|document|plan|brief)/i.test(response) + ) { + artifacts.push({ title: "Strategy Document", status: "in_progress" }); + } + + return artifacts; +} + /** * Chat relay endpoint — calls the adapter directly and streams the response * back via SSE. Bypasses the heartbeat queue for real-time conversation. @@ -60,6 +92,9 @@ export function agentChatRoutes(db: Db) { // Send initial event res.write(`data: ${JSON.stringify({ type: "start", agentId, agentName: agent.name })}\n\n`); + // Create runId upfront so it's accessible in catch block + const runId = randomUUID(); + try { // Resolve adapter config with secrets const config = parseObject(agent.adapterConfig); @@ -72,12 +107,25 @@ export function agentChatRoutes(db: Db) { // Get adapter const adapter = getServerAdapter(agent.adapterType); + // Create a heartbeat run record so the agent can use the runId in API calls + // (activity_log.run_id has a FK to heartbeat_runs) + const now = new Date(); + await db.insert(heartbeatRuns).values({ + id: runId, + companyId: agent.companyId, + agentId: agent.id, + invocationSource: "chat_relay", + triggerDetail: `chat_relay:${taskId}`, + status: "running", + startedAt: now, + }); + // Execute directly — stream stdout chunks as SSE events let fullResponse = ""; const startTime = Date.now(); const result = await adapter.execute({ - runId: randomUUID(), + runId, agent: agent as any, // DB row matches adapter expectation runtime: { sessionId: null, @@ -104,6 +152,22 @@ export function agentChatRoutes(db: Db) { }, }); + // Finalize the heartbeat run + await db + .update(heartbeatRuns) + .set({ + status: result.exitCode === 0 ? "completed" : "failed", + finishedAt: new Date(), + exitCode: result.exitCode, + resultJson: { + model: result.model ?? null, + provider: result.provider ?? null, + costUsd: result.costUsd ?? null, + }, + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, runId)); + // Save the agent's full response as a comment if (fullResponse.trim()) { await issueSvc.addComment(taskId, fullResponse.trim(), { @@ -126,6 +190,17 @@ export function agentChatRoutes(db: Db) { ); } } catch (err) { + // Mark the run as failed on error (best-effort) + await db + .update(heartbeatRuns) + .set({ + status: "failed", + finishedAt: new Date(), + error: err instanceof Error ? err.message : "Relay execution failed", + updatedAt: new Date(), + }) + .where(eq(heartbeatRuns.id, runId)) + .catch(() => {}); // Send error event if (res.writable) { const message = err instanceof Error ? err.message : "Relay execution failed"; @@ -138,5 +213,334 @@ export function agentChatRoutes(db: Db) { } }); + /** + * Save a canned/simulated response as an agent comment. + * Used by the frontend to persist instant responses. + */ + router.post("/agents/:id/chat/canned", async (req, res) => { + const agentId = req.params.id; + const { taskId, message } = req.body as { taskId: string; message: string }; + + if (!taskId || !message) { + res.status(400).json({ error: "taskId and message are required" }); + return; + } + + const agent = await db + .select() + .from(agentsTable) + .where(eq(agentsTable.id, agentId)) + .then((rows) => rows[0] ?? null); + + if (!agent) { + throw notFound("Agent not found"); + } + + const issueSvc = issueService(db); + const comment = await issueSvc.addComment(taskId, message, { + agentId: agent.id, + }); + + res.json(comment); + }); + + /** + * Generate an artifact document in the background. + * Called by the frontend after the observer detects an artifact to create. + * Spawns claude to generate the content, saves it as a document, + * and updates the work product status. + */ + router.post("/agents/:id/chat/generate-artifact", async (req, res) => { + const agentId = req.params.id; + const { taskId, artifactTitle, workProductId, conversationContext } = req.body as { + taskId: string; + artifactTitle: string; + workProductId: string; + conversationContext: string; + }; + + if (!taskId || !artifactTitle) { + res.status(400).json({ error: "taskId and artifactTitle are required" }); + return; + } + + const agent = await db + .select() + .from(agentsTable) + .where(eq(agentsTable.id, agentId)) + .then((rows) => rows[0] ?? null); + + if (!agent) { + throw notFound("Agent not found"); + } + + // Respond immediately — generation happens in background + res.json({ status: "generating" }); + + // Generate document in background + const docKey = artifactTitle.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, ""); + const prompt = `You are ${agent.name}, CEO of a company. Based on the conversation below, create a detailed, well-structured "${artifactTitle}" document in markdown format. + +CONVERSATION CONTEXT: +${conversationContext} + +Write the "${artifactTitle}" now. Be specific, actionable, and thorough. Use markdown headings, bullet points, and clear structure. Do not include any preamble — start directly with the document content.`; + + const proc = spawn("claude", [ + "-p", prompt, + "--output-format", "json", + "--model", "sonnet", + "--no-session-persistence", + ], { + stdio: ["pipe", "pipe", "pipe"], + cwd: "/tmp", + env: { ...process.env }, + }); + + let output = ""; + const timeout = setTimeout(() => proc.kill("SIGTERM"), 120000); + + proc.stdout.on("data", (data: Buffer) => { output += data.toString(); }); + proc.stderr.on("data", (data: Buffer) => { + console.error("[generate-artifact stderr]", data.toString()); + }); + + proc.on("close", async () => { + clearTimeout(timeout); + const docsSvc = documentService(db); + const issueSvc = issueService(db); + + try { + // Parse the result + let docContent = ""; + try { + const parsed = JSON.parse(output); + docContent = parsed.result ?? output; + } catch { + docContent = output; + } + + if (!docContent.trim()) return; + + // Save as document + await docsSvc.upsertIssueDocument({ + issueId: taskId, + key: docKey, + title: artifactTitle, + format: "markdown", + body: docContent.trim(), + createdByAgentId: agent.id, + }); + + // Update work product to ready_for_review + if (workProductId) { + await db + .update(issueWorkProducts) + .set({ + status: "ready_for_review", + reviewState: "needs_board_review", + summary: `${artifactTitle} is ready for your review`, + updatedAt: new Date(), + }) + .where(eq(issueWorkProducts.id, workProductId)); + } + } catch (err) { + console.error("[generate-artifact] failed:", err); + } + }); + }); + + /** + * Lightweight chat endpoint — spawns `claude` CLI directly, bypassing + * the adapter pipeline. Streams response via SSE. Much faster cold start. + */ + router.post("/agents/:id/chat/stream", async (req, res) => { + const agentId = req.params.id; + const { taskId, message } = req.body as { taskId: string; message: string }; + + if (!taskId || !message) { + res.status(400).json({ error: "taskId and message are required" }); + return; + } + + // Look up agent + const agent = await db + .select() + .from(agentsTable) + .where(eq(agentsTable.id, agentId)) + .then((rows) => rows[0] ?? null); + + if (!agent) { + throw notFound("Agent not found"); + } + + // Save user message as comment + const issueSvc = issueService(db); + await issueSvc.addComment(taskId, message, { + userId: (req as any).actor?.userId ?? null, + }); + + // Build conversation history from recent comments + const comments = await issueSvc.listComments(taskId); + const sorted = [...comments].sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + const recent = sorted.slice(-20); + const history = recent + .map((c) => { + const role = c.authorAgentId ? "CEO" : "USER"; + return `${role}: ${c.body}`; + }) + .join("\n\n"); + + // Build system prompt from agent instructions file or inline + const config = parseObject(agent.adapterConfig); + let systemPrompt = `You are ${agent.name}, the CEO of this company. The user is the board of directors. + +IMPORTANT RULES: +- Be conversational, strategic, and concise. +- When the board asks you to create something (a hiring plan, strategy doc, etc.), respond with a SHORT acknowledgment (1-2 sentences max). Do NOT write the full document in chat. Just confirm you'll start working on it. The system will handle document creation separately. +- When discussing strategy, priorities, or giving advice, be thorough and helpful. +- Never reference tools, files, code, or technical systems. You are a CEO, not an engineer.`; + const instructionsPath = (config as any).instructionsFilePath; + if (instructionsPath && typeof instructionsPath === "string") { + try { + const instructions = fs.readFileSync(instructionsPath, "utf-8"); + systemPrompt = instructions; + } catch { + // Fall back to default + } + } + + // Compose the prompt with conversation history + const prompt = history + ? `Here is the conversation so far:\n\n${history}\n\nRespond to the latest message from the user.` + : message; + + // Set up SSE + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }); + res.flushHeaders(); + res.write(`data: ${JSON.stringify({ type: "start", agentId, agentName: agent.name })}\n\n`); + + // Spawn claude CLI directly — no adapter overhead + const args = [ + "-p", "-", + "--output-format", "stream-json", + "--verbose", + "--append-system-prompt", systemPrompt, + "--model", "sonnet", + "--no-session-persistence", + ]; + + const proc = spawn("claude", args, { + stdio: ["pipe", "pipe", "pipe"], + cwd: "/tmp", // Run in neutral directory so Claude doesn't read project files + env: { ...process.env }, + }); + + let fullResponse = ""; + const startTime = Date.now(); + let killed = false; + + // 60s timeout + const timeout = setTimeout(() => { + killed = true; + proc.kill("SIGTERM"); + }, 60000); + + // Stream stdout — parse stream-json events + let stdoutBuf = ""; + proc.stdout.on("data", (data: Buffer) => { + stdoutBuf += data.toString(); + const lines = stdoutBuf.split("\n"); + stdoutBuf = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const event = JSON.parse(line); + // stream-json emits objects with type: "assistant", "result", etc. + // Text content comes in assistant messages + if (event.type === "assistant" && event.message?.content) { + for (const block of event.message.content) { + if (block.type === "text" && block.text && res.writable) { + fullResponse += block.text; + res.write(`data: ${JSON.stringify({ type: "chunk", text: block.text })}\n\n`); + } + } + } else if (event.type === "content_block_delta" && event.delta?.text) { + fullResponse += event.delta.text; + if (res.writable) { + res.write(`data: ${JSON.stringify({ type: "chunk", text: event.delta.text })}\n\n`); + } + } else if (event.type === "result" && event.result && !fullResponse) { + // Fallback: if we missed the assistant message, grab from result + fullResponse = event.result; + if (res.writable) { + res.write(`data: ${JSON.stringify({ type: "chunk", text: event.result })}\n\n`); + } + } + } catch { + // Not JSON or unknown format — skip + } + } + }); + + // Log stderr for debugging + proc.stderr.on("data", (data: Buffer) => { + console.error("[chat/stream stderr]", data.toString()); + }); + + proc.on("close", async (exitCode) => { + clearTimeout(timeout); + + // Save full response as agent comment + if (fullResponse.trim()) { + try { + await issueSvc.addComment(taskId, fullResponse.trim(), { + agentId: agent.id, + }); + } catch { /* best effort */ } + } + + // Send completion event + const duration = Date.now() - startTime; + if (res.writable) { + res.write( + `data: ${JSON.stringify({ + type: "done", + duration, + exitCode: exitCode ?? 0, + timedOut: killed, + })}\n\n`, + ); + } + + // Detect if the CEO committed to creating an artifact + const artifacts = detectArtifactCommitments(fullResponse); + if (artifacts.length > 0 && res.writable) { + res.write(`data: ${JSON.stringify({ type: "observer", actions: { artifacts, tasks: [] } })}\n\n`); + } + if (res.writable) res.end(); + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + if (res.writable) { + res.write(`data: ${JSON.stringify({ type: "error", message: err.message })}\n\n`); + res.end(); + } + }); + + // Pipe the prompt to stdin + proc.stdin.write(prompt); + proc.stdin.end(); + }); + return router; } diff --git a/ui/public/paperclip-thinking.svg b/ui/public/paperclip-thinking.svg index 5fb88a0c..b52c3c6b 100644 --- a/ui/public/paperclip-thinking.svg +++ b/ui/public/paperclip-thinking.svg @@ -10,7 +10,7 @@ 78.2250% { opacity:0; } 100% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; opacity:0; } } - .p { animation: draw 3.840s linear infinite; } + .p { animation: draw 0.8s linear infinite; } diff --git a/ui/src/components/ArtifactsPanel.tsx b/ui/src/components/ArtifactsPanel.tsx index 69c1e2bd..1e8b6330 100644 --- a/ui/src/components/ArtifactsPanel.tsx +++ b/ui/src/components/ArtifactsPanel.tsx @@ -299,18 +299,16 @@ function DocumentViewer({ {needsAction && (

This document needs your review.

-
- + -
)} diff --git a/ui/src/components/CEOChatPanel.tsx b/ui/src/components/CEOChatPanel.tsx index 133a2c48..6d153873 100644 --- a/ui/src/components/CEOChatPanel.tsx +++ b/ui/src/components/CEOChatPanel.tsx @@ -2,7 +2,6 @@ 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"; @@ -11,7 +10,6 @@ import { Loader2, Send, CheckCircle2, - Sparkles, History, Search, X, @@ -81,20 +79,6 @@ function isSystemChunk(text: string): boolean { return false; } -/** - * Detect if a user message is asking the CEO to create a plan/hire. - */ -function isAskingForPlan(message: string): boolean { - const planPatterns = [ - /\b(hiring|team|org)\s*(plan|strategy)\b/i, - /\b(build|create|draft|start|write)\s*(a\s+)?(hiring|team|the)\s*(plan)?\b/i, - /\bget started\b/i, - /\bhire\b.*\b(team|agents?|roles?)\b/i, - /\blet'?s\s+(build|start|go|do it)\b/i, - /\bready to\s+(hire|build|plan)\b/i, - ]; - return planPatterns.some((p) => p.test(message)); -} /** Animated paperclip SVG thinking indicator */ function PaperclipThinking({ className }: { className?: string }) { @@ -103,23 +87,12 @@ function PaperclipThinking({ className }: { className?: string }) { src="/paperclip-thinking.svg" alt="" className={cn("inline-block", className)} - style={{ width: 14, height: 14 }} + style={{ width: 17, height: 17 }} /> ); } -/** - * Detects whether a comment body contains a structured hiring plan. - */ -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, - /\|\s*role\s*\|/i, - ]; - return planPatterns.some((pattern) => pattern.test(body)); -} + const QUEUED_MESSAGES = [ "Heartbeat triggered, waking up...", @@ -175,33 +148,33 @@ function getProgressStep(elapsed: number): string | null { return "Almost ready..."; } -/** Context-aware suggestion chips */ +/** Context-aware suggestion chips — label IS the message */ function getSuggestionChips( hasActiveRun: boolean, hasPlanDetected: boolean, hasComments: boolean, -): Array<{ label: string; message: string }> { +): string[] { if (hasPlanDetected) { return [ - { label: "I want to make changes", message: "I'd like to make some changes to the plan before approving." }, - { label: "Add another role", message: "Can you add another role to the plan?" }, + "I want to make changes", + "Add another role", ]; } if (hasActiveRun) { return [ - { label: "What can I do while waiting?", message: "What can I do while you're working on the plan?" }, - { label: "Tell me about team structure", message: "Tell me about how you're thinking about the team structure." }, + "What can I do while waiting?", + "Tell me about team structure", ]; } if (hasComments) { return [ - { label: "What should we prioritize?", message: "What should we prioritize first?" }, - { label: "Create a new project", message: "Let's create a new project to work on." }, + "What should we prioritize?", + "Create a new project", ]; } return [ - { label: "Let's talk strategy", message: "Before we hire anyone, I'd like to discuss our strategy and priorities." }, - { label: "What do you need from me?", message: "What information do you need from me to get started?" }, + "Let's talk strategy", + "What do you need from me?", ]; } @@ -232,8 +205,12 @@ export function CEOChatPanel({ const [welcomePhase, setWelcomePhase] = useState<"typing" | "message">("typing"); // Optimistic typing indicator — shows immediately after user sends const [optimisticTyping, setOptimisticTyping] = useState(false); + // Optimistic user message — shown instantly before server confirms + const [optimisticMessage, setOptimisticMessage] = useState(null); const scrollRef = useRef(null); const inputRef = useRef(null); + // Track whether we've already created a draft artifact in the current send cycle + const draftCreatedRef = useRef(false); // Poll comments — faster when waiting for a response const { data: rawComments, isLoading } = useQuery({ @@ -242,12 +219,8 @@ export function CEOChatPanel({ refetchInterval: optimisticTyping ? 2000 : 4000, }); - // Poll heartbeat — faster when actively waiting - const { data: activeRun } = useQuery({ - queryKey: queryKeys.issues.activeRun(taskId), - queryFn: () => heartbeatsApi.activeRunForIssue(taskId), - refetchInterval: optimisticTyping ? 1500 : 3000, - }); + // Heartbeat polling disabled — the stream endpoint handles chat directly. + const activeRun = null as any; const comments = useMemo( () => @@ -259,89 +232,94 @@ export function CEOChatPanel({ [rawComments], ); - // Welcome typing animation — show "typing" for 2.5s then reveal message + // Welcome message — show typing indicator, then persist as agent comment + const welcomeSavedRef = useRef(false); useEffect(() => { - if (comments && comments.length === 0 && welcomePhase === "typing") { - const timer = setTimeout(() => setWelcomePhase("message"), 2500); + if (comments && comments.length === 0 && welcomePhase === "typing" && !welcomeSavedRef.current) { + welcomeSavedRef.current = true; + // Build the welcome text + let welcomeText = `Hello! I'm **${agentName}**${companyName ? `, your CEO at **${companyName}**` : ", your CEO"}.`; + if (companyGoal) { + welcomeText += `\n\nOur mission: *${companyGoal}*`; + } + welcomeText += `\n\nI'd love to understand your vision and priorities before we start building the team. What's most important to you right now?`; + + // Save as agent comment after a brief typing delay + const timer = setTimeout(() => { + fetch(`/api/agents/${agentId}/chat/canned`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskId, message: welcomeText }), + }).then(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) }); + setWelcomePhase("message"); + }).catch(() => { + setWelcomePhase("message"); + }); + }, 1200); return () => clearTimeout(timer); } - }, [comments, welcomePhase]); + }, [comments, welcomePhase, agentId, agentName, companyName, companyGoal, taskId, queryClient]); - // Clear optimistic typing when a new agent comment arrives + + // Clear optimistic typing when a NEW agent comment arrives (not the welcome) + const commentCountAtSendRef = useRef(0); useEffect(() => { if (optimisticTyping && comments?.length) { - const lastComment = comments[comments.length - 1]; - if (lastComment.authorAgentId) { - setOptimisticTyping(false); + // Only clear if a new agent comment appeared since we started sending + if (comments.length > commentCountAtSendRef.current) { + const newComments = comments.slice(commentCountAtSendRef.current); + if (newComments.some((c) => c.authorAgentId)) { + setOptimisticTyping(false); + } } } }, [comments, optimisticTyping]); - // Auto-scroll + // Clear optimistic message once it appears in the real comment list + useEffect(() => { + if (optimisticMessage && comments?.length) { + const hasUserMsg = comments.some((c) => c.authorUserId && c.body === optimisticMessage); + if (hasUserMsg) setOptimisticMessage(null); + } + }, [comments, optimisticMessage]); + + // Detect hiring plan + // Plan detection removed — handled by server-side observer pattern in /chat/stream + + // Streaming response state + // Streaming: buffer holds all received text, visible is what's shown (typewriter) + const [streamingText, setStreamingText] = useState(""); + const streamingBufferRef = useRef(""); + const streamingTimerRef = useRef | null>(null); + + // Typewriter effect — progressively reveal streaming buffer + useEffect(() => { + if (streamingBufferRef.current.length > streamingText.length) { + if (!streamingTimerRef.current) { + streamingTimerRef.current = setInterval(() => { + setStreamingText((prev) => { + const buf = streamingBufferRef.current; + if (prev.length >= buf.length) { + if (streamingTimerRef.current) clearInterval(streamingTimerRef.current); + streamingTimerRef.current = null; + return prev; + } + // Reveal 2-4 chars per tick for natural typing feel + const step = Math.floor(Math.random() * 3) + 2; + return buf.slice(0, Math.min(prev.length + step, buf.length)); + }); + }, 12); + } + } + }, [streamingText]); + + // Auto-scroll on new comments or streaming text useEffect(() => { if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight; } - }, [comments?.length]); - - // Detect hiring plan - useEffect(() => { - if (!comments || detectedPlanCommentId) return; - - 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); - } - - for (let i = comments.length - 1; i > cutoffIdx; i--) { - const c = comments[i]; - if (c.authorAgentId && detectHiringPlan(c.body)) { - setDetectedPlanCommentId(c.id); - // Update existing draft artifact to "ready_for_review", or create one - (async () => { - try { - const wps = await issuesApi.listWorkProducts(taskId); - const existing = wps.find((wp) => wp.title === "Hiring Plan"); - if (existing) { - await issuesApi.updateWorkProduct(existing.id, { - status: "ready_for_review", - reviewState: "needs_board_review", - summary: "Hiring plan is ready for your review", - }); - } else { - await issuesApi.createWorkProduct(taskId, { - type: "document", - title: "Hiring Plan", - provider: "paperclip", - status: "ready_for_review", - reviewState: "needs_board_review", - isPrimary: true, - summary: "Hiring plan is ready for your review", - }); - } - } catch { /* non-critical */ } - })(); - // Notify parent - issuesApi.getDocument(taskId, "plan").then((doc) => { - onPlanDetected?.(doc.body ?? c.body); - }).catch(() => { - onPlanDetected?.(c.body); - }); - // Invalidate work products so ArtifactsPanel picks it up - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.workProducts(taskId), - }); - break; - } - } - }, [comments, detectedPlanCommentId, ignoreBeforeCommentId, taskId, onPlanDetected, queryClient]); - - // Streaming response state - const [streamingText, setStreamingText] = useState(""); + }, [comments?.length, streamingText]); // Send message — try streaming relay first, fall back to poll-based const sendMessage = useCallback(async (body: string) => { @@ -349,51 +327,34 @@ export function CEOChatPanel({ if (!trimmed || sending) return; setSending(true); setInput(""); + setOptimisticMessage(trimmed); setOptimisticTyping(true); - - // If user is asking for a plan, create a draft artifact immediately - if (isAskingForPlan(trimmed)) { - issuesApi.createWorkProduct(taskId, { - type: "document", - title: "Hiring Plan", - provider: "paperclip", - status: "draft", - reviewState: "none", - isPrimary: true, - summary: "Your CEO is drafting a hiring plan...", - }).then(() => { - queryClient.invalidateQueries({ - queryKey: queryKeys.issues.workProducts(taskId), - }); - }).catch(() => { /* may already exist */ }); - } + commentCountAtSendRef.current = comments?.length ?? 0; + draftCreatedRef.current = false; const latestId = comments?.[comments.length - 1]?.id ?? null; setIgnoreBeforeCommentId(latestId); setDetectedPlanCommentId(null); - // Ensure task is assigned to agent try { - await issuesApi.update(taskId, { assigneeUserId: null }); - } catch { /* ok */ } - try { - await issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }); - } catch { /* ok */ } - - try { - // Try streaming relay - const res = await fetch(`/api/agents/${agentId}/chat/relay`, { + // Try lightweight streaming endpoint (longer timeout — CLI needs startup time) + const controller = new AbortController(); + const fetchTimeout = setTimeout(() => controller.abort(), 60000); + const res = await fetch(`/api/agents/${agentId}/chat/stream`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ taskId, message: trimmed }), + signal: controller.signal, }); + clearTimeout(fetchTimeout); if (!res.ok || !res.body) { throw new Error("Relay not available"); } - setOptimisticTyping(false); setStreamingText(""); + streamingBufferRef.current = ""; + if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; } const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -412,30 +373,90 @@ export function CEOChatPanel({ try { const event = JSON.parse(line.slice(6)); if (event.type === "chunk" && !isSystemChunk(event.text)) { - setStreamingText((prev) => prev + event.text); + // Clear typing indicator on first real chunk + setOptimisticTyping(false); + // Add to buffer — typewriter effect will reveal progressively + streamingBufferRef.current += event.text; + // Kick the typewriter if it hasn't started + setStreamingText((prev) => prev || streamingBufferRef.current.slice(0, 1)); } else if (event.type === "done") { - setStreamingText(""); + // Flush remaining buffer instantly + setStreamingText(streamingBufferRef.current); + if (streamingTimerRef.current) clearInterval(streamingTimerRef.current); + streamingTimerRef.current = null; // Refresh comments to pick up persisted messages queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId), }); + } else if (event.type === "observer" && event.actions) { + // Observer agent detected artifacts or tasks to create + const actions = event.actions as { + artifacts?: Array<{ title: string; status: string }>; + tasks?: Array<{ title: string; description: string }>; + }; + // Build conversation context for artifact generation + const convoContext = comments?.map((c) => { + const role = c.authorAgentId ? "CEO" : "USER"; + return `${role}: ${c.body}`; + }).join("\n\n") ?? ""; + + for (const artifact of actions.artifacts ?? []) { + issuesApi.createWorkProduct(taskId, { + type: "document", + title: artifact.title, + provider: "paperclip", + status: "draft", + reviewState: "none", + isPrimary: true, + summary: `${agentName} is working on ${artifact.title}...`, + }).then((wp) => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) }); + // Trigger background document generation + fetch(`/api/agents/${agentId}/chat/generate-artifact`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskId, + artifactTitle: artifact.title, + workProductId: (wp as any).id, + conversationContext: convoContext, + }), + }).catch(() => {}); + }).catch(() => {}); + // Assign task to CEO + issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }).catch(() => {}); + } + for (const task of actions.tasks ?? []) { + issuesApi.create(companyId, { + title: task.title, + description: task.description, + assigneeAgentId: agentId, + status: "todo", + }).then(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) }); + }).catch(() => {}); + } } else if (event.type === "error") { setStreamingText(""); - // Fall through — comments will still be polled + streamingBufferRef.current = ""; + if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; } } } catch { /* malformed SSE line, skip */ } } } - setStreamingText(""); + // Wait briefly for typewriter to finish, then clear + setTimeout(() => { + setStreamingText(""); + streamingBufferRef.current = ""; + if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; } + }, 500); queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId), }); } catch { - // Fallback: use the old comment-and-poll approach - try { - await issuesApi.addComment(taskId, trimmed, true, true); - } catch { /* already saved by relay, or genuinely failed */ } + // Stream endpoint failed or timed out — message was already saved server-side, + // so just refresh comments and let polling pick up any response queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId), }); @@ -444,7 +465,7 @@ export function CEOChatPanel({ setOptimisticTyping(false); inputRef.current?.focus(); } - }, [sending, taskId, agentId, queryClient, comments]); + }, [sending, taskId, agentId, companyId, agentName, queryClient, comments]); const handleSend = useCallback(() => { sendMessage(input); @@ -494,14 +515,12 @@ export function CEOChatPanel({ : `${elapsed}s`; const progressStep = getProgressStep(elapsed); - const suggestionChips = getSuggestionChips(!!hasActiveRun, !!detectedPlanCommentId, !!comments?.length); + const suggestionChips = getSuggestionChips(!!hasActiveRun, false, !!comments?.length); // Dynamic placeholder const placeholder = hasActiveRun ? `${agentName} is working...` - : detectedPlanCommentId - ? "Ask your CEO to revise the plan..." - : "Send a message..."; + : "Send a message..."; if (isLoading) { return ( @@ -596,21 +615,14 @@ export function CEOChatPanel({ )} - {/* Progress step indicator */} - {showStatus && progressStep && ( -
- - {progressStep} -
- )} {/* Messages */}
- {/* CEO Welcome — typing indicator then message */} - {comments?.length === 0 && welcomePhase === "typing" && ( + {/* CEO Welcome — typing indicator until welcome comment is saved and loaded */} + {comments !== undefined && comments.length === 0 && welcomePhase === "typing" && (
{usePaperclipIndicator ? ( @@ -623,30 +635,9 @@ export function CEOChatPanel({ {agentName} is composing a message...
)} - {comments?.length === 0 && welcomePhase === "message" && ( -
-
- - {agentName} - -
-

- Hello! I'm {agentName}{companyName ? <>, your CEO at {companyName} : ", your CEO"}. -

- {companyGoal && ( -

- Our mission: {companyGoal} -

- )} -

- I'd love to understand your vision and priorities before we start building the team. What's most important to you right now? -

-
- )} {comments?.map((comment) => { const isAgent = Boolean(comment.authorAgentId); - const isPlan = detectedPlanCommentId === comment.id; // Hide comments that are entirely system output const displayBody = isAgent ? cleanAgentMessage(comment.body) : comment.body; if (isAgent && !displayBody) return null; @@ -669,77 +660,23 @@ export function CEOChatPanel({ > {isAgent ? agentName : "You"} - {isPlan && ( - - - Hiring plan detected - - )}
{displayBody}
- {/* Inline plan link — opens in artifacts pane */} - {isPlan && ( - - )} ); })} - {/* Status indicator — click to toggle between paperclip SVG and blue dot */} - {showStatus && ( - - )} - {/* Streaming response — shows text as it arrives from the relay */} + {/* Streaming response — shows text as it arrives */} {streamingText && (
{agentName} - streaming
{streamingText} @@ -747,8 +684,22 @@ export function CEOChatPanel({
)} + {/* Optimistic user message — shows instantly before server confirms */} + {optimisticMessage && ( +
+
+ + You + +
+
+ {optimisticMessage} +
+
+ )} + {/* Optimistic typing indicator — shows immediately after user sends */} - {optimisticTyping && !showStatus && ( + {optimisticTyping && (
{usePaperclipIndicator ? ( @@ -763,18 +714,18 @@ export function CEOChatPanel({ )}
- {/* Suggestion chips */} -
+ {/* Suggestion chips — hide after 4 messages */} + {(comments?.length ?? 0) < 4 &&
{suggestionChips.map((chip) => ( ))} -
+
} {/* Input area */}
diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index 91492fd5..d1c8a428 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -1103,18 +1103,17 @@ Follow this structure for every role in the plan.`,
)} - {step === 1 && onboardingPath !== "grow" && ( + {/* Step 1a: Name your company */} + {step === 1 && onboardingPath !== "grow" && !missionPath && (
-

{!missionPath ? "Name your company" : "Define your mission"}

+

Name your company

- {!missionPath - ? "What will your company be called?" - : "Your mission drives everything — your CEO, your hires, and the work your company will do."} + What will your company be called?

@@ -1134,44 +1133,93 @@ Follow this structure for every role in the plan.`, placeholder="Acme Corp" value={companyName} onChange={(e) => setCompanyName(e.target.value)} - autoFocus={!missionPath} + onKeyDown={(e) => { + if (e.key === "Enter" && companyName.trim()) { + e.preventDefault(); + setMissionPath("direct"); + } + }} + autoFocus />
+
+ + {companyName.trim() && ( + + )} +
+
+ )} - {/* Mission path selector — only shows after company name is entered */} - {!missionPath && companyName.trim() && ( -
- -
- - -
+ {/* Step 1b: Define your mission */} + {step === 1 && onboardingPath !== "grow" && missionPath && ( +
+
+
+
- )} +
+

Define your mission

+

+ Your mission drives everything — your CEO, your hires, and the work {companyName} will do. +

+
+
+ + {/* Mission path selector */} +
+ +
+ + +
+
{/* Direct mission input */} {missionPath === "direct" && ( -
+
-
)} {/* Questionnaire path */} {missionPath === "questionnaire" && !missionConfirmed && ( -
+
)} {/* Questionnaire result — editable mission */} {missionPath === "questionnaire" && missionConfirmed && ( -
+
)} diff --git a/ui/src/pages/Chat.tsx b/ui/src/pages/Chat.tsx index 9aa3937f..ccbdcd07 100644 --- a/ui/src/pages/Chat.tsx +++ b/ui/src/pages/Chat.tsx @@ -104,14 +104,19 @@ export function Chat() { const taskId = useMemo(() => { if (taskIdParam) return taskIdParam; + // Find a planning/chat task by title, or fall back to any CEO-assigned task const planningTask = issues?.find( (i) => i.title.toLowerCase().includes("hiring plan") || i.title.toLowerCase().includes("build hiring plan") || - i.title.toLowerCase().includes("plan ai agents"), + i.title.toLowerCase().includes("plan ai agents") || + i.title.toLowerCase().includes("chat with ceo"), ); - return planningTask?.id ?? null; - }, [taskIdParam, issues]); + if (planningTask) return planningTask.id; + // Fall back: any task assigned to the CEO agent + const ceoTask = ceoAgent && issues?.find((i) => i.assigneeAgentId === ceoAgent.id); + return ceoTask?.id ?? null; + }, [taskIdParam, issues, ceoAgent]); // Build conversations list from CEO-assigned issues const conversations: ChatConversation[] = useMemo(() => { @@ -156,29 +161,42 @@ export function Chat() { planMarkdown = doc.body ?? ""; } catch { /* fallback */ } - if (planMarkdown) { - const roles = parseRolesFromPlan(planMarkdown); - for (const role of roles) { + // Parse plan and create hire tasks + const roles = planMarkdown ? parseRolesFromPlan(planMarkdown) : []; + for (const role of roles) { + try { await issuesApi.create(selectedCompanyId, { title: `Hire: ${role.name}`, description: `Hire a ${role.name} for the company.\n\n${role.spec}`, assigneeAgentId: ceoAgent.id, status: "todo", }); - } - queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); - - // Confirmation in chat - await issuesApi.addComment( - taskId, - `Plan approved! ${roles.length} hire task${roles.length === 1 ? "" : "s"} created. Let's build the team.`, - false, false, - ); - queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) }); + } catch { /* skip failed task creation */ } } + queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) }); + + // Confirmation in chat + try { + await issuesApi.addComment(taskId, `Plan approved! ${roles.length} hire task${roles.length === 1 ? "" : "s"} created.`); + } catch { /* non-critical */ } + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) }); + + // Trigger CEO to respond immediately via stream endpoint + fetch(`/api/agents/${ceoAgent.id}/chat/canned`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + taskId, + message: `Great news! The plan has been approved. I've created ${roles.length} hire task${roles.length === 1 ? "" : "s"} and I'll start working on them right away. You can track progress in the Tasks view.`, + }), + }).then(() => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) }); + }).catch(() => {}); queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) }); - } catch { /* non-critical */ } + } catch (err) { + console.error("Approve failed:", err); + } }, [taskId, selectedCompanyId, ceoAgent, queryClient]); // Reject: update work product to changes_requested, tell CEO to revise