Compare commits
14 commits
PAP-878-cr
...
sockmonste
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e144a611 | ||
|
|
f312f22f27 | ||
|
|
1d06bd62c5 | ||
|
|
08e0e91af0 | ||
|
|
9bc683b17b | ||
|
|
8abbc48c71 | ||
|
|
4b3cda97e4 | ||
|
|
fbd87da6d6 | ||
|
|
2d8003d2f5 | ||
|
|
05a2848b02 | ||
|
|
0c1582ef47 | ||
|
|
b60fcd8d06 | ||
|
|
b4ef0618e5 | ||
|
|
a35fac7281 |
41 changed files with 4971 additions and 259 deletions
36
UX-EXPERIMENTS.md
Normal file
36
UX-EXPERIMENTS.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Onboarding UX Experiments
|
||||
|
||||
Tracking file for UX prototyping on the `sockmonster-UX-experimentation` branch.
|
||||
|
||||
## Ideas & Feedback
|
||||
|
||||
<!-- Add your onboarding feedback and ideas here. We'll update status as we go. -->
|
||||
|
||||
| # | Idea | Status | Commit(s) | Notes |
|
||||
|---|------|--------|-----------|-------|
|
||||
| 1 | Mission mandatory + two paths | Done | a35fac7 | Questionnaire + direct input, prompt chips |
|
||||
| 2 | Launch celebration (step 2) | Done | a35fac7 | "Company is live!" moment after mission |
|
||||
| 3 | CEO creation reframed | Done | a35fac7 | "Bring your CEO to life" / "give it a heartbeat" |
|
||||
| 4 | Chat with CEO | Done | b4ef061 | OnboardingChat polls comments, detects plans |
|
||||
| 5 | Hiring plan review | Done | b60fcd8 | Editable role cards, add/edit/remove, revise with CEO |
|
||||
| 6 | Make your first hires | Done | b60fcd8 | Creates hire tasks per approved role |
|
||||
| 7 | Chat comment order fix | Done | pending | Sort chronologically (oldest first) |
|
||||
| 8 | Chat reopen/interrupt fix | Done | pending | User comments reopen + interrupt so CEO wakes up |
|
||||
| 9 | Rich heartbeat status in chat | Done | — | Cycling status messages with elapsed timer |
|
||||
| 10 | Merge steps 5+6, add guided tour | Todo | — | Remove redundant confirm step, add post-wizard orientation |
|
||||
| 11 | Re-invokable guided tour | Todo | — | User can re-trigger tour from settings or help menu |
|
||||
| 12 | Persistent CEO chat in dashboard | Future | — | Long-term: CEO chat as command center, drives toward tasks/goals/projects |
|
||||
|
||||
## Experiment Log
|
||||
|
||||
### 2026-03-17: Initial 6-step wizard prototype
|
||||
|
||||
Rewrote the onboarding wizard from 4 steps to 6 steps:
|
||||
1. Define mission (required) — two paths: direct or questionnaire
|
||||
2. Launch celebration — "Your company is live!"
|
||||
3. Create CEO — reframed as "bring to life" / heartbeat
|
||||
4. Chat with CEO — placeholder (needs OnboardingChat)
|
||||
5. Review hiring plan — placeholder (needs editable cards)
|
||||
6. Make first hires — placeholder (needs task creation logic)
|
||||
|
||||
Steps 1-3 are functional. Steps 4-6 have placeholder UI.
|
||||
208
cli/src/commands/client/board.ts
Normal file
208
cli/src/commands/client/board.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { Command } from "commander";
|
||||
import {
|
||||
removeMaintainerOnlySkillSymlinks,
|
||||
resolvePaperclipSkillsDir,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
interface BoardSetupOptions extends BaseClientOptions {
|
||||
companyId?: string;
|
||||
installSkills?: boolean;
|
||||
}
|
||||
|
||||
interface SkillsInstallSummary {
|
||||
tool: string;
|
||||
target: string;
|
||||
linked: string[];
|
||||
removed: string[];
|
||||
skipped: string[];
|
||||
failed: Array<{ name: string; error: string }>;
|
||||
}
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function claudeSkillsHome(): string {
|
||||
const fromEnv = process.env.CLAUDE_HOME?.trim();
|
||||
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
|
||||
return path.join(base, "skills");
|
||||
}
|
||||
|
||||
async function installSkillsForTarget(
|
||||
sourceSkillsDir: string,
|
||||
targetSkillsDir: string,
|
||||
tool: string,
|
||||
): Promise<SkillsInstallSummary> {
|
||||
const summary: SkillsInstallSummary = {
|
||||
tool,
|
||||
target: targetSkillsDir,
|
||||
linked: [],
|
||||
removed: [],
|
||||
skipped: [],
|
||||
failed: [],
|
||||
};
|
||||
|
||||
await fs.mkdir(targetSkillsDir, { recursive: true });
|
||||
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
|
||||
summary.removed = await removeMaintainerOnlySkillSymlinks(
|
||||
targetSkillsDir,
|
||||
entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name),
|
||||
);
|
||||
|
||||
// Only install the board skill
|
||||
const boardEntry = entries.find((e) => e.isDirectory() && e.name === "paperclip-board");
|
||||
if (!boardEntry) {
|
||||
summary.failed.push({ name: "paperclip-board", error: "Skill directory not found" });
|
||||
return summary;
|
||||
}
|
||||
|
||||
const source = path.join(sourceSkillsDir, boardEntry.name);
|
||||
const target = path.join(targetSkillsDir, boardEntry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
|
||||
if (existing) {
|
||||
if (existing.isSymbolicLink()) {
|
||||
await fs.unlink(target);
|
||||
} else {
|
||||
summary.skipped.push(boardEntry.name);
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
summary.linked.push(boardEntry.name);
|
||||
} catch (err) {
|
||||
summary.failed.push({
|
||||
name: boardEntry.name,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
function buildBoardEnvExports(input: { apiBase: string; companyId?: string }): string {
|
||||
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
|
||||
const lines = [`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`];
|
||||
if (input.companyId) {
|
||||
lines.push(`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function registerBoardCommands(program: Command): void {
|
||||
const board = program.command("board").description("Board member operations");
|
||||
|
||||
addCommonClientOptions(
|
||||
board
|
||||
.command("setup")
|
||||
.description(
|
||||
"Install the board-member skill for Claude Code and print shell exports for managing your Paperclip company",
|
||||
)
|
||||
.option("-C, --company-id <id>", "Company ID (if you already have one)")
|
||||
.option("--no-install-skills", "Skip installing the board skill into ~/.claude/skills")
|
||||
.action(async (opts: BoardSetupOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Attempt to auto-detect company if not provided
|
||||
let companyId = opts.companyId?.trim() || ctx.companyId;
|
||||
if (!companyId) {
|
||||
try {
|
||||
const companies = await ctx.api.get<Array<{ id: string; name: string }>>(
|
||||
"/api/companies",
|
||||
);
|
||||
if (companies && companies.length === 1) {
|
||||
companyId = companies[0].id;
|
||||
console.log(`Auto-detected company: ${companies[0].name} (${companyId})`);
|
||||
} else if (companies && companies.length > 1) {
|
||||
console.log(
|
||||
"Multiple companies found. Pass --company-id or set PAPERCLIP_COMPANY_ID:",
|
||||
);
|
||||
for (const c of companies) {
|
||||
console.log(` ${c.id} ${c.name}`);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Server might not be running yet — that's OK
|
||||
}
|
||||
}
|
||||
|
||||
// Install skills
|
||||
const installSummaries: SkillsInstallSummary[] = [];
|
||||
if (opts.installSkills !== false) {
|
||||
const skillsDir = await resolvePaperclipSkillsDir(__moduleDir, [
|
||||
path.resolve(process.cwd(), "skills"),
|
||||
]);
|
||||
if (!skillsDir) {
|
||||
console.log(
|
||||
"Warning: Could not locate skills directory. Skipping skill installation.",
|
||||
);
|
||||
} else {
|
||||
installSummaries.push(
|
||||
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const exportsText = buildBoardEnvExports({
|
||||
apiBase: ctx.api.apiBase,
|
||||
companyId,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
const output = {
|
||||
companyId,
|
||||
skills: installSummaries,
|
||||
exports: exportsText,
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
// Print summary
|
||||
console.log("");
|
||||
console.log("Board setup complete!");
|
||||
console.log("");
|
||||
|
||||
if (installSummaries.length > 0) {
|
||||
for (const summary of installSummaries) {
|
||||
if (summary.linked.length > 0) {
|
||||
console.log(
|
||||
`Skill installed: ${summary.linked.join(", ")} → ${summary.target}`,
|
||||
);
|
||||
}
|
||||
for (const failed of summary.failed) {
|
||||
console.log(` Failed: ${failed.name}: ${failed.error}`);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
console.log("# Run this in your shell before launching Claude Code:");
|
||||
console.log(exportsText);
|
||||
console.log("");
|
||||
console.log("# Then start Claude Code:");
|
||||
console.log("claude");
|
||||
if (!companyId) {
|
||||
console.log("");
|
||||
console.log(
|
||||
"Note: No company detected. Claude Code will guide you through creating one.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
{ includeCompany: false },
|
||||
);
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.
|
|||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
import { registerBoardCommands } from "./commands/client/board.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
|
|
@ -138,6 +139,7 @@ registerActivityCommands(program);
|
|||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
registerBoardCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
|
|
|||
177
doc/BOARD-CHAT-GUIDE.md
Normal file
177
doc/BOARD-CHAT-GUIDE.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Board Chat Guide
|
||||
|
||||
A step-by-step guide for managing your Paperclip company through Claude Code in the terminal.
|
||||
|
||||
## What is this?
|
||||
|
||||
Paperclip is a control plane for AI-agent companies. You create a company, hire AI agents, assign them tasks, and manage their work. Normally you'd do this through the web dashboard, but the **Board Chat** skill lets you do everything through a natural conversation with Claude in your terminal.
|
||||
|
||||
Think of it like texting an assistant who happens to have full access to your company's operations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, you need two things:
|
||||
|
||||
1. **Paperclip running locally** — Ask your engineer to set this up. They'll run `pnpm dev` and tell you the URL (usually `http://localhost:3000`).
|
||||
|
||||
2. **Claude Code installed** — This is Anthropic's CLI tool. Install it by running:
|
||||
```
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
## Setup (one time, ~2 minutes)
|
||||
|
||||
### Step 1: Install the board skill
|
||||
|
||||
Open your terminal and navigate to the Paperclip project folder:
|
||||
|
||||
```
|
||||
cd ~/Projects/DEV/paperclip
|
||||
```
|
||||
|
||||
Run the setup command:
|
||||
|
||||
```
|
||||
pnpm paperclipai board setup
|
||||
```
|
||||
|
||||
This does two things:
|
||||
- Installs the board skill so Claude knows how to manage Paperclip
|
||||
- Shows you which companies exist (if any)
|
||||
|
||||
### Step 2: Set your environment
|
||||
|
||||
The setup command prints one or two lines starting with `export`. Copy and paste them into your terminal:
|
||||
|
||||
```
|
||||
export PAPERCLIP_API_URL='http://localhost:3000'
|
||||
```
|
||||
|
||||
If you already have a company, it will also show:
|
||||
```
|
||||
export PAPERCLIP_COMPANY_ID='your-company-id-here'
|
||||
```
|
||||
|
||||
Paste these lines and press Enter. They tell Claude where your Paperclip server is.
|
||||
|
||||
### Step 3: Launch Claude Code
|
||||
|
||||
```
|
||||
claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
The `--dangerously-skip-permissions` flag lets Claude run commands without asking you to approve each one. This is safe because it's only talking to your local Paperclip server.
|
||||
|
||||
That's it. You're in. Start typing.
|
||||
|
||||
## Your first conversation
|
||||
|
||||
### Starting a new company
|
||||
|
||||
```
|
||||
You: I want to start a new company called Megacorp. Our mission is to
|
||||
build the best widget marketplace on the internet.
|
||||
```
|
||||
|
||||
Claude will create the company and guide you through setting up your first CEO agent.
|
||||
|
||||
### If you already have a company
|
||||
|
||||
```
|
||||
You: What's happening today?
|
||||
```
|
||||
|
||||
Claude will show you a dashboard: how many agents you have, open tasks, budget usage, and anything that needs your attention.
|
||||
|
||||
## Common things you can ask
|
||||
|
||||
### Company overview
|
||||
- "What's the status of my company?"
|
||||
- "Show me the dashboard"
|
||||
- "How much have we spent this month?"
|
||||
|
||||
### Hiring agents
|
||||
- "Help me build a hiring plan"
|
||||
- "I need a frontend engineer and a content writer"
|
||||
- "Show me the candidates' system prompts"
|
||||
- "Approve all hires"
|
||||
|
||||
### Managing tasks
|
||||
- "What tasks are open?"
|
||||
- "What's the CEO working on?"
|
||||
- "Create a task to build a landing page and assign it to the frontend engineer"
|
||||
|
||||
### Approvals
|
||||
- "Are there any pending approvals?"
|
||||
- "Approve the designer hire"
|
||||
- "Reject the icon library request — too expensive"
|
||||
|
||||
### Costs
|
||||
- "How are my costs today?"
|
||||
- "Show me a breakdown by agent"
|
||||
|
||||
### Agent management
|
||||
- "Show me all my agents"
|
||||
- "What's the frontend engineer's system prompt?"
|
||||
- "Change the designer's focus to include UX research"
|
||||
|
||||
## Tips
|
||||
|
||||
### Be natural
|
||||
You don't need to use special commands or syntax. Just talk like you're chatting with a colleague. Claude understands context.
|
||||
|
||||
### Iterate on plans
|
||||
When building a hiring plan or strategy, you can go back and forth:
|
||||
```
|
||||
You: Cut the SEO specialist. Add a designer instead.
|
||||
You: Actually, make the designer focus on UX research too.
|
||||
You: Looks good. Hire them all.
|
||||
```
|
||||
|
||||
### Check the web UI
|
||||
Everything Claude does through chat is also visible in the Paperclip web dashboard. Go to `http://localhost:3000` in your browser to see the spatial view of your company — org chart, task board, cost graphs.
|
||||
|
||||
### Session continuity
|
||||
When you close the terminal and come back later, Claude won't remember your previous conversation. But it will read the decision log and check the dashboard, so it knows the current state of your company.
|
||||
|
||||
Start a new session the same way:
|
||||
```
|
||||
export PAPERCLIP_API_URL='http://localhost:3000'
|
||||
export PAPERCLIP_COMPANY_ID='your-company-id'
|
||||
claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
Then just say "What's happening?" and pick up where you left off.
|
||||
|
||||
### Editing across surfaces
|
||||
You can edit things (like hiring plans or agent prompts) in three places:
|
||||
1. **In chat** — describe the change and Claude makes it
|
||||
2. **In a file** — Claude can create local `.md` files you can edit in any text editor
|
||||
3. **In the web UI** — edit directly in the dashboard, then tell Claude "sync up"
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "PAPERCLIP_API_URL is not set"
|
||||
You forgot to run the `export` command. Paste it again:
|
||||
```
|
||||
export PAPERCLIP_API_URL='http://localhost:3000'
|
||||
```
|
||||
|
||||
### Claude keeps asking for permission to run commands
|
||||
You launched Claude without the permissions flag. Exit with Ctrl+C and relaunch:
|
||||
```
|
||||
claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
### Nothing happens / commands fail
|
||||
The Paperclip server probably isn't running. Ask your engineer to start it with `pnpm dev`.
|
||||
|
||||
### Claude seems confused about my company
|
||||
Start fresh by telling Claude your company ID:
|
||||
```
|
||||
You: My company ID is abc123-def456. Show me the dashboard.
|
||||
```
|
||||
|
||||
## What's next
|
||||
|
||||
Once you're comfortable with the terminal experience, you can also try the **Board Chat** in the web UI — go to `http://localhost:3000` and click "Board Chat" in the sidebar. Same conversation, but inside the dashboard where you can see your agents and tasks alongside the chat.
|
||||
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/board-chat"
|
||||
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
|
||||
|
|
@ -26,6 +26,7 @@ import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
|||
import { llmRoutes } from "./routes/llms.js";
|
||||
import { assetRoutes } from "./routes/assets.js";
|
||||
import { accessRoutes } from "./routes/access.js";
|
||||
import { agentChatRoutes } from "./routes/agent-chat.js";
|
||||
import { pluginRoutes } from "./routes/plugins.js";
|
||||
import { pluginUiStaticRoutes } from "./routes/plugin-ui-static.js";
|
||||
import { applyUiBranding } from "./ui-branding.js";
|
||||
|
|
@ -137,6 +138,7 @@ export async function createApp(
|
|||
);
|
||||
api.use("/companies", companyRoutes(db));
|
||||
api.use(agentRoutes(db));
|
||||
api.use(agentChatRoutes(db));
|
||||
api.use(assetRoutes(db, opts.storageService));
|
||||
api.use(projectRoutes(db));
|
||||
api.use(issueRoutes(db, opts.storageService));
|
||||
|
|
|
|||
861
server/src/routes/agent-chat.ts
Normal file
861
server/src/routes/agent-chat.ts
Normal file
|
|
@ -0,0 +1,861 @@
|
|||
import { Router } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Db } 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";
|
||||
|
||||
/**
|
||||
* Parse structured action signals from CEO response.
|
||||
* CEO is prompted to include %%ACTIONS%%{...}%%/ACTIONS%% at the end of each response.
|
||||
* Falls back to regex pattern matching if no structured signal found.
|
||||
*/
|
||||
function parseStructuredActions(response: string): {
|
||||
artifacts: Array<{ title: string; type?: string }>;
|
||||
tasks: Array<{ title: string; assignTo?: string }>;
|
||||
} | null {
|
||||
const match = response.match(/%%ACTIONS%%([\s\S]*?)%%\/ACTIONS%%/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[1].trim());
|
||||
return {
|
||||
artifacts: Array.isArray(parsed.artifacts) ? parsed.artifacts : [],
|
||||
tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: detect artifact commitments via regex pattern matching.
|
||||
* Used when CEO doesn't output structured signal.
|
||||
*/
|
||||
function detectArtifactCommitments(response: string): Array<{ title: string; status: string }> {
|
||||
const artifacts: Array<{ title: string; status: string }> = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip structured action signals from response text before persisting.
|
||||
*/
|
||||
function stripActionSignals(response: string): string {
|
||||
return response.replace(/%%ACTIONS%%[\s\S]*?%%\/ACTIONS%%/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat relay endpoint — calls the adapter directly and streams the response
|
||||
* back via SSE. Bypasses the heartbeat queue for real-time conversation.
|
||||
*
|
||||
* Comments are persisted normally so the conversation is durable.
|
||||
*/
|
||||
export function agentChatRoutes(db: Db) {
|
||||
const router = Router();
|
||||
|
||||
router.post("/agents/:id/chat/relay", 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 agentSvc = agentService(db);
|
||||
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 the user's message as a comment
|
||||
const issueSvc = issueService(db);
|
||||
await issueSvc.addComment(taskId, message, {
|
||||
userId: (req as any).actor?.userId ?? null,
|
||||
});
|
||||
|
||||
// Set up SSE streaming response
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders();
|
||||
|
||||
// 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);
|
||||
const secretsSvc = secretService(db);
|
||||
const { config: resolvedConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
agent.companyId,
|
||||
config,
|
||||
);
|
||||
|
||||
// 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,
|
||||
agent: agent as any, // DB row matches adapter expectation
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: resolvedConfig,
|
||||
context: {
|
||||
chatMessage: message,
|
||||
taskId,
|
||||
issueId: taskId,
|
||||
source: "chat_relay",
|
||||
wakeReason: "chat_relay",
|
||||
},
|
||||
onLog: async (stream, chunk) => {
|
||||
if (stream === "stdout" && res.writable) {
|
||||
fullResponse += chunk;
|
||||
res.write(`data: ${JSON.stringify({ type: "chunk", text: chunk })}\n\n`);
|
||||
}
|
||||
},
|
||||
onMeta: async () => {
|
||||
// Silently consume metadata
|
||||
},
|
||||
});
|
||||
|
||||
// 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(), {
|
||||
agentId: agent.id,
|
||||
});
|
||||
}
|
||||
|
||||
// Send completion event
|
||||
const duration = Date.now() - startTime;
|
||||
if (res.writable) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: "done",
|
||||
model: result.model ?? null,
|
||||
provider: result.provider ?? null,
|
||||
costUsd: result.costUsd ?? null,
|
||||
duration,
|
||||
exitCode: result.exitCode,
|
||||
})}\n\n`,
|
||||
);
|
||||
}
|
||||
} 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";
|
||||
res.write(`data: ${JSON.stringify({ type: "error", message })}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
if (res.writable) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
// Update task to in_review and reassign to board
|
||||
try {
|
||||
await issueSvc.update(taskId, {
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
// assigneeUserId will be set by the frontend or left for inbox to pick up
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
|
||||
// Post CEO notification in chat
|
||||
try {
|
||||
await issueSvc.addComment(taskId,
|
||||
`The **${artifactTitle}** is ready for your review. Take a look in the Artifacts panel when you're ready.`,
|
||||
{ agentId: agent.id },
|
||||
);
|
||||
} catch { /* best effort */ }
|
||||
} 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. Keep responses to 1-3 sentences for action acknowledgments.
|
||||
- Be biased for action. When the board asks you to create something, confirm immediately in ONE sentence. Do NOT write the full document in chat. The system handles document creation separately.
|
||||
- When discussing strategy or giving advice, be helpful but brief. Ask clarifying questions if needed, but don't over-discuss — drive toward creating a task.
|
||||
- Never reference tools, files, code, or technical systems. You are a CEO, not an engineer.
|
||||
- When creating plans that involve hiring, default to AI agents unless the board explicitly specifies human roles.
|
||||
|
||||
STRUCTURED SIGNAL (REQUIRED):
|
||||
At the END of every response, on its own line, output an action signal:
|
||||
%%ACTIONS%%{"tasks":[],"artifacts":[]}%%/ACTIONS%%
|
||||
|
||||
If you are committing to create something, populate the arrays:
|
||||
- artifacts: [{"title":"Hiring Plan","type":"document"}]
|
||||
- tasks: [{"title":"Build landing page","assignTo":"engineer"}]
|
||||
|
||||
If nothing to create, output empty arrays. ALWAYS include this signal line.`;
|
||||
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);
|
||||
|
||||
// Parse structured actions before stripping
|
||||
const structuredActions = parseStructuredActions(fullResponse);
|
||||
|
||||
// Strip action signals before persisting
|
||||
const cleanedResponse = stripActionSignals(fullResponse);
|
||||
|
||||
// Save cleaned response as agent comment
|
||||
if (cleanedResponse) {
|
||||
try {
|
||||
await issueSvc.addComment(taskId, cleanedResponse, {
|
||||
agentId: agent.id,
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
// Send observer event with detected actions (Layer 2)
|
||||
if (structuredActions && (structuredActions.artifacts.length > 0 || structuredActions.tasks.length > 0)) {
|
||||
if (res.writable) {
|
||||
res.write(`data: ${JSON.stringify({
|
||||
type: "observer",
|
||||
actions: {
|
||||
artifacts: structuredActions.artifacts.map((a) => ({ title: a.title, status: "in_progress" })),
|
||||
tasks: structuredActions.tasks,
|
||||
},
|
||||
})}\n\n`);
|
||||
}
|
||||
} else {
|
||||
// Fallback: regex-based detection
|
||||
const artifacts = detectArtifactCommitments(fullResponse);
|
||||
if (artifacts.length > 0 && res.writable) {
|
||||
res.write(`data: ${JSON.stringify({ type: "observer", actions: { artifacts, tasks: [] } })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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`,
|
||||
);
|
||||
}
|
||||
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();
|
||||
});
|
||||
|
||||
// ── Board Concierge Chat ──────────────────────────────────────────────
|
||||
// Same streaming pattern as /agents/:id/chat/stream but uses the
|
||||
// board-member skill as the system prompt instead of the CEO agent's
|
||||
// prompt. Allows the board to manage their company from the web UI chat.
|
||||
|
||||
let _boardSkillCache: string | null = null;
|
||||
|
||||
function loadBoardSkill(): string {
|
||||
if (_boardSkillCache) return _boardSkillCache;
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const skillPath = path.resolve(__dirname, "../../../skills/paperclip-board/SKILL.md");
|
||||
try {
|
||||
let content = fs.readFileSync(skillPath, "utf-8");
|
||||
// Strip YAML frontmatter
|
||||
content = content.replace(/^---[\s\S]*?---\s*\n/, "");
|
||||
_boardSkillCache = content;
|
||||
return content;
|
||||
} catch {
|
||||
return "You are a board-level assistant helping a human manage their AI-agent company through Paperclip. Help them create companies, hire agents, approve tasks, and monitor their organization.";
|
||||
}
|
||||
}
|
||||
|
||||
router.post("/board/chat/stream", async (req, res) => {
|
||||
const { companyId, message, taskId } = req.body as {
|
||||
companyId: string;
|
||||
message: string;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
if (!companyId || !message) {
|
||||
res.status(400).json({ error: "companyId and message are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const issueSvc = issueService(db);
|
||||
let issueId = taskId;
|
||||
|
||||
// Find or create the Board Operations issue
|
||||
if (!issueId) {
|
||||
const companyIssues = await issueSvc.list(companyId, {
|
||||
q: "Board Operations",
|
||||
});
|
||||
const boardIssue = companyIssues.find(
|
||||
(i: any) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
|
||||
);
|
||||
if (boardIssue) {
|
||||
issueId = boardIssue.id;
|
||||
} else {
|
||||
const created = await issueSvc.create(companyId, {
|
||||
title: "Board Operations",
|
||||
description: "Standing issue for board concierge conversations and decision log",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
issueId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedIssueId = issueId!;
|
||||
|
||||
// Save user message as comment
|
||||
await issueSvc.addComment(resolvedIssueId, message, {
|
||||
userId: (req as any).actor?.userId ?? "local-board",
|
||||
});
|
||||
|
||||
// Build conversation history from recent comments
|
||||
const comments = await issueSvc.listComments(resolvedIssueId);
|
||||
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 ? "ASSISTANT" : "USER";
|
||||
return `${role}: ${c.body}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
|
||||
// Load board skill as system prompt
|
||||
const systemPrompt = loadBoardSkill();
|
||||
|
||||
// Compose 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", issueId: resolvedIssueId })}\n\n`);
|
||||
|
||||
// Spawn claude CLI with board skill
|
||||
const args = [
|
||||
"-p",
|
||||
"-",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--append-system-prompt",
|
||||
systemPrompt,
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--dangerously-skip-permissions",
|
||||
];
|
||||
|
||||
// Determine the API URL for the spawned process
|
||||
const serverAddr = (req as any).socket?.localAddress ?? "127.0.0.1";
|
||||
const serverPort = (req as any).socket?.localPort ?? 3000;
|
||||
const apiUrl = `http://${serverAddr === "::" || serverAddr === "::1" ? "127.0.0.1" : serverAddr}:${serverPort}`;
|
||||
|
||||
const proc = spawn("claude", args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
cwd: "/tmp",
|
||||
env: {
|
||||
...process.env,
|
||||
PAPERCLIP_API_URL: apiUrl,
|
||||
PAPERCLIP_COMPANY_ID: companyId,
|
||||
},
|
||||
});
|
||||
|
||||
let fullResponse = "";
|
||||
const startTime = Date.now();
|
||||
let killed = false;
|
||||
|
||||
// 120s timeout (board conversations can involve multiple API calls)
|
||||
const timeout = setTimeout(() => {
|
||||
killed = true;
|
||||
proc.kill("SIGTERM");
|
||||
}, 120000);
|
||||
|
||||
// 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);
|
||||
// Helper: send a status event for tool use
|
||||
const sendToolStatus = (toolName: string) => {
|
||||
if (!res.writable) return;
|
||||
let status = "Working...";
|
||||
const name = toolName.toLowerCase();
|
||||
if (name.includes("bash")) status = "Running a command...";
|
||||
else if (name.includes("read")) status = "Reading a file...";
|
||||
else if (name.includes("grep") || name.includes("search")) status = "Searching...";
|
||||
else if (name.includes("glob")) status = "Finding files...";
|
||||
else if (name.includes("write") || name.includes("edit")) status = "Editing a file...";
|
||||
else if (name.includes("agent")) status = "Delegating work...";
|
||||
else status = `Using ${toolName}...`;
|
||||
res.write(`data: ${JSON.stringify({ type: "status", text: status })}\n\n`);
|
||||
};
|
||||
|
||||
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 (block.type === "tool_use" && block.name) {
|
||||
sendToolStatus(block.name);
|
||||
}
|
||||
}
|
||||
} 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 === "content_block_start" && event.content_block?.type === "tool_use") {
|
||||
sendToolStatus(event.content_block.name ?? "tool");
|
||||
} else if (event.subtype === "tool_use" || (event.type === "tool_use" && event.name)) {
|
||||
sendToolStatus(event.name ?? "tool");
|
||||
} else if (event.type === "result" && event.result && !fullResponse) {
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data: Buffer) => {
|
||||
console.error("[board/chat/stream stderr]", data.toString());
|
||||
});
|
||||
|
||||
proc.on("close", async (exitCode) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Save response as a comment (strip any action signals)
|
||||
const cleanedResponse = stripActionSignals(fullResponse).trim();
|
||||
if (cleanedResponse) {
|
||||
try {
|
||||
// Save as a system/board comment (no agentId)
|
||||
await issueSvc.addComment(resolvedIssueId, cleanedResponse, {
|
||||
userId: "board-concierge",
|
||||
});
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
if (res.writable) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
type: "done",
|
||||
issueId: resolvedIssueId,
|
||||
duration,
|
||||
exitCode: exitCode ?? 0,
|
||||
timedOut: killed,
|
||||
})}\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;
|
||||
}
|
||||
623
skills/paperclip-board/SKILL.md
Normal file
623
skills/paperclip-board/SKILL.md
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
---
|
||||
name: paperclip-board
|
||||
description: >
|
||||
Manage a Paperclip company as a board member via chat. Covers onboarding
|
||||
(company creation, CEO setup, hiring plans), agent management, approvals,
|
||||
task monitoring, cost oversight, and work product review. Use this skill
|
||||
whenever the user wants to interact with their Paperclip control plane.
|
||||
---
|
||||
|
||||
# Paperclip Board Skill
|
||||
|
||||
You are a board-level assistant helping a human manage their AI-agent company through Paperclip. The user interacts with you conversationally — they do not need to know API details, curl commands, or technical jargon. Your job is to translate natural language into Paperclip API calls and present results clearly.
|
||||
|
||||
## Authentication & Environment
|
||||
|
||||
**Environment variables** (set by `paperclipai board setup`):
|
||||
- `PAPERCLIP_API_URL` — base URL of the Paperclip server (e.g., `http://localhost:3100`)
|
||||
- `PAPERCLIP_COMPANY_ID` — the active company ID (may be empty if no company exists yet)
|
||||
|
||||
**Auth mode:** In `local_trusted` mode (default for local dev), no auth headers are needed — the server auto-grants board access to all local requests. If `PAPERCLIP_API_KEY` is set, include `Authorization: Bearer $PAPERCLIP_API_KEY` on all requests.
|
||||
|
||||
**Making API calls:** Use `curl -sS` via bash. All endpoints are under `/api`. All request/response bodies are JSON. Always use `Content-Type: application/json` on POST/PATCH/PUT requests.
|
||||
|
||||
**Critical rules:**
|
||||
- Always re-read a document or config from the API before modifying it (write-path freshness)
|
||||
- Never hard-code the API URL — always use `$PAPERCLIP_API_URL`
|
||||
- Always include web UI links in responses: `$PAPERCLIP_API_URL/{companyPrefix}/...`
|
||||
- Present results conversationally — summarize, don't dump JSON
|
||||
|
||||
## Session Startup
|
||||
|
||||
Every time you begin a new conversation with the user:
|
||||
|
||||
1. Check if `PAPERCLIP_API_URL` is set. If not, tell the user to run `pnpm paperclipai board setup`.
|
||||
2. Check if `PAPERCLIP_COMPANY_ID` is set.
|
||||
- If set: fetch the dashboard to understand current state.
|
||||
- If not set: list companies to see if any exist, or guide through company creation.
|
||||
3. Check if a decision log exists: `GET $PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?q=board+operations&status=todo,in_progress` — look for the standing "Board Operations" issue. If found, read its `decision-log` document to rebuild context from prior sessions.
|
||||
4. Greet the user with a brief status summary.
|
||||
|
||||
```bash
|
||||
# Fetch dashboard
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/dashboard"
|
||||
```
|
||||
|
||||
Present the dashboard as:
|
||||
```
|
||||
{Company Name} Dashboard
|
||||
────────────────────────
|
||||
Agents: {active} active, {paused} paused
|
||||
Tasks: {open} open ({inProgress} in progress, {blocked} blocked)
|
||||
Budget: ${monthSpendCents/100} / ${monthBudgetCents/100} this month ({utilization}%)
|
||||
Pending approvals: {pendingApprovals}
|
||||
|
||||
{If pendingApprovals > 0: list them briefly}
|
||||
{If blocked > 0: mention blocked tasks}
|
||||
```
|
||||
|
||||
## Onboarding Flow
|
||||
|
||||
Guide the user through these steps when they're setting up for the first time.
|
||||
|
||||
### Step 1: Create or Select a Company
|
||||
|
||||
```bash
|
||||
# List existing companies
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies"
|
||||
|
||||
# Create a new company
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Company Name",
|
||||
"description": "Company mission / description",
|
||||
"budgetMonthlyCents": 50000
|
||||
}'
|
||||
```
|
||||
|
||||
Ask the user for:
|
||||
- Company name
|
||||
- Mission / description (store in `description` field)
|
||||
- Monthly budget (suggest a reasonable default like $500 = 50000 cents)
|
||||
|
||||
The response includes the company `id` and auto-generated `issuePrefix`. Tell the user both.
|
||||
|
||||
After creating, set `PAPERCLIP_COMPANY_ID` for subsequent calls. Also set `requireBoardApprovalForNewAgents: true` so all hires go through governance:
|
||||
|
||||
```bash
|
||||
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/companies/{companyId}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"requireBoardApprovalForNewAgents": true}'
|
||||
```
|
||||
|
||||
### Step 2: Create the CEO Agent
|
||||
|
||||
The CEO is the first agent. Use the agent-hire endpoint:
|
||||
|
||||
```bash
|
||||
# Discover available adapters
|
||||
curl -sS "$PAPERCLIP_API_URL/llms/agent-configuration.txt"
|
||||
|
||||
# Read adapter-specific docs (e.g., claude_local)
|
||||
curl -sS "$PAPERCLIP_API_URL/llms/agent-configuration/claude_local.txt"
|
||||
|
||||
# Discover available icons
|
||||
curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt"
|
||||
|
||||
# Submit hire request
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "CEO Name",
|
||||
"role": "ceo",
|
||||
"title": "Chief Executive Officer",
|
||||
"icon": "crown",
|
||||
"capabilities": "Strategic planning, team management, task delegation",
|
||||
"adapterType": "claude_local",
|
||||
"adapterConfig": {
|
||||
"cwd": "/path/to/working/directory",
|
||||
"model": "sonnet"
|
||||
},
|
||||
"runtimeConfig": {
|
||||
"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}
|
||||
},
|
||||
"permissions": {"canCreateAgents": true},
|
||||
"budgetMonthlyCents": 10000
|
||||
}'
|
||||
```
|
||||
|
||||
Guide the user through:
|
||||
- CEO name and icon (show available icons)
|
||||
- Working directory (where the CEO will operate)
|
||||
- Adapter type (default: `claude_local`)
|
||||
- Budget
|
||||
|
||||
Generate the CEO's system prompt using the Agent System Prompt Template (Section D below).
|
||||
|
||||
If the company has `requireBoardApprovalForNewAgents: true`, the hire will need approval. Check if an approval was created and auto-approve it for the CEO (since the user just asked to create it):
|
||||
|
||||
```bash
|
||||
# Check pending approvals
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/approvals?status=pending"
|
||||
|
||||
# Approve the CEO hire
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{approvalId}/approve" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decisionNote": "CEO hire approved by board during onboarding"}'
|
||||
```
|
||||
|
||||
### Step 3: Create the Board Operations Issue
|
||||
|
||||
Create a standing issue for decision logging and board operations:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Board Operations",
|
||||
"description": "Standing issue for board decision log and operations tracking",
|
||||
"status": "in_progress",
|
||||
"priority": "medium"
|
||||
}'
|
||||
```
|
||||
|
||||
Then create the decision log document:
|
||||
|
||||
```bash
|
||||
curl -sS -X PUT "$PAPERCLIP_API_URL/api/issues/{boardIssueId}/documents/decision-log" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Decision Log",
|
||||
"format": "markdown",
|
||||
"body": "# Decision Log — {Company Name}\n\n## {today date}\n- Created company {name} with mission: {description}\n- Hired CEO agent \"{ceo name}\"\n"
|
||||
}'
|
||||
```
|
||||
|
||||
Also write this to a local file at `./artifacts/decision-log.md` so the user can view it directly.
|
||||
|
||||
### Step 4: Launch the Company
|
||||
|
||||
Start the CEO's first heartbeat:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/agents/{ceoId}/heartbeat/invoke" \
|
||||
-H "Content-Type: application/json"
|
||||
```
|
||||
|
||||
## Hiring Plan Loop
|
||||
|
||||
When the user wants to build a hiring plan:
|
||||
|
||||
1. **Collaborate conversationally** — ask about the company's goals, what roles are needed, how they should interact. Use your judgment to suggest roles.
|
||||
|
||||
2. **Store as a document artifact** — create an issue for the hiring plan, then attach the plan as a document:
|
||||
|
||||
```bash
|
||||
# Create the hiring plan issue
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Hiring Plan",
|
||||
"description": "Develop and execute the team hiring plan",
|
||||
"status": "in_progress",
|
||||
"priority": "high"
|
||||
}'
|
||||
|
||||
# Attach the plan document
|
||||
curl -sS -X PUT "$PAPERCLIP_API_URL/api/issues/{issueId}/documents/hiring-plan" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Hiring Plan",
|
||||
"format": "markdown",
|
||||
"body": "# Hiring Plan\n\n## Roles\n\n### 1. Role Name\n- Focus: ...\n- Reports to: ...\n- Budget: ...\n"
|
||||
}'
|
||||
```
|
||||
|
||||
3. **Also write a local file** at `./artifacts/hiring-plan.md` so the user can open and edit it directly.
|
||||
|
||||
4. **Iterate** — when the user suggests changes:
|
||||
- In chat: update both the API document and local file
|
||||
- If user says they edited the file: re-read `./artifacts/hiring-plan.md` and sync to API
|
||||
- If user says they edited in web UI: re-fetch from API with `GET /api/issues/{id}/documents/hiring-plan`
|
||||
|
||||
5. **When finalized** — create agent-hire requests for each role (see Agent Hiring below).
|
||||
|
||||
## Agent System Prompt Template
|
||||
|
||||
Every new agent's system prompt MUST include these sections by default (unless the board explicitly overrides):
|
||||
|
||||
```markdown
|
||||
# {Agent Name}
|
||||
|
||||
## Model
|
||||
{Which model this agent uses. Default: sonnet}
|
||||
|
||||
## Description
|
||||
{One-line role summary}
|
||||
|
||||
## Expertise
|
||||
{Core expertise — what this agent knows, how it thinks, what it does}
|
||||
|
||||
## Priorities
|
||||
{Ordered list of what matters most for this agent's work}
|
||||
|
||||
## Boundaries
|
||||
{What this agent should NOT do, scope limits, guardrails}
|
||||
|
||||
## Tool Permissions
|
||||
{Which tools/APIs this agent can use, and any exclusions}
|
||||
|
||||
## Communication Guidelines
|
||||
{How this agent reports status, asks for help, formats output}
|
||||
|
||||
## Collaboration & Escalation
|
||||
{Which agents this one works with, when to escalate, to whom}
|
||||
```
|
||||
|
||||
Present each agent's draft system prompt to the user for review before submitting the hire.
|
||||
|
||||
## Agent Hiring
|
||||
|
||||
For each agent to hire:
|
||||
|
||||
```bash
|
||||
# Compare existing agent configurations
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-configurations"
|
||||
|
||||
# Submit hire request
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-hires" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Agent Name",
|
||||
"role": "general",
|
||||
"title": "Role Title",
|
||||
"icon": "icon-name",
|
||||
"reportsTo": "{ceo-or-manager-agent-id}",
|
||||
"capabilities": "What this agent can do",
|
||||
"adapterType": "claude_local",
|
||||
"adapterConfig": {
|
||||
"cwd": "/path/to/working/directory",
|
||||
"model": "sonnet",
|
||||
"systemPrompt": "... the full system prompt from the template ..."
|
||||
},
|
||||
"runtimeConfig": {
|
||||
"heartbeat": {"enabled": true, "intervalSec": 300, "wakeOnDemand": true}
|
||||
},
|
||||
"budgetMonthlyCents": 5000
|
||||
}'
|
||||
```
|
||||
|
||||
### Cross-Agent Escalation Path Updates
|
||||
|
||||
When a new agent is hired, update existing agents' Collaboration & Escalation sections:
|
||||
|
||||
1. **Org-based (deterministic):** Identify agents in the same reporting chain (same `reportsTo` or the CEO). These always need to know about the new hire.
|
||||
|
||||
2. **Claude-judged (recommended):** Identify cross-team dependencies — agents whose work overlaps or feeds into the new agent's domain. Include your reasoning.
|
||||
|
||||
3. **Present all proposed changes for board approval** — distinguish the two categories:
|
||||
|
||||
```
|
||||
Hiring @designer — proposed escalation path updates:
|
||||
|
||||
Org-based (same reporting chain):
|
||||
@ceo — add: "@designer handles brand assets, visual design, UX research.
|
||||
Route design reviews through @designer."
|
||||
@frontend-engineer — add: "Escalate visual design decisions to @designer.
|
||||
Request mockups before building new UI components."
|
||||
|
||||
Additionally recommended:
|
||||
@content-strategist — add: "Request visual assets (headers, social images)
|
||||
from @designer. Coordinate brand voice with design."
|
||||
Reason: Content pipeline will need visual assets for blog posts and social.
|
||||
|
||||
Approve these updates? (approve all / review individually / edit)
|
||||
```
|
||||
|
||||
4. Only after board approval, update each affected agent:
|
||||
|
||||
```bash
|
||||
# Fetch current config first (write-path freshness)
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/{agentId}"
|
||||
|
||||
# Update the agent's config with new escalation paths
|
||||
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/agents/{agentId}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"adapterConfig": { ... updated config with new Collaboration section ... }
|
||||
}'
|
||||
```
|
||||
|
||||
5. Log the changes and reasoning in the decision log.
|
||||
|
||||
## Approvals
|
||||
|
||||
```bash
|
||||
# List pending approvals
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/approvals?status=pending"
|
||||
|
||||
# Approve
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{id}/approve" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decisionNote": "Approved by board"}'
|
||||
|
||||
# Reject
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{id}/reject" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decisionNote": "Reason for rejection"}'
|
||||
|
||||
# Request revision
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/approvals/{id}/request-revision" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"decisionNote": "Please adjust X, Y, Z"}'
|
||||
```
|
||||
|
||||
Present approvals as:
|
||||
```
|
||||
Pending Approvals
|
||||
─────────────────
|
||||
1. [hire] Designer — submitted by @ceo
|
||||
View: {baseUrl}/{prefix}/approvals/{id}
|
||||
→ approve / reject / request revision
|
||||
|
||||
2. [tool] Icon library ($12/mo) — requested by @designer
|
||||
→ approve / reject
|
||||
```
|
||||
|
||||
For batch approval: list all pending, let the user approve all or review individually.
|
||||
|
||||
## Task Management
|
||||
|
||||
```bash
|
||||
# List open tasks
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?status=todo,in_progress,blocked"
|
||||
|
||||
# Get task detail
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}"
|
||||
|
||||
# Get task comments
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/comments"
|
||||
|
||||
# Create a task
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Task title",
|
||||
"description": "What needs to be done",
|
||||
"status": "todo",
|
||||
"priority": "medium",
|
||||
"assigneeAgentId": "{agent-id}",
|
||||
"projectId": "{project-id}",
|
||||
"parentId": "{parent-issue-id}"
|
||||
}'
|
||||
|
||||
# Update a task
|
||||
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/issues/{issueId}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "done", "comment": "Completed"}'
|
||||
|
||||
# Add a comment
|
||||
curl -sS -X POST "$PAPERCLIP_API_URL/api/issues/{issueId}/comments" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"body": "Comment text in markdown"}'
|
||||
|
||||
# Search issues
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/issues?q=search+term"
|
||||
```
|
||||
|
||||
Present tasks as:
|
||||
```
|
||||
{PREFIX}-{number}: {title} [{status}] → @{assignee}
|
||||
Priority: {priority}
|
||||
Latest: "{last comment snippet...}"
|
||||
View: {baseUrl}/{prefix}/issues/{identifier}
|
||||
```
|
||||
|
||||
## Agent Monitoring
|
||||
|
||||
```bash
|
||||
# List all agents
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agents"
|
||||
|
||||
# Get agent detail
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}"
|
||||
|
||||
# Get agent config revisions (change history)
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}/config-revisions"
|
||||
```
|
||||
|
||||
Present agents as:
|
||||
```
|
||||
Team Overview
|
||||
─────────────
|
||||
@ceo (Atlas) — active, last heartbeat 5m ago
|
||||
Budget: $45 / $100 (45%)
|
||||
Working on: PAP-12 Homepage redesign
|
||||
|
||||
@frontend-engineer — active, last heartbeat 2m ago
|
||||
Budget: $30 / $50 (60%)
|
||||
Working on: PAP-15 Blog template
|
||||
```
|
||||
|
||||
## Cost Monitoring
|
||||
|
||||
```bash
|
||||
# Overall summary
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/summary"
|
||||
|
||||
# Breakdown by agent
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/by-agent"
|
||||
|
||||
# Breakdown by project
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/by-project"
|
||||
|
||||
# Optional date range
|
||||
curl -sS "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/costs/summary?from=2026-03-01&to=2026-03-31"
|
||||
```
|
||||
|
||||
Present costs as:
|
||||
```
|
||||
Costs This Month
|
||||
────────────────
|
||||
Total: $145.23 / $500.00 (29%)
|
||||
|
||||
By Agent:
|
||||
@ceo $45.12 (31%)
|
||||
@frontend-eng $62.30 (43%)
|
||||
@content-strat $37.81 (26%)
|
||||
```
|
||||
|
||||
## Work Products
|
||||
|
||||
```bash
|
||||
# List work products for an issue
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/work-products"
|
||||
|
||||
# View a document
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/documents/{key}"
|
||||
|
||||
# View document revisions
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/{issueId}/documents/{key}/revisions"
|
||||
```
|
||||
|
||||
Present work products with status and links:
|
||||
```
|
||||
Work Products — PAP-12
|
||||
──────────────────────
|
||||
1. Homepage mockup [ready_for_review] — artifact
|
||||
View: {baseUrl}/{prefix}/issues/PAP-12#document-mockup
|
||||
|
||||
2. Feature branch [active] — branch
|
||||
URL: https://github.com/...
|
||||
```
|
||||
|
||||
## Editing Agent System Prompts
|
||||
|
||||
Three ways the user can edit system prompts:
|
||||
|
||||
**In chat:** User describes changes, you update via API:
|
||||
```bash
|
||||
# Always re-fetch before modifying
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}"
|
||||
|
||||
# Then update
|
||||
curl -sS -X PATCH "$PAPERCLIP_API_URL/api/agents/{id}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"adapterConfig": { ... updated config ... }}'
|
||||
```
|
||||
|
||||
**Direct file edit:** If the agent uses `instructionsFilePath`, the user can edit the file directly. When they tell you they're done, re-read the file and confirm changes.
|
||||
|
||||
**Web UI edit:** User edits at `{baseUrl}/{prefix}/agents/{agentUrlKey}`. When they say "sync up," re-fetch from the API.
|
||||
|
||||
**Viewing change history:**
|
||||
```bash
|
||||
curl -sS "$PAPERCLIP_API_URL/api/agents/{id}/config-revisions"
|
||||
```
|
||||
|
||||
Present as a changelog:
|
||||
```
|
||||
Config History — @designer
|
||||
──────────────────────────
|
||||
Rev 3 (2026-03-21 14:30) — changed: systemPrompt
|
||||
Added UX research to expertise section
|
||||
|
||||
Rev 2 (2026-03-21 10:15) — changed: budgetMonthlyCents
|
||||
Budget increased from $50 to $100
|
||||
|
||||
Rev 1 (2026-03-20 16:00) — initial configuration
|
||||
```
|
||||
|
||||
## Decision Log
|
||||
|
||||
Maintain a decision log for session continuity. Log major decisions — not every interaction.
|
||||
|
||||
**What to log:**
|
||||
- Company creation and configuration changes
|
||||
- Agents hired, modified, or removed
|
||||
- Budget changes
|
||||
- Strategic decisions (what was prioritized, what was cut and why)
|
||||
- Approvals granted or rejected with reasoning
|
||||
|
||||
**When to log:**
|
||||
- After completing a significant action (hiring, approving, budget change)
|
||||
- At the end of a session if notable decisions were made
|
||||
|
||||
**How to log:**
|
||||
1. Update the API document:
|
||||
```bash
|
||||
# Fetch current log
|
||||
curl -sS "$PAPERCLIP_API_URL/api/issues/{boardIssueId}/documents/decision-log"
|
||||
|
||||
# Update with new entries appended
|
||||
curl -sS -X PUT "$PAPERCLIP_API_URL/api/issues/{boardIssueId}/documents/decision-log" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Decision Log",
|
||||
"format": "markdown",
|
||||
"body": "... existing content ... \n\n## {date}\n- New decision\n",
|
||||
"baseRevisionId": "{current revision id}"
|
||||
}'
|
||||
```
|
||||
2. Also update the local file at `./artifacts/decision-log.md`.
|
||||
|
||||
## Presentation Rules
|
||||
|
||||
- Use markdown tables for lists (agents, tasks, costs)
|
||||
- Use bold for status values: **in_progress**, **blocked**, **completed**
|
||||
- Always include web UI links: `View: {PAPERCLIP_API_URL}/{prefix}/issues/{identifier}`
|
||||
- For org charts: generate mermaid diagrams or ASCII art
|
||||
- Smart summaries: surface what needs attention first, then the rest
|
||||
- Task format: `PAP-123: Build landing page [in_progress] → @engineer`
|
||||
- Keep responses concise — the user can ask to drill deeper
|
||||
- When presenting multiple items for action (approvals, hires), number them for easy reference
|
||||
- Derive the company's URL prefix from any issue identifier (e.g., `PAP-315` → prefix is `PAP`)
|
||||
|
||||
## Link Format
|
||||
|
||||
All web UI links must include the company prefix:
|
||||
- Issues: `/{prefix}/issues/{identifier}` (e.g., `/PAP/issues/PAP-12`)
|
||||
- Agents: `/{prefix}/agents/{agent-url-key}`
|
||||
- Approvals: `/{prefix}/approvals/{approval-id}`
|
||||
- Projects: `/{prefix}/projects/{project-url-key}`
|
||||
- Documents: `/{prefix}/issues/{identifier}#document-{key}`
|
||||
|
||||
## Key Endpoints Reference
|
||||
|
||||
| Action | Method | Endpoint |
|
||||
|--------|--------|----------|
|
||||
| List companies | GET | `/api/companies` |
|
||||
| Create company | POST | `/api/companies` |
|
||||
| Update company | PATCH | `/api/companies/:id` |
|
||||
| Get company | GET | `/api/companies/:id` |
|
||||
| Dashboard | GET | `/api/companies/:companyId/dashboard` |
|
||||
| List agents | GET | `/api/companies/:companyId/agents` |
|
||||
| Get agent | GET | `/api/agents/:id` |
|
||||
| Update agent | PATCH | `/api/agents/:id` |
|
||||
| Agent configs | GET | `/api/companies/:companyId/agent-configurations` |
|
||||
| Config revisions | GET | `/api/agents/:id/config-revisions` |
|
||||
| Hire agent | POST | `/api/companies/:companyId/agent-hires` |
|
||||
| Invoke heartbeat | POST | `/api/agents/:id/heartbeat/invoke` |
|
||||
| List issues | GET | `/api/companies/:companyId/issues` |
|
||||
| Create issue | POST | `/api/companies/:companyId/issues` |
|
||||
| Get issue | GET | `/api/issues/:id` |
|
||||
| Update issue | PATCH | `/api/issues/:id` |
|
||||
| Issue comments | GET | `/api/issues/:id/comments` |
|
||||
| Add comment | POST | `/api/issues/:id/comments` |
|
||||
| Issue documents | GET | `/api/issues/:id/documents` |
|
||||
| Get document | GET | `/api/issues/:id/documents/:key` |
|
||||
| Create/update doc | PUT | `/api/issues/:id/documents/:key` |
|
||||
| Work products | GET | `/api/issues/:id/work-products` |
|
||||
| List approvals | GET | `/api/companies/:companyId/approvals` |
|
||||
| Approve | POST | `/api/approvals/:id/approve` |
|
||||
| Reject | POST | `/api/approvals/:id/reject` |
|
||||
| Request revision | POST | `/api/approvals/:id/request-revision` |
|
||||
| Cost summary | GET | `/api/companies/:companyId/costs/summary` |
|
||||
| Costs by agent | GET | `/api/companies/:companyId/costs/by-agent` |
|
||||
| Costs by project | GET | `/api/companies/:companyId/costs/by-project` |
|
||||
| Adapter docs | GET | `/llms/agent-configuration.txt` |
|
||||
| Adapter detail | GET | `/llms/agent-configuration/:adapterType.txt` |
|
||||
| Agent icons | GET | `/llms/agent-icons.txt` |
|
||||
| Set instructions | PATCH | `/api/agents/:id/instructions-path` |
|
||||
| Search issues | GET | `/api/companies/:companyId/issues?q=term` |
|
||||
17
ui/public/paperclip-thinking.svg
Normal file
17
ui/public/paperclip-thinking.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-1.00 -1.00 26.00 26.00"
|
||||
style="transform:rotate(0deg);transform-origin:50% 50%;">
|
||||
<defs></defs>
|
||||
<style>
|
||||
@keyframes draw {
|
||||
0% { stroke-dasharray:0.000 85.717; stroke-dashoffset:-85.717; opacity:1; animation-timing-function:cubic-bezier(0.455, 0.03, 0.515, 0.955); }
|
||||
39.0625% { stroke-dasharray:85.717 85.717; stroke-dashoffset:0.000; opacity:1; animation-timing-function:cubic-bezier(0.55, 0.055, 0.675, 0.19); }
|
||||
78.1250% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; opacity:1; }
|
||||
78.2250% { opacity:0; }
|
||||
100% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; opacity:0; }
|
||||
}
|
||||
.p { animation: draw 1s 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"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -1,11 +1,20 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Layout } from "./components/Layout";
|
||||
import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||
import { authApi } from "./api/auth";
|
||||
import { healthApi } from "./api/health";
|
||||
import { Artifacts } from "./pages/Artifacts";
|
||||
import { BoardChat } from "./pages/BoardChat";
|
||||
import { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
|
|
@ -108,11 +117,19 @@ function CloudAccessGate() {
|
|||
return <Outlet />;
|
||||
}
|
||||
|
||||
function LegacyChatToBoardRoomRedirect() {
|
||||
const { search, hash } = useLocation();
|
||||
return <Navigate to={{ pathname: "/board-chat", search, hash }} replace />;
|
||||
}
|
||||
|
||||
function boardRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="chat" element={<LegacyChatToBoardRoomRedirect />} />
|
||||
<Route path="board-chat" element={<BoardChat />} />
|
||||
<Route path="artifacts" element={<Artifacts />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
|
|
@ -317,6 +334,8 @@ export function App() {
|
|||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="chat" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function OpenClawGatewayConfigFields({
|
|||
className={inputClass}
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="issue">Per issue</option>
|
||||
<option value="issue">Per task</option>
|
||||
<option value="run">Per run</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
|
|
|||
337
ui/src/components/ArtifactsPanel.tsx
Normal file
337
ui/src/components/ArtifactsPanel.tsx
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
FileText,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
Globe,
|
||||
Server,
|
||||
Package,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
X,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ArtifactsPanelProps {
|
||||
taskId: string;
|
||||
isAgentWorking?: boolean;
|
||||
/** Open the document viewer directly to a specific doc */
|
||||
openDocKey?: string | null;
|
||||
openDocTitle?: string | null;
|
||||
onClearOpenDoc?: () => void;
|
||||
/** Approval callbacks — called from the document viewer */
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
type FilterValue = "all" | "in_progress" | "for_review" | "completed";
|
||||
|
||||
const FILTERS: Array<{ label: string; value: FilterValue }> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "For Review", value: "for_review" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
];
|
||||
|
||||
function matchesFilter(wp: IssueWorkProduct, filter: FilterValue): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "in_progress") return wp.status === "active" || wp.status === "draft";
|
||||
if (filter === "for_review") return wp.status === "ready_for_review";
|
||||
if (filter === "completed") return wp.status === "approved" || wp.status === "merged";
|
||||
return true;
|
||||
}
|
||||
|
||||
function typeIcon(type: string) {
|
||||
switch (type) {
|
||||
case "document": return FileText;
|
||||
case "pull_request": return GitBranch;
|
||||
case "branch": return GitBranch;
|
||||
case "commit": return GitCommit;
|
||||
case "preview_url": return Globe;
|
||||
case "runtime_service": return Server;
|
||||
case "artifact": return Package;
|
||||
default: return FileText;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
case "draft":
|
||||
return { label: "In Progress", className: "bg-blue-500/10 text-blue-600 dark:text-blue-400" };
|
||||
case "ready_for_review":
|
||||
return { label: "For Review", className: "bg-amber-500/10 text-amber-600 dark:text-amber-400" };
|
||||
case "approved":
|
||||
case "merged":
|
||||
return { label: "Completed", className: "bg-green-500/10 text-green-600 dark:text-green-400" };
|
||||
case "changes_requested":
|
||||
return { label: "Changes Requested", className: "bg-orange-500/10 text-orange-600 dark:text-orange-400" };
|
||||
case "failed":
|
||||
return { label: "Failed", className: "bg-red-500/10 text-red-600 dark:text-red-400" };
|
||||
default:
|
||||
return { label: status, className: "bg-muted text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtifactsPanel({ taskId, isAgentWorking, openDocKey, openDocTitle, onClearOpenDoc, onApprove, onReject }: ArtifactsPanelProps) {
|
||||
const [filter, setFilter] = useState<FilterValue>("all");
|
||||
const [viewingDoc, setViewingDoc] = useState<{ key: string; title: string } | null>(null);
|
||||
|
||||
const { data: workProducts, isLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.workProducts(taskId),
|
||||
queryFn: () => issuesApi.listWorkProducts(taskId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Open doc from parent (e.g. clicking plan link in chat)
|
||||
const effectiveViewingDoc = openDocKey
|
||||
? { key: openDocKey, title: openDocTitle ?? "Document" }
|
||||
: viewingDoc;
|
||||
|
||||
const handleBack = () => {
|
||||
setViewingDoc(null);
|
||||
onClearOpenDoc?.();
|
||||
};
|
||||
|
||||
// Find the work product for the currently viewed doc to know its status
|
||||
const viewedWorkProduct = effectiveViewingDoc
|
||||
? (workProducts ?? []).find((wp) => wp.title === effectiveViewingDoc.title)
|
||||
: null;
|
||||
|
||||
const filtered = (workProducts ?? []).filter((wp) => matchesFilter(wp, filter));
|
||||
|
||||
// Document viewer
|
||||
if (effectiveViewingDoc) {
|
||||
return (
|
||||
<DocumentViewer
|
||||
taskId={taskId}
|
||||
docKey={effectiveViewingDoc.key}
|
||||
title={effectiveViewingDoc.title}
|
||||
onBack={handleBack}
|
||||
status={viewedWorkProduct?.status ?? null}
|
||||
reviewState={viewedWorkProduct?.reviewState ?? null}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-artifacts-panel>
|
||||
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h3 className="text-sm font-semibold">Artifacts</h3>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="px-4 py-2 flex flex-wrap gap-1 border-b border-border">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-medium transition-colors",
|
||||
filter === f.value
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => setFilter(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Work products list */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-auto-hide">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Package className="h-8 w-8 mx-auto text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{workProducts?.length === 0
|
||||
? "Your team's deliverables and plans will appear here as they're produced."
|
||||
: "No artifacts match this filter."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{filtered.map((wp) => {
|
||||
const Icon = typeIcon(wp.type);
|
||||
const badge = statusBadge(wp.status);
|
||||
const isDraft = wp.status === "draft" || wp.status === "active";
|
||||
const showGenerating = isDraft && isAgentWorking;
|
||||
return (
|
||||
<button
|
||||
key={wp.id}
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 hover:bg-accent/30 transition-colors",
|
||||
showGenerating && "bg-muted/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (wp.type === "document") {
|
||||
setViewingDoc({ key: "plan", title: wp.title });
|
||||
} else if (wp.url) {
|
||||
window.open(wp.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{showGenerating ? (
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<span className="relative flex h-4 w-4">
|
||||
<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-4 w-4 bg-cyan-500" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Icon className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{wp.title}</span>
|
||||
{wp.url && (
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground capitalize">
|
||||
{wp.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
{showGenerating ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-600 dark:text-cyan-400">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded-full", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{wp.summary && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1 line-clamp-2">
|
||||
{wp.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentViewer({
|
||||
taskId,
|
||||
docKey,
|
||||
title,
|
||||
onBack,
|
||||
status,
|
||||
reviewState,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
taskId: string;
|
||||
docKey: string;
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
status: string | null;
|
||||
reviewState: string | null;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
}) {
|
||||
const { data: doc, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.documents(taskId),
|
||||
queryFn: () => issuesApi.getDocument(taskId, docKey),
|
||||
});
|
||||
|
||||
const needsAction = status === "ready_for_review" || reviewState === "needs_board_review";
|
||||
const isApproved = status === "approved" || reviewState === "approved";
|
||||
const isRejected = status === "changes_requested" || reviewState === "changes_requested";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
||||
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold flex-1 truncate">{title}</h3>
|
||||
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-auto-hide p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading document...
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">Document not available yet.</p>
|
||||
) : doc?.body ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownBody>{doc.body}</MarkdownBody>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Document is empty.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky action footer */}
|
||||
{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-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();
|
||||
}}>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isApproved && (
|
||||
<div className="border-t border-green-500/30 bg-green-500/5 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<p className="text-[13px] font-medium text-green-700 dark:text-green-400">
|
||||
Approved — hire tasks created
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRejected && (
|
||||
<div className="border-t border-orange-500/30 bg-orange-500/5 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
<p className="text-[13px] font-medium text-orange-700 dark:text-orange-400">
|
||||
Changes requested — CEO is revising
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Link } from "@/lib/router";
|
||||
import { Link, useLocation } from "@/lib/router";
|
||||
import { Menu } from "lucide-react";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
|
|
@ -30,11 +30,23 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
|||
);
|
||||
}
|
||||
|
||||
const BOARD_ROOM_ROUTE_SEGMENT = "board-chat";
|
||||
|
||||
export function BreadcrumbBar() {
|
||||
const { breadcrumbs } = useBreadcrumbs();
|
||||
const location = useLocation();
|
||||
const { toggleSidebar, isMobile } = useSidebar();
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
|
||||
const displayBreadcrumbs = useMemo(() => {
|
||||
const onBoardRoom = location.pathname
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.includes(BOARD_ROOM_ROUTE_SEGMENT);
|
||||
if (!onBoardRoom || breadcrumbs.length !== 1) return breadcrumbs;
|
||||
return [{ ...breadcrumbs[0], label: "Board Room" }];
|
||||
}, [breadcrumbs, location.pathname]);
|
||||
|
||||
const globalToolbarSlotContext = useMemo(
|
||||
() => ({
|
||||
companyId: selectedCompanyId ?? null,
|
||||
|
|
@ -45,7 +57,7 @@ export function BreadcrumbBar() {
|
|||
|
||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||
|
||||
if (breadcrumbs.length === 0) {
|
||||
if (displayBreadcrumbs.length === 0) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||
{globalToolbarSlots}
|
||||
|
|
@ -66,13 +78,13 @@ export function BreadcrumbBar() {
|
|||
);
|
||||
|
||||
// Single breadcrumb = page title (uppercase)
|
||||
if (breadcrumbs.length === 1) {
|
||||
if (displayBreadcrumbs.length === 1) {
|
||||
return (
|
||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||
{menuButton}
|
||||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||
{breadcrumbs[0].label}
|
||||
{displayBreadcrumbs[0].label}
|
||||
</h1>
|
||||
</div>
|
||||
{globalToolbarSlots}
|
||||
|
|
@ -87,8 +99,8 @@ export function BreadcrumbBar() {
|
|||
<div className="min-w-0 overflow-hidden flex-1">
|
||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||
<BreadcrumbList className="flex-nowrap">
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
{displayBreadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === displayBreadcrumbs.length - 1;
|
||||
return (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && <BreadcrumbSeparator />}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export function CommandPalette() {
|
|||
if (v && isMobile) setSidebarOpen(false);
|
||||
}}>
|
||||
<CommandInput
|
||||
placeholder="Search issues, agents, projects..."
|
||||
placeholder="Search tasks, agents, projects..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
|
|
@ -121,7 +121,7 @@ export function CommandPalette() {
|
|||
}}
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create new issue
|
||||
Create new task
|
||||
<span className="ml-auto text-xs text-muted-foreground">C</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
|
|
@ -152,7 +152,7 @@ export function CommandPalette() {
|
|||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/issues")}>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
Issues
|
||||
Tasks
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/projects")}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
|
|
@ -179,7 +179,7 @@ export function CommandPalette() {
|
|||
{visibleIssues.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Issues">
|
||||
<CommandGroup heading="Tasks">
|
||||
{visibleIssues.slice(0, 10).map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
|
|
|
|||
61
ui/src/components/FrontDoor.tsx
Normal file
61
ui/src/components/FrontDoor.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Rocket, Sparkles } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface FrontDoorProps {
|
||||
onChoose: (path: "create" | "grow") => void;
|
||||
}
|
||||
|
||||
export function FrontDoor({ onChoose }: FrontDoorProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-8">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Welcome to Paperclip
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
How would you like to get started?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-lg w-full">
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
|
||||
"hover:border-foreground hover:bg-accent/30 transition-all",
|
||||
"text-center group cursor-pointer",
|
||||
)}
|
||||
onClick={() => onChoose("create")}
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
|
||||
<Rocket className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Create a new company</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Start from scratch with a mission, hire a CEO, and build your team.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
|
||||
"hover:border-foreground hover:bg-accent/30 transition-all",
|
||||
"text-center group cursor-pointer",
|
||||
)}
|
||||
onClick={() => onChoose("grow")}
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Grow my existing company</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add AI-powered agents to your current workflows.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -322,9 +322,9 @@ export function IssuesList({
|
|||
setIssueSearch(e.target.value);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
placeholder="Search tasks..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
aria-label="Search tasks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -586,8 +586,8 @@ export function IssuesList({
|
|||
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No issues match the current filters or search."
|
||||
action="Create Issue"
|
||||
message="No tasks match the current filters or search."
|
||||
action="Create Task"
|
||||
onAction={() => openNewIssue(newIssueDefaults())}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@ export function Layout() {
|
|||
<CompanyRail />
|
||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||
<div className="border-t border-r border-border px-3 py-3 bg-background">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
|
|
@ -339,7 +339,7 @@ export function Layout() {
|
|||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-r border-border px-3 py-2">
|
||||
<div className="border-t border-r border-border px-3 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href="https://docs.paperclip.ing/"
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||
const items = useMemo<MobileNavItem[]>(
|
||||
() => [
|
||||
{ type: "link", to: "/dashboard", label: "Home", icon: House },
|
||||
{ type: "link", to: "/issues", label: "Issues", icon: CircleDot },
|
||||
{ type: "link", to: "/issues", label: "Tasks", icon: CircleDot },
|
||||
{ type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() },
|
||||
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -814,7 +814,7 @@ export function NewIssueDialog() {
|
|||
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create task. Try again.";
|
||||
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
||||
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||
|
||||
|
|
@ -981,7 +981,7 @@ export function NewIssueDialog() {
|
|||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<textarea
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
placeholder="Task title"
|
||||
rows={1}
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
|
|
@ -1463,7 +1463,7 @@ export function NewIssueDialog() {
|
|||
>
|
||||
<span className="inline-flex items-center justify-center gap-1.5">
|
||||
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
|
||||
<span>{createIssue.isPending ? "Creating..." : "Create Task"}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
481
ui/src/components/OnboardingChat.tsx
Normal file
481
ui/src/components/OnboardingChat.tsx
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
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";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Loader2, Send, CheckCircle2, ArrowRight } from "lucide-react";
|
||||
|
||||
interface OnboardingChatProps {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
companyName: string;
|
||||
companyGoal: string;
|
||||
onPlanDetected?: (planMarkdown: string) => void;
|
||||
onReviewPlan?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a comment body contains a structured hiring plan.
|
||||
* Looks for markdown headers or bullet lists that mention roles/positions.
|
||||
*/
|
||||
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, // bullet list with bold items (role names)
|
||||
/\|\s*role\s*\|/i, // markdown table with "Role" header
|
||||
];
|
||||
return planPatterns.some((pattern) => pattern.test(body));
|
||||
}
|
||||
|
||||
const QUEUED_MESSAGES = [
|
||||
"Heartbeat triggered, waking up...",
|
||||
"Initializing...",
|
||||
"Getting ready...",
|
||||
];
|
||||
|
||||
const RUNNING_MESSAGES = [
|
||||
"Working on a response...",
|
||||
"Reading the conversation...",
|
||||
"Thinking through the plan...",
|
||||
"Drafting a response...",
|
||||
"Still working...",
|
||||
"Almost there...",
|
||||
];
|
||||
|
||||
const WAITING_MESSAGES = [
|
||||
"Waiting to wake up...",
|
||||
"Heartbeat pending...",
|
||||
"Should wake up soon...",
|
||||
];
|
||||
|
||||
function getCyclingMessage(messages: string[], elapsed: number, agentName: string): string {
|
||||
// Cycle through messages every 5 seconds
|
||||
const idx = Math.floor(elapsed / 5) % messages.length;
|
||||
return `${agentName} · ${messages[idx]}`;
|
||||
}
|
||||
|
||||
function getRunStatusMessage(status: string, agentName: string, elapsed: number): string {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return getCyclingMessage(QUEUED_MESSAGES, elapsed, agentName);
|
||||
case "running":
|
||||
return getCyclingMessage(RUNNING_MESSAGES, elapsed, agentName);
|
||||
case "succeeded":
|
||||
return `${agentName} finished`;
|
||||
case "failed":
|
||||
return `${agentName} encountered an error`;
|
||||
case "cancelled":
|
||||
return `${agentName}'s run was cancelled`;
|
||||
case "timed_out":
|
||||
return `${agentName}'s run timed out`;
|
||||
default:
|
||||
return `${agentName} is thinking...`;
|
||||
}
|
||||
}
|
||||
|
||||
export function OnboardingChat({
|
||||
taskId,
|
||||
agentId,
|
||||
agentName,
|
||||
companyName,
|
||||
companyGoal,
|
||||
onPlanDetected,
|
||||
onReviewPlan,
|
||||
}: OnboardingChatProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [detectedPlanCommentId, setDetectedPlanCommentId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
// Track the comment ID after which we should ignore old plan detections
|
||||
// (set when user sends a new message to request revisions)
|
||||
const [ignoreBeforeCommentId, setIgnoreBeforeCommentId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
data: rawComments,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.issues.comments(taskId),
|
||||
queryFn: () => issuesApi.listComments(taskId),
|
||||
refetchInterval: 4000,
|
||||
});
|
||||
|
||||
// Poll for active heartbeat run on this task
|
||||
const { data: activeRun } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(taskId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(taskId),
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
// Sort comments chronologically (oldest first) for chat-style display
|
||||
const comments = useMemo(
|
||||
() =>
|
||||
rawComments
|
||||
? [...rawComments].sort(
|
||||
(a, b) =>
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)
|
||||
: undefined,
|
||||
[rawComments],
|
||||
);
|
||||
|
||||
// Auto-scroll to bottom when new comments arrive
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [comments?.length]);
|
||||
|
||||
// Detect hiring plan in agent comments.
|
||||
// Only considers agent comments newer than the user's last message AND
|
||||
// newer than any "ignore" marker (set when user asks for revisions).
|
||||
useEffect(() => {
|
||||
if (!comments || !onPlanDetected || detectedPlanCommentId) return;
|
||||
|
||||
// Find the cutoff — the later of the user's last message or the ignore marker
|
||||
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);
|
||||
}
|
||||
|
||||
// Only scan agent comments after the cutoff
|
||||
for (let i = comments.length - 1; i > cutoffIdx; i--) {
|
||||
const c = comments[i];
|
||||
if (c.authorAgentId && detectHiringPlan(c.body)) {
|
||||
setDetectedPlanCommentId(c.id);
|
||||
// Fetch the full plan document — it has richer role descriptions
|
||||
issuesApi.getDocument(taskId, "plan").then((doc) => {
|
||||
onPlanDetected(doc.body ?? c.body);
|
||||
}).catch(() => {
|
||||
onPlanDetected(c.body);
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [comments, onPlanDetected, detectedPlanCommentId, ignoreBeforeCommentId, taskId]);
|
||||
|
||||
const sendMessage = useCallback(async (body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
// Ensure the task is assigned to the CEO and in_progress before commenting.
|
||||
try {
|
||||
await issuesApi.update(taskId, { assigneeUserId: null });
|
||||
} catch { /* may already be null */ }
|
||||
try {
|
||||
await issuesApi.update(taskId, {
|
||||
assigneeAgentId: agentId,
|
||||
status: "in_progress",
|
||||
});
|
||||
} catch { /* may already be assigned */ }
|
||||
|
||||
await issuesApi.addComment(taskId, trimmed, true, true);
|
||||
setInput("");
|
||||
// Clear detected plan — user is asking for revisions
|
||||
const latestId = comments?.[comments.length - 1]?.id ?? null;
|
||||
setIgnoreBeforeCommentId(latestId);
|
||||
setDetectedPlanCommentId(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.comments(taskId),
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [sending, taskId, agentId, queryClient, comments]);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
sendMessage(input);
|
||||
}, [input, sendMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
// Determine if we should show a status indicator
|
||||
const lastComment = comments?.[comments.length - 1];
|
||||
const isWaitingForAgent =
|
||||
lastComment && lastComment.authorUserId && !lastComment.authorAgentId;
|
||||
const hasActiveRun = activeRun && (activeRun.status === "queued" || activeRun.status === "running");
|
||||
const showStatus = isWaitingForAgent || hasActiveRun;
|
||||
|
||||
// Elapsed timer — ticks every second while waiting
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const waitingSince = useMemo(() => {
|
||||
if (!showStatus || !lastComment) return null;
|
||||
// Use the user's last message timestamp as the start time
|
||||
if (lastComment.authorUserId) return new Date(lastComment.createdAt).getTime();
|
||||
// If an active run exists, use its creation time
|
||||
if (hasActiveRun && activeRun.createdAt) return new Date(activeRun.createdAt).getTime();
|
||||
return null;
|
||||
}, [showStatus, lastComment, hasActiveRun, activeRun]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!waitingSince) { setElapsed(0); return; }
|
||||
setElapsed(Math.floor((Date.now() - waitingSince) / 1000));
|
||||
const interval = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - waitingSince) / 1000));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [waitingSince]);
|
||||
|
||||
const elapsedStr = elapsed >= 60
|
||||
? `${Math.floor(elapsed / 60)}m ${elapsed % 60}s`
|
||||
: `${elapsed}s`;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading conversation...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto space-y-3 mb-3 min-h-[180px] max-h-[320px] pr-1"
|
||||
>
|
||||
{/* CEO welcome message + chips — delayed reveal */}
|
||||
<WelcomeMessage
|
||||
agentName={agentName}
|
||||
companyName={companyName}
|
||||
companyGoal={companyGoal}
|
||||
hasComments={Boolean(comments?.length)}
|
||||
onDiscuss={() => {
|
||||
setInput("I want to discuss the plan before you get started.");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
onStart={() => sendMessage("Yes, get started on the hiring plan!")}
|
||||
/>
|
||||
{comments?.map((comment) => {
|
||||
const isAgent = Boolean(comment.authorAgentId);
|
||||
const isPlan =
|
||||
detectedPlanCommentId === comment.id;
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-2 text-sm",
|
||||
isAgent
|
||||
? "bg-muted/50 border border-border mr-8"
|
||||
: "bg-accent/50 border border-accent ml-8",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-medium uppercase tracking-wide",
|
||||
isAgent
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground/70",
|
||||
)}
|
||||
>
|
||||
{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-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<MarkdownBody>
|
||||
{isAgent
|
||||
? comment.body.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
: comment.body}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status indicator — shows real heartbeat run status */}
|
||||
{showStatus && (
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasActiveRun ? (
|
||||
<>
|
||||
<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)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan ready CTA */}
|
||||
{detectedPlanCommentId && onReviewPlan && (
|
||||
<div className="rounded-md border border-green-500/30 bg-green-500/5 px-3 py-3 mb-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Your CEO has prepared a hiring plan
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Review it, make edits, then approve.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" onClick={onReviewPlan}>
|
||||
Review plan
|
||||
<ArrowRight className="h-3.5 w-3.5 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="flex-1 rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder={detectedPlanCommentId ? "Ask your CEO to revise the plan..." : "Message your CEO..."}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={!detectedPlanCommentId}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!input.trim() || sending}
|
||||
onClick={handleSend}
|
||||
className="shrink-0"
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WelcomeMessage({
|
||||
agentName,
|
||||
companyName,
|
||||
companyGoal,
|
||||
hasComments,
|
||||
onDiscuss,
|
||||
onStart,
|
||||
}: {
|
||||
agentName: string;
|
||||
companyName: string;
|
||||
companyGoal: string;
|
||||
hasComments: boolean;
|
||||
onDiscuss: () => void;
|
||||
onStart: () => void;
|
||||
}) {
|
||||
const [phase, setPhase] = useState<"waking" | "composing" | "message" | "chips">("waking");
|
||||
|
||||
useEffect(() => {
|
||||
const t1 = setTimeout(() => setPhase("composing"), 2500);
|
||||
const t2 = setTimeout(() => setPhase("message"), 5500);
|
||||
const t3 = setTimeout(() => setPhase("chips"), 6500);
|
||||
return () => { clearTimeout(t1); clearTimeout(t2); clearTimeout(t3); };
|
||||
}, []);
|
||||
|
||||
const showMessage = phase === "message" || phase === "chips";
|
||||
const showChips = phase === "chips" && !hasComments;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Message — appears after typing indicator */}
|
||||
{showMessage && (
|
||||
<div className="rounded-md px-3 py-2 text-sm bg-muted/50 border border-border mr-8 animate-in fade-in duration-300">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{agentName}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
Hello board! Thank you for appointing me CEO of <strong>{companyName}</strong>.
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
Our mission is: <em>{companyGoal}</em>
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
I'm ready to build a hiring plan. Shall I get started?
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chips — fade in after message */}
|
||||
{showChips && (
|
||||
<div className="flex gap-2 ml-auto justify-end animate-in fade-in duration-500">
|
||||
<button
|
||||
className="rounded-full border border-border px-3 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onDiscuss}
|
||||
>
|
||||
Let's discuss first
|
||||
</button>
|
||||
<button
|
||||
className="rounded-full border border-foreground bg-foreground text-background px-3 py-1 text-xs hover:opacity-90 transition-opacity"
|
||||
onClick={onStart}
|
||||
>
|
||||
Yes, get started!
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Typing indicator — anchored at bottom of scroll area, before real status messages */}
|
||||
{!showMessage && (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
{!showMessage && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground px-3 py-2">
|
||||
<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>
|
||||
{phase === "waking"
|
||||
? `${agentName} is waking up...`
|
||||
: `${agentName} is composing a message...`}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -3,6 +3,8 @@ import {
|
|||
CircleDot,
|
||||
Target,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Package,
|
||||
DollarSign,
|
||||
History,
|
||||
Search,
|
||||
|
|
@ -18,6 +20,7 @@ import { SidebarAgents } from "./SidebarAgents";
|
|||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -35,6 +38,17 @@ export function Sidebar() {
|
|||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
|
||||
const { data: sidebarAgents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const allAgentsPaused = (() => {
|
||||
if (!sidebarAgents || sidebarAgents.length === 0) return false;
|
||||
const nonTerminated = sidebarAgents.filter((a) => a.status !== "terminated");
|
||||
return nonTerminated.length > 0 && nonTerminated.every((a) => a.status === "paused");
|
||||
})();
|
||||
|
||||
function openSearch() {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||
}
|
||||
|
|
@ -48,13 +62,7 @@ export function Sidebar() {
|
|||
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
|
||||
{/* Top bar: Company name (bold) + Search — aligned with top sections (no visible border) */}
|
||||
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||
{selectedCompany?.brandColor && (
|
||||
<div
|
||||
className="w-4 h-4 rounded-sm shrink-0 ml-1"
|
||||
style={{ backgroundColor: selectedCompany.brandColor }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1 text-sm font-bold text-foreground truncate pl-1">
|
||||
<span className="flex-1 min-w-0 text-sm font-bold text-foreground truncate">
|
||||
{selectedCompany?.name ?? "Select company"}
|
||||
</span>
|
||||
<Button
|
||||
|
|
@ -69,15 +77,23 @@ export function Sidebar() {
|
|||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Issue button aligned with nav items */}
|
||||
<SidebarNavItem to="/board-chat" label="Board Room" icon={MessageSquare} />
|
||||
{/* New Task button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
<span className="truncate">New Task</span>
|
||||
</button>
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
to="/dashboard"
|
||||
label="Dashboard"
|
||||
icon={LayoutDashboard}
|
||||
liveCount={allAgentsPaused ? undefined : liveRunCount}
|
||||
badge={allAgentsPaused ? "Paused" : undefined}
|
||||
badgeTone={allAgentsPaused ? "warning" : undefined}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
|
|
@ -96,8 +112,9 @@ export function Sidebar() {
|
|||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ interface SidebarNavItemProps {
|
|||
icon: LucideIcon;
|
||||
end?: boolean;
|
||||
className?: string;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
badge?: number | string;
|
||||
badgeTone?: "default" | "danger" | "warning";
|
||||
alert?: boolean;
|
||||
liveCount?: number;
|
||||
}
|
||||
|
|
@ -59,13 +59,15 @@ export function SidebarNavItem({
|
|||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||
</span>
|
||||
)}
|
||||
{badge != null && badge > 0 && (
|
||||
{badge != null && (typeof badge === "string" ? badge.length > 0 : badge > 0) && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
|
||||
badgeTone === "danger"
|
||||
? "bg-red-600/90 text-red-50"
|
||||
: "bg-primary text-primary-foreground",
|
||||
: badgeTone === "warning"
|
||||
? "bg-amber-500/90 text-amber-50"
|
||||
: "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function ToastViewport() {
|
|||
<aside
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed bottom-3 left-3 z-[120] w-full max-w-sm px-1"
|
||||
className="pointer-events-none fixed bottom-3 right-3 z-[120] w-full max-w-sm px-1"
|
||||
>
|
||||
<ol className="flex w-full flex-col-reverse gap-2">
|
||||
{toasts.map((toast) => (
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ interface NewGoalDefaults {
|
|||
}
|
||||
|
||||
interface OnboardingOptions {
|
||||
initialStep?: 1 | 2 | 3 | 4;
|
||||
initialStep?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
companyId?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,23 +178,47 @@
|
|||
background: oklch(0.5 0 0);
|
||||
}
|
||||
|
||||
/* Auto-hide scrollbar: fully invisible by default, visible on container hover */
|
||||
/* Auto-hide scrollbar (Sidebar nav, Board chat, etc.): same 8px + radius as .dark scrollbars */
|
||||
.scrollbar-auto-hide {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
.scrollbar-auto-hide::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||
background: transparent !important;
|
||||
}
|
||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||
background: transparent !important;
|
||||
border-radius: 4px;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
.dark .scrollbar-auto-hide:hover {
|
||||
scrollbar-color: oklch(0.4 0 0) oklch(0.205 0 0);
|
||||
}
|
||||
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.205 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.4 0 0) !important;
|
||||
}
|
||||
.scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||
.dark .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0 0) !important;
|
||||
}
|
||||
:root:not(.dark) .scrollbar-auto-hide:hover {
|
||||
scrollbar-color: oklch(0.55 0 0) oklch(0.97 0 0);
|
||||
}
|
||||
:root:not(.dark) .scrollbar-auto-hide:hover::-webkit-scrollbar-track {
|
||||
background: oklch(0.97 0 0) !important;
|
||||
}
|
||||
:root:not(.dark) .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.55 0 0) !important;
|
||||
}
|
||||
:root:not(.dark) .scrollbar-auto-hide:hover::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.45 0 0) !important;
|
||||
}
|
||||
|
||||
/* Expandable dialog transition for max-width changes */
|
||||
[data-slot="dialog-content"] {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,10 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||
"activity",
|
||||
"inbox",
|
||||
"design-guide",
|
||||
"chat",
|
||||
"board-chat",
|
||||
"artifacts",
|
||||
"onboarding",
|
||||
]);
|
||||
|
||||
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);
|
||||
|
|
|
|||
|
|
@ -1093,10 +1093,10 @@ function AgentOverview({
|
|||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart runs={runs} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||
<PriorityChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||
<IssueStatusChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||
|
|
@ -1980,7 +1980,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
|
|||
>
|
||||
{clearSessionsForTouchedIssues.isPending
|
||||
? "clearing session..."
|
||||
: "clear session for these issues"}
|
||||
: "clear session for these tasks"}
|
||||
</button>
|
||||
{clearSessionsForTouchedIssues.isError && (
|
||||
<p className="text-[11px] text-destructive mt-1">
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ export function ApprovalDetail() {
|
|||
? {
|
||||
label:
|
||||
(linkedIssues?.length ?? 0) > 1
|
||||
? "Review linked issues"
|
||||
: "Review linked issue",
|
||||
? "Review linked tasks"
|
||||
: "Review linked task",
|
||||
to: `/issues/${primaryLinkedIssue.identifier ?? primaryLinkedIssue.id}`,
|
||||
}
|
||||
: linkedAgentId
|
||||
|
|
|
|||
62
ui/src/pages/Artifacts.tsx
Normal file
62
ui/src/pages/Artifacts.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ArtifactsPanel } from "../components/ArtifactsPanel";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function Artifacts() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Artifacts" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
// Find tasks that might have work products — use the planning task or any active task
|
||||
const { data: issues, isLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const taskId = useMemo(() => {
|
||||
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"),
|
||||
);
|
||||
return planningTask?.id ?? issues?.[0]?.id ?? null;
|
||||
}, [issues]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!taskId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-sm">
|
||||
<h2 className="text-lg font-semibold">No artifacts yet</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Artifacts will appear here as your agents produce deliverables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3rem)] -m-6 -mt-2">
|
||||
<ArtifactsPanel taskId={taskId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
609
ui/src/pages/BoardChat.tsx
Normal file
609
ui/src/pages/BoardChat.tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import {
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { History, ListFilter, MessageSquarePlus, Send } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/**
|
||||
* Board Concierge Chat — a chat interface powered by the board-member skill.
|
||||
* Uses /board/chat/stream to invoke Claude with the board skill as system prompt.
|
||||
* The user manages their Paperclip company through natural conversation.
|
||||
*/
|
||||
/** Hit zone to the right of the 1px line (line sits on chat pane’s right edge). */
|
||||
const SPLIT_DIVIDER_PX = 12;
|
||||
const SPLIT_MIN_PANE_PX = 280;
|
||||
/** Chat pane share of width below the divider (agent feed gets the rest). */
|
||||
const DEFAULT_CHAT_FRACTION = 2 / 3;
|
||||
|
||||
const AGENT_FEED_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "in-progress", label: "In Progress" },
|
||||
{ value: "for-review", label: "In Review" },
|
||||
{ value: "completed", label: "Done" },
|
||||
] as const;
|
||||
|
||||
type AgentFeedFilterValue = (typeof AGENT_FEED_FILTER_OPTIONS)[number]["value"];
|
||||
|
||||
/** Wrapped markdown in bubbles; pre/table scroll horizontally when needed. */
|
||||
const BOARD_CHAT_MARKDOWN_CLASS =
|
||||
"max-w-full overflow-visible [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_table]:block [&_table]:max-w-full [&_table]:overflow-x-auto";
|
||||
|
||||
const boardChatBubbleShell =
|
||||
"min-w-0 max-w-[85%] break-words px-3 py-2 text-sm overflow-x-auto overflow-y-visible";
|
||||
|
||||
export function BoardChat() {
|
||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Board Room" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const splitContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [chatPaneFraction, setChatPaneFraction] = useState(DEFAULT_CHAT_FRACTION);
|
||||
const splitDragging = useRef(false);
|
||||
const [agentFeedFilter, setAgentFeedFilter] = useState<AgentFeedFilterValue>("all");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = splitContainerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
setContainerWidth(el.clientWidth);
|
||||
});
|
||||
ro.observe(el);
|
||||
setContainerWidth(el.clientWidth);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const innerWidth = Math.max(0, containerWidth - SPLIT_DIVIDER_PX);
|
||||
const splitLowerPx = SPLIT_MIN_PANE_PX;
|
||||
const splitUpperPx = innerWidth - SPLIT_MIN_PANE_PX;
|
||||
const minChatFraction =
|
||||
innerWidth > 0 ? Math.min(1, SPLIT_MIN_PANE_PX / innerWidth) : 0;
|
||||
const maxChatFraction =
|
||||
innerWidth > 0 ? Math.max(0, 1 - SPLIT_MIN_PANE_PX / innerWidth) : 1;
|
||||
const leftPaneWidth =
|
||||
innerWidth > 0
|
||||
? splitUpperPx < splitLowerPx
|
||||
? Math.max(0, Math.round(innerWidth / 2))
|
||||
: Math.round(
|
||||
innerWidth *
|
||||
Math.min(
|
||||
maxChatFraction,
|
||||
Math.max(minChatFraction, chatPaneFraction),
|
||||
),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const handleSplitDragStart = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
splitDragging.current = true;
|
||||
const startX = e.clientX;
|
||||
const startWidth = leftPaneWidth;
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!splitDragging.current) return;
|
||||
const containerW = splitContainerRef.current?.clientWidth ?? containerWidth;
|
||||
const inner = containerW - SPLIT_DIVIDER_PX;
|
||||
const lower = SPLIT_MIN_PANE_PX;
|
||||
const upper = inner - SPLIT_MIN_PANE_PX;
|
||||
const next = startWidth + ev.clientX - startX;
|
||||
if (inner <= 0) return;
|
||||
if (upper < lower) {
|
||||
setChatPaneFraction(0.5);
|
||||
} else {
|
||||
const clamped = Math.min(upper, Math.max(lower, next));
|
||||
setChatPaneFraction(clamped / inner);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
splitDragging.current = false;
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
},
|
||||
[containerWidth, leftPaneWidth],
|
||||
);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [streamingText, setStreamingText] = useState("");
|
||||
const [statusText, setStatusText] = useState("");
|
||||
const [boardIssueId, setBoardIssueId] = useState<string | null>(null);
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Reset state and clear cached comments when company changes
|
||||
const prevCompanyRef = useRef(selectedCompanyId);
|
||||
useEffect(() => {
|
||||
if (prevCompanyRef.current !== selectedCompanyId) {
|
||||
if (boardIssueId) {
|
||||
queryClient.removeQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
|
||||
}
|
||||
setBoardIssueId(null);
|
||||
setStreamingText("");
|
||||
setStatusText("");
|
||||
setInput("");
|
||||
setSending(false);
|
||||
setOptimisticMessage(null);
|
||||
prevCompanyRef.current = selectedCompanyId;
|
||||
}
|
||||
}, [selectedCompanyId, boardIssueId, queryClient]);
|
||||
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const ceoAgent = useMemo(
|
||||
() => agents?.find((a) => a.role === "ceo" && a.status !== "terminated"),
|
||||
[agents],
|
||||
);
|
||||
|
||||
// Find or detect the board operations issue
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!issues) {
|
||||
setBoardIssueId(null);
|
||||
return;
|
||||
}
|
||||
const boardIssue = issues.find(
|
||||
(i) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
|
||||
);
|
||||
setBoardIssueId(boardIssue?.id ?? null);
|
||||
}, [issues]);
|
||||
|
||||
// Fetch comments for the board issue
|
||||
const { data: comments } = useQuery({
|
||||
queryKey: queryKeys.issues.comments(boardIssueId ?? ""),
|
||||
queryFn: () => issuesApi.listComments(boardIssueId!),
|
||||
enabled: !!boardIssueId,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const sortedComments = (comments ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
|
||||
// Clear optimistic message once server-persisted comments include it
|
||||
useEffect(() => {
|
||||
if (optimisticMessage && sortedComments.length > 0) {
|
||||
const lastUserComment = [...sortedComments]
|
||||
.reverse()
|
||||
.find((c) => !c.authorAgentId && c.authorUserId !== "board-concierge");
|
||||
if (lastUserComment?.body === optimisticMessage) {
|
||||
setOptimisticMessage(null);
|
||||
}
|
||||
}
|
||||
}, [sortedComments, optimisticMessage]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [sortedComments.length, streamingText, statusText, optimisticMessage]);
|
||||
|
||||
// Elapsed timer for thinking state
|
||||
useEffect(() => {
|
||||
if (sending) {
|
||||
setElapsedSec(0);
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedSec((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (elapsedTimerRef.current) {
|
||||
clearInterval(elapsedTimerRef.current);
|
||||
elapsedTimerRef.current = null;
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
||||
};
|
||||
}, [sending]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || sending || !selectedCompanyId) return;
|
||||
|
||||
// Show user message immediately
|
||||
setOptimisticMessage(trimmed);
|
||||
setSending(true);
|
||||
setInput("");
|
||||
setStreamingText("");
|
||||
setStatusText("Connecting...");
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const fetchTimeout = setTimeout(() => controller.abort(), 130000);
|
||||
const res = await fetch("/api/board/chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
companyId: selectedCompanyId,
|
||||
message: trimmed,
|
||||
taskId: boardIssueId ?? undefined,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(fetchTimeout);
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error("Board chat stream not available");
|
||||
}
|
||||
|
||||
setStatusText("Thinking...");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let accumulated = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6));
|
||||
if (event.type === "chunk" && event.text) {
|
||||
accumulated += event.text;
|
||||
setStreamingText(accumulated);
|
||||
setStatusText("");
|
||||
} else if (event.type === "status" && event.text) {
|
||||
setStatusText(event.text);
|
||||
} else if (event.type === "start" && event.issueId) {
|
||||
setBoardIssueId(event.issueId);
|
||||
} else if (event.type === "done") {
|
||||
if (event.issueId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.comments(event.issueId),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* malformed SSE line */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setStreamingText("");
|
||||
setStatusText("");
|
||||
if (boardIssueId) {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(boardIssueId) });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Board chat error:", err);
|
||||
setStatusText("");
|
||||
} finally {
|
||||
setSending(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[sending, selectedCompanyId, boardIssueId, queryClient],
|
||||
);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
sendMessage(input);
|
||||
}, [input, sendMessage]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center max-w-sm">
|
||||
<h2 className="text-lg font-semibold">No company selected</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Select a company to start chatting with your board concierge.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100%+3rem)] flex-col -m-6">
|
||||
<div
|
||||
ref={splitContainerRef}
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-row"
|
||||
>
|
||||
{/* Left: chat (self-contained pane) — 2/3 default until container is measured for drag math */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-0 min-w-0 shrink-0 flex-col bg-background",
|
||||
innerWidth <= 0 && "w-2/3",
|
||||
)}
|
||||
style={innerWidth > 0 ? { width: leftPaneWidth } : undefined}
|
||||
>
|
||||
<div className="relative flex shrink-0 items-center justify-between gap-2 px-4 py-3">
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{ceoAgent?.name ?? "Board Room"}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedCompany?.name ?? "Your company"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="chat history"
|
||||
>
|
||||
<History className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">chat history</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-muted-foreground"
|
||||
aria-label="new chat"
|
||||
>
|
||||
<MessageSquarePlus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">new chat</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{/* Messages — scroll viewport flush right so the scrollbar sits on the pane/divider edge */}
|
||||
<div className="scrollbar-auto-hide min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden">
|
||||
<div className="flex flex-col gap-4 px-4 py-3">
|
||||
{sortedComments.length === 0 && !streamingText && !sending && !optimisticMessage && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Ask me anything about your company — hiring, tasks, costs, approvals.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{[
|
||||
"What's happening today?",
|
||||
"Help me build a hiring plan",
|
||||
"Show me my costs",
|
||||
"List pending approvals",
|
||||
].map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
onClick={() => sendMessage(suggestion)}
|
||||
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedComments.map((comment) => {
|
||||
const isUser = !comment.authorAgentId && comment.authorUserId !== "board-concierge";
|
||||
return (
|
||||
<div
|
||||
key={comment.id}
|
||||
className={cn("flex", isUser ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
boardChatBubbleShell,
|
||||
isUser
|
||||
? "bg-blue-600 text-white [border-radius:12px_12px_0px_12px]"
|
||||
: "bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
|
||||
)}
|
||||
>
|
||||
<MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>
|
||||
{comment.body ?? ""}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Optimistic user message — shows instantly before server persists */}
|
||||
{optimisticMessage && (
|
||||
<div className="flex justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
boardChatBubbleShell,
|
||||
"bg-blue-600 text-white [border-radius:12px_12px_0px_12px]",
|
||||
)}
|
||||
>
|
||||
{optimisticMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming response */}
|
||||
{streamingText && (
|
||||
<div className="flex justify-start">
|
||||
<div
|
||||
className={cn(
|
||||
boardChatBubbleShell,
|
||||
"bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
|
||||
)}
|
||||
>
|
||||
<MarkdownBody className={BOARD_CHAT_MARKDOWN_CLASS}>{streamingText}</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status bar — always visible while sending, independent from the chat bubble */}
|
||||
{sending && (
|
||||
<div className="flex items-center gap-2 pl-1 text-xs text-muted-foreground">
|
||||
<img src="/paperclip-thinking.svg" alt="" className="inline-block shrink-0" style={{ width: 14, height: 14 }} />
|
||||
<span>{statusText || "Thinking..."}</span>
|
||||
{elapsedSec > 0 && (
|
||||
<span className="opacity-50">{elapsedSec}s</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="shrink-0 border-t border-border px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value.replace(/\r?\n/g, " "))}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything about your company..."
|
||||
rows={1}
|
||||
wrap="off"
|
||||
className="min-h-9 min-w-0 flex-1 resize-none overflow-x-auto whitespace-nowrap [border-radius:12px] border border-border bg-background px-3 py-1.5 text-sm leading-5 focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sending}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resize handle: 1px line on chat edge; drag target extends into gutter */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize board chat and agent feed"
|
||||
className="group relative flex w-3 shrink-0 cursor-col-resize bg-background"
|
||||
onMouseDown={handleSplitDragStart}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 bottom-0 left-0 w-px bg-border transition-colors group-hover:bg-foreground/20"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Agent Feed (self-contained pane) */}
|
||||
<aside className="flex min-h-0 min-w-0 flex-1 flex-col bg-background">
|
||||
<div className="relative flex shrink-0 items-start justify-between gap-2 px-4 py-3">
|
||||
<div
|
||||
className="pointer-events-none absolute bottom-0 h-px bg-border"
|
||||
style={{
|
||||
left: -SPLIT_DIVIDER_PX,
|
||||
right: 0,
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold">Agent Feed</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Live activity from your agents
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
aria-label="filter by"
|
||||
>
|
||||
<ListFilter />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">filter by</TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuRadioGroup
|
||||
value={agentFeedFilter}
|
||||
onValueChange={(v) => setAgentFeedFilter(v as AgentFeedFilterValue)}
|
||||
>
|
||||
{AGENT_FEED_FILTER_OPTIONS.map(({ value, label }) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
{label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
|
||||
<p className="max-w-[14rem] text-center text-sm text-muted-foreground">
|
||||
Activity from your agents will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ export function Companies() {
|
|||
<div className="flex items-center gap-1.5">
|
||||
<CircleDot className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{issueCount} {issueCount === 1 ? "issue" : "issues"}
|
||||
{issueCount} {issueCount === 1 ? "task" : "tasks"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 tabular-nums">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -19,7 +19,7 @@ import { ActivityRow } from "../components/ActivityRow";
|
|||
import { Identity } from "../components/Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle, Pause, Play } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
|
|
@ -35,7 +35,10 @@ export function Dashboard() {
|
|||
const { selectedCompanyId, companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||
const [allPaused, setAllPaused] = useState(false);
|
||||
const [isPauseToggling, setIsPauseToggling] = useState(false);
|
||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||
const hydratedActivityRef = useRef(false);
|
||||
const activityAnimationTimersRef = useRef<number[]>([]);
|
||||
|
|
@ -46,6 +49,37 @@ export function Dashboard() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// Sync allPaused state with actual agent data
|
||||
useEffect(() => {
|
||||
if (agents && agents.length > 0) {
|
||||
const nonTerminated = agents.filter((a) => a.status !== "terminated");
|
||||
if (nonTerminated.length > 0) {
|
||||
setAllPaused(nonTerminated.every((a) => a.status === "paused"));
|
||||
}
|
||||
}
|
||||
}, [agents]);
|
||||
|
||||
async function handleTogglePauseAll() {
|
||||
if (!agents || !selectedCompanyId || isPauseToggling) return;
|
||||
setIsPauseToggling(true);
|
||||
try {
|
||||
if (allPaused) {
|
||||
// Resume all paused agents
|
||||
const paused = agents.filter((a) => a.status === "paused");
|
||||
await Promise.all(paused.map((a) => agentsApi.resume(a.id, selectedCompanyId)));
|
||||
} else {
|
||||
// Pause all active/running agents
|
||||
const active = agents.filter((a) => a.status === "active" || a.status === "running");
|
||||
await Promise.all(active.map((a) => agentsApi.pause(a.id, selectedCompanyId)));
|
||||
}
|
||||
setAllPaused(!allPaused);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId) });
|
||||
} finally {
|
||||
setIsPauseToggling(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Dashboard" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
|
@ -206,6 +240,33 @@ export function Dashboard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{agents && agents.length > 0 && (
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
onClick={handleTogglePauseAll}
|
||||
disabled={isPauseToggling}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
allPaused
|
||||
? "bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
: "bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{allPaused ? (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{isPauseToggling ? "Resuming..." : "Resume All"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
{isPauseToggling ? "Pausing..." : "Pause All"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActiveAgentsPanel companyId={selectedCompanyId!} />
|
||||
|
||||
{data && (
|
||||
|
|
@ -287,10 +348,10 @@ export function Dashboard() {
|
|||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart runs={runs ?? []} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||
<PriorityChart issues={issues ?? []} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||
<IssueStatusChart issues={issues ?? []} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||
|
|
|
|||
|
|
@ -769,7 +769,7 @@ export function DesignGuide() {
|
|||
<SubSection title="Metric Cards">
|
||||
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
|
||||
<MetricCard icon={CircleDot} value={48} label="Open Issues" />
|
||||
<MetricCard icon={CircleDot} value={48} label="Open Tasks" />
|
||||
<MetricCard icon={DollarSign} value="$1,234" label="Monthly Cost" description="Under budget" />
|
||||
<MetricCard icon={Zap} value="99.9%" label="Uptime" />
|
||||
</div>
|
||||
|
|
@ -1139,9 +1139,9 @@ export function DesignGuide() {
|
|||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* GROUPED LIST (Issues pattern) */}
|
||||
{/* GROUPED LIST (Tasks pattern) */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Grouped List (Issues pattern)">
|
||||
<Section title="Grouped List (Tasks pattern)">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
|
||||
<StatusIcon status="in_progress" />
|
||||
|
|
@ -1310,7 +1310,7 @@ export function DesignGuide() {
|
|||
<div className="border border-border rounded-md divide-y divide-border text-sm">
|
||||
{[
|
||||
["Cmd+K / Ctrl+K", "Open Command Palette"],
|
||||
["C", "New Issue (outside inputs)"],
|
||||
["C", "New Task (outside inputs)"],
|
||||
["[", "Toggle Sidebar"],
|
||||
["]", "Toggle Properties Panel"],
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
<DetailRow label="Project">
|
||||
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
<DetailRow label="Source task">
|
||||
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ function FailedRunCard({
|
|||
</Link>
|
||||
) : (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"}
|
||||
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked task"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -596,7 +596,7 @@ export function Inbox() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent tasks</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
|
|
|
|||
|
|
@ -59,17 +59,17 @@ type CommentReassignment = {
|
|||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.created": "created the issue",
|
||||
"issue.updated": "updated the issue",
|
||||
"issue.checked_out": "checked out the issue",
|
||||
"issue.released": "released the issue",
|
||||
"issue.created": "created the task",
|
||||
"issue.updated": "updated the task",
|
||||
"issue.checked_out": "checked out the task",
|
||||
"issue.released": "released the task",
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"issue.deleted": "deleted the task",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
"agent.paused": "paused the agent",
|
||||
|
|
@ -160,8 +160,8 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
|||
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||
parts.push(
|
||||
details.assigneeAgentId || details.assigneeUserId
|
||||
? "assigned the issue"
|
||||
: "unassigned the issue",
|
||||
? "assigned the task"
|
||||
: "unassigned the task",
|
||||
);
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
|
|
@ -267,7 +267,7 @@ export function IssueDetail() {
|
|||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Tasks", href: "/issues" },
|
||||
[location.state],
|
||||
);
|
||||
|
||||
|
|
@ -560,7 +560,7 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
const titleLabel = issue?.title ?? issueId ?? "Task";
|
||||
setBreadcrumbs([
|
||||
sourceBreadcrumb,
|
||||
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
|
||||
|
|
@ -763,7 +763,7 @@ export function IssueDetail() {
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={copyIssueToClipboard}
|
||||
title="Copy issue as markdown"
|
||||
title="Copy task as markdown"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
|
@ -782,7 +782,7 @@ export function IssueDetail() {
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={copyIssueToClipboard}
|
||||
title="Copy issue as markdown"
|
||||
title="Copy task as markdown"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
|
@ -978,7 +978,7 @@ export function IssueDetail() {
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="subissues" className="gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Sub-issues
|
||||
Sub-tasks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -68,14 +68,14 @@ export function Issues() {
|
|||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
"Tasks",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
setBreadcrumbs([{ label: "Tasks" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
|
|
@ -93,7 +93,7 @@ export function Issues() {
|
|||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
|
||||
return <EmptyState icon={CircleDot} message="Select a company to view tasks." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function MyIssues() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "My Issues" }]);
|
||||
setBreadcrumbs([{ label: "My Tasks" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
|
|
@ -27,7 +27,7 @@ export function MyIssues() {
|
|||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
|
||||
return <EmptyState icon={ListTodo} message="Select a company to view your tasks." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -44,7 +44,7 @@ export function MyIssues() {
|
|||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{myIssues.length === 0 && (
|
||||
<EmptyState icon={ListTodo} message="No issues assigned to you." />
|
||||
<EmptyState icon={ListTodo} message="No tasks assigned to you." />
|
||||
)}
|
||||
|
||||
{myIssues.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ export function ProjectDetail() {
|
|||
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "list", label: "Issues" },
|
||||
{ value: "list", label: "Tasks" },
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ const surfaceOptions: Array<{
|
|||
},
|
||||
{
|
||||
id: "live",
|
||||
label: "Issue Widget",
|
||||
label: "Task Widget",
|
||||
eyebrow: "Live stream",
|
||||
description: "The issue-detail live run widget, optimized for following an active run without leaving the task page.",
|
||||
description: "The task-detail live run widget, optimized for following an active run without leaving the task page.",
|
||||
icon: RadioTower,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue