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:
parent
4b3cda97e4
commit
8abbc48c71
7 changed files with 861 additions and 325 deletions
122
scripts/dev-fresh-chat.sh
Executable file
122
scripts/dev-fresh-chat.sh
Executable 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
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue