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:
parent
1d06bd62c5
commit
f312f22f27
10 changed files with 344 additions and 1440 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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">·</span>
|
||||
<Loader2 className={cn("h-3 w-3", a.status === "generating" ? "animate-spin text-cyan-500" : "text-green-500")} />
|
||||
{a.title} — {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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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/"
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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"] {
|
||||
|
|
|
|||
|
|
@ -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 pane’s 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue