From 1d06bd62c59060e886f05268404ec8e209fd5b53 Mon Sep 17 00:00:00 2001 From: scotttong Date: Fri, 20 Mar 2026 19:15:19 -0700 Subject: [PATCH] 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) --- doc/BOARD-CHAT-GUIDE.md | 177 +++++++++++++++++++++++++++++++ server/src/routes/agent-chat.ts | 37 ++++--- skills/paperclip-board/SKILL.md | 3 + ui/public/paperclip-thinking.svg | 2 +- ui/src/pages/BoardChat.tsx | 89 +++++++++++----- 5 files changed, 265 insertions(+), 43 deletions(-) create mode 100644 doc/BOARD-CHAT-GUIDE.md diff --git a/doc/BOARD-CHAT-GUIDE.md b/doc/BOARD-CHAT-GUIDE.md new file mode 100644 index 00000000..c11fda3f --- /dev/null +++ b/doc/BOARD-CHAT-GUIDE.md @@ -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. diff --git a/server/src/routes/agent-chat.ts b/server/src/routes/agent-chat.ts index af4158ce..63e1ac5a 100644 --- a/server/src/routes/agent-chat.ts +++ b/server/src/routes/agent-chat.ts @@ -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) { diff --git a/skills/paperclip-board/SKILL.md b/skills/paperclip-board/SKILL.md index 55fa03b4..4bbb4db9 100644 --- a/skills/paperclip-board/SKILL.md +++ b/skills/paperclip-board/SKILL.md @@ -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} diff --git a/ui/public/paperclip-thinking.svg b/ui/public/paperclip-thinking.svg index b52c3c6b..2019d0de 100644 --- a/ui/public/paperclip-thinking.svg +++ b/ui/public/paperclip-thinking.svg @@ -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; } diff --git a/ui/src/pages/BoardChat.tsx b/ui/src/pages/BoardChat.tsx index 407dd204..9f068d01 100644 --- a/ui/src/pages/BoardChat.tsx +++ b/ui/src/pages/BoardChat.tsx @@ -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(null); const [elapsedSec, setElapsedSec] = useState(0); + const [optimisticMessage, setOptimisticMessage] = useState(null); const messagesEndRef = useRef(null); const inputRef = useRef(null); const elapsedTimerRef = useRef | 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 */}
- {sortedComments.length === 0 && !streamingText && !sending && ( + {sortedComments.length === 0 && !streamingText && !sending && !optimisticMessage && (

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

+
+ {optimisticMessage} +
+
+ )} + {/* Streaming response */} {streamingText && (
@@ -265,18 +308,14 @@ export function BoardChat() {
)} - {/* Status / thinking indicator */} - {sending && !streamingText && ( -
-
-
- - {statusText || "Thinking..."} - {elapsedSec > 0 && ( - {elapsedSec}s - )} -
-
+ {/* Status bar — always visible while sending, independent from the chat bubble */} + {sending && ( +
+ + {statusText || "Thinking..."} + {elapsedSec > 0 && ( + {elapsedSec}s + )}
)} @@ -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} />