Compare commits

...
Sign in to create a new pull request.

14 commits

Author SHA1 Message Date
scotttong
b6e144a611 experiment: board chat split-pane fraction, markdown wrap, tooltips
ResizeObserver-driven chat/agent pane split; wrapped markdown bubbles with
horizontal scroll for pre/table; new thread/history affordances.

Made-with: Cursor
2026-03-22 02:37:39 -07:00
scotttong
f312f22f27 experiment: unify board chat — Board Room, legacy redirect, split pane
- Remove legacy Chat page and CEOChatPanel; board concierge lives at /board-chat only
- Redirect /chat to /board-chat (preserve search and hash)
- Sidebar: single 'Board Room' nav item; drop duplicate Chat/Board Chat entries
- Breadcrumbs: label board-chat as 'Board Room' when a single crumb
- BoardChat: resizable chat + Agent Feed column, feed filter menu, starter
  prompts, bubble/input/status polish
- Onboarding: post-wizard launch targets board-chat where applicable
- Layout/index.css and dev-fresh-chat.sh: small spacing/script alignment

Made-with: Cursor
2026-03-22 02:09:34 -07:00
scotttong
1d06bd62c5 experiment: board chat UX polish and beginner guide
- Fix company isolation: reset chat state when switching companies,
  clear stale comment cache, fix Board Operations issue creation
  (status: todo instead of in_progress to avoid assignee requirement)
- Optimistic user messages: show user's message instantly before server
  round-trip for a natural chat feel
- Live status indicators: forward tool-use events from Claude CLI as
  SSE status messages (Running a command, Reading a file, Searching, etc.)
  shown as a separate bar below the streaming response
- Paperclip SVG thinking animation (slowed to 1s loop)
- Chat bubble styling: blue user bubbles, shaped corners via raw CSS
  to bypass --radius:0 design system
- Agent system prompt template: add Model field (default: sonnet)
- Add beginner guide: doc/BOARD-CHAT-GUIDE.md

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 19:15:19 -07:00
scotttong
08e0e91af0 experiment: board concierge skill + web UI chat surface
Add a board-member skill that teaches Claude how to manage a Paperclip
company via chat — covering onboarding, hiring plans, approvals, task
monitoring, cost oversight, and agent system prompt management.

Phase 1 (Claude Code surface):
- Board skill at skills/paperclip-board/SKILL.md with full API reference
- CLI bootstrap command `paperclipai board setup` that installs the skill
  and prints env exports

Phase 2 (Web UI surface):
- New /board/chat/stream endpoint that spawns Claude with the board skill
  as system prompt, passing PAPERCLIP_API_URL and PAPERCLIP_COMPANY_ID
- BoardChat page with streaming responses, status indicators, and
  conversation persistence via Board Operations issue
- Sidebar nav link and route registration

The skill is a portable knowledge layer — same document powers Claude Code
(Surface 1), web UI chat (Surface 2), and future MCP server (Surface 3).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 14:05:57 -07:00
scotttong
9bc683b17b experiment: two-layer observer, canned openers, structured action signals
Layer 1 (instant): detectUserIntent() on user message creates work product
+ fires background artifact generation immediately.
Layer 2 (CEO confirm): parseStructuredActions() on %%ACTIONS%% signal in
CEO response, with regex fallback. Deduplicates against Layer 1.

Also adds: canned openers for common messages (hybrid with real streaming),
message action metadata bars, cleanAgentMessage strips action signals,
generate-artifact now posts CEO comment and updates task to in_review.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 00:52:49 -07:00
scotttong
8abbc48c71 experiment: direct CLI streaming, observer pattern, artifact generation
Replace adapter-based chat relay with lightweight claude CLI streaming
endpoint. CEO responses now arrive in 2-5s instead of minutes.

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:07:26 -07:00
scotttong
4b3cda97e4 experiment: filter system output from chat, approval UX, wizard sub-steps
- Strip JSON/system init output from agent messages in chat (no more
  raw tool dumps or session IDs visible to users)
- Filter streaming relay chunks that look like system output
- Create "in progress" artifact when user asks for a hiring plan
- Swap approval button order: Reject (left), Approve (right, green)
- Fix chat/artifact pane height to fill viewport without clipping
- Progressive disclosure in wizard step 1: name first, then mission
- Hide agent comments that are entirely system output

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:20:32 -07:00
scotttong
fbd87da6d6 experiment: add chat relay endpoint for real-time streaming responses
New POST /api/agents/:id/chat/relay endpoint that calls the adapter
directly and streams stdout back via SSE, bypassing the heartbeat
queue. Comments are persisted normally so conversations stay durable.
Frontend tries the relay first, falls back to poll-based flow if
unavailable.

Backend: 1 new file (agent-chat.ts), 1 line in app.ts.
Frontend: streaming fetch in CEOChatPanel with fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:02:10 -07:00
scotttong
2d8003d2f5 experiment: 3-panel CEO chat, artifacts, front door, and UX overhaul
New core product layout: resizable chat + artifacts panel replaces the
old wizard-only flow. Front door (create/grow), onboarding exits to chat,
CEO discusses strategy before planning. Approval actions live in the
artifacts pane, not inline in chat. Chat history drawer, animated
paperclip thinking indicator, optimistic typing, faster polling.

Rename Issue → Task across all frontend UI labels (16 files).
Add global pause/resume all agents on dashboard with sidebar badge.
Move toasts to bottom-right. Add Artifacts page and sidebar nav item.
Reorder wizard: Mission → CEO config → Launch (exits to chat).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:45:21 -07:00
scotttong
05a2848b02 experiment: CEO welcome flow, merged steps, orientation screen, UX polish
CEO welcome & user agency:
- Removed auto-posted user message — CEO greets the board first
- "CEO is waking up..." → "CEO is composing..." → welcome message fades in
- Response chips ("Yes, get started!" / "Let's discuss first") fade in after
- Planning task created unassigned — CEO only wakes when user initiates
- "Yes" chip sends directly; "Discuss" pre-fills input for editing

Merged steps 5+6:
- "Approve & hire" on the Plan step creates hire tasks directly
- No redundant confirmation step
- Step 6 is now a welcome/orientation screen (hidden from nav tabs)

Orientation screen:
- Shows what to expect: Tasks, Agents, Approvals, Dashboard
- "Go to dashboard" button closes wizard and navigates

Nav tabs cleaned up:
- Mission → Launch → CEO → Plan → Review (5 visible tabs)
- Orientation step exists but not in nav (no redundant rockets)

Chat UX polish:
- Single-line input (like a URL bar) instead of multi-line textarea
- CEO welcome message always visible in conversation history
- Structured task description tells CEO exact format for role specs
- "Confirm mission" button hidden until user has chosen a path
- Switching paths preserves previously entered text

Parser improvements:
- Handles N. **Role Name** with indented bullets (fallback format)
- Summary field populated from first expertise line when no explicit summary
- Better skip patterns for non-role sections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 03:18:14 -07:00
scotttong
0c1582ef47 experiment: chat UX fixes, structured role cards, plan parser improvements
Chat fixes:
- Comment order: sort chronologically (oldest first)
- Reopen+interrupt: user messages reassign task to CEO so it always wakes up
- Strip markdown links from CEO messages to keep user focused on wizard
- Cycling status messages (rotate every 5s) with elapsed timer
- "Review plan" CTA properly disappears when user sends follow-up
- Fetch plan document (not comment summary) for richer role data

Structured role cards:
- 7 fields: Summary, Expertise, Priorities, Boundaries, Tools, Communication, Collaboration
- Collapsible card view with "Show more" / "Show less"
- Full edit mode with labeled textareas per field
- Hire tasks include structured role spec in description

Plan parser:
- Handles "## Role N: Name" format with ### sub-sections
- Handles "### N. Name" format with **Label:** bullets
- Maps CEO's labels (Why→Summary, Responsibilities→Expertise, etc.)
- Skips non-role sections (Summary, Next Steps, Mission, etc.)

Other:
- localStorage persistence for wizard state (survives page refresh)
- Cleaned up step 6 summary (removed redundant company/CEO entries)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:35:55 -07:00
scotttong
b60fcd8d06 experiment: add hiring plan review (step 5) and hire tasks (step 6)
Step 5 - Review Hiring Plan:
- Parses CEO's markdown plan into structured role cards
- Each role has a checkbox (include/exclude), edit button, delete button
- Inline editing for role name and description
- "Add role" button to create new roles manually
- "Revise with CEO" button returns to chat (step 4)
- Collapsible raw plan view for reference
- "Approve" button only enabled when at least one role is selected

Step 6 - Make Your First Hires:
- Summary showing company, CEO, and all approved roles
- "Make your first hires" creates one task per approved role
  assigned to the CEO (e.g., "Hire: Content Strategist")
- Navigates to the task list after completion

Plan parsing handles "- **Role Name**: description" markdown format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:41:27 -07:00
scotttong
b4ef0618e5 experiment: add OnboardingChat component and wire into step 4
Creates a new OnboardingChat component that provides an embedded chat
experience between the user and the CEO agent during onboarding.

How it works:
- After CEO creation (step 3), a planning task is auto-created and
  assigned to the CEO with the company mission as context
- An initial comment kicks off the conversation asking the CEO to
  propose a hiring plan
- OnboardingChat polls issuesApi.listComments every 4 seconds
- Messages render as chat bubbles (user on right, agent on left)
- A "thinking" indicator shows when waiting for the agent
- Automatically detects hiring plan patterns in agent responses
  (markdown headers/lists with role names)
- Calls onPlanDetected callback when a plan is found

The existing issue comment system is the backbone — no new server
endpoints needed. The agent wakes up automatically when comments
are posted via the existing wakeup-on-comment pipeline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:38:53 -07:00
scotttong
a35fac7281 experiment: redesign onboarding wizard with 6-step mission-driven flow
Replaces the old 4-step wizard (Company → Agent → Task → Launch) with a new
6-step flow that puts the user in control of key moments:

1. Define your mission (required, with questionnaire or direct input)
2. Launch your company! (celebration moment)
3. Bring your CEO to life (agent creation, reframed)
4. Chat with your CEO (placeholder for hiring plan chat)
5. Review hiring plan (placeholder for editable role cards)
6. Make your first hires (summary + task creation)

Key changes:
- Mission/goal is now mandatory (was optional)
- Two paths to define mission: "I know my mission" or "Help me figure it out"
- Prompt chips for inspiration
- Questionnaire generates a draft mission from 4 questions
- Company launch is a celebrated moment before CEO creation
- Step type expanded from 1-4 to 1-6 in DialogContext
- Agent creation step reframed as "giving the CEO a heartbeat"
- Steps 4-5 are placeholders for the chat and plan review components

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:22:15 -07:00
41 changed files with 4971 additions and 259 deletions

36
UX-EXPERIMENTS.md Normal file
View 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.

View 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 },
);
}

View file

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

@ -0,0 +1,122 @@
#!/bin/bash
# Kills the dev server, restarts it, creates a fresh company + CEO + task, opens chat.
# Usage: ./scripts/dev-fresh-chat.sh [company-name]
set -e
cd "$(dirname "$0")/.."
BASE="http://127.0.0.1:3000/api"
NAME="${1:-Dev Test $(date +%H%M%S)}"
# Kill existing server
echo "Killing existing server..."
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
sleep 1
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
sleep 1
# Start dev server in background
echo "Starting dev server..."
npm run dev > /tmp/paperclip-dev.log 2>&1 &
DEV_PID=$!
# Wait for server to be ready
echo -n "Waiting for server"
for i in $(seq 1 30); do
if curl -s "$BASE/health" > /dev/null 2>&1; then
echo " ready!"
break
fi
echo -n "."
sleep 1
if [ "$i" -eq 30 ]; then
echo " timed out! Check /tmp/paperclip-dev.log"
exit 1
fi
done
# Archive old test companies (keep "strata" and "faceless")
echo "Cleaning up old companies..."
COMPANIES=$(curl -s "$BASE/companies")
echo "$COMPANIES" | python3 -c "
import sys, json
companies = json.load(sys.stdin)
keep = {'strata', 'faceless'}
for c in companies:
name_lower = c.get('name', '').lower()
if not any(k in name_lower for k in keep) and not c.get('archivedAt'):
cid = c['id']
print(f\" archiving: {c['name']} ({cid})\")
" 2>/dev/null | while read -r line; do echo "$line"; done
# Actually archive them
echo "$COMPANIES" | python3 -c "
import sys, json
companies = json.load(sys.stdin)
keep = {'strata', 'faceless'}
for c in companies:
name_lower = c.get('name', '').lower()
if not any(k in name_lower for k in keep) and not c.get('archivedAt'):
print(c['id'])
" 2>/dev/null | while read -r CID; do
curl -s -X POST "$BASE/companies/$CID/archive" > /dev/null 2>&1 || true
done
MISSION="Create educational and news content about AI (technology, use cases, applications, policies) for elderly audiences on a faceless YouTube channel. Goal: \$5k MRR in passive income within 6 months."
echo "Creating company: $NAME"
COMPANY=$(curl -s -X POST "$BASE/companies" \
-H "Content-Type: application/json" \
-d "{\"name\": \"$NAME\"}")
COMPANY_ID=$(echo "$COMPANY" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
PREFIX=$(echo "$COMPANY" | python3 -c "import sys,json; print(json.load(sys.stdin)['issuePrefix'])")
echo " id: $COMPANY_ID prefix: $PREFIX"
echo "Setting company mission..."
curl -s -X POST "$BASE/companies/$COMPANY_ID/goals" \
-H "Content-Type: application/json" \
-d "{\"title\": \"$MISSION\", \"level\": \"company\"}" > /dev/null
echo "Creating CEO agent..."
AGENT=$(curl -s -X POST "$BASE/companies/$COMPANY_ID/agents" \
-H "Content-Type: application/json" \
-d '{
"name": "CEO",
"role": "ceo",
"adapterType": "claude_local",
"adapterConfig": {},
"runtimeConfig": {
"heartbeat": { "enabled": false, "intervalSec": 3600, "wakeOnDemand": false, "cooldownSec": 10, "maxConcurrentRuns": 1 }
}
}')
AGENT_ID=$(echo "$AGENT" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " agent id: $AGENT_ID"
# Create a lightweight chat task (needed for the comment system)
echo "Creating chat task..."
TASK=$(curl -s -X POST "$BASE/companies/$COMPANY_ID/issues" \
-H "Content-Type: application/json" \
-d "{
\"title\": \"Chat with CEO\",
\"description\": \"CEO onboarding conversation. Company mission: $MISSION\",
\"status\": \"in_progress\",
\"assigneeAgentId\": \"$AGENT_ID\"
}")
TASK_ID=$(echo "$TASK" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " task id: $TASK_ID"
URL="http://localhost:3000/$PREFIX/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

View file

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

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

View 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` |

View 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

View file

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

View file

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

View 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>
);
}

View file

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

View file

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

View 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>
);
}

View file

@ -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())}
/>
)}

View file

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

View file

@ -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 },
{

View file

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

View 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

View file

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

View file

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

View file

@ -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) => (

View file

@ -15,7 +15,7 @@ interface NewGoalDefaults {
}
interface OnboardingOptions {
initialStep?: 1 | 2 | 3 | 4;
initialStep?: 1 | 2 | 3 | 4 | 5 | 6;
companyId?: string;
}

View file

@ -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"] {

View file

@ -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"]);

View file

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

View file

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

View 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
View 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 panes 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>
);
}

View file

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

View file

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

View file

@ -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"],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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" },

View file

@ -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,
},
{