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>
This commit is contained in:
parent
08e0e91af0
commit
1d06bd62c5
5 changed files with 265 additions and 43 deletions
177
doc/BOARD-CHAT-GUIDE.md
Normal file
177
doc/BOARD-CHAT-GUIDE.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Board Chat Guide
|
||||
|
||||
A step-by-step guide for managing your Paperclip company through Claude Code in the terminal.
|
||||
|
||||
## What is this?
|
||||
|
||||
Paperclip is a control plane for AI-agent companies. You create a company, hire AI agents, assign them tasks, and manage their work. Normally you'd do this through the web dashboard, but the **Board Chat** skill lets you do everything through a natural conversation with Claude in your terminal.
|
||||
|
||||
Think of it like texting an assistant who happens to have full access to your company's operations.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you start, you need two things:
|
||||
|
||||
1. **Paperclip running locally** — Ask your engineer to set this up. They'll run `pnpm dev` and tell you the URL (usually `http://localhost:3000`).
|
||||
|
||||
2. **Claude Code installed** — This is Anthropic's CLI tool. Install it by running:
|
||||
```
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
```
|
||||
|
||||
## Setup (one time, ~2 minutes)
|
||||
|
||||
### Step 1: Install the board skill
|
||||
|
||||
Open your terminal and navigate to the Paperclip project folder:
|
||||
|
||||
```
|
||||
cd ~/Projects/DEV/paperclip
|
||||
```
|
||||
|
||||
Run the setup command:
|
||||
|
||||
```
|
||||
pnpm paperclipai board setup
|
||||
```
|
||||
|
||||
This does two things:
|
||||
- Installs the board skill so Claude knows how to manage Paperclip
|
||||
- Shows you which companies exist (if any)
|
||||
|
||||
### Step 2: Set your environment
|
||||
|
||||
The setup command prints one or two lines starting with `export`. Copy and paste them into your terminal:
|
||||
|
||||
```
|
||||
export PAPERCLIP_API_URL='http://localhost:3000'
|
||||
```
|
||||
|
||||
If you already have a company, it will also show:
|
||||
```
|
||||
export PAPERCLIP_COMPANY_ID='your-company-id-here'
|
||||
```
|
||||
|
||||
Paste these lines and press Enter. They tell Claude where your Paperclip server is.
|
||||
|
||||
### Step 3: Launch Claude Code
|
||||
|
||||
```
|
||||
claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
The `--dangerously-skip-permissions` flag lets Claude run commands without asking you to approve each one. This is safe because it's only talking to your local Paperclip server.
|
||||
|
||||
That's it. You're in. Start typing.
|
||||
|
||||
## Your first conversation
|
||||
|
||||
### Starting a new company
|
||||
|
||||
```
|
||||
You: I want to start a new company called Megacorp. Our mission is to
|
||||
build the best widget marketplace on the internet.
|
||||
```
|
||||
|
||||
Claude will create the company and guide you through setting up your first CEO agent.
|
||||
|
||||
### If you already have a company
|
||||
|
||||
```
|
||||
You: What's happening today?
|
||||
```
|
||||
|
||||
Claude will show you a dashboard: how many agents you have, open tasks, budget usage, and anything that needs your attention.
|
||||
|
||||
## Common things you can ask
|
||||
|
||||
### Company overview
|
||||
- "What's the status of my company?"
|
||||
- "Show me the dashboard"
|
||||
- "How much have we spent this month?"
|
||||
|
||||
### Hiring agents
|
||||
- "Help me build a hiring plan"
|
||||
- "I need a frontend engineer and a content writer"
|
||||
- "Show me the candidates' system prompts"
|
||||
- "Approve all hires"
|
||||
|
||||
### Managing tasks
|
||||
- "What tasks are open?"
|
||||
- "What's the CEO working on?"
|
||||
- "Create a task to build a landing page and assign it to the frontend engineer"
|
||||
|
||||
### Approvals
|
||||
- "Are there any pending approvals?"
|
||||
- "Approve the designer hire"
|
||||
- "Reject the icon library request — too expensive"
|
||||
|
||||
### Costs
|
||||
- "How are my costs today?"
|
||||
- "Show me a breakdown by agent"
|
||||
|
||||
### Agent management
|
||||
- "Show me all my agents"
|
||||
- "What's the frontend engineer's system prompt?"
|
||||
- "Change the designer's focus to include UX research"
|
||||
|
||||
## Tips
|
||||
|
||||
### Be natural
|
||||
You don't need to use special commands or syntax. Just talk like you're chatting with a colleague. Claude understands context.
|
||||
|
||||
### Iterate on plans
|
||||
When building a hiring plan or strategy, you can go back and forth:
|
||||
```
|
||||
You: Cut the SEO specialist. Add a designer instead.
|
||||
You: Actually, make the designer focus on UX research too.
|
||||
You: Looks good. Hire them all.
|
||||
```
|
||||
|
||||
### Check the web UI
|
||||
Everything Claude does through chat is also visible in the Paperclip web dashboard. Go to `http://localhost:3000` in your browser to see the spatial view of your company — org chart, task board, cost graphs.
|
||||
|
||||
### Session continuity
|
||||
When you close the terminal and come back later, Claude won't remember your previous conversation. But it will read the decision log and check the dashboard, so it knows the current state of your company.
|
||||
|
||||
Start a new session the same way:
|
||||
```
|
||||
export PAPERCLIP_API_URL='http://localhost:3000'
|
||||
export PAPERCLIP_COMPANY_ID='your-company-id'
|
||||
claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
Then just say "What's happening?" and pick up where you left off.
|
||||
|
||||
### Editing across surfaces
|
||||
You can edit things (like hiring plans or agent prompts) in three places:
|
||||
1. **In chat** — describe the change and Claude makes it
|
||||
2. **In a file** — Claude can create local `.md` files you can edit in any text editor
|
||||
3. **In the web UI** — edit directly in the dashboard, then tell Claude "sync up"
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "PAPERCLIP_API_URL is not set"
|
||||
You forgot to run the `export` command. Paste it again:
|
||||
```
|
||||
export PAPERCLIP_API_URL='http://localhost:3000'
|
||||
```
|
||||
|
||||
### Claude keeps asking for permission to run commands
|
||||
You launched Claude without the permissions flag. Exit with Ctrl+C and relaunch:
|
||||
```
|
||||
claude --dangerously-skip-permissions
|
||||
```
|
||||
|
||||
### Nothing happens / commands fail
|
||||
The Paperclip server probably isn't running. Ask your engineer to start it with `pnpm dev`.
|
||||
|
||||
### Claude seems confused about my company
|
||||
Start fresh by telling Claude your company ID:
|
||||
```
|
||||
You: My company ID is abc123-def456. Show me the dashboard.
|
||||
```
|
||||
|
||||
## What's next
|
||||
|
||||
Once you're comfortable with the terminal experience, you can also try the **Board Chat** in the web UI — go to `http://localhost:3000` and click "Board Chat" in the sidebar. Same conversation, but inside the dashboard where you can see your agents and tasks alongside the chat.
|
||||
|
|
@ -669,7 +669,7 @@ If nothing to create, output empty arrays. ALWAYS include this signal line.`;
|
|||
const created = await issueSvc.create(companyId, {
|
||||
title: "Board Operations",
|
||||
description: "Standing issue for board concierge conversations and decision log",
|
||||
status: "in_progress",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
});
|
||||
issueId = created.id;
|
||||
|
|
@ -764,11 +764,28 @@ If nothing to create, output empty arrays. ALWAYS include this signal line.`;
|
|||
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) {
|
||||
|
|
@ -776,20 +793,10 @@ If nothing to create, output empty arrays. ALWAYS include this signal line.`;
|
|||
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" && res.writable) {
|
||||
// Forward tool-use status so UI can show activity
|
||||
const toolName = event.content_block.name ?? "working";
|
||||
let statusText = "Working...";
|
||||
if (toolName === "Bash" || toolName === "bash") {
|
||||
statusText = "Running a command...";
|
||||
} else if (toolName === "Read" || toolName === "read") {
|
||||
statusText = "Reading a file...";
|
||||
} else if (toolName === "Grep" || toolName === "grep") {
|
||||
statusText = "Searching...";
|
||||
} else {
|
||||
statusText = `Using ${toolName}...`;
|
||||
}
|
||||
res.write(`data: ${JSON.stringify({ type: "status", text: statusText })}\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) {
|
||||
|
|
|
|||
|
|
@ -230,6 +230,9 @@ Every new agent's system prompt MUST include these sections by default (unless t
|
|||
```markdown
|
||||
# {Agent Name}
|
||||
|
||||
## Model
|
||||
{Which model this agent uses. Default: sonnet}
|
||||
|
||||
## Description
|
||||
{One-line role summary}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
78.2250% { opacity:0; }
|
||||
100% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; opacity:0; }
|
||||
}
|
||||
.p { animation: draw 0.8s linear infinite; }
|
||||
.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"/>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
|
@ -6,7 +6,7 @@ import { issuesApi } from "../api/issues";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MarkdownBody } from "../components/MarkdownBody";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Send } from "lucide-react";
|
||||
import { Send } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
/**
|
||||
|
|
@ -29,10 +29,28 @@ export function BoardChat() {
|
|||
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]);
|
||||
|
||||
// Find or detect the board operations issue
|
||||
const { data: issues } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
|
|
@ -41,13 +59,14 @@ export function BoardChat() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!issues) return;
|
||||
if (!issues) {
|
||||
setBoardIssueId(null);
|
||||
return;
|
||||
}
|
||||
const boardIssue = issues.find(
|
||||
(i) => i.title === "Board Operations" && i.status !== "done" && i.status !== "cancelled",
|
||||
);
|
||||
if (boardIssue) {
|
||||
setBoardIssueId(boardIssue.id);
|
||||
}
|
||||
setBoardIssueId(boardIssue?.id ?? null);
|
||||
}, [issues]);
|
||||
|
||||
// Fetch comments for the board issue
|
||||
|
|
@ -62,14 +81,26 @@ export function BoardChat() {
|
|||
.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]);
|
||||
}, [sortedComments.length, streamingText, statusText, optimisticMessage]);
|
||||
|
||||
// Elapsed timer for thinking state
|
||||
useEffect(() => {
|
||||
if (sending && !streamingText) {
|
||||
if (sending) {
|
||||
setElapsedSec(0);
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedSec((prev) => prev + 1);
|
||||
|
|
@ -83,12 +114,15 @@ export function BoardChat() {
|
|||
return () => {
|
||||
if (elapsedTimerRef.current) clearInterval(elapsedTimerRef.current);
|
||||
};
|
||||
}, [sending, streamingText]);
|
||||
}, [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("");
|
||||
|
|
@ -211,7 +245,7 @@ export function BoardChat() {
|
|||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
||||
{sortedComments.length === 0 && !streamingText && !sending && (
|
||||
{sortedComments.length === 0 && !streamingText && !sending && !optimisticMessage && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ask me anything about your company — hiring, tasks, costs, approvals.
|
||||
|
|
@ -256,6 +290,15 @@ export function BoardChat() {
|
|||
);
|
||||
})}
|
||||
|
||||
{/* Optimistic user message — shows instantly before server persists */}
|
||||
{optimisticMessage && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[85%] px-3 py-2 text-sm bg-blue-600 text-white [border-radius:12px_12px_0px_12px]">
|
||||
{optimisticMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming response */}
|
||||
{streamingText && (
|
||||
<div className="flex justify-start">
|
||||
|
|
@ -265,18 +308,14 @@ export function BoardChat() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Status / thinking indicator */}
|
||||
{sending && !streamingText && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg px-3 py-2 text-sm bg-muted text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin shrink-0" />
|
||||
<span>{statusText || "Thinking..."}</span>
|
||||
{elapsedSec > 0 && (
|
||||
<span className="text-xs opacity-60">{elapsedSec}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Status bar — always visible while sending, independent from the chat bubble */}
|
||||
{sending && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground pl-1">
|
||||
<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>
|
||||
)}
|
||||
|
||||
|
|
@ -293,7 +332,7 @@ export function BoardChat() {
|
|||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything about your company..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
className="flex-1 resize-none [border-radius:12px] border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
disabled={sending}
|
||||
/>
|
||||
<Button
|
||||
|
|
@ -302,11 +341,7 @@ export function BoardChat() {
|
|||
disabled={!input.trim() || sending}
|
||||
className="shrink-0"
|
||||
>
|
||||
{sending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue