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 { Router } from "express";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import fs from "node:fs";
|
||||||
import type { Db } from "@paperclipai/db";
|
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 { eq } from "drizzle-orm";
|
||||||
import { getServerAdapter } from "../adapters/index.js";
|
import { getServerAdapter } from "../adapters/index.js";
|
||||||
import {
|
import {
|
||||||
agentService,
|
agentService,
|
||||||
issueService,
|
issueService,
|
||||||
|
documentService,
|
||||||
secretService,
|
secretService,
|
||||||
} from "../services/index.js";
|
} from "../services/index.js";
|
||||||
import { notFound } from "../errors.js";
|
import { notFound } from "../errors.js";
|
||||||
import { parseObject } from "../adapters/utils.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
|
* Chat relay endpoint — calls the adapter directly and streams the response
|
||||||
* back via SSE. Bypasses the heartbeat queue for real-time conversation.
|
* back via SSE. Bypasses the heartbeat queue for real-time conversation.
|
||||||
|
|
@ -60,6 +92,9 @@ export function agentChatRoutes(db: Db) {
|
||||||
// Send initial event
|
// Send initial event
|
||||||
res.write(`data: ${JSON.stringify({ type: "start", agentId, agentName: agent.name })}\n\n`);
|
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 {
|
try {
|
||||||
// Resolve adapter config with secrets
|
// Resolve adapter config with secrets
|
||||||
const config = parseObject(agent.adapterConfig);
|
const config = parseObject(agent.adapterConfig);
|
||||||
|
|
@ -72,12 +107,25 @@ export function agentChatRoutes(db: Db) {
|
||||||
// Get adapter
|
// Get adapter
|
||||||
const adapter = getServerAdapter(agent.adapterType);
|
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
|
// Execute directly — stream stdout chunks as SSE events
|
||||||
let fullResponse = "";
|
let fullResponse = "";
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|
||||||
const result = await adapter.execute({
|
const result = await adapter.execute({
|
||||||
runId: randomUUID(),
|
runId,
|
||||||
agent: agent as any, // DB row matches adapter expectation
|
agent: agent as any, // DB row matches adapter expectation
|
||||||
runtime: {
|
runtime: {
|
||||||
sessionId: null,
|
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
|
// Save the agent's full response as a comment
|
||||||
if (fullResponse.trim()) {
|
if (fullResponse.trim()) {
|
||||||
await issueSvc.addComment(taskId, fullResponse.trim(), {
|
await issueSvc.addComment(taskId, fullResponse.trim(), {
|
||||||
|
|
@ -126,6 +190,17 @@ export function agentChatRoutes(db: Db) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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
|
// Send error event
|
||||||
if (res.writable) {
|
if (res.writable) {
|
||||||
const message = err instanceof Error ? err.message : "Relay execution failed";
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
78.2250% { opacity:0; }
|
78.2250% { opacity:0; }
|
||||||
100% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; 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>
|
</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"
|
<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"/>
|
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 && (
|
{needsAction && (
|
||||||
<div className="border-t border-border px-4 py-3 bg-background shrink-0">
|
<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>
|
<p className="text-[11px] text-muted-foreground mb-2">This document needs your review.</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Button size="sm" variant="outline" className="h-8 text-xs flex-1 text-destructive hover:text-destructive" onClick={() => {
|
<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?.();
|
onReject?.();
|
||||||
onBack();
|
onBack();
|
||||||
}}>
|
}}>
|
||||||
<XCircle className="h-3.5 w-3.5 mr-1" />
|
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { IssueComment } from "@paperclipai/shared";
|
import type { IssueComment } from "@paperclipai/shared";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
|
|
@ -11,7 +10,6 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Send,
|
Send,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Sparkles,
|
|
||||||
History,
|
History,
|
||||||
Search,
|
Search,
|
||||||
X,
|
X,
|
||||||
|
|
@ -81,20 +79,6 @@ function isSystemChunk(text: string): boolean {
|
||||||
return false;
|
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 */
|
/** Animated paperclip SVG thinking indicator */
|
||||||
function PaperclipThinking({ className }: { className?: string }) {
|
function PaperclipThinking({ className }: { className?: string }) {
|
||||||
|
|
@ -103,23 +87,12 @@ function PaperclipThinking({ className }: { className?: string }) {
|
||||||
src="/paperclip-thinking.svg"
|
src="/paperclip-thinking.svg"
|
||||||
alt=""
|
alt=""
|
||||||
className={cn("inline-block", className)}
|
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 = [
|
const QUEUED_MESSAGES = [
|
||||||
"Heartbeat triggered, waking up...",
|
"Heartbeat triggered, waking up...",
|
||||||
|
|
@ -175,33 +148,33 @@ function getProgressStep(elapsed: number): string | null {
|
||||||
return "Almost ready...";
|
return "Almost ready...";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Context-aware suggestion chips */
|
/** Context-aware suggestion chips — label IS the message */
|
||||||
function getSuggestionChips(
|
function getSuggestionChips(
|
||||||
hasActiveRun: boolean,
|
hasActiveRun: boolean,
|
||||||
hasPlanDetected: boolean,
|
hasPlanDetected: boolean,
|
||||||
hasComments: boolean,
|
hasComments: boolean,
|
||||||
): Array<{ label: string; message: string }> {
|
): string[] {
|
||||||
if (hasPlanDetected) {
|
if (hasPlanDetected) {
|
||||||
return [
|
return [
|
||||||
{ label: "I want to make changes", message: "I'd like to make some changes to the plan before approving." },
|
"I want to make changes",
|
||||||
{ label: "Add another role", message: "Can you add another role to the plan?" },
|
"Add another role",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (hasActiveRun) {
|
if (hasActiveRun) {
|
||||||
return [
|
return [
|
||||||
{ label: "What can I do while waiting?", message: "What can I do while you're working on the plan?" },
|
"What can I do while waiting?",
|
||||||
{ label: "Tell me about team structure", message: "Tell me about how you're thinking about the team structure." },
|
"Tell me about team structure",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (hasComments) {
|
if (hasComments) {
|
||||||
return [
|
return [
|
||||||
{ label: "What should we prioritize?", message: "What should we prioritize first?" },
|
"What should we prioritize?",
|
||||||
{ label: "Create a new project", message: "Let's create a new project to work on." },
|
"Create a new project",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ label: "Let's talk strategy", message: "Before we hire anyone, I'd like to discuss our strategy and priorities." },
|
"Let's talk strategy",
|
||||||
{ label: "What do you need from me?", message: "What information do you need from me to get started?" },
|
"What do you need from me?",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,8 +205,12 @@ export function CEOChatPanel({
|
||||||
const [welcomePhase, setWelcomePhase] = useState<"typing" | "message">("typing");
|
const [welcomePhase, setWelcomePhase] = useState<"typing" | "message">("typing");
|
||||||
// Optimistic typing indicator — shows immediately after user sends
|
// Optimistic typing indicator — shows immediately after user sends
|
||||||
const [optimisticTyping, setOptimisticTyping] = useState(false);
|
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 scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(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
|
// Poll comments — faster when waiting for a response
|
||||||
const { data: rawComments, isLoading } = useQuery({
|
const { data: rawComments, isLoading } = useQuery({
|
||||||
|
|
@ -242,12 +219,8 @@ export function CEOChatPanel({
|
||||||
refetchInterval: optimisticTyping ? 2000 : 4000,
|
refetchInterval: optimisticTyping ? 2000 : 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Poll heartbeat — faster when actively waiting
|
// Heartbeat polling disabled — the stream endpoint handles chat directly.
|
||||||
const { data: activeRun } = useQuery({
|
const activeRun = null as any;
|
||||||
queryKey: queryKeys.issues.activeRun(taskId),
|
|
||||||
queryFn: () => heartbeatsApi.activeRunForIssue(taskId),
|
|
||||||
refetchInterval: optimisticTyping ? 1500 : 3000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const comments = useMemo(
|
const comments = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -259,89 +232,94 @@ export function CEOChatPanel({
|
||||||
[rawComments],
|
[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(() => {
|
useEffect(() => {
|
||||||
if (comments && comments.length === 0 && welcomePhase === "typing") {
|
if (comments && comments.length === 0 && welcomePhase === "typing" && !welcomeSavedRef.current) {
|
||||||
const timer = setTimeout(() => setWelcomePhase("message"), 2500);
|
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);
|
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(() => {
|
useEffect(() => {
|
||||||
if (optimisticTyping && comments?.length) {
|
if (optimisticTyping && comments?.length) {
|
||||||
const lastComment = comments[comments.length - 1];
|
// Only clear if a new agent comment appeared since we started sending
|
||||||
if (lastComment.authorAgentId) {
|
if (comments.length > commentCountAtSendRef.current) {
|
||||||
setOptimisticTyping(false);
|
const newComments = comments.slice(commentCountAtSendRef.current);
|
||||||
|
if (newComments.some((c) => c.authorAgentId)) {
|
||||||
|
setOptimisticTyping(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [comments, optimisticTyping]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
}
|
}
|
||||||
}, [comments?.length]);
|
}, [comments?.length, streamingText]);
|
||||||
|
|
||||||
// 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("");
|
|
||||||
|
|
||||||
// Send message — try streaming relay first, fall back to poll-based
|
// Send message — try streaming relay first, fall back to poll-based
|
||||||
const sendMessage = useCallback(async (body: string) => {
|
const sendMessage = useCallback(async (body: string) => {
|
||||||
|
|
@ -349,51 +327,34 @@ export function CEOChatPanel({
|
||||||
if (!trimmed || sending) return;
|
if (!trimmed || sending) return;
|
||||||
setSending(true);
|
setSending(true);
|
||||||
setInput("");
|
setInput("");
|
||||||
|
setOptimisticMessage(trimmed);
|
||||||
setOptimisticTyping(true);
|
setOptimisticTyping(true);
|
||||||
|
commentCountAtSendRef.current = comments?.length ?? 0;
|
||||||
// If user is asking for a plan, create a draft artifact immediately
|
draftCreatedRef.current = false;
|
||||||
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 */ });
|
|
||||||
}
|
|
||||||
|
|
||||||
const latestId = comments?.[comments.length - 1]?.id ?? null;
|
const latestId = comments?.[comments.length - 1]?.id ?? null;
|
||||||
setIgnoreBeforeCommentId(latestId);
|
setIgnoreBeforeCommentId(latestId);
|
||||||
setDetectedPlanCommentId(null);
|
setDetectedPlanCommentId(null);
|
||||||
|
|
||||||
// Ensure task is assigned to agent
|
|
||||||
try {
|
try {
|
||||||
await issuesApi.update(taskId, { assigneeUserId: null });
|
// Try lightweight streaming endpoint (longer timeout — CLI needs startup time)
|
||||||
} catch { /* ok */ }
|
const controller = new AbortController();
|
||||||
try {
|
const fetchTimeout = setTimeout(() => controller.abort(), 60000);
|
||||||
await issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" });
|
const res = await fetch(`/api/agents/${agentId}/chat/stream`, {
|
||||||
} catch { /* ok */ }
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try streaming relay
|
|
||||||
const res = await fetch(`/api/agents/${agentId}/chat/relay`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ taskId, message: trimmed }),
|
body: JSON.stringify({ taskId, message: trimmed }),
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
clearTimeout(fetchTimeout);
|
||||||
|
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
throw new Error("Relay not available");
|
throw new Error("Relay not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptimisticTyping(false);
|
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
|
streamingBufferRef.current = "";
|
||||||
|
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
||||||
|
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
|
|
@ -412,30 +373,90 @@ export function CEOChatPanel({
|
||||||
try {
|
try {
|
||||||
const event = JSON.parse(line.slice(6));
|
const event = JSON.parse(line.slice(6));
|
||||||
if (event.type === "chunk" && !isSystemChunk(event.text)) {
|
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") {
|
} 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
|
// Refresh comments to pick up persisted messages
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.issues.comments(taskId),
|
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") {
|
} else if (event.type === "error") {
|
||||||
setStreamingText("");
|
setStreamingText("");
|
||||||
// Fall through — comments will still be polled
|
streamingBufferRef.current = "";
|
||||||
|
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
|
||||||
}
|
}
|
||||||
} catch { /* malformed SSE line, skip */ }
|
} 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({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.issues.comments(taskId),
|
queryKey: queryKeys.issues.comments(taskId),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback: use the old comment-and-poll approach
|
// Stream endpoint failed or timed out — message was already saved server-side,
|
||||||
try {
|
// so just refresh comments and let polling pick up any response
|
||||||
await issuesApi.addComment(taskId, trimmed, true, true);
|
|
||||||
} catch { /* already saved by relay, or genuinely failed */ }
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: queryKeys.issues.comments(taskId),
|
queryKey: queryKeys.issues.comments(taskId),
|
||||||
});
|
});
|
||||||
|
|
@ -444,7 +465,7 @@ export function CEOChatPanel({
|
||||||
setOptimisticTyping(false);
|
setOptimisticTyping(false);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [sending, taskId, agentId, queryClient, comments]);
|
}, [sending, taskId, agentId, companyId, agentName, queryClient, comments]);
|
||||||
|
|
||||||
const handleSend = useCallback(() => {
|
const handleSend = useCallback(() => {
|
||||||
sendMessage(input);
|
sendMessage(input);
|
||||||
|
|
@ -494,14 +515,12 @@ export function CEOChatPanel({
|
||||||
: `${elapsed}s`;
|
: `${elapsed}s`;
|
||||||
|
|
||||||
const progressStep = getProgressStep(elapsed);
|
const progressStep = getProgressStep(elapsed);
|
||||||
const suggestionChips = getSuggestionChips(!!hasActiveRun, !!detectedPlanCommentId, !!comments?.length);
|
const suggestionChips = getSuggestionChips(!!hasActiveRun, false, !!comments?.length);
|
||||||
|
|
||||||
// Dynamic placeholder
|
// Dynamic placeholder
|
||||||
const placeholder = hasActiveRun
|
const placeholder = hasActiveRun
|
||||||
? `${agentName} is working...`
|
? `${agentName} is working...`
|
||||||
: detectedPlanCommentId
|
: "Send a message...";
|
||||||
? "Ask your CEO to revise the plan..."
|
|
||||||
: "Send a message...";
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -596,21 +615,14 @@ export function CEOChatPanel({
|
||||||
</div>
|
</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 */}
|
{/* Messages */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex-1 overflow-y-auto scrollbar-auto-hide space-y-2.5 p-4"
|
className="flex-1 overflow-y-auto scrollbar-auto-hide space-y-2.5 p-4"
|
||||||
>
|
>
|
||||||
{/* CEO Welcome — typing indicator then message */}
|
{/* CEO Welcome — typing indicator until welcome comment is saved and loaded */}
|
||||||
{comments?.length === 0 && welcomePhase === "typing" && (
|
{comments !== undefined && comments.length === 0 && welcomePhase === "typing" && (
|
||||||
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-2">
|
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-2">
|
||||||
{usePaperclipIndicator ? (
|
{usePaperclipIndicator ? (
|
||||||
<PaperclipThinking />
|
<PaperclipThinking />
|
||||||
|
|
@ -623,30 +635,9 @@ export function CEOChatPanel({
|
||||||
{agentName} is composing a message...
|
{agentName} is composing a message...
|
||||||
</div>
|
</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) => {
|
{comments?.map((comment) => {
|
||||||
const isAgent = Boolean(comment.authorAgentId);
|
const isAgent = Boolean(comment.authorAgentId);
|
||||||
const isPlan = detectedPlanCommentId === comment.id;
|
|
||||||
// Hide comments that are entirely system output
|
// Hide comments that are entirely system output
|
||||||
const displayBody = isAgent ? cleanAgentMessage(comment.body) : comment.body;
|
const displayBody = isAgent ? cleanAgentMessage(comment.body) : comment.body;
|
||||||
if (isAgent && !displayBody) return null;
|
if (isAgent && !displayBody) return null;
|
||||||
|
|
@ -669,77 +660,23 @@ export function CEOChatPanel({
|
||||||
>
|
>
|
||||||
{isAgent ? agentName : "You"}
|
{isAgent ? agentName : "You"}
|
||||||
</span>
|
</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>
|
||||||
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||||
<MarkdownBody>{displayBody}</MarkdownBody>
|
<MarkdownBody>{displayBody}</MarkdownBody>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Status indicator — click to toggle between paperclip SVG and blue dot */}
|
{/* Streaming response — shows text as it arrives */}
|
||||||
{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 */}
|
|
||||||
{streamingText && (
|
{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="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">
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
{agentName}
|
{agentName}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-cyan-500 font-medium">streaming</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||||
<MarkdownBody>{streamingText}</MarkdownBody>
|
<MarkdownBody>{streamingText}</MarkdownBody>
|
||||||
|
|
@ -747,8 +684,22 @@ export function CEOChatPanel({
|
||||||
</div>
|
</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 */}
|
{/* 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">
|
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-1.5">
|
||||||
{usePaperclipIndicator ? (
|
{usePaperclipIndicator ? (
|
||||||
<PaperclipThinking />
|
<PaperclipThinking />
|
||||||
|
|
@ -763,18 +714,18 @@ export function CEOChatPanel({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suggestion chips */}
|
{/* Suggestion chips — hide after 4 messages */}
|
||||||
<div className="px-3 pb-1.5 flex flex-wrap gap-1">
|
{(comments?.length ?? 0) < 4 && <div className="px-3 pb-1.5 flex flex-wrap gap-1">
|
||||||
{suggestionChips.map((chip) => (
|
{suggestionChips.map((chip) => (
|
||||||
<button
|
<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"
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>}
|
||||||
|
|
||||||
{/* Input area */}
|
{/* Input area */}
|
||||||
<div className="flex items-center gap-1.5 px-3 pb-3 pt-1.5 border-t border-border">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && onboardingPath !== "grow" && (
|
{/* Step 1a: Name your company */}
|
||||||
|
{step === 1 && onboardingPath !== "grow" && !missionPath && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="flex items-center gap-3 mb-1">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
<div className="bg-muted/50 p-2">
|
<div className="bg-muted/50 p-2">
|
||||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p className="text-xs text-muted-foreground">
|
||||||
{!missionPath
|
What will your company be called?
|
||||||
? "What will your company be called?"
|
|
||||||
: "Your mission drives everything — your CEO, your hires, and the work your company will do."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1134,44 +1133,93 @@ Follow this structure for every role in the plan.`,
|
||||||
placeholder="Acme Corp"
|
placeholder="Acme Corp"
|
||||||
value={companyName}
|
value={companyName}
|
||||||
onChange={(e) => setCompanyName(e.target.value)}
|
onChange={(e) => setCompanyName(e.target.value)}
|
||||||
autoFocus={!missionPath}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && companyName.trim()) {
|
||||||
|
e.preventDefault();
|
||||||
|
setMissionPath("direct");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Step 1b: Define your mission */}
|
||||||
{!missionPath && companyName.trim() && (
|
{step === 1 && onboardingPath !== "grow" && missionPath && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-5">
|
||||||
<label className="text-xs text-foreground block">
|
<div className="flex items-center gap-3 mb-1">
|
||||||
How would you like to define your mission?
|
<div className="bg-muted/50 p-2">
|
||||||
</label>
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||||
<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>
|
|
||||||
</div>
|
</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 */}
|
{/* Direct mission input */}
|
||||||
{missionPath === "direct" && (
|
{missionPath === "direct" && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 animate-in fade-in duration-200">
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label
|
<label
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1208,18 +1256,12 @@ Follow this structure for every role in the plan.`,
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
onClick={() => setMissionPath(null)}
|
|
||||||
>
|
|
||||||
← Choose a different path
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Questionnaire path */}
|
{/* Questionnaire path */}
|
||||||
{missionPath === "questionnaire" && !missionConfirmed && (
|
{missionPath === "questionnaire" && !missionConfirmed && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 animate-in fade-in duration-200">
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
What does your company do?
|
What does your company do?
|
||||||
|
|
@ -1277,18 +1319,12 @@ Follow this structure for every role in the plan.`,
|
||||||
Generate my mission
|
Generate my mission
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors block"
|
|
||||||
onClick={() => setMissionPath(null)}
|
|
||||||
>
|
|
||||||
← Choose a different path
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Questionnaire result — editable mission */}
|
{/* Questionnaire result — editable mission */}
|
||||||
{missionPath === "questionnaire" && missionConfirmed && (
|
{missionPath === "questionnaire" && missionConfirmed && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3 animate-in fade-in duration-200">
|
||||||
<div className="group">
|
<div className="group">
|
||||||
<label className="text-xs text-foreground mb-1 block">
|
<label className="text-xs text-foreground mb-1 block">
|
||||||
Here's your draft mission — edit it however you like:
|
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 */}
|
{/* Confirm mission note */}
|
||||||
{companyGoal.trim() && companyName.trim() && (
|
{companyGoal.trim() && (
|
||||||
<p className="text-[11px] text-muted-foreground italic">
|
<p className="text-[11px] text-muted-foreground italic">
|
||||||
You can always change your mission later in settings.
|
You can always change your mission later in settings.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setMissionPath(null)}
|
||||||
|
>
|
||||||
|
← Change company name
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -104,14 +104,19 @@ export function Chat() {
|
||||||
|
|
||||||
const taskId = useMemo(() => {
|
const taskId = useMemo(() => {
|
||||||
if (taskIdParam) return taskIdParam;
|
if (taskIdParam) return taskIdParam;
|
||||||
|
// Find a planning/chat task by title, or fall back to any CEO-assigned task
|
||||||
const planningTask = issues?.find(
|
const planningTask = issues?.find(
|
||||||
(i) =>
|
(i) =>
|
||||||
i.title.toLowerCase().includes("hiring plan") ||
|
i.title.toLowerCase().includes("hiring plan") ||
|
||||||
i.title.toLowerCase().includes("build 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;
|
if (planningTask) return planningTask.id;
|
||||||
}, [taskIdParam, issues]);
|
// 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
|
// Build conversations list from CEO-assigned issues
|
||||||
const conversations: ChatConversation[] = useMemo(() => {
|
const conversations: ChatConversation[] = useMemo(() => {
|
||||||
|
|
@ -156,29 +161,42 @@ export function Chat() {
|
||||||
planMarkdown = doc.body ?? "";
|
planMarkdown = doc.body ?? "";
|
||||||
} catch { /* fallback */ }
|
} catch { /* fallback */ }
|
||||||
|
|
||||||
if (planMarkdown) {
|
// Parse plan and create hire tasks
|
||||||
const roles = parseRolesFromPlan(planMarkdown);
|
const roles = planMarkdown ? parseRolesFromPlan(planMarkdown) : [];
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
|
try {
|
||||||
await issuesApi.create(selectedCompanyId, {
|
await issuesApi.create(selectedCompanyId, {
|
||||||
title: `Hire: ${role.name}`,
|
title: `Hire: ${role.name}`,
|
||||||
description: `Hire a ${role.name} for the company.\n\n${role.spec}`,
|
description: `Hire a ${role.name} for the company.\n\n${role.spec}`,
|
||||||
assigneeAgentId: ceoAgent.id,
|
assigneeAgentId: ceoAgent.id,
|
||||||
status: "todo",
|
status: "todo",
|
||||||
});
|
});
|
||||||
}
|
} catch { /* skip failed task creation */ }
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
|
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) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
|
||||||
} catch { /* non-critical */ }
|
} catch (err) {
|
||||||
|
console.error("Approve failed:", err);
|
||||||
|
}
|
||||||
}, [taskId, selectedCompanyId, ceoAgent, queryClient]);
|
}, [taskId, selectedCompanyId, ceoAgent, queryClient]);
|
||||||
|
|
||||||
// Reject: update work product to changes_requested, tell CEO to revise
|
// Reject: update work product to changes_requested, tell CEO to revise
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue