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'])")
|
TASK_ID=$(echo "$TASK" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
echo " task id: $TASK_ID"
|
echo " task id: $TASK_ID"
|
||||||
|
|
||||||
URL="http://localhost:3000/$PREFIX/chat?taskId=$TASK_ID"
|
URL="http://localhost:3000/$PREFIX/board-chat"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Ready! Open:"
|
echo "Ready! Open:"
|
||||||
echo " $URL"
|
echo " $URL"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
import { useEffect, useRef } from "react";
|
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 { useQuery } from "@tanstack/react-query";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Layout } from "./components/Layout";
|
import { Layout } from "./components/Layout";
|
||||||
|
|
@ -7,7 +14,6 @@ import { OnboardingWizard } from "./components/OnboardingWizard";
|
||||||
import { authApi } from "./api/auth";
|
import { authApi } from "./api/auth";
|
||||||
import { healthApi } from "./api/health";
|
import { healthApi } from "./api/health";
|
||||||
import { Artifacts } from "./pages/Artifacts";
|
import { Artifacts } from "./pages/Artifacts";
|
||||||
import { Chat } from "./pages/Chat";
|
|
||||||
import { BoardChat } from "./pages/BoardChat";
|
import { BoardChat } from "./pages/BoardChat";
|
||||||
import { Dashboard } from "./pages/Dashboard";
|
import { Dashboard } from "./pages/Dashboard";
|
||||||
import { Companies } from "./pages/Companies";
|
import { Companies } from "./pages/Companies";
|
||||||
|
|
@ -111,12 +117,17 @@ function CloudAccessGate() {
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyChatToBoardRoomRedirect() {
|
||||||
|
const { search, hash } = useLocation();
|
||||||
|
return <Navigate to={{ pathname: "/board-chat", search, hash }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function boardRoutes() {
|
function boardRoutes() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Route index element={<Navigate to="dashboard" replace />} />
|
<Route index element={<Navigate to="dashboard" replace />} />
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="chat" element={<Chat />} />
|
<Route path="chat" element={<LegacyChatToBoardRoomRedirect />} />
|
||||||
<Route path="board-chat" element={<BoardChat />} />
|
<Route path="board-chat" element={<BoardChat />} />
|
||||||
<Route path="artifacts" element={<Artifacts />} />
|
<Route path="artifacts" element={<Artifacts />} />
|
||||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
<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 { Menu } from "lucide-react";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
|
|
@ -30,11 +30,23 @@ function GlobalToolbarPlugins({ context }: { context: GlobalToolbarContext }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOARD_ROOM_ROUTE_SEGMENT = "board-chat";
|
||||||
|
|
||||||
export function BreadcrumbBar() {
|
export function BreadcrumbBar() {
|
||||||
const { breadcrumbs } = useBreadcrumbs();
|
const { breadcrumbs } = useBreadcrumbs();
|
||||||
|
const location = useLocation();
|
||||||
const { toggleSidebar, isMobile } = useSidebar();
|
const { toggleSidebar, isMobile } = useSidebar();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
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(
|
const globalToolbarSlotContext = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
companyId: selectedCompanyId ?? null,
|
companyId: selectedCompanyId ?? null,
|
||||||
|
|
@ -45,7 +57,7 @@ export function BreadcrumbBar() {
|
||||||
|
|
||||||
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
const globalToolbarSlots = <GlobalToolbarPlugins context={globalToolbarSlotContext} />;
|
||||||
|
|
||||||
if (breadcrumbs.length === 0) {
|
if (displayBreadcrumbs.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center justify-end">
|
||||||
{globalToolbarSlots}
|
{globalToolbarSlots}
|
||||||
|
|
@ -66,13 +78,13 @@ export function BreadcrumbBar() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Single breadcrumb = page title (uppercase)
|
// Single breadcrumb = page title (uppercase)
|
||||||
if (breadcrumbs.length === 1) {
|
if (displayBreadcrumbs.length === 1) {
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
<div className="border-b border-border px-4 md:px-6 h-12 shrink-0 flex items-center">
|
||||||
{menuButton}
|
{menuButton}
|
||||||
<div className="min-w-0 overflow-hidden flex-1">
|
<div className="min-w-0 overflow-hidden flex-1">
|
||||||
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
<h1 className="text-sm font-semibold uppercase tracking-wider truncate">
|
||||||
{breadcrumbs[0].label}
|
{displayBreadcrumbs[0].label}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
{globalToolbarSlots}
|
{globalToolbarSlots}
|
||||||
|
|
@ -87,8 +99,8 @@ export function BreadcrumbBar() {
|
||||||
<div className="min-w-0 overflow-hidden flex-1">
|
<div className="min-w-0 overflow-hidden flex-1">
|
||||||
<Breadcrumb className="min-w-0 overflow-hidden">
|
<Breadcrumb className="min-w-0 overflow-hidden">
|
||||||
<BreadcrumbList className="flex-nowrap">
|
<BreadcrumbList className="flex-nowrap">
|
||||||
{breadcrumbs.map((crumb, i) => {
|
{displayBreadcrumbs.map((crumb, i) => {
|
||||||
const isLast = i === breadcrumbs.length - 1;
|
const isLast = i === displayBreadcrumbs.length - 1;
|
||||||
return (
|
return (
|
||||||
<Fragment key={i}>
|
<Fragment key={i}>
|
||||||
{i > 0 && <BreadcrumbSeparator />}
|
{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 />
|
<CompanyRail />
|
||||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-1">
|
||||||
<a
|
<a
|
||||||
href="https://docs.paperclip.ing/"
|
href="https://docs.paperclip.ing/"
|
||||||
|
|
@ -339,7 +339,7 @@ export function Layout() {
|
||||||
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-1">
|
||||||
<a
|
<a
|
||||||
href="https://docs.paperclip.ing/"
|
href="https://docs.paperclip.ing/"
|
||||||
|
|
|
||||||
|
|
@ -605,10 +605,9 @@ export function OnboardingWizard() {
|
||||||
|
|
||||||
function handleLaunchToChat() {
|
function handleLaunchToChat() {
|
||||||
const prefix = createdCompanyPrefix;
|
const prefix = createdCompanyPrefix;
|
||||||
const taskId = planningTaskId;
|
|
||||||
reset();
|
reset();
|
||||||
closeOnboarding();
|
closeOnboarding();
|
||||||
navigate(prefix ? `/${prefix}/chat${taskId ? `?taskId=${taskId}` : ""}` : "/dashboard");
|
navigate(prefix ? `/${prefix}/board-chat` : "/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAdapterConfig(): Record<string, unknown> {
|
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">
|
<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) */}
|
{/* 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">
|
<div className="flex items-center gap-1 px-3 h-12 shrink-0">
|
||||||
{selectedCompany?.brandColor && (
|
<span className="flex-1 min-w-0 text-sm font-bold text-foreground truncate">
|
||||||
<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">
|
|
||||||
{selectedCompany?.name ?? "Select company"}
|
{selectedCompany?.name ?? "Select company"}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<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">
|
<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">
|
<div className="flex flex-col gap-0.5">
|
||||||
<SidebarNavItem to="/chat" label="Chat" icon={MessageSquare} />
|
<SidebarNavItem to="/board-chat" label="Board Room" icon={MessageSquare} />
|
||||||
<SidebarNavItem to="/board-chat" label="Board Chat" icon={MessageSquare} />
|
|
||||||
{/* New Task button aligned with nav items */}
|
{/* New Task button aligned with nav items */}
|
||||||
<button
|
<button
|
||||||
onClick={() => openNewIssue()}
|
onClick={() => openNewIssue()}
|
||||||
|
|
|
||||||
|
|
@ -178,23 +178,47 @@
|
||||||
background: oklch(0.5 0 0);
|
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 {
|
.scrollbar-auto-hide::-webkit-scrollbar-track {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
}
|
}
|
||||||
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
.scrollbar-auto-hide::-webkit-scrollbar-thumb {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
border-radius: 4px;
|
||||||
transition: background 150ms ease;
|
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;
|
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;
|
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;
|
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 */
|
/* Expandable dialog transition for max-width changes */
|
||||||
[data-slot="dialog-content"] {
|
[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 { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { MarkdownBody } from "../components/MarkdownBody";
|
import { MarkdownBody } from "../components/MarkdownBody";
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
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.
|
* Uses /board/chat/stream to invoke Claude with the board skill as system prompt.
|
||||||
* The user manages their Paperclip company through natural conversation.
|
* 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() {
|
export function BoardChat() {
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBreadcrumbs([{ label: "Board Chat" }]);
|
setBreadcrumbs([{ label: "Board Room" }]);
|
||||||
}, [setBreadcrumbs]);
|
}, [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 [input, setInput] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [streamingText, setStreamingText] = useState("");
|
const [streamingText, setStreamingText] = useState("");
|
||||||
|
|
@ -51,6 +116,17 @@ export function BoardChat() {
|
||||||
}
|
}
|
||||||
}, [selectedCompanyId, boardIssueId, queryClient]);
|
}, [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
|
// Find or detect the board operations issue
|
||||||
const { data: issues } = useQuery({
|
const { data: issues } = useQuery({
|
||||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||||
|
|
@ -234,116 +310,210 @@ export function BoardChat() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100%+3rem)] -m-6">
|
<div className="flex h-[calc(100%+3rem)] flex-col -m-6">
|
||||||
{/* Header */}
|
<div
|
||||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
ref={splitContainerRef}
|
||||||
<h2 className="text-sm font-semibold">Board Concierge</h2>
|
className="flex min-h-0 min-w-0 flex-1 flex-row"
|
||||||
<p className="text-xs text-muted-foreground">
|
>
|
||||||
{selectedCompany?.name ?? "Your company"} — manage your org through chat
|
{/* Left: chat (self-contained pane) */}
|
||||||
</p>
|
<div
|
||||||
</div>
|
className="flex min-h-0 min-w-0 shrink-0 flex-col bg-background"
|
||||||
|
style={{ width: leftPaneWidth }}
|
||||||
{/* Messages */}
|
>
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-4">
|
<div className="relative shrink-0 px-4 py-3">
|
||||||
{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
|
<div
|
||||||
key={comment.id}
|
className="pointer-events-none absolute bottom-0 left-0 right-0 h-px bg-border"
|
||||||
className={cn("flex", isUser ? "justify-end" : "justify-start")}
|
aria-hidden
|
||||||
>
|
/>
|
||||||
<div
|
<h3 className="text-sm font-semibold">
|
||||||
className={cn(
|
{ceoAgent?.name ?? "Board Room"}
|
||||||
"max-w-[85%] px-3 py-2 text-sm",
|
</h3>
|
||||||
isUser
|
<p className="text-xs text-muted-foreground">
|
||||||
? "bg-blue-600 text-white [border-radius:12px_12px_0px_12px]"
|
{selectedCompany?.name ?? "Your company"}
|
||||||
: "bg-muted text-foreground [border-radius:12px_12px_12px_0px]",
|
</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>
|
<Send className="h-4 w-4" />
|
||||||
</div>
|
</Button>
|
||||||
</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}
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
||||||
</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