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
This commit is contained in:
scotttong 2026-03-22 02:09:34 -07:00
parent 1d06bd62c5
commit f312f22f27
10 changed files with 344 additions and 1440 deletions

View file

@ -108,7 +108,7 @@ TASK=$(curl -s -X POST "$BASE/companies/$COMPANY_ID/issues" \
TASK_ID=$(echo "$TASK" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo " task id: $TASK_ID"
URL="http://localhost:3000/$PREFIX/chat?taskId=$TASK_ID"
URL="http://localhost:3000/$PREFIX/board-chat"
echo ""
echo "Ready! Open:"
echo " $URL"

View file

@ -1,5 +1,12 @@
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";
@ -7,7 +14,6 @@ import { OnboardingWizard } from "./components/OnboardingWizard";
import { authApi } from "./api/auth";
import { healthApi } from "./api/health";
import { Artifacts } from "./pages/Artifacts";
import { Chat } from "./pages/Chat";
import { BoardChat } from "./pages/BoardChat";
import { Dashboard } from "./pages/Dashboard";
import { Companies } from "./pages/Companies";
@ -111,12 +117,17 @@ 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={<Chat />} />
<Route path="chat" element={<LegacyChatToBoardRoomRedirect />} />
<Route path="board-chat" element={<BoardChat />} />
<Route path="artifacts" element={<Artifacts />} />
<Route path="onboarding" element={<OnboardingRoutePage />} />

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

@ -1,935 +0,0 @@
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 { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { MarkdownBody } from "./MarkdownBody";
import { cn } from "../lib/utils";
import {
Loader2,
Send,
CheckCircle2,
History,
Search,
X,
Plus,
} from "lucide-react";
export interface ChatConversation {
id: string;
title: string;
lastMessage?: string;
updatedAt: string;
isActive?: boolean;
}
interface CEOChatPanelProps {
taskId: string;
agentId: string;
agentName: string;
companyId: string;
companyName?: string;
companyGoal?: string;
conversations?: ChatConversation[];
onSwitchConversation?: (taskId: string) => void;
onNewConversation?: () => void;
onPlanDetected?: (planMarkdown: string) => void;
onPlanApproved?: () => void;
onAgentWorkingChange?: (working: boolean) => void;
onOpenArtifact?: (key: string, title: string) => void;
}
/**
* Clean agent message content strip system init JSON, code blocks with
* raw config/tool dumps, structured signals, and other non-conversational output.
*/
function cleanAgentMessage(body: string): string {
let cleaned = body;
// Strip structured action signals
cleaned = cleaned.replace(/%%ACTIONS%%[\s\S]*?%%\/ACTIONS%%/g, "");
// Remove markdown links
cleaned = cleaned.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
// Remove lines that look like raw JSON objects (system init, config dumps)
cleaned = cleaned.replace(/^\s*\{["\w].*["\w]\}\s*$/gm, "");
// Remove code blocks containing JSON or system data
cleaned = cleaned.replace(/```(?:json|plaintext|text)?\s*\n?\{[\s\S]*?\}\s*\n?```/g, "");
// Remove lines that are clearly system output (tool lists, session IDs, etc.)
cleaned = cleaned.replace(/^.*"(?:type|subtype|session_id|tools|mcp_servers|model|permissionMode|slash_commands|agents)".*$/gm, "");
// Remove excessive blank lines
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
return cleaned.trim();
}
/**
* Pattern-match user input to return an instant canned CEO opener.
* Returns null if no pattern matches fall through to pure streaming.
*/
function getCannedOpener(message: string): string | null {
const lower = message.toLowerCase().trim();
// Greetings
if (/^(hi|hello|hey|howdy|sup|what's up|yo)\b/.test(lower)) {
return "Great to have you here! I've been reviewing our mission. What would you like to tackle first \u2014 building the team, or mapping out strategy?";
}
// Hiring plan / team building
if (/hiring\s*plan|build.*team|hire|team\s*plan|staffing/.test(lower)) {
return "On it. I'll start drafting a hiring plan tailored to our mission right now.";
}
// Strategy / roadmap
if (/strateg|roadmap|priorities|game\s*plan/.test(lower)) {
return "Good call. Let me pull together a strategic brief based on our goals.";
}
// Generic "build/create/draft X"
const buildMatch = lower.match(/(?:build|create|draft|write|make|start|set up)\s+(?:a\s+|an\s+|the\s+)?(.+)/);
if (buildMatch) {
return "Got it. I'll get that started and have something for you to review shortly.";
}
return null;
}
/**
* Layer 1 observer: detect actionable intent from the user's message.
* Returns task/artifact info to create immediately, before the CEO responds.
*/
function detectUserIntent(message: string): { taskTitle: string; artifactTitle: string } | null {
const lower = message.toLowerCase().trim();
// Hiring plan
if (/hiring\s*plan|build.*team|team\s*plan|staffing\s*plan/.test(lower)) {
return { taskTitle: "Create hiring plan", artifactTitle: "Hiring Plan" };
}
// Strategy
if (/strateg(?:y|ic)\s*(?:doc|document|plan|brief)?|roadmap/.test(lower)) {
return { taskTitle: "Create strategy document", artifactTitle: "Strategy Document" };
}
// Generic "build/create X"
const buildMatch = lower.match(/(?:build|create|draft|write|make)\s+(?:a\s+|an\s+|the\s+)?(.+?)(?:\s+for\s+|\s+about\s+|$)/);
if (buildMatch) {
const thing = buildMatch[1].replace(/[.!?]+$/, "").trim();
if (thing.length > 2 && thing.length < 60) {
const title = thing.split(/\s+/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
return { taskTitle: `Create ${thing}`, artifactTitle: title };
}
}
return null;
}
/** Metadata about actions triggered by a message */
interface MessageAction {
taskId?: string;
taskTitle?: string;
artifacts?: Array<{ title: string; status: string }>;
}
/**
* Check if a streaming chunk looks like system/init output rather than
* conversational text. Used to filter relay streaming.
*/
function isSystemChunk(text: string): boolean {
// JSON-like content
if (/^\s*\{/.test(text) && /"type"\s*:/.test(text)) return true;
// Tool/permission dumps
if (/"tools"\s*:\s*\[/.test(text)) return true;
if (/"mcp_servers"\s*:\s*\[/.test(text)) return true;
if (/"session_id"\s*:/.test(text)) return true;
return false;
}
/** Animated paperclip SVG thinking indicator */
function PaperclipThinking({ className }: { className?: string }) {
return (
<img
src="/paperclip-thinking.svg"
alt=""
className={cn("inline-block", className)}
style={{ width: 17, height: 17 }}
/>
);
}
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 {
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...`;
}
}
/** Stepped progress indicator for long waits */
function getProgressStep(elapsed: number): string | null {
if (elapsed < 10) return null;
if (elapsed < 30) return "Analyzing your mission...";
if (elapsed < 60) return "Drafting the plan...";
if (elapsed < 90) return "Detailing roles and responsibilities...";
return "Almost ready...";
}
/** Context-aware suggestion chips — label IS the message */
function getSuggestionChips(
hasActiveRun: boolean,
hasPlanDetected: boolean,
hasComments: boolean,
): string[] {
if (hasPlanDetected) {
return [
"I want to make changes",
"Add another role",
];
}
if (hasActiveRun) {
return [
"What can I do while waiting?",
"Tell me about team structure",
];
}
if (hasComments) {
return [
"What should we prioritize?",
"Build a hiring plan",
];
}
return [
"Build a hiring plan",
"Let's talk strategy",
];
}
export function CEOChatPanel({
taskId,
agentId,
agentName,
companyId,
companyName,
companyGoal,
conversations,
onSwitchConversation,
onNewConversation,
onPlanDetected,
onPlanApproved,
onAgentWorkingChange,
onOpenArtifact,
}: CEOChatPanelProps) {
const queryClient = useQueryClient();
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [detectedPlanCommentId, setDetectedPlanCommentId] = useState<string | null>(null);
const [ignoreBeforeCommentId, setIgnoreBeforeCommentId] = useState<string | null>(null);
const [usePaperclipIndicator, setUsePaperclipIndicator] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const [drawerSearch, setDrawerSearch] = useState("");
// Welcome typing animation — phases: typing → message
const [welcomePhase, setWelcomePhase] = useState<"typing" | "message">("typing");
// Optimistic typing indicator — shows immediately after user sends
const [optimisticTyping, setOptimisticTyping] = useState(false);
// Optimistic user message — shown instantly before server confirms
const [optimisticMessage, setOptimisticMessage] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Track whether we've already created a draft artifact in the current send cycle
const draftCreatedRef = useRef(false);
// Track actions (tasks/artifacts) associated with messages
const [messageActions, setMessageActions] = useState<Map<number, MessageAction>>(new Map());
// Poll comments — faster when waiting for a response
const { data: rawComments, isLoading } = useQuery({
queryKey: queryKeys.issues.comments(taskId),
queryFn: () => issuesApi.listComments(taskId),
refetchInterval: optimisticTyping ? 2000 : 4000,
});
// Heartbeat polling disabled — the stream endpoint handles chat directly.
const activeRun = null as any;
const comments = useMemo(
() =>
rawComments
? [...rawComments].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
)
: undefined,
[rawComments],
);
// Welcome message — show typing indicator, then persist as agent comment
const welcomeSavedRef = useRef(false);
useEffect(() => {
if (comments && comments.length === 0 && welcomePhase === "typing" && !welcomeSavedRef.current) {
welcomeSavedRef.current = true;
// Build the welcome text
let welcomeText = `Hello! I'm **${agentName}**${companyName ? `, your CEO at **${companyName}**` : ", your CEO"}.`;
if (companyGoal) {
welcomeText += `\n\nOur mission: *${companyGoal}*`;
}
welcomeText += `\n\nI'd love to understand your vision and priorities before we start building the team. What's most important to you right now?`;
// Save as agent comment after a brief typing delay
const timer = setTimeout(() => {
fetch(`/api/agents/${agentId}/chat/canned`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, message: welcomeText }),
}).then(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
setWelcomePhase("message");
}).catch(() => {
setWelcomePhase("message");
});
}, 1200);
return () => clearTimeout(timer);
}
}, [comments, welcomePhase, agentId, agentName, companyName, companyGoal, taskId, queryClient]);
// Clear optimistic typing when a NEW agent comment arrives (not the welcome)
const commentCountAtSendRef = useRef(0);
useEffect(() => {
if (optimisticTyping && comments?.length) {
// Only clear if a new agent comment appeared since we started sending
if (comments.length > commentCountAtSendRef.current) {
const newComments = comments.slice(commentCountAtSendRef.current);
if (newComments.some((c) => c.authorAgentId)) {
setOptimisticTyping(false);
}
}
}
}, [comments, optimisticTyping]);
// Clear optimistic message once it appears in the real comment list
useEffect(() => {
if (optimisticMessage && comments?.length) {
const hasUserMsg = comments.some((c) => c.authorUserId && c.body === optimisticMessage);
if (hasUserMsg) setOptimisticMessage(null);
}
}, [comments, optimisticMessage]);
// Detect hiring plan
// Plan detection removed — handled by server-side observer pattern in /chat/stream
// Streaming response state
// Streaming: buffer holds all received text, visible is what's shown (typewriter)
const [streamingText, setStreamingText] = useState("");
const streamingBufferRef = useRef("");
const streamingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Typewriter effect — progressively reveal streaming buffer
useEffect(() => {
if (streamingBufferRef.current.length > streamingText.length) {
if (!streamingTimerRef.current) {
streamingTimerRef.current = setInterval(() => {
setStreamingText((prev) => {
const buf = streamingBufferRef.current;
if (prev.length >= buf.length) {
if (streamingTimerRef.current) clearInterval(streamingTimerRef.current);
streamingTimerRef.current = null;
return prev;
}
// Reveal 2-4 chars per tick for natural typing feel
const step = Math.floor(Math.random() * 3) + 2;
return buf.slice(0, Math.min(prev.length + step, buf.length));
});
}, 12);
}
}
}, [streamingText]);
// Auto-scroll on new comments or streaming text
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [comments?.length, streamingText]);
// Build conversation context string from comments (used for artifact generation)
const buildConvoContext = useCallback(() => {
return comments?.map((c) => {
const role = c.authorAgentId ? "CEO" : "USER";
return `${role}: ${c.body}`;
}).join("\n\n") ?? "";
}, [comments]);
// Handle observer actions (Layer 2) — create tasks/artifacts if not already created by Layer 1
const handleObserverActions = useCallback(async (
actions: { artifacts?: Array<{ title: string; status: string }>; tasks?: Array<{ title: string; description?: string }> },
messageIndex: number,
) => {
const convoContext = buildConvoContext();
for (const artifact of actions.artifacts ?? []) {
// Dedup: skip if Layer 1 already created this artifact
if (draftCreatedRef.current) continue;
draftCreatedRef.current = true;
try {
const wp = await issuesApi.createWorkProduct(taskId, {
type: "document",
title: artifact.title,
provider: "paperclip",
status: "draft",
reviewState: "none",
isPrimary: true,
summary: `${agentName} is working on ${artifact.title}...`,
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
// Update message actions metadata
setMessageActions((prev) => {
const next = new Map(prev);
const existing = next.get(messageIndex) ?? {};
next.set(messageIndex, {
...existing,
artifacts: [...(existing.artifacts ?? []), { title: artifact.title, status: "generating" }],
});
return next;
});
// Fire background generation
fetch(`/api/agents/${agentId}/chat/generate-artifact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId,
artifactTitle: artifact.title,
workProductId: (wp as any).id,
conversationContext: convoContext,
}),
}).catch(() => {});
// Assign task to CEO
issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }).catch(() => {});
} catch { /* best effort */ }
}
for (const task of actions.tasks ?? []) {
try {
await issuesApi.create(companyId, {
title: task.title,
description: task.description,
assigneeAgentId: agentId,
status: "todo",
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(companyId) });
} catch { /* best effort */ }
}
}, [taskId, agentId, companyId, agentName, queryClient, buildConvoContext]);
// Send message — canned+stream hybrid with two-layer observer
const sendMessage = useCallback(async (body: string) => {
const trimmed = body.trim();
if (!trimmed || sending) return;
setSending(true);
setInput("");
setOptimisticMessage(trimmed);
commentCountAtSendRef.current = comments?.length ?? 0;
draftCreatedRef.current = false;
const latestId = comments?.[comments.length - 1]?.id ?? null;
setIgnoreBeforeCommentId(latestId);
setDetectedPlanCommentId(null);
const messageIndex = (comments?.length ?? 0) + 1; // Index for the CEO's response message
// --- Layer 1: Instant user intent detection ---
const intent = detectUserIntent(trimmed);
if (intent) {
draftCreatedRef.current = true;
// Create task + work product immediately
try {
const wp = await issuesApi.createWorkProduct(taskId, {
type: "document",
title: intent.artifactTitle,
provider: "paperclip",
status: "draft",
reviewState: "none",
isPrimary: true,
summary: `${agentName} is working on ${intent.artifactTitle}...`,
});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
// Set message actions metadata
setMessageActions((prev) => {
const next = new Map(prev);
next.set(messageIndex, {
taskTitle: intent.taskTitle,
artifacts: [{ title: intent.artifactTitle, status: "generating" }],
});
return next;
});
// Fire background artifact generation
const convoContext = buildConvoContext() + `\n\nUSER: ${trimmed}`;
fetch(`/api/agents/${agentId}/chat/generate-artifact`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId,
artifactTitle: intent.artifactTitle,
workProductId: (wp as any).id,
conversationContext: convoContext,
}),
}).catch(() => {});
// Update task status
issuesApi.update(taskId, { assigneeAgentId: agentId, status: "in_progress" }).catch(() => {});
} catch { /* best effort — proceed with chat */ }
}
// --- Canned + Stream hybrid ---
const cannedText = getCannedOpener(trimmed);
// Initialize streaming buffer
setStreamingText("");
streamingBufferRef.current = "";
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
if (cannedText) {
// Start typewriter with canned text immediately — no typing indicator
setOptimisticTyping(false);
streamingBufferRef.current = cannedText;
setStreamingText(cannedText.slice(0, 1)); // Kick typewriter
} else {
// No canned match — show typing indicator until first chunk
setOptimisticTyping(true);
}
try {
const controller = new AbortController();
const fetchTimeout = setTimeout(() => controller.abort(), 60000);
const res = await fetch(`/api/agents/${agentId}/chat/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ taskId, message: trimmed }),
signal: controller.signal,
});
clearTimeout(fetchTimeout);
if (!res.ok || !res.body) {
throw new Error("Relay not available");
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
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" && !isSystemChunk(event.text)) {
setOptimisticTyping(false);
if (cannedText) {
// Append real stream after canned text with a space separator
const separator = streamingBufferRef.current.length === cannedText.length ? " " : "";
streamingBufferRef.current += separator + event.text;
} else {
streamingBufferRef.current += event.text;
}
// Kick typewriter if not started
setStreamingText((prev) => prev || streamingBufferRef.current.slice(0, 1));
} else if (event.type === "done") {
// Flush remaining buffer
setStreamingText(streamingBufferRef.current);
if (streamingTimerRef.current) clearInterval(streamingTimerRef.current);
streamingTimerRef.current = null;
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
} else if (event.type === "observer" && event.actions) {
// Layer 2: CEO structured signal — create tasks/artifacts if Layer 1 didn't
handleObserverActions(event.actions, messageIndex);
} else if (event.type === "error") {
setStreamingText("");
streamingBufferRef.current = "";
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
}
} catch { /* malformed SSE line */ }
}
}
// Brief delay for typewriter to finish, then clear
setTimeout(() => {
setStreamingText("");
streamingBufferRef.current = "";
if (streamingTimerRef.current) { clearInterval(streamingTimerRef.current); streamingTimerRef.current = null; }
}, 500);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
} catch {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
} finally {
setSending(false);
setOptimisticTyping(false);
inputRef.current?.focus();
}
}, [sending, taskId, agentId, companyId, agentName, queryClient, comments, buildConvoContext, handleObserverActions]);
const handleSend = useCallback(() => {
sendMessage(input);
}, [input, sendMessage]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
// Status indicators
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;
// Notify parent of working state changes
useEffect(() => {
onAgentWorkingChange?.(!!showStatus);
}, [showStatus, onAgentWorkingChange]);
// Elapsed timer
const [elapsed, setElapsed] = useState(0);
const waitingSince = useMemo(() => {
if (!showStatus || !lastComment) return null;
if (lastComment.authorUserId) return new Date(lastComment.createdAt).getTime();
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`;
const progressStep = getProgressStep(elapsed);
const suggestionChips = getSuggestionChips(!!hasActiveRun, false, !!comments?.length);
// Dynamic placeholder
const placeholder = hasActiveRun
? `${agentName} is working...`
: "Send a message...";
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 conversation...
</div>
);
}
const filteredConversations = (conversations ?? []).filter((c) =>
!drawerSearch || c.title.toLowerCase().includes(drawerSearch.toLowerCase()),
);
return (
<div className="flex flex-col h-full relative">
{/* Chat header */}
<div className="px-3 py-2 border-b border-border flex items-center gap-2 shrink-0">
<button
className="text-muted-foreground hover:text-foreground transition-colors p-1 rounded"
onClick={() => setDrawerOpen(true)}
title="Chat history"
>
<History className="h-4 w-4" />
</button>
<span className="text-[13px] font-medium flex-1 truncate">{agentName}</span>
</div>
{/* Chat history drawer — slides over chat */}
{drawerOpen && (
<div className="absolute inset-0 z-20 bg-background flex flex-col animate-in slide-in-from-left duration-200">
<div className="px-3 py-2 border-b border-border flex items-center gap-2 shrink-0">
<button
className="text-muted-foreground hover:text-foreground p-1 rounded"
onClick={() => { setDrawerOpen(false); setDrawerSearch(""); }}
>
<X className="h-4 w-4" />
</button>
<span className="text-[13px] font-medium flex-1">Conversations</span>
{onNewConversation && (
<button
className="text-muted-foreground hover:text-foreground p-1 rounded"
onClick={onNewConversation}
title="New conversation"
>
<Plus className="h-4 w-4" />
</button>
)}
</div>
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 rounded-md border border-border px-2.5 py-1.5">
<Search className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<input
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/50"
placeholder="Search conversations..."
value={drawerSearch}
onChange={(e) => setDrawerSearch(e.target.value)}
autoFocus
/>
</div>
</div>
<div className="flex-1 overflow-y-auto scrollbar-auto-hide">
{filteredConversations.length === 0 ? (
<div className="px-3 py-6 text-center text-[12px] text-muted-foreground">
{conversations?.length === 0 ? "No conversations yet" : "No matches"}
</div>
) : (
filteredConversations.map((conv) => (
<button
key={conv.id}
className={cn(
"w-full text-left px-3 py-2.5 border-b border-border hover:bg-accent/30 transition-colors",
conv.isActive && "bg-accent/50",
)}
onClick={() => {
onSwitchConversation?.(conv.id);
setDrawerOpen(false);
setDrawerSearch("");
}}
>
<p className="text-[13px] font-medium truncate">{conv.title}</p>
{conv.lastMessage && (
<p className="text-[11px] text-muted-foreground truncate mt-0.5">{conv.lastMessage}</p>
)}
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
{new Date(conv.updatedAt).toLocaleDateString()}
</p>
</button>
))
)}
</div>
</div>
)}
{/* Messages */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto scrollbar-auto-hide space-y-2.5 p-4"
>
{/* CEO Welcome — typing indicator until welcome comment is saved and loaded */}
{comments !== undefined && comments.length === 0 && welcomePhase === "typing" && (
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-2">
{usePaperclipIndicator ? (
<PaperclipThinking />
) : (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500" />
</span>
)}
{agentName} is composing a message...
</div>
)}
{comments?.map((comment, idx) => {
const isAgent = Boolean(comment.authorAgentId);
// Hide comments that are entirely system output
const displayBody = isAgent ? cleanAgentMessage(comment.body) : comment.body;
if (isAgent && !displayBody) return null;
const actions = isAgent ? messageActions.get(idx) : undefined;
return (
<div key={comment.id}>
<div
className={cn(
"rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed",
isAgent
? "bg-muted/50 border border-border mr-6"
: "bg-accent/50 border border-accent ml-6",
)}
>
<div className="flex items-center gap-1.5 mb-0.5">
<span
className={cn(
"text-[10px] font-medium uppercase tracking-wide",
isAgent ? "text-muted-foreground" : "text-foreground/70",
)}
>
{isAgent ? agentName : "You"}
</span>
</div>
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{displayBody}</MarkdownBody>
</div>
</div>
{/* Task/artifact metadata bar */}
{actions && (
<div className="flex items-center gap-2 mt-1 ml-1 text-[11px] text-muted-foreground">
{actions.taskTitle && (
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-cyan-500" />
Task created
</span>
)}
{actions.artifacts?.map((a) => (
<button
key={a.title}
className="flex items-center gap-1 hover:text-foreground transition-colors"
onClick={() => onOpenArtifact?.(a.title.toLowerCase().replace(/\s+/g, "-"), a.title)}
>
<span className="text-muted-foreground/60">&middot;</span>
<Loader2 className={cn("h-3 w-3", a.status === "generating" ? "animate-spin text-cyan-500" : "text-green-500")} />
{a.title} &mdash; {a.status === "generating" ? "generating..." : "ready for review"}
</button>
))}
</div>
)}
</div>
);
})}
{/* Streaming response — shows text as it arrives */}
{streamingText && (
<div className="rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed bg-muted/50 border border-border mr-6 animate-in fade-in duration-150">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
{agentName}
</span>
</div>
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{streamingText}</MarkdownBody>
</div>
</div>
)}
{/* Optimistic user message — shows instantly before server confirms */}
{optimisticMessage && (
<div className="rounded-md px-2.5 py-1.5 text-[13px] leading-relaxed bg-accent/50 border border-accent ml-6">
<div className="flex items-center gap-1.5 mb-0.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-foreground/70">
You
</span>
</div>
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MarkdownBody>{optimisticMessage}</MarkdownBody>
</div>
</div>
)}
{/* Optimistic typing indicator — shows immediately after user sends */}
{optimisticTyping && (
<div className="flex items-center gap-2 text-[12px] text-muted-foreground px-3 py-1.5">
{usePaperclipIndicator ? (
<PaperclipThinking />
) : (
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500" />
</span>
)}
{agentName} is typing...
</div>
)}
</div>
{/* Suggestion chips — hide after 4 messages */}
{(comments?.length ?? 0) < 4 && <div className="px-3 pb-1.5 flex flex-wrap gap-1">
{suggestionChips.map((chip) => (
<button
key={chip}
className="rounded-full border border-border px-2 py-0.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
onClick={() => { setInput(chip); inputRef.current?.focus(); }}
>
{chip}
</button>
))}
</div>}
{/* Input area */}
<div className="flex items-center gap-1.5 px-3 pb-3 pt-1.5 border-t border-border">
<input
ref={inputRef}
type="text"
className="flex-1 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-[13px] outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
placeholder={placeholder}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
/>
<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>
);
}

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

@ -605,10 +605,9 @@ export function OnboardingWizard() {
function handleLaunchToChat() {
const prefix = createdCompanyPrefix;
const taskId = planningTaskId;
reset();
closeOnboarding();
navigate(prefix ? `/${prefix}/chat${taskId ? `?taskId=${taskId}` : ""}` : "/dashboard");
navigate(prefix ? `/${prefix}/board-chat` : "/dashboard");
}
function buildAdapterConfig(): Record<string, unknown> {

View file

@ -62,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
@ -83,8 +77,7 @@ 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">
<SidebarNavItem to="/chat" label="Chat" icon={MessageSquare} />
<SidebarNavItem to="/board-chat" label="Board Chat" icon={MessageSquare} />
<SidebarNavItem to="/board-chat" label="Board Room" icon={MessageSquare} />
{/* New Task button aligned with nav items */}
<button
onClick={() => openNewIssue()}

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

@ -1,12 +1,20 @@
import { useEffect, useState, useRef, useCallback } from "react";
import { useEffect, 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 { Send } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ListFilter, Send } from "lucide-react";
import { cn } from "../lib/utils";
/**
@ -14,15 +22,72 @@ import { cn } from "../lib/utils";
* 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;
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"];
/** One row of content; bubble scrolls horizontally so nothing wraps to a second line. */
const BOARD_CHAT_MARKDOWN_SINGLE_LINE =
"!flex w-max max-w-none flex-nowrap items-center gap-x-2 [&>*]:!my-0 [&>*]:shrink-0 [&>*]:whitespace-nowrap [&>ul]:inline-flex [&>ul]:flex-nowrap [&>ul]:gap-x-2 [&>ul]:!m-0 [&>ul]:!py-0 [&>ul]:!pl-0 [&>ol]:inline-flex [&>ol]:flex-nowrap [&>ol]:gap-x-2 [&>ol]:!m-0 [&>ol]:!py-0 [&>ol]:!pl-0 [&_li]:whitespace-nowrap";
const boardChatBubbleShell = "min-w-0 max-w-[85%] overflow-x-auto overflow-y-hidden px-3 py-2 text-sm";
export function BoardChat() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
useEffect(() => {
setBreadcrumbs([{ label: "Board Chat" }]);
setBreadcrumbs([{ label: "Board Room" }]);
}, [setBreadcrumbs]);
const splitContainerRef = useRef<HTMLDivElement>(null);
const [leftPaneWidth, setLeftPaneWidth] = useState(480);
const splitDragging = useRef(false);
const [agentFeedFilter, setAgentFeedFilter] = useState<AgentFeedFilterValue>("all");
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 ?? startWidth + 400;
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 (upper < lower) {
setLeftPaneWidth(Math.max(0, Math.round(inner / 2)));
} else {
setLeftPaneWidth(Math.min(upper, Math.max(lower, next)));
}
};
const onMouseUp = () => {
splitDragging.current = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
},
[leftPaneWidth],
);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [streamingText, setStreamingText] = useState("");
@ -51,6 +116,17 @@ export function BoardChat() {
}
}, [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!),
@ -234,116 +310,210 @@ export function BoardChat() {
}
return (
<div className="flex flex-col h-[calc(100%+3rem)] -m-6">
{/* Header */}
<div className="shrink-0 border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Board Concierge</h2>
<p className="text-xs text-muted-foreground">
{selectedCompany?.name ?? "Your company"} manage your org through chat
</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
{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.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{[
"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="px-3 py-1.5 text-xs rounded-full border border-border text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
>
{suggestion}
</button>
))}
</div>
</div>
)}
{sortedComments.map((comment) => {
const isUser = !comment.authorAgentId && comment.authorUserId !== "board-concierge";
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) */}
<div
className="flex min-h-0 min-w-0 shrink-0 flex-col bg-background"
style={{ width: leftPaneWidth }}
>
<div className="relative shrink-0 px-4 py-3">
<div
key={comment.id}
className={cn("flex", isUser ? "justify-end" : "justify-start")}
>
<div
className={cn(
"max-w-[85%] px-3 py-2 text-sm",
isUser
? "bg-blue-600 text-white [border-radius:12px_12px_0px_12px]"
: "bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
)}
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
aria-hidden
/>
<h3 className="text-sm font-semibold">
{ceoAgent?.name ?? "Board Room"}
</h3>
<p className="text-xs text-muted-foreground">
{selectedCompany?.name ?? "Your company"}
</p>
</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_SINGLE_LINE}>
{comment.body ?? ""}
</MarkdownBody>
</div>
</div>
);
})}
{/* Optimistic user message — shows instantly before server persists */}
{optimisticMessage && (
<div className="flex justify-end">
<div
className={cn(
boardChatBubbleShell,
"whitespace-nowrap 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_SINGLE_LINE}>{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"
>
<MarkdownBody>{comment.body ?? ""}</MarkdownBody>
</div>
</div>
);
})}
{/* 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}
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
{/* Streaming response */}
{streamingText && (
<div className="flex justify-start">
<div className="max-w-[85%] [border-radius:12px_12px_12px_0px] px-3 py-2 text-sm bg-muted text-foreground">
<MarkdownBody>{streamingText}</MarkdownBody>
</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>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="shrink-0 border-t border-border p-3">
<div className="flex gap-2 items-end">
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything about your company..."
rows={1}
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
size="icon"
onClick={handleSend}
disabled={!input.trim() || sending}
className="shrink-0"
>
<Send className="h-4 w-4" />
</Button>
</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>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0 text-muted-foreground"
aria-label="Filter agent feed"
>
<ListFilter />
</Button>
</DropdownMenuTrigger>
<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

@ -1,370 +0,0 @@
import { useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
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 { goalsApi } from "../api/goals";
import { queryKeys } from "../lib/queryKeys";
import { CEOChatPanel, type ChatConversation } from "../components/CEOChatPanel";
import { ArtifactsPanel } from "../components/ArtifactsPanel";
import { Loader2 } from "lucide-react";
export function Chat() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
useEffect(() => {
setBreadcrumbs([{ label: "Chat" }]);
}, [setBreadcrumbs]);
const [searchParams] = useSearchParams();
const taskIdParam = searchParams.get("taskId");
const [agentWorking, setAgentWorking] = useState(false);
const [openDocKey, setOpenDocKey] = useState<string | null>(null);
const [openDocTitle, setOpenDocTitle] = useState<string | null>(null);
// Resizable chat pane
const [chatWidth, setChatWidth] = useState(360);
const dragging = useRef(false);
const handleDragStart = useCallback((e: React.MouseEvent) => {
e.preventDefault();
dragging.current = true;
const startX = e.clientX;
const startWidth = chatWidth;
const onMouseMove = (ev: MouseEvent) => {
if (!dragging.current) return;
const newWidth = Math.min(600, Math.max(280, startWidth + ev.clientX - startX));
setChatWidth(newWidth);
};
const onMouseUp = () => {
dragging.current = false;
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
}, [chatWidth]);
const handleAgentWorkingChange = useCallback((working: boolean) => {
setAgentWorking(working);
}, []);
const handleOpenArtifact = useCallback((key: string, title: string) => {
setOpenDocKey(key);
setOpenDocTitle(title);
}, []);
const handleClearOpenDoc = useCallback(() => {
setOpenDocKey(null);
setOpenDocTitle(null);
}, []);
// Find CEO agent
const { data: agents, isLoading: agentsLoading } = 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],
);
const navigate = useNavigate();
// Fetch all issues for the conversation list + task finding
const { data: allIssues, isLoading: issuesLoading } = useQuery({
queryKey: queryKeys.issues.list(selectedCompanyId!),
queryFn: () => issuesApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
// Only use subset for task finding when no param
const issues = !taskIdParam ? allIssues : undefined;
// Get company goal for the greeting
const { data: goals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const companyGoal = useMemo(() => {
const goal = goals?.find((g) => g.level === "company");
return goal?.title ?? "";
}, [goals]);
const taskId = useMemo(() => {
if (taskIdParam) return taskIdParam;
// Find a planning/chat task by title, or fall back to any CEO-assigned task
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") ||
i.title.toLowerCase().includes("chat with ceo"),
);
if (planningTask) return planningTask.id;
// Fall back: any task assigned to the CEO agent
const ceoTask = ceoAgent && issues?.find((i) => i.assigneeAgentId === ceoAgent.id);
return ceoTask?.id ?? null;
}, [taskIdParam, issues, ceoAgent]);
// Build conversations list from CEO-assigned issues
const conversations: ChatConversation[] = useMemo(() => {
if (!allIssues || !ceoAgent) return [];
return allIssues
.filter((i) => i.assigneeAgentId === ceoAgent.id || i.id === taskId)
.map((i) => ({
id: i.id,
title: i.title,
updatedAt: String(i.updatedAt ?? i.createdAt),
isActive: i.id === taskId,
}))
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [allIssues, ceoAgent, taskId]);
const handleSwitchConversation = useCallback((newTaskId: string) => {
const prefix = selectedCompany?.issuePrefix;
if (prefix) {
navigate(`/${prefix}/chat?taskId=${newTaskId}`);
}
}, [selectedCompany, navigate]);
// Approve: update work product status + create hire tasks
const handleApprove = useCallback(async () => {
if (!taskId || !selectedCompanyId || !ceoAgent) return;
try {
// Update work product to approved
const wps = await issuesApi.listWorkProducts(taskId);
const planWp = wps.find((wp) => wp.title === "Hiring Plan");
if (planWp) {
await issuesApi.updateWorkProduct(planWp.id, {
status: "approved",
reviewState: "approved",
summary: "Hiring plan approved by the board",
});
}
// Parse plan and create hire tasks
let planMarkdown = "";
try {
const doc = await issuesApi.getDocument(taskId, "plan");
planMarkdown = doc.body ?? "";
} catch { /* fallback */ }
// Parse plan and create hire tasks
const roles = planMarkdown ? parseRolesFromPlan(planMarkdown) : [];
for (const role of roles) {
try {
await issuesApi.create(selectedCompanyId, {
title: `Hire: ${role.name}`,
description: `Hire a ${role.name} for the company.\n\n${role.spec}`,
assigneeAgentId: ceoAgent.id,
status: "todo",
});
} catch { /* skip failed task creation */ }
}
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
// Confirmation in chat
try {
await issuesApi.addComment(taskId, `Plan approved! ${roles.length} hire task${roles.length === 1 ? "" : "s"} created.`);
} catch { /* non-critical */ }
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
// Trigger CEO to respond immediately via stream endpoint
fetch(`/api/agents/${ceoAgent.id}/chat/canned`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskId,
message: `Great news! The plan has been approved. I've created ${roles.length} hire task${roles.length === 1 ? "" : "s"} and I'll start working on them right away. You can track progress in the Tasks view.`,
}),
}).then(() => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
}).catch(() => {});
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
} catch (err) {
console.error("Approve failed:", err);
}
}, [taskId, selectedCompanyId, ceoAgent, queryClient]);
// Reject: update work product to changes_requested, tell CEO to revise
const handleReject = useCallback(async () => {
if (!taskId || !ceoAgent) return;
try {
// Update existing work product status — don't create a new one
const wps = await issuesApi.listWorkProducts(taskId);
const planWp = wps.find((wp) => wp.title === "Hiring Plan");
if (planWp) {
await issuesApi.updateWorkProduct(planWp.id, {
status: "changes_requested",
reviewState: "changes_requested",
summary: "Board requested changes to the hiring plan",
});
}
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
// Tell CEO to revise via chat comment
await issuesApi.addComment(
taskId,
"I'd like you to revise the hiring plan. Please update the existing plan document with changes.",
true, true,
);
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
} catch { /* non-critical */ }
}, [taskId, ceoAgent, queryClient]);
const isLoading = agentsLoading || issuesLoading;
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 (!ceoAgent) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-sm">
<h2 className="text-lg font-semibold">No CEO agent found</h2>
<p className="text-sm text-muted-foreground mt-2">
Create a company with a CEO agent through onboarding to use the chat.
</p>
</div>
</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 planning task found</h2>
<p className="text-sm text-muted-foreground mt-2">
Start a conversation with your CEO by creating a planning task.
</p>
</div>
</div>
);
}
return (
<div className="flex h-[calc(100%+3rem)] -m-6">
{/* Left: Chat */}
<div className="shrink-0 border-r border-border" style={{ width: chatWidth }}>
<CEOChatPanel
taskId={taskId}
agentId={ceoAgent.id}
agentName={ceoAgent.name}
companyId={selectedCompanyId!}
companyName={selectedCompany?.name}
companyGoal={companyGoal}
conversations={conversations}
onSwitchConversation={handleSwitchConversation}
onAgentWorkingChange={handleAgentWorkingChange}
onOpenArtifact={handleOpenArtifact}
/>
</div>
{/* Drag handle */}
<div
className="w-1 shrink-0 cursor-col-resize bg-border hover:bg-foreground/20 transition-colors"
onMouseDown={handleDragStart}
/>
{/* Right: Artifacts */}
<div className="flex-1 min-w-0 hidden lg:block">
<ArtifactsPanel
taskId={taskId}
isAgentWorking={agentWorking}
openDocKey={openDocKey}
openDocTitle={openDocTitle}
onClearOpenDoc={handleClearOpenDoc}
onApprove={handleApprove}
onReject={handleReject}
/>
</div>
</div>
);
}
/**
* Minimal parser to extract role names and specs from a hiring plan markdown.
*/
function parseRolesFromPlan(markdown: string): Array<{ name: string; spec: string }> {
const roles: Array<{ name: string; spec: string }> = [];
const seen = new Set<string>();
const rolePattern = /^(?:role\s*\d+[:.]\s*|\d+[.)]\s*)/i;
const roleHeadingRegex = /^#{2,3}\s+(.+)$/gm;
let match: RegExpExecArray | null;
const positions: Array<{ title: string; start: number; contentStart: number }> = [];
while ((match = roleHeadingRegex.exec(markdown)) !== null) {
if (rolePattern.test(match[1].trim())) {
positions.push({
title: match[1].trim(),
start: match.index,
contentStart: match.index + match[0].length,
});
}
}
for (let i = 0; i < positions.length; i++) {
const end = i + 1 < positions.length ? positions[i + 1].start : markdown.length;
const body = markdown.slice(positions[i].contentStart, end).trim();
let name = positions[i].title
.replace(/^role\s*\d*[:.]\s*/i, "")
.replace(/^\d+[.)]\s*/, "")
.replace(/\*\*/g, "")
.trim();
if (name.length < 3 || seen.has(name.toLowerCase())) continue;
seen.add(name.toLowerCase());
roles.push({ name, spec: body });
}
// Fallback: numbered bold roles
if (roles.length === 0) {
const lines = markdown.split("\n");
let currentName = "";
let currentSpec: string[] = [];
for (const line of lines) {
const roleMatch = line.match(/^\s*(\d+)[.)]\s+\*\*([^*]+)\*\*/);
if (roleMatch) {
if (currentName && !seen.has(currentName.toLowerCase())) {
seen.add(currentName.toLowerCase());
roles.push({ name: currentName, spec: currentSpec.join("\n") });
}
currentName = roleMatch[2].trim();
currentSpec = [];
continue;
}
if (currentName && line.trim()) {
currentSpec.push(line.trim());
}
}
if (currentName && !seen.has(currentName.toLowerCase())) {
roles.push({ name: currentName, spec: currentSpec.join("\n") });
}
}
return roles;
}