experiment: direct CLI streaming, observer pattern, artifact generation

Replace adapter-based chat relay with lightweight claude CLI streaming
endpoint. CEO responses now arrive in 2-5s instead of minutes.

Key changes:
- POST /agents/:id/chat/stream — spawns claude -p directly, SSE streaming
- POST /agents/:id/chat/canned — persist welcome/approval messages
- POST /agents/:id/chat/generate-artifact — background doc generation
- Server-side detectArtifactCommitments() replaces unreliable AI observer
- Frontend: optimistic user messages, typewriter streaming, observer events
- Onboarding: separated name/mission substeps, back navigation
- Dev script: full server restart + fresh company + mission setup
- Removed: canned responses, heartbeat polling, inline plan detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
scotttong 2026-03-19 22:07:26 -07:00
parent 4b3cda97e4
commit 8abbc48c71
7 changed files with 861 additions and 325 deletions

122
scripts/dev-fresh-chat.sh Executable file
View file

@ -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

View file

@ -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;
}

View file

@ -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; }
</style>
<path class="p" d="M16 6 l-8.414 8.586 a2.000 2.000 0 0 0 2.828 2.828 l8.414 -8.586 a4.000 4.000 0 1 0 -5.657 -5.657 l-8.379 8.551 a6.000 6.000 0 1 0 8.485 8.485 l8.379 -8.551" fill="none" stroke="#ffffff"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -299,18 +299,16 @@ function DocumentViewer({
{needsAction && (
<div className="border-t border-border px-4 py-3 bg-background shrink-0">
<p className="text-[11px] text-muted-foreground mb-2">This document needs your review.</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="h-8 text-xs flex-1 text-destructive hover:text-destructive" onClick={() => {
<div className="flex items-center gap-3">
<Button size="lg" className="h-11 px-8 text-base font-semibold flex-1 rounded-lg bg-green-700 hover:bg-green-800 text-white border-0" onClick={onApprove}>
Approve
</Button>
<Button size="lg" className="h-11 px-8 text-base font-semibold flex-1 rounded-lg bg-red-900 hover:bg-red-950 text-white border-0" onClick={() => {
onReject?.();
onBack();
}}>
<XCircle className="h-3.5 w-3.5 mr-1" />
Reject
</Button>
<Button size="sm" className="h-8 text-xs flex-1 bg-green-600 hover:bg-green-700 text-white" onClick={onApprove}>
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
Approve
</Button>
</div>
</div>
)}

View file

@ -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<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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<ReturnType<typeof setInterval> | 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({
</div>
)}
{/* Progress step indicator */}
{showStatus && progressStep && (
<div className="px-4 py-2 border-b border-border bg-muted/30 text-xs text-muted-foreground flex items-center gap-2">
<Sparkles className="h-3 w-3 animate-pulse" />
{progressStep}
</div>
)}
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto scrollbar-auto-hide space-y-2.5 p-4"
>
{/* 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" && (
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-2">
{usePaperclipIndicator ? (
<PaperclipThinking />
@ -623,30 +635,9 @@ export function CEOChatPanel({
{agentName} is composing a message...
</div>
)}
{comments?.length === 0 && welcomePhase === "message" && (
<div className="rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed bg-muted/50 border border-border mr-6 animate-in fade-in duration-300">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{agentName}
</span>
</div>
<p>
Hello! I'm <strong>{agentName}</strong>{companyName ? <>, your CEO at <strong>{companyName}</strong></> : ", your CEO"}.
</p>
{companyGoal && (
<p className="mt-0.5">
Our mission: <em>{companyGoal}</em>
</p>
)}
<p className="mt-0.5">
I'd love to understand your vision and priorities before we start building the team. What's most important to you right now?
</p>
</div>
)}
{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"}
</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-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{displayBody}</MarkdownBody>
</div>
</div>
{/* Inline plan link — opens in artifacts pane */}
{isPlan && (
<button
className="flex items-center gap-1.5 mt-1 mr-6 px-2.5 py-1.5 rounded-md border border-green-500/30 bg-green-500/5 hover:bg-green-500/10 transition-colors text-left"
onClick={() => onOpenArtifact?.("plan", "Hiring Plan")}
>
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
<span className="text-[12px] font-medium">Hiring Plan</span>
<span className="text-[11px] text-muted-foreground"> tap to review in Artifacts</span>
</button>
)}
</div>
);
})}
{/* Status indicator — click to toggle between paperclip SVG and blue dot */}
{showStatus && (
<button
className="flex items-center justify-between text-[12px] text-muted-foreground px-3 py-1.5 w-full text-left hover:bg-muted/30 transition-colors"
onClick={() => setUsePaperclipIndicator((v) => !v)}
title="Click to toggle thinking indicator style"
>
<div className="flex items-center gap-2">
{hasActiveRun ? (
<>
{usePaperclipIndicator ? (
<PaperclipThinking />
) : (
<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>
)}
{getRunStatusMessage(activeRun.status, agentName, elapsed)}
</>
) : (
<>
{usePaperclipIndicator ? (
<PaperclipThinking />
) : (
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
)}
{getCyclingMessage(WAITING_MESSAGES, elapsed, agentName)}
</>
)}
</div>
<span className="text-[11px] text-muted-foreground/60 tabular-nums shrink-0">
{elapsedStr}
</span>
</button>
)}
{/* Streaming response — shows text as it arrives from the relay */}
{/* Streaming response — shows text as it arrives */}
{streamingText && (
<div className="rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed bg-muted/50 border border-border mr-6 animate-in fade-in duration-150">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{agentName}
</span>
<span className="text-[10px] text-cyan-500 font-medium">streaming</span>
</div>
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{streamingText}</MarkdownBody>
@ -747,8 +684,22 @@ export function CEOChatPanel({
</div>
)}
{/* Optimistic user message — shows instantly before server confirms */}
{optimisticMessage && (
<div className="rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed bg-accent/50 border border-accent ml-6">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-foreground/70">
You
</span>
</div>
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{optimisticMessage}</MarkdownBody>
</div>
</div>
)}
{/* Optimistic typing indicator — shows immediately after user sends */}
{optimisticTyping && !showStatus && (
{optimisticTyping && (
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-1.5">
{usePaperclipIndicator ? (
<PaperclipThinking />
@ -763,18 +714,18 @@ export function CEOChatPanel({
)}
</div>
{/* Suggestion chips */}
<div className="px-3 pb-1.5 flex flex-wrap gap-1">
{/* Suggestion chips — hide after 4 messages */}
{(comments?.length ?? 0) < 4 && <div className="px-3 pb-1.5 flex flex-wrap gap-1">
{suggestionChips.map((chip) => (
<button
key={chip.label}
key={chip}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
onClick={() => sendMessage(chip.message)}
onClick={() => { setInput(chip); inputRef.current?.focus(); }}
>
{chip.label}
{chip}
</button>
))}
</div>
</div>}
{/* Input area */}
<div className="flex items-center gap-1.5 px-3 pb-3 pt-1.5 border-t border-border">

View file

@ -1103,18 +1103,17 @@ Follow this structure for every role in the plan.`,
</div>
)}
{step === 1 && onboardingPath !== "grow" && (
{/* Step 1a: Name your company */}
{step === 1 && onboardingPath !== "grow" && !missionPath && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<h3 className="font-medium">{!missionPath ? "Name your company" : "Define your mission"}</h3>
<h3 className="font-medium">Name your company</h3>
<p className="text-xs text-muted-foreground">
{!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?
</p>
</div>
</div>
@ -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
/>
</div>
<div className="flex items-center justify-between">
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => { setOnboardingPath(null); setStep(0); }}
>
Back to start
</button>
{companyName.trim() && (
<Button
size="sm"
onClick={() => setMissionPath("direct")}
className="gap-1.5"
>
Next
<ArrowRight className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
)}
{/* Mission path selector — only shows after company name is entered */}
{!missionPath && companyName.trim() && (
<div className="space-y-3">
<label className="text-xs text-foreground block">
How would you like to define your mission?
</label>
<div className="grid grid-cols-2 gap-2">
<button
className="flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs hover:bg-accent/50 transition-colors"
onClick={() => setMissionPath("direct")}
>
<Sparkles className="h-4 w-4" />
<span className="font-medium">I know my mission</span>
<span className="text-muted-foreground text-[10px]">
Type it directly
</span>
</button>
<button
className="flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs hover:bg-accent/50 transition-colors"
onClick={() => setMissionPath("questionnaire")}
>
<ListTodo className="h-4 w-4" />
<span className="font-medium">Help me figure it out</span>
<span className="text-muted-foreground text-[10px]">
Answer a few questions
</span>
</button>
</div>
{/* Step 1b: Define your mission */}
{step === 1 && onboardingPath !== "grow" && missionPath && (
<div className="space-y-5">
<div className="flex items-center gap-3 mb-1">
<div className="bg-muted/50 p-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
</div>
)}
<div>
<h3 className="font-medium">Define your mission</h3>
<p className="text-xs text-muted-foreground">
Your mission drives everything your CEO, your hires, and the work <strong>{companyName}</strong> will do.
</p>
</div>
</div>
{/* Mission path selector */}
<div className="space-y-3">
<label className="text-xs text-foreground block">
How would you like to define your mission?
</label>
<div className="grid grid-cols-2 gap-2">
<button
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
missionPath === "direct"
? "border-foreground bg-accent/50"
: "border-border hover:bg-accent/50"
)}
onClick={() => setMissionPath("direct")}
>
<Sparkles className="h-4 w-4" />
<span className="font-medium">I know my mission</span>
<span className="text-muted-foreground text-[10px]">
Type it directly
</span>
</button>
<button
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors",
missionPath === "questionnaire"
? "border-foreground bg-accent/50"
: "border-border hover:bg-accent/50"
)}
onClick={() => setMissionPath("questionnaire")}
>
<ListTodo className="h-4 w-4" />
<span className="font-medium">Help me figure it out</span>
<span className="text-muted-foreground text-[10px]">
Answer a few questions
</span>
</button>
</div>
</div>
{/* Direct mission input */}
{missionPath === "direct" && (
<div className="space-y-3">
<div className="space-y-3 animate-in fade-in duration-200">
<div className="group">
<label
className={cn(
@ -1208,18 +1256,12 @@ Follow this structure for every role in the plan.`,
</button>
))}
</div>
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMissionPath(null)}
>
Choose a different path
</button>
</div>
)}
{/* Questionnaire path */}
{missionPath === "questionnaire" && !missionConfirmed && (
<div className="space-y-3">
<div className="space-y-3 animate-in fade-in duration-200">
<div className="group">
<label className="text-xs text-muted-foreground mb-1 block">
What does your company do?
@ -1277,18 +1319,12 @@ Follow this structure for every role in the plan.`,
Generate my mission
</Button>
)}
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors block"
onClick={() => setMissionPath(null)}
>
Choose a different path
</button>
</div>
)}
{/* Questionnaire result — editable mission */}
{missionPath === "questionnaire" && missionConfirmed && (
<div className="space-y-3">
<div className="space-y-3 animate-in fade-in duration-200">
<div className="group">
<label className="text-xs text-foreground mb-1 block">
Here's your draft mission edit it however you like:
@ -1310,11 +1346,18 @@ Follow this structure for every role in the plan.`,
)}
{/* Confirm mission note */}
{companyGoal.trim() && companyName.trim() && (
{companyGoal.trim() && (
<p className="text-[11px] text-muted-foreground italic">
You can always change your mission later in settings.
</p>
)}
<button
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
onClick={() => setMissionPath(null)}
>
Change company name
</button>
</div>
)}

View file

@ -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