From f312f22f27d9e425faea33ee80f94e92d110e514 Mon Sep 17 00:00:00 2001 From: scotttong Date: Sun, 22 Mar 2026 02:09:34 -0700 Subject: [PATCH] =?UTF-8?q?experiment:=20unify=20board=20chat=20=E2=80=94?= =?UTF-8?q?=20Board=20Room,=20legacy=20redirect,=20split=20pane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/dev-fresh-chat.sh | 2 +- ui/src/App.tsx | 17 +- ui/src/components/BreadcrumbBar.tsx | 24 +- ui/src/components/CEOChatPanel.tsx | 935 ------------------------- ui/src/components/Layout.tsx | 4 +- ui/src/components/OnboardingWizard.tsx | 3 +- ui/src/components/Sidebar.tsx | 11 +- ui/src/index.css | 32 +- ui/src/pages/BoardChat.tsx | 386 +++++++--- ui/src/pages/Chat.tsx | 370 ---------- 10 files changed, 344 insertions(+), 1440 deletions(-) delete mode 100644 ui/src/components/CEOChatPanel.tsx delete mode 100644 ui/src/pages/Chat.tsx diff --git a/scripts/dev-fresh-chat.sh b/scripts/dev-fresh-chat.sh index 6a6c7eaf..1fcfdf52 100755 --- a/scripts/dev-fresh-chat.sh +++ b/scripts/dev-fresh-chat.sh @@ -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" diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b9d3769d..9f5b520f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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 ; } +function LegacyChatToBoardRoomRedirect() { + const { search, hash } = useLocation(); + return ; +} + function boardRoutes() { return ( <> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx index a4d1462a..370facb2 100644 --- a/ui/src/components/BreadcrumbBar.tsx +++ b/ui/src/components/BreadcrumbBar.tsx @@ -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 = ; - if (breadcrumbs.length === 0) { + if (displayBreadcrumbs.length === 0) { return (
{globalToolbarSlots} @@ -66,13 +78,13 @@ export function BreadcrumbBar() { ); // Single breadcrumb = page title (uppercase) - if (breadcrumbs.length === 1) { + if (displayBreadcrumbs.length === 1) { return (
{menuButton}

- {breadcrumbs[0].label} + {displayBreadcrumbs[0].label}

{globalToolbarSlots} @@ -87,8 +99,8 @@ export function BreadcrumbBar() {
- {breadcrumbs.map((crumb, i) => { - const isLast = i === breadcrumbs.length - 1; + {displayBreadcrumbs.map((crumb, i) => { + const isLast = i === displayBreadcrumbs.length - 1; return ( {i > 0 && } diff --git a/ui/src/components/CEOChatPanel.tsx b/ui/src/components/CEOChatPanel.tsx deleted file mode 100644 index 53149807..00000000 --- a/ui/src/components/CEOChatPanel.tsx +++ /dev/null @@ -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 ( - - ); -} - - - -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(null); - const [ignoreBeforeCommentId, setIgnoreBeforeCommentId] = useState(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(null); - const scrollRef = useRef(null); - const inputRef = useRef(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>(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 | 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 ( -
- - Loading conversation... -
- ); - } - - const filteredConversations = (conversations ?? []).filter((c) => - !drawerSearch || c.title.toLowerCase().includes(drawerSearch.toLowerCase()), - ); - - return ( -
- {/* Chat header */} -
- - {agentName} -
- - {/* Chat history drawer — slides over chat */} - {drawerOpen && ( -
-
- - Conversations - {onNewConversation && ( - - )} -
-
-
- - setDrawerSearch(e.target.value)} - autoFocus - /> -
-
-
- {filteredConversations.length === 0 ? ( -
- {conversations?.length === 0 ? "No conversations yet" : "No matches"} -
- ) : ( - filteredConversations.map((conv) => ( - - )) - )} -
-
- )} - - - {/* Messages */} -
- {/* CEO Welcome — typing indicator until welcome comment is saved and loaded */} - {comments !== undefined && comments.length === 0 && welcomePhase === "typing" && ( -
- {usePaperclipIndicator ? ( - - ) : ( - - - - - )} - {agentName} is composing a message... -
- )} - - {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 ( -
-
-
- - {isAgent ? agentName : "You"} - -
-
- {displayBody} -
-
- {/* Task/artifact metadata bar */} - {actions && ( -
- {actions.taskTitle && ( - - - Task created - - )} - {actions.artifacts?.map((a) => ( - - ))} -
- )} -
- ); - })} - - {/* Streaming response — shows text as it arrives */} - {streamingText && ( -
-
- - {agentName} - -
-
- {streamingText} -
-
- )} - - {/* Optimistic user message — shows instantly before server confirms */} - {optimisticMessage && ( -
-
- - You - -
-
- {optimisticMessage} -
-
- )} - - {/* Optimistic typing indicator — shows immediately after user sends */} - {optimisticTyping && ( -
- {usePaperclipIndicator ? ( - - ) : ( - - - - - )} - {agentName} is typing... -
- )} -
- - {/* Suggestion chips — hide after 4 messages */} - {(comments?.length ?? 0) < 4 &&
- {suggestionChips.map((chip) => ( - - ))} -
} - - {/* Input area */} -
- setInput(e.target.value)} - onKeyDown={handleKeyDown} - autoFocus - /> - -
-
- ); -} - diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 8d2e3e6f..5af37cde 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -286,7 +286,7 @@ export function Layout() { {isInstanceSettingsRoute ? : }
-
+ -
+
{ diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index c867be88..9cc77b6b 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -62,13 +62,7 @@ export function Sidebar() {