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.
-
-
{
+
+
+ Approve
+
+ {
onReject?.();
onBack();
}}>
-
Reject
-
-
- Approve
-
)}
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 && (
- onOpenArtifact?.("plan", "Hiring Plan")}
- >
-
- Hiring Plan
- — tap to review in Artifacts
-
- )}
);
})}
- {/* Status indicator — click to toggle between paperclip SVG and blue dot */}
- {showStatus && (
- setUsePaperclipIndicator((v) => !v)}
- title="Click to toggle thinking indicator style"
- >
-
- {hasActiveRun ? (
- <>
- {usePaperclipIndicator ? (
-
- ) : (
-
-
-
-
- )}
- {getRunStatusMessage(activeRun.status, agentName, elapsed)}
- >
- ) : (
- <>
- {usePaperclipIndicator ? (
-
- ) : (
-
- )}
- {getCyclingMessage(WAITING_MESSAGES, elapsed, agentName)}
- >
- )}
-
-
- {elapsedStr}
-
-
- )}
- {/* 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) => (
sendMessage(chip.message)}
+ onClick={() => { setInput(chip); inputRef.current?.focus(); }}
>
- {chip.label}
+ {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
/>
+
+
{ setOnboardingPath(null); setStep(0); }}
+ >
+ ← Back to start
+
+ {companyName.trim() && (
+
setMissionPath("direct")}
+ className="gap-1.5"
+ >
+ Next
+
+
+ )}
+
+
+ )}
- {/* Mission path selector — only shows after company name is entered */}
- {!missionPath && companyName.trim() && (
-
-
- How would you like to define your mission?
-
-
- setMissionPath("direct")}
- >
-
- I know my mission
-
- Type it directly
-
-
- setMissionPath("questionnaire")}
- >
-
- Help me figure it out
-
- Answer a few questions
-
-
-
+ {/* 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 */}
+
+
+ How would you like to define your mission?
+
+
+ setMissionPath("direct")}
+ >
+
+ I know my mission
+
+ Type it directly
+
+
+ setMissionPath("questionnaire")}
+ >
+
+ Help me figure it out
+
+ Answer a few questions
+
+
+
+
{/* Direct mission input */}
{missionPath === "direct" && (
-
+
))}
-
setMissionPath(null)}
- >
- ← Choose a different path
-
)}
{/* Questionnaire path */}
{missionPath === "questionnaire" && !missionConfirmed && (
-
+
What does your company do?
@@ -1277,18 +1319,12 @@ Follow this structure for every role in the plan.`,
Generate my mission
)}
- setMissionPath(null)}
- >
- ← Choose a different path
-
)}
{/* Questionnaire result — editable mission */}
{missionPath === "questionnaire" && missionConfirmed && (
-
+
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() && (
You can always change your mission later in settings.
)}
+
+ setMissionPath(null)}
+ >
+ ← Change company name
+
)}
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