experiment: 3-panel CEO chat, artifacts, front door, and UX overhaul
New core product layout: resizable chat + artifacts panel replaces the old wizard-only flow. Front door (create/grow), onboarding exits to chat, CEO discusses strategy before planning. Approval actions live in the artifacts pane, not inline in chat. Chat history drawer, animated paperclip thinking indicator, optimistic typing, faster polling. Rename Issue → Task across all frontend UI labels (16 files). Add global pause/resume all agents on dashboard with sidebar badge. Move toasts to bottom-right. Add Artifacts page and sidebar nav item. Reorder wizard: Mission → CEO config → Launch (exits to chat). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05a2848b02
commit
2d8003d2f5
28 changed files with 1835 additions and 109 deletions
17
ui/public/paperclip-thinking.svg
Normal file
17
ui/public/paperclip-thinking.svg
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="-1.00 -1.00 26.00 26.00"
|
||||
style="transform:rotate(0deg);transform-origin:50% 50%;">
|
||||
<defs></defs>
|
||||
<style>
|
||||
@keyframes draw {
|
||||
0% { stroke-dasharray:0.000 85.717; stroke-dashoffset:-85.717; opacity:1; animation-timing-function:cubic-bezier(0.455, 0.03, 0.515, 0.955); }
|
||||
39.0625% { stroke-dasharray:85.717 85.717; stroke-dashoffset:0.000; opacity:1; animation-timing-function:cubic-bezier(0.55, 0.055, 0.675, 0.19); }
|
||||
78.1250% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; opacity:1; }
|
||||
78.2250% { opacity:0; }
|
||||
100% { stroke-dasharray:0.000 85.717; stroke-dashoffset:0.000; opacity:0; }
|
||||
}
|
||||
.p { animation: draw 3.840s linear infinite; }
|
||||
</style>
|
||||
<path class="p" d="M16 6 l-8.414 8.586 a2.000 2.000 0 0 0 2.828 2.828 l8.414 -8.586 a4.000 4.000 0 1 0 -5.657 -5.657 l-8.379 8.551 a6.000 6.000 0 1 0 8.485 8.485 l8.379 -8.551" fill="none" stroke="#ffffff"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -6,6 +6,8 @@ import { Layout } from "./components/Layout";
|
|||
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 { Dashboard } from "./pages/Dashboard";
|
||||
import { Companies } from "./pages/Companies";
|
||||
import { Agents } from "./pages/Agents";
|
||||
|
|
@ -113,6 +115,8 @@ function boardRoutes() {
|
|||
<>
|
||||
<Route index element={<Navigate to="dashboard" replace />} />
|
||||
<Route path="dashboard" element={<Dashboard />} />
|
||||
<Route path="chat" element={<Chat />} />
|
||||
<Route path="artifacts" element={<Artifacts />} />
|
||||
<Route path="onboarding" element={<OnboardingRoutePage />} />
|
||||
<Route path="companies" element={<Companies />} />
|
||||
<Route path="company/settings" element={<CompanySettings />} />
|
||||
|
|
@ -317,6 +321,8 @@ export function App() {
|
|||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
<Route path="chat" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="artifacts" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ export function OpenClawGatewayConfigFields({
|
|||
className={inputClass}
|
||||
>
|
||||
<option value="fixed">Fixed</option>
|
||||
<option value="issue">Per issue</option>
|
||||
<option value="issue">Per task</option>
|
||||
<option value="run">Per run</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
|
|
|||
339
ui/src/components/ArtifactsPanel.tsx
Normal file
339
ui/src/components/ArtifactsPanel.tsx
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { IssueWorkProduct } from "@paperclipai/shared";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
FileText,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
Globe,
|
||||
Server,
|
||||
Package,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
X,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface ArtifactsPanelProps {
|
||||
taskId: string;
|
||||
isAgentWorking?: boolean;
|
||||
/** Open the document viewer directly to a specific doc */
|
||||
openDocKey?: string | null;
|
||||
openDocTitle?: string | null;
|
||||
onClearOpenDoc?: () => void;
|
||||
/** Approval callbacks — called from the document viewer */
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
}
|
||||
|
||||
type FilterValue = "all" | "in_progress" | "for_review" | "completed";
|
||||
|
||||
const FILTERS: Array<{ label: string; value: FilterValue }> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "In Progress", value: "in_progress" },
|
||||
{ label: "For Review", value: "for_review" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
];
|
||||
|
||||
function matchesFilter(wp: IssueWorkProduct, filter: FilterValue): boolean {
|
||||
if (filter === "all") return true;
|
||||
if (filter === "in_progress") return wp.status === "active" || wp.status === "draft";
|
||||
if (filter === "for_review") return wp.status === "ready_for_review";
|
||||
if (filter === "completed") return wp.status === "approved" || wp.status === "merged";
|
||||
return true;
|
||||
}
|
||||
|
||||
function typeIcon(type: string) {
|
||||
switch (type) {
|
||||
case "document": return FileText;
|
||||
case "pull_request": return GitBranch;
|
||||
case "branch": return GitBranch;
|
||||
case "commit": return GitCommit;
|
||||
case "preview_url": return Globe;
|
||||
case "runtime_service": return Server;
|
||||
case "artifact": return Package;
|
||||
default: return FileText;
|
||||
}
|
||||
}
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case "active":
|
||||
case "draft":
|
||||
return { label: "In Progress", className: "bg-blue-500/10 text-blue-600 dark:text-blue-400" };
|
||||
case "ready_for_review":
|
||||
return { label: "For Review", className: "bg-amber-500/10 text-amber-600 dark:text-amber-400" };
|
||||
case "approved":
|
||||
case "merged":
|
||||
return { label: "Completed", className: "bg-green-500/10 text-green-600 dark:text-green-400" };
|
||||
case "changes_requested":
|
||||
return { label: "Changes Requested", className: "bg-orange-500/10 text-orange-600 dark:text-orange-400" };
|
||||
case "failed":
|
||||
return { label: "Failed", className: "bg-red-500/10 text-red-600 dark:text-red-400" };
|
||||
default:
|
||||
return { label: status, className: "bg-muted text-muted-foreground" };
|
||||
}
|
||||
}
|
||||
|
||||
export function ArtifactsPanel({ taskId, isAgentWorking, openDocKey, openDocTitle, onClearOpenDoc, onApprove, onReject }: ArtifactsPanelProps) {
|
||||
const [filter, setFilter] = useState<FilterValue>("all");
|
||||
const [viewingDoc, setViewingDoc] = useState<{ key: string; title: string } | null>(null);
|
||||
|
||||
const { data: workProducts, isLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.workProducts(taskId),
|
||||
queryFn: () => issuesApi.listWorkProducts(taskId),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Open doc from parent (e.g. clicking plan link in chat)
|
||||
const effectiveViewingDoc = openDocKey
|
||||
? { key: openDocKey, title: openDocTitle ?? "Document" }
|
||||
: viewingDoc;
|
||||
|
||||
const handleBack = () => {
|
||||
setViewingDoc(null);
|
||||
onClearOpenDoc?.();
|
||||
};
|
||||
|
||||
// Find the work product for the currently viewed doc to know its status
|
||||
const viewedWorkProduct = effectiveViewingDoc
|
||||
? (workProducts ?? []).find((wp) => wp.title === effectiveViewingDoc.title)
|
||||
: null;
|
||||
|
||||
const filtered = (workProducts ?? []).filter((wp) => matchesFilter(wp, filter));
|
||||
|
||||
// Document viewer
|
||||
if (effectiveViewingDoc) {
|
||||
return (
|
||||
<DocumentViewer
|
||||
taskId={taskId}
|
||||
docKey={effectiveViewingDoc.key}
|
||||
title={effectiveViewingDoc.title}
|
||||
onBack={handleBack}
|
||||
status={viewedWorkProduct?.status ?? null}
|
||||
reviewState={viewedWorkProduct?.reviewState ?? null}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" data-artifacts-panel>
|
||||
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<h3 className="text-sm font-semibold">Artifacts</h3>
|
||||
</div>
|
||||
|
||||
{/* Filter chips */}
|
||||
<div className="px-4 py-2 flex flex-wrap gap-1 border-b border-border">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
className={cn(
|
||||
"rounded-full px-2.5 py-0.5 text-[11px] font-medium transition-colors",
|
||||
filter === f.value
|
||||
? "bg-foreground text-background"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => setFilter(f.value)}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Work products list */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-auto-hide">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading...
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<Package className="h-8 w-8 mx-auto text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{workProducts?.length === 0
|
||||
? "Your team's deliverables and plans will appear here as they're produced."
|
||||
: "No artifacts match this filter."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{filtered.map((wp) => {
|
||||
const Icon = typeIcon(wp.type);
|
||||
const badge = statusBadge(wp.status);
|
||||
const isDraft = wp.status === "draft" || wp.status === "active";
|
||||
const showGenerating = isDraft && isAgentWorking;
|
||||
return (
|
||||
<button
|
||||
key={wp.id}
|
||||
className={cn(
|
||||
"w-full text-left px-4 py-3 hover:bg-accent/30 transition-colors",
|
||||
showGenerating && "bg-muted/30",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (wp.type === "document") {
|
||||
setViewingDoc({ key: "plan", title: wp.title });
|
||||
} else if (wp.url) {
|
||||
window.open(wp.url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{showGenerating ? (
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<span className="relative flex h-4 w-4">
|
||||
<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-4 w-4 bg-cyan-500" />
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Icon className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{wp.title}</span>
|
||||
{wp.url && (
|
||||
<ExternalLink className="h-3 w-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground capitalize">
|
||||
{wp.type.replace(/_/g, " ")}
|
||||
</span>
|
||||
{showGenerating ? (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-cyan-500/10 text-cyan-600 dark:text-cyan-400">
|
||||
<Loader2 className="h-2.5 w-2.5 animate-spin" />
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn("text-[10px] font-medium px-1.5 py-0.5 rounded-full", badge.className)}>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{wp.summary && (
|
||||
<p className="text-[11px] text-muted-foreground mt-1 line-clamp-2">
|
||||
{wp.summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentViewer({
|
||||
taskId,
|
||||
docKey,
|
||||
title,
|
||||
onBack,
|
||||
status,
|
||||
reviewState,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
taskId: string;
|
||||
docKey: string;
|
||||
title: string;
|
||||
onBack: () => void;
|
||||
status: string | null;
|
||||
reviewState: string | null;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
}) {
|
||||
const { data: doc, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.issues.documents(taskId),
|
||||
queryFn: () => issuesApi.getDocument(taskId, docKey),
|
||||
});
|
||||
|
||||
const needsAction = status === "ready_for_review" || reviewState === "needs_board_review";
|
||||
const isApproved = status === "approved" || reviewState === "approved";
|
||||
const isRejected = status === "changes_requested" || reviewState === "changes_requested";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-4 py-3 border-b border-border flex items-center gap-2">
|
||||
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<h3 className="text-sm font-semibold flex-1 truncate">{title}</h3>
|
||||
<button onClick={onBack} className="text-muted-foreground hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-auto-hide p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground text-sm">
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Loading document...
|
||||
</div>
|
||||
) : error ? (
|
||||
<p className="text-sm text-muted-foreground">Document not available yet.</p>
|
||||
) : doc?.body ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownBody>{doc.body}</MarkdownBody>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Document is empty.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky action footer */}
|
||||
{needsAction && (
|
||||
<div className="border-t border-border px-4 py-3 bg-background shrink-0">
|
||||
<p className="text-[11px] text-muted-foreground mb-2">This document needs your review.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-8 text-xs flex-1" onClick={onApprove}>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 mr-1" />
|
||||
Approve
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-8 text-xs flex-1" onClick={() => {
|
||||
onReject?.();
|
||||
onBack();
|
||||
}}>
|
||||
<RotateCcw className="h-3.5 w-3.5 mr-1" />
|
||||
Request Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isApproved && (
|
||||
<div className="border-t border-green-500/30 bg-green-500/5 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
<p className="text-[13px] font-medium text-green-700 dark:text-green-400">
|
||||
Approved — hire tasks created
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isRejected && (
|
||||
<div className="border-t border-orange-500/30 bg-orange-500/5 px-4 py-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-orange-500" />
|
||||
<p className="text-[13px] font-medium text-orange-700 dark:text-orange-400">
|
||||
Changes requested — CEO is revising
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
664
ui/src/components/CEOChatPanel.tsx
Normal file
664
ui/src/components/CEOChatPanel.tsx
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
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 { heartbeatsApi } from "../api/heartbeats";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
Loader2,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
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;
|
||||
}
|
||||
|
||||
/** Animated paperclip SVG thinking indicator */
|
||||
function PaperclipThinking({ className }: { className?: string }) {
|
||||
return (
|
||||
<img
|
||||
src="/paperclip-thinking.svg"
|
||||
alt=""
|
||||
className={cn("inline-block", className)}
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects whether a comment body contains a structured hiring plan.
|
||||
*/
|
||||
function detectHiringPlan(body: string): boolean {
|
||||
const planPatterns = [
|
||||
/##?\s*(hiring|team|org|roles|plan)/i,
|
||||
/##?\s*(proposed|recommended)\s*(roles|hires|team)/i,
|
||||
/\n-\s+\*\*[^*]+\*\*/g,
|
||||
/\|\s*role\s*\|/i,
|
||||
];
|
||||
return planPatterns.some((pattern) => pattern.test(body));
|
||||
}
|
||||
|
||||
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 */
|
||||
function getSuggestionChips(
|
||||
hasActiveRun: boolean,
|
||||
hasPlanDetected: boolean,
|
||||
hasComments: boolean,
|
||||
): Array<{ label: string; message: string }> {
|
||||
if (hasPlanDetected) {
|
||||
return [
|
||||
{ label: "I want to make changes", message: "I'd like to make some changes to the plan before approving." },
|
||||
{ label: "Add another role", message: "Can you add another role to the plan?" },
|
||||
];
|
||||
}
|
||||
if (hasActiveRun) {
|
||||
return [
|
||||
{ label: "What can I do while waiting?", message: "What can I do while you're working on the plan?" },
|
||||
{ label: "Tell me about team structure", message: "Tell me about how you're thinking about the team structure." },
|
||||
];
|
||||
}
|
||||
if (hasComments) {
|
||||
return [
|
||||
{ label: "What should we prioritize?", message: "What should we prioritize first?" },
|
||||
{ label: "Create a new project", message: "Let's create a new project to work on." },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ label: "Let's talk strategy", message: "Before we hire anyone, I'd like to discuss our strategy and priorities." },
|
||||
{ label: "What do you need from me?", message: "What information do you need from me to get started?" },
|
||||
];
|
||||
}
|
||||
|
||||
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);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
// Poll heartbeat — faster when actively waiting
|
||||
const { data: activeRun } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(taskId),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(taskId),
|
||||
refetchInterval: optimisticTyping ? 1500 : 3000,
|
||||
});
|
||||
|
||||
const comments = useMemo(
|
||||
() =>
|
||||
rawComments
|
||||
? [...rawComments].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||
)
|
||||
: undefined,
|
||||
[rawComments],
|
||||
);
|
||||
|
||||
// Welcome typing animation — show "typing" for 2.5s then reveal message
|
||||
useEffect(() => {
|
||||
if (comments && comments.length === 0 && welcomePhase === "typing") {
|
||||
const timer = setTimeout(() => setWelcomePhase("message"), 2500);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [comments, welcomePhase]);
|
||||
|
||||
// Clear optimistic typing when a new agent comment arrives
|
||||
useEffect(() => {
|
||||
if (optimisticTyping && comments?.length) {
|
||||
const lastComment = comments[comments.length - 1];
|
||||
if (lastComment.authorAgentId) {
|
||||
setOptimisticTyping(false);
|
||||
}
|
||||
}
|
||||
}, [comments, optimisticTyping]);
|
||||
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [comments?.length]);
|
||||
|
||||
// Detect hiring plan
|
||||
useEffect(() => {
|
||||
if (!comments || detectedPlanCommentId) return;
|
||||
|
||||
let cutoffIdx = -1;
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
if (comments[i].authorUserId) { cutoffIdx = i; break; }
|
||||
}
|
||||
if (ignoreBeforeCommentId) {
|
||||
const ignoreIdx = comments.findIndex((c) => c.id === ignoreBeforeCommentId);
|
||||
if (ignoreIdx >= 0) cutoffIdx = Math.max(cutoffIdx, ignoreIdx);
|
||||
}
|
||||
|
||||
for (let i = comments.length - 1; i > cutoffIdx; i--) {
|
||||
const c = comments[i];
|
||||
if (c.authorAgentId && detectHiringPlan(c.body)) {
|
||||
setDetectedPlanCommentId(c.id);
|
||||
// Update existing draft artifact to "ready_for_review", or create one
|
||||
(async () => {
|
||||
try {
|
||||
const wps = await issuesApi.listWorkProducts(taskId);
|
||||
const existing = wps.find((wp) => wp.title === "Hiring Plan");
|
||||
if (existing) {
|
||||
await issuesApi.updateWorkProduct(existing.id, {
|
||||
status: "ready_for_review",
|
||||
reviewState: "needs_board_review",
|
||||
summary: "Hiring plan is ready for your review",
|
||||
});
|
||||
} else {
|
||||
await issuesApi.createWorkProduct(taskId, {
|
||||
type: "document",
|
||||
title: "Hiring Plan",
|
||||
provider: "paperclip",
|
||||
status: "ready_for_review",
|
||||
reviewState: "needs_board_review",
|
||||
isPrimary: true,
|
||||
summary: "Hiring plan is ready for your review",
|
||||
});
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
})();
|
||||
// Notify parent
|
||||
issuesApi.getDocument(taskId, "plan").then((doc) => {
|
||||
onPlanDetected?.(doc.body ?? c.body);
|
||||
}).catch(() => {
|
||||
onPlanDetected?.(c.body);
|
||||
});
|
||||
// Invalidate work products so ArtifactsPanel picks it up
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.workProducts(taskId),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [comments, detectedPlanCommentId, ignoreBeforeCommentId, taskId, onPlanDetected, queryClient]);
|
||||
|
||||
// Send message
|
||||
const sendMessage = useCallback(async (body: string) => {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
try {
|
||||
await issuesApi.update(taskId, { assigneeUserId: null });
|
||||
} catch { /* may already be null */ }
|
||||
try {
|
||||
await issuesApi.update(taskId, {
|
||||
assigneeAgentId: agentId,
|
||||
status: "in_progress",
|
||||
});
|
||||
} catch { /* may already be assigned */ }
|
||||
|
||||
await issuesApi.addComment(taskId, trimmed, true, true);
|
||||
setInput("");
|
||||
setOptimisticTyping(true); // Show typing indicator immediately
|
||||
const latestId = comments?.[comments.length - 1]?.id ?? null;
|
||||
setIgnoreBeforeCommentId(latestId);
|
||||
setDetectedPlanCommentId(null);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.issues.comments(taskId),
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [sending, taskId, agentId, queryClient, comments]);
|
||||
|
||||
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, !!detectedPlanCommentId, !!comments?.length);
|
||||
|
||||
// Dynamic placeholder
|
||||
const placeholder = hasActiveRun
|
||||
? `${agentName} is working...`
|
||||
: detectedPlanCommentId
|
||||
? "Ask your CEO to revise the plan..."
|
||||
: "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>
|
||||
)}
|
||||
|
||||
{/* Progress step indicator */}
|
||||
{showStatus && progressStep && (
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<Sparkles className="h-3 w-3 animate-pulse" />
|
||||
{progressStep}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto scrollbar-auto-hide space-y-2.5 p-4"
|
||||
>
|
||||
{/* CEO Welcome — typing indicator then message */}
|
||||
{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?.length === 0 && welcomePhase === "message" && (
|
||||
<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-300">
|
||||
<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>
|
||||
<p>
|
||||
Hello! I'm <strong>{agentName}</strong>{companyName ? <>, your CEO at <strong>{companyName}</strong></> : ", your CEO"}.
|
||||
</p>
|
||||
{companyGoal && (
|
||||
<p className="mt-0.5">
|
||||
Our mission: <em>{companyGoal}</em>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-0.5">
|
||||
I'd love to understand your vision and priorities before we start building the team. What's most important to you right now?
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments?.map((comment) => {
|
||||
const isAgent = Boolean(comment.authorAgentId);
|
||||
const isPlan = detectedPlanCommentId === comment.id;
|
||||
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>
|
||||
{isPlan && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-green-600 dark:text-green-400 font-medium">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Hiring plan detected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-xs dark:prose-invert max-w-none text-[13px] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
<MarkdownBody>
|
||||
{isAgent
|
||||
? comment.body.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
: comment.body}
|
||||
</MarkdownBody>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline plan link — opens in artifacts pane */}
|
||||
{isPlan && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 mt-1 mr-6 px-2.5 py-1.5 rounded-md border border-green-500/30 bg-green-500/5 hover:bg-green-500/10 transition-colors text-left"
|
||||
onClick={() => onOpenArtifact?.("plan", "Hiring Plan")}
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
|
||||
<span className="text-[12px] font-medium">Hiring Plan</span>
|
||||
<span className="text-[11px] text-muted-foreground">— tap to review in Artifacts</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Status indicator — click to toggle between paperclip SVG and blue dot */}
|
||||
{showStatus && (
|
||||
<button
|
||||
className="flex items-center justify-between text-[12px] text-muted-foreground px-3 py-1.5 w-full text-left hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setUsePaperclipIndicator((v) => !v)}
|
||||
title="Click to toggle thinking indicator style"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasActiveRun ? (
|
||||
<>
|
||||
{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>
|
||||
)}
|
||||
{getRunStatusMessage(activeRun.status, agentName, elapsed)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{usePaperclipIndicator ? (
|
||||
<PaperclipThinking />
|
||||
) : (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||
)}
|
||||
{getCyclingMessage(WAITING_MESSAGES, elapsed, agentName)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums shrink-0">
|
||||
{elapsedStr}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Optimistic typing indicator — shows immediately after user sends */}
|
||||
{optimisticTyping && !showStatus && (
|
||||
<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 */}
|
||||
<div className="px-3 pb-1.5 flex flex-wrap gap-1">
|
||||
{suggestionChips.map((chip) => (
|
||||
<button
|
||||
key={chip.label}
|
||||
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={() => sendMessage(chip.message)}
|
||||
>
|
||||
{chip.label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +106,7 @@ export function CommandPalette() {
|
|||
if (v && isMobile) setSidebarOpen(false);
|
||||
}}>
|
||||
<CommandInput
|
||||
placeholder="Search issues, agents, projects..."
|
||||
placeholder="Search tasks, agents, projects..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
|
|
@ -121,7 +121,7 @@ export function CommandPalette() {
|
|||
}}
|
||||
>
|
||||
<SquarePen className="mr-2 h-4 w-4" />
|
||||
Create new issue
|
||||
Create new task
|
||||
<span className="ml-auto text-xs text-muted-foreground">C</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
|
|
@ -152,7 +152,7 @@ export function CommandPalette() {
|
|||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/issues")}>
|
||||
<CircleDot className="mr-2 h-4 w-4" />
|
||||
Issues
|
||||
Tasks
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go("/projects")}>
|
||||
<Hexagon className="mr-2 h-4 w-4" />
|
||||
|
|
@ -179,7 +179,7 @@ export function CommandPalette() {
|
|||
{visibleIssues.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Issues">
|
||||
<CommandGroup heading="Tasks">
|
||||
{visibleIssues.slice(0, 10).map((issue) => (
|
||||
<CommandItem
|
||||
key={issue.id}
|
||||
|
|
|
|||
61
ui/src/components/FrontDoor.tsx
Normal file
61
ui/src/components/FrontDoor.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { Rocket, Sparkles } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface FrontDoorProps {
|
||||
onChoose: (path: "create" | "grow") => void;
|
||||
}
|
||||
|
||||
export function FrontDoor({ onChoose }: FrontDoorProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] px-8">
|
||||
<div className="text-center mb-10">
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Welcome to Paperclip
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
How would you like to get started?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-lg w-full">
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
|
||||
"hover:border-foreground hover:bg-accent/30 transition-all",
|
||||
"text-center group cursor-pointer",
|
||||
)}
|
||||
onClick={() => onChoose("create")}
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
|
||||
<Rocket className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Create a new company</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Start from scratch with a mission, hire a CEO, and build your team.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-3 rounded-lg border-2 border-border p-6",
|
||||
"hover:border-foreground hover:bg-accent/30 transition-all",
|
||||
"text-center group cursor-pointer",
|
||||
)}
|
||||
onClick={() => onChoose("grow")}
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-3 group-hover:bg-accent transition-colors">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">Grow my existing company</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Add AI-powered agents to your current workflows.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -322,9 +322,9 @@ export function IssuesList({
|
|||
setIssueSearch(e.target.value);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder="Search issues..."
|
||||
placeholder="Search tasks..."
|
||||
className="pl-7 text-xs sm:text-sm"
|
||||
aria-label="Search issues"
|
||||
aria-label="Search tasks"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -586,8 +586,8 @@ export function IssuesList({
|
|||
{!isLoading && filtered.length === 0 && viewState.viewMode === "list" && (
|
||||
<EmptyState
|
||||
icon={CircleDot}
|
||||
message="No issues match the current filters or search."
|
||||
action="Create Issue"
|
||||
message="No tasks match the current filters or search."
|
||||
action="Create Task"
|
||||
onAction={() => openNewIssue(newIssueDefaults())}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) {
|
|||
const items = useMemo<MobileNavItem[]>(
|
||||
() => [
|
||||
{ type: "link", to: "/dashboard", label: "Home", icon: House },
|
||||
{ type: "link", to: "/issues", label: "Issues", icon: CircleDot },
|
||||
{ type: "link", to: "/issues", label: "Tasks", icon: CircleDot },
|
||||
{ type: "action", label: "Create", icon: SquarePen, onClick: () => openNewIssue() },
|
||||
{ type: "link", to: "/agents/all", label: "Agents", icon: Users },
|
||||
{
|
||||
|
|
|
|||
|
|
@ -814,7 +814,7 @@ export function NewIssueDialog() {
|
|||
const hasSavedDraft = Boolean(savedDraft?.title.trim() || savedDraft?.description.trim());
|
||||
const canDiscardDraft = hasDraft || hasSavedDraft;
|
||||
const createIssueErrorMessage =
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create issue. Try again.";
|
||||
createIssue.error instanceof Error ? createIssue.error.message : "Failed to create task. Try again.";
|
||||
const stagedDocuments = stagedFiles.filter((file) => file.kind === "document");
|
||||
const stagedAttachments = stagedFiles.filter((file) => file.kind === "attachment");
|
||||
|
||||
|
|
@ -981,7 +981,7 @@ export function NewIssueDialog() {
|
|||
<div className="px-4 pt-4 pb-2 shrink-0">
|
||||
<textarea
|
||||
className="w-full text-lg font-semibold bg-transparent outline-none resize-none overflow-hidden placeholder:text-muted-foreground/50"
|
||||
placeholder="Issue title"
|
||||
placeholder="Task title"
|
||||
rows={1}
|
||||
value={title}
|
||||
onChange={(e) => {
|
||||
|
|
@ -1463,7 +1463,7 @@ export function NewIssueDialog() {
|
|||
>
|
||||
<span className="inline-flex items-center justify-center gap-1.5">
|
||||
{createIssue.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
<span>{createIssue.isPending ? "Creating..." : "Create Issue"}</span>
|
||||
<span>{createIssue.isPending ? "Creating..." : "Create Task"}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import { AsciiArtAnimation } from "./AsciiArtAnimation";
|
|||
import { ChoosePathButton } from "./PathInstructionsModal";
|
||||
import { HintIcon } from "./agent-config-primitives";
|
||||
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
|
||||
import { FrontDoor } from "./FrontDoor";
|
||||
import {
|
||||
Building2,
|
||||
Bot,
|
||||
|
|
@ -58,7 +59,7 @@ import {
|
|||
MessageSquare
|
||||
} from "lucide-react";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
type Step = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
type AdapterType =
|
||||
| "claude_local"
|
||||
| "codex_local"
|
||||
|
|
@ -348,13 +349,19 @@ export function OnboardingWizard() {
|
|||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const initialStep = onboardingOptions.initialStep ?? 1;
|
||||
const initialStep = onboardingOptions.initialStep ?? 0;
|
||||
const existingCompanyId = onboardingOptions.companyId;
|
||||
|
||||
// Restore saved state from localStorage (read once on mount)
|
||||
const saved = useMemo(loadSavedState, []);
|
||||
|
||||
const [step, setStep] = useState<Step>((saved?.step as Step) ?? initialStep);
|
||||
const [onboardingPath, setOnboardingPath] = useState<"create" | "grow" | null>((saved?.onboardingPath as "create" | "grow" | null) ?? null);
|
||||
|
||||
// "Grow existing" questionnaire fields
|
||||
const [growWorkflows, setGrowWorkflows] = useState((saved?.growWorkflows as string) ?? "");
|
||||
const [growPainPoints, setGrowPainPoints] = useState((saved?.growPainPoints as string) ?? "");
|
||||
const [growAutomate, setGrowAutomate] = useState((saved?.growAutomate as string) ?? "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modelOpen, setModelOpen] = useState(false);
|
||||
|
|
@ -452,6 +459,7 @@ export function OnboardingWizard() {
|
|||
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
|
||||
createdCompanyId, createdCompanyPrefix, createdAgentId,
|
||||
planningTaskId, planContent, hiringRoles,
|
||||
onboardingPath, growWorkflows, growPainPoints, growAutomate,
|
||||
};
|
||||
localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(state));
|
||||
}, [
|
||||
|
|
@ -459,6 +467,7 @@ export function OnboardingWizard() {
|
|||
q1, q2, q3, q4, agentName, adapterType, cwd, model, command, args, url,
|
||||
createdCompanyId, createdCompanyPrefix, createdAgentId,
|
||||
planningTaskId, planContent, hiringRoles,
|
||||
onboardingPath, growWorkflows, growPainPoints, growAutomate,
|
||||
]);
|
||||
|
||||
// Resize textarea when step 3 is shown or description changes
|
||||
|
|
@ -550,7 +559,11 @@ export function OnboardingWizard() {
|
|||
|
||||
function reset() {
|
||||
localStorage.removeItem(ONBOARDING_STORAGE_KEY);
|
||||
setStep(1);
|
||||
setStep(0);
|
||||
setOnboardingPath(null);
|
||||
setGrowWorkflows("");
|
||||
setGrowPainPoints("");
|
||||
setGrowAutomate("");
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setCompanyName("");
|
||||
|
|
@ -590,6 +603,14 @@ export function OnboardingWizard() {
|
|||
closeOnboarding();
|
||||
}
|
||||
|
||||
function handleLaunchToChat() {
|
||||
const prefix = createdCompanyPrefix;
|
||||
const taskId = planningTaskId;
|
||||
reset();
|
||||
closeOnboarding();
|
||||
navigate(prefix ? `/${prefix}/chat${taskId ? `?taskId=${taskId}` : ""}` : "/dashboard");
|
||||
}
|
||||
|
||||
function buildAdapterConfig(): Record<string, unknown> {
|
||||
const adapter = getUIAdapter(adapterType);
|
||||
const config = adapter.buildAdapterConfig({
|
||||
|
|
@ -680,7 +701,7 @@ export function OnboardingWizard() {
|
|||
queryKey: queryKeys.goals.list(company.id)
|
||||
});
|
||||
|
||||
setStep(2);
|
||||
setStep(2); // → CEO config (was celebration, now swapped)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create company");
|
||||
} finally {
|
||||
|
|
@ -753,13 +774,21 @@ export function OnboardingWizard() {
|
|||
|
||||
// Create the planning task unassigned — the CEO only gets assigned
|
||||
// when the user sends their first message (user controls initiation)
|
||||
const isGrowPath = onboardingPath === "grow";
|
||||
const growContext = isGrowPath
|
||||
? `\n\nExisting workflows: ${growWorkflows}\nPain points: ${growPainPoints}\nFirst automation priority: ${growAutomate}`
|
||||
: "";
|
||||
const planningIssue = await issuesApi.create(createdCompanyId, {
|
||||
title: "Build hiring plan with CEO",
|
||||
description: `Company mission: ${companyGoal}
|
||||
title: isGrowPath ? "Plan AI agents for existing company" : "Strategy & hiring plan with CEO",
|
||||
description: `Company mission: ${companyGoal}${growContext}
|
||||
|
||||
Collaborate with the board to create a hiring plan for the company.
|
||||
You are the CEO of this company. The board (the user) has just appointed you. Your first conversation should focus on STRATEGY — ask the board about their vision, priorities, and constraints. DO NOT immediately create a hiring plan. Instead:
|
||||
|
||||
IMPORTANT: When writing the hiring plan document, use this exact format for EACH role. Use ## headings for each role (e.g. "## 1. Role Name") and ### sub-headings for each section within the role:
|
||||
1. FIRST: Greet the board briefly, then ask strategic questions to understand their priorities. Have a real conversation.
|
||||
2. ONLY WHEN the board says they're ready (e.g. "let's build the plan", "get started", "hire the team"), THEN create the hiring plan document.
|
||||
3. When you do create the plan, save it as a document using the plan key.${isGrowPath ? "\n4. Focus on agents that address the existing pain points and automate current workflows." : ""}
|
||||
|
||||
When writing the hiring plan document, use this exact format for EACH role. Use ## headings for each role (e.g. "## 1. Role Name") and ### sub-headings for each section within the role:
|
||||
|
||||
## 1. Role Name
|
||||
### Summary
|
||||
|
|
@ -782,7 +811,8 @@ Follow this structure for every role in the plan.`,
|
|||
});
|
||||
setPlanningTaskId(planningIssue.id);
|
||||
|
||||
setStep(4);
|
||||
// Go to launch celebration step (step 3)
|
||||
setStep(3);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
} finally {
|
||||
|
|
@ -895,9 +925,10 @@ Follow this structure for every role in the plan.`,
|
|||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
if (step === 0) return; // front door requires click
|
||||
if (step === 1 && companyName.trim() && companyGoal.trim()) handleStep1Next();
|
||||
else if (step === 2) setStep(3);
|
||||
else if (step === 3 && agentName.trim()) handleStep2Next();
|
||||
else if (step === 2 && agentName.trim()) handleStep2Next();
|
||||
else if (step === 3) handleLaunchToChat();
|
||||
else if (step === 4) setStep(5);
|
||||
else if (step === 5) setStep(6);
|
||||
else if (step === 6) handleLaunch();
|
||||
|
|
@ -928,7 +959,18 @@ Follow this structure for every role in the plan.`,
|
|||
<span className="sr-only">Close</span>
|
||||
</button>
|
||||
|
||||
{/* Left half — form */}
|
||||
{/* Step 0: Front Door — full-screen choice */}
|
||||
{step === 0 && (
|
||||
<div className="w-full flex flex-col overflow-y-auto">
|
||||
<FrontDoor onChoose={(path) => {
|
||||
setOnboardingPath(path);
|
||||
setStep(1);
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left half — form (steps 1+) */}
|
||||
{step !== 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex flex-col overflow-y-auto transition-[width] duration-500 ease-in-out",
|
||||
|
|
@ -941,10 +983,8 @@ Follow this structure for every role in the plan.`,
|
|||
{(
|
||||
[
|
||||
{ step: 1 as Step, label: "Mission", icon: Building2 },
|
||||
{ step: 2 as Step, label: "Launch", icon: Rocket },
|
||||
{ step: 3 as Step, label: "CEO", icon: Bot },
|
||||
{ step: 4 as Step, label: "Plan", icon: Sparkles },
|
||||
{ step: 5 as Step, label: "Review", icon: ListTodo },
|
||||
{ step: 2 as Step, label: "CEO", icon: Bot },
|
||||
{ step: 3 as Step, label: "Launch", icon: Rocket },
|
||||
] as const
|
||||
).map(({ step: s, label, icon: Icon }) => (
|
||||
<button
|
||||
|
|
@ -965,7 +1005,105 @@ Follow this structure for every role in the plan.`,
|
|||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
{step === 1 && (
|
||||
{step === 1 && onboardingPath === "grow" && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium">Tell us about your company</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
We'll use this to configure your CEO and plan which agents to add.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className={cn("text-xs mb-1 block transition-colors", companyName.trim() ? "text-foreground" : "text-muted-foreground group-focus-within:text-foreground")}>
|
||||
Company name
|
||||
</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="Acme Corp"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">What does your company do?</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. We create educational YouTube content about AI"
|
||||
value={q1}
|
||||
onChange={(e) => setQ1(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">What are your current workflows?</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
|
||||
placeholder="e.g. Manual content creation, spreadsheet tracking, email outreach"
|
||||
value={growWorkflows}
|
||||
onChange={(e) => setGrowWorkflows(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">What pain points would you solve with AI?</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
|
||||
placeholder="e.g. Can't produce content fast enough, no time for social media"
|
||||
value={growPainPoints}
|
||||
onChange={(e) => setGrowPainPoints(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="group">
|
||||
<label className="text-xs text-muted-foreground mb-1 block">What would you automate first?</label>
|
||||
<input
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||
placeholder="e.g. Social media scheduling and content repurposing"
|
||||
value={growAutomate}
|
||||
onChange={(e) => setGrowAutomate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{companyName.trim() && q1.trim() && (
|
||||
<>
|
||||
{!companyGoal.trim() && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const parts = [q1.trim()];
|
||||
if (growPainPoints.trim()) parts.push(`Key challenge: ${growPainPoints.trim()}`);
|
||||
if (growAutomate.trim()) parts.push(`First priority: automate ${growAutomate.trim().toLowerCase()}`);
|
||||
setCompanyGoal(parts.join(". "));
|
||||
}}
|
||||
>
|
||||
Generate mission from answers
|
||||
</Button>
|
||||
)}
|
||||
{companyGoal.trim() && (
|
||||
<div className="group">
|
||||
<label className="text-xs text-foreground mb-1 block">Generated mission — edit however you like:</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50 resize-none min-h-[60px]"
|
||||
value={companyGoal}
|
||||
onChange={(e) => setCompanyGoal(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() => { setOnboardingPath(null); setStep(0); }}
|
||||
>
|
||||
← Back to start
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && onboardingPath !== "grow" && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
|
|
@ -1179,27 +1317,8 @@ Follow this structure for every role in the plan.`,
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Launch celebration */}
|
||||
{/* Step 2: Create your CEO */}
|
||||
{step === 2 && (
|
||||
<div className="space-y-6 text-center py-4">
|
||||
<div className="text-5xl">🚀</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{companyName} is live!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your company has been created with the mission:
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1 italic">
|
||||
"{companyGoal}"
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Next, let's bring your CEO to life.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Create your CEO (was step 2) */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-5">
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="bg-muted/50 p-2">
|
||||
|
|
@ -1671,6 +1790,25 @@ Follow this structure for every role in the plan.`,
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Launch celebration → exits to chat */}
|
||||
{step === 3 && (
|
||||
<div className="space-y-6 text-center py-4">
|
||||
<div className="text-5xl">🚀</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{companyName} is live!</h3>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Your company has been created. Your CEO is ready.
|
||||
</p>
|
||||
<p className="text-sm font-medium mt-1 italic">
|
||||
"{companyGoal}"
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start a conversation with your CEO to discuss strategy and build your team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Chat with CEO */}
|
||||
{step === 4 && (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -1892,7 +2030,7 @@ Follow this structure for every role in the plan.`,
|
|||
{/* Footer navigation */}
|
||||
<div className="flex items-center justify-between mt-8">
|
||||
<div>
|
||||
{step > 1 && step > (onboardingOptions.initialStep ?? 1) && (
|
||||
{step > 1 && step > (onboardingOptions.initialStep ?? 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
|
@ -1905,7 +2043,7 @@ Follow this structure for every role in the plan.`,
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{step === 1 && missionPath && (missionPath !== "questionnaire" || missionConfirmed) && (
|
||||
{step === 1 && (onboardingPath === "grow" || (missionPath && (missionPath !== "questionnaire" || missionConfirmed))) && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!companyName.trim() || !companyGoal.trim() || loading}
|
||||
|
|
@ -1920,15 +2058,6 @@ Follow this structure for every role in the plan.`,
|
|||
</Button>
|
||||
)}
|
||||
{step === 2 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setStep(3)}
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5 mr-1" />
|
||||
Hire your CEO
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={
|
||||
|
|
@ -1944,6 +2073,15 @@ Follow this structure for every role in the plan.`,
|
|||
{loading ? "Bringing to life..." : "Give it a heartbeat"}
|
||||
</Button>
|
||||
)}
|
||||
{step === 3 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleLaunchToChat}
|
||||
>
|
||||
<Rocket className="h-3.5 w-3.5 mr-1" />
|
||||
Launch company
|
||||
</Button>
|
||||
)}
|
||||
{step === 4 && !planContent && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
|
@ -1977,8 +2115,9 @@ Follow this structure for every role in the plan.`,
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right half — ASCII art (hidden on mobile) */}
|
||||
{/* Right half — ASCII art (hidden on mobile, only for step 1) */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden md:block overflow-hidden bg-[#1d1d1d] transition-[width,opacity] duration-500 ease-in-out",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import {
|
|||
CircleDot,
|
||||
Target,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Package,
|
||||
DollarSign,
|
||||
History,
|
||||
Search,
|
||||
|
|
@ -18,6 +20,7 @@ import { SidebarAgents } from "./SidebarAgents";
|
|||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useInboxBadge } from "../hooks/useInboxBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -35,6 +38,17 @@ export function Sidebar() {
|
|||
});
|
||||
const liveRunCount = liveRuns?.length ?? 0;
|
||||
|
||||
const { data: sidebarAgents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const allAgentsPaused = (() => {
|
||||
if (!sidebarAgents || sidebarAgents.length === 0) return false;
|
||||
const nonTerminated = sidebarAgents.filter((a) => a.status !== "terminated");
|
||||
return nonTerminated.length > 0 && nonTerminated.every((a) => a.status === "paused");
|
||||
})();
|
||||
|
||||
function openSearch() {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||
}
|
||||
|
|
@ -69,15 +83,23 @@ export function Sidebar() {
|
|||
|
||||
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{/* New Issue button aligned with nav items */}
|
||||
<SidebarNavItem to="/chat" label="Chat" icon={MessageSquare} />
|
||||
{/* New Task button aligned with nav items */}
|
||||
<button
|
||||
onClick={() => openNewIssue()}
|
||||
className="flex items-center gap-2.5 px-3 py-2 text-[13px] font-medium text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<SquarePen className="h-4 w-4 shrink-0" />
|
||||
<span className="truncate">New Issue</span>
|
||||
<span className="truncate">New Task</span>
|
||||
</button>
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} liveCount={liveRunCount} />
|
||||
<SidebarNavItem
|
||||
to="/dashboard"
|
||||
label="Dashboard"
|
||||
icon={LayoutDashboard}
|
||||
liveCount={allAgentsPaused ? undefined : liveRunCount}
|
||||
badge={allAgentsPaused ? "Paused" : undefined}
|
||||
badgeTone={allAgentsPaused ? "warning" : undefined}
|
||||
/>
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
|
|
@ -96,8 +118,9 @@ export function Sidebar() {
|
|||
</div>
|
||||
|
||||
<SidebarSection label="Work">
|
||||
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
|
||||
<SidebarNavItem to="/issues" label="Tasks" icon={CircleDot} />
|
||||
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
|
||||
<SidebarNavItem to="/artifacts" label="Artifacts" icon={Package} />
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarProjects />
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ interface SidebarNavItemProps {
|
|||
icon: LucideIcon;
|
||||
end?: boolean;
|
||||
className?: string;
|
||||
badge?: number;
|
||||
badgeTone?: "default" | "danger";
|
||||
badge?: number | string;
|
||||
badgeTone?: "default" | "danger" | "warning";
|
||||
alert?: boolean;
|
||||
liveCount?: number;
|
||||
}
|
||||
|
|
@ -59,13 +59,15 @@ export function SidebarNavItem({
|
|||
<span className="text-[11px] font-medium text-blue-600 dark:text-blue-400">{liveCount} live</span>
|
||||
</span>
|
||||
)}
|
||||
{badge != null && badge > 0 && (
|
||||
{badge != null && (typeof badge === "string" ? badge.length > 0 : badge > 0) && (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-full px-1.5 py-0.5 text-xs leading-none",
|
||||
badgeTone === "danger"
|
||||
? "bg-red-600/90 text-red-50"
|
||||
: "bg-primary text-primary-foreground",
|
||||
: badgeTone === "warning"
|
||||
? "bg-amber-500/90 text-amber-50"
|
||||
: "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export function ToastViewport() {
|
|||
<aside
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
className="pointer-events-none fixed bottom-3 left-3 z-[120] w-full max-w-sm px-1"
|
||||
className="pointer-events-none fixed bottom-3 right-3 z-[120] w-full max-w-sm px-1"
|
||||
>
|
||||
<ol className="flex w-full flex-col-reverse gap-2">
|
||||
{toasts.map((toast) => (
|
||||
|
|
|
|||
|
|
@ -1093,10 +1093,10 @@ function AgentOverview({
|
|||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart runs={runs} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||
<PriorityChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||
<IssueStatusChart issues={assignedIssues} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||
|
|
@ -1980,7 +1980,7 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
|
|||
>
|
||||
{clearSessionsForTouchedIssues.isPending
|
||||
? "clearing session..."
|
||||
: "clear session for these issues"}
|
||||
: "clear session for these tasks"}
|
||||
</button>
|
||||
{clearSessionsForTouchedIssues.isError && (
|
||||
<p className="text-[11px] text-destructive mt-1">
|
||||
|
|
|
|||
|
|
@ -156,8 +156,8 @@ export function ApprovalDetail() {
|
|||
? {
|
||||
label:
|
||||
(linkedIssues?.length ?? 0) > 1
|
||||
? "Review linked issues"
|
||||
: "Review linked issue",
|
||||
? "Review linked tasks"
|
||||
: "Review linked task",
|
||||
to: `/issues/${primaryLinkedIssue.identifier ?? primaryLinkedIssue.id}`,
|
||||
}
|
||||
: linkedAgentId
|
||||
|
|
|
|||
62
ui/src/pages/Artifacts.tsx
Normal file
62
ui/src/pages/Artifacts.tsx
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ArtifactsPanel } from "../components/ArtifactsPanel";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function Artifacts() {
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Artifacts" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
// Find tasks that might have work products — use the planning task or any active task
|
||||
const { data: issues, isLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.list(selectedCompanyId!),
|
||||
queryFn: () => issuesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const taskId = useMemo(() => {
|
||||
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"),
|
||||
);
|
||||
return planningTask?.id ?? issues?.[0]?.id ?? null;
|
||||
}, [issues]);
|
||||
|
||||
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 (!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 artifacts yet</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Artifacts will appear here as your agents produce deliverables.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-3rem)] -m-6 -mt-2">
|
||||
<ArtifactsPanel taskId={taskId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
ui/src/pages/Chat.tsx
Normal file
352
ui/src/pages/Chat.tsx
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
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;
|
||||
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"),
|
||||
);
|
||||
return planningTask?.id ?? null;
|
||||
}, [taskIdParam, issues]);
|
||||
|
||||
// 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 */ }
|
||||
|
||||
if (planMarkdown) {
|
||||
const roles = parseRolesFromPlan(planMarkdown);
|
||||
for (const role of roles) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId) });
|
||||
|
||||
// Confirmation in chat
|
||||
await issuesApi.addComment(
|
||||
taskId,
|
||||
`Plan approved! ${roles.length} hire task${roles.length === 1 ? "" : "s"} created. Let's build the team.`,
|
||||
false, false,
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(taskId) });
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.workProducts(taskId) });
|
||||
} catch { /* non-critical */ }
|
||||
}, [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(100vh-3rem)] -m-6 -mt-2">
|
||||
{/* 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;
|
||||
}
|
||||
|
|
@ -241,7 +241,7 @@ export function Companies() {
|
|||
<div className="flex items-center gap-1.5">
|
||||
<CircleDot className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
{issueCount} {issueCount === 1 ? "issue" : "issues"}
|
||||
{issueCount} {issueCount === 1 ? "task" : "tasks"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 tabular-nums">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { dashboardApi } from "../api/dashboard";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -19,7 +19,7 @@ import { ActivityRow } from "../components/ActivityRow";
|
|||
import { Identity } from "../components/Identity";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn, formatCents } from "../lib/utils";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle } from "lucide-react";
|
||||
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, PauseCircle, Pause, Play } from "lucide-react";
|
||||
import { ActiveAgentsPanel } from "../components/ActiveAgentsPanel";
|
||||
import { ChartCard, RunActivityChart, PriorityChart, IssueStatusChart, SuccessRateChart } from "../components/ActivityCharts";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
|
|
@ -35,7 +35,10 @@ export function Dashboard() {
|
|||
const { selectedCompanyId, companies } = useCompany();
|
||||
const { openOnboarding } = useDialog();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const [animatedActivityIds, setAnimatedActivityIds] = useState<Set<string>>(new Set());
|
||||
const [allPaused, setAllPaused] = useState(false);
|
||||
const [isPauseToggling, setIsPauseToggling] = useState(false);
|
||||
const seenActivityIdsRef = useRef<Set<string>>(new Set());
|
||||
const hydratedActivityRef = useRef(false);
|
||||
const activityAnimationTimersRef = useRef<number[]>([]);
|
||||
|
|
@ -46,6 +49,37 @@ export function Dashboard() {
|
|||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// Sync allPaused state with actual agent data
|
||||
useEffect(() => {
|
||||
if (agents && agents.length > 0) {
|
||||
const nonTerminated = agents.filter((a) => a.status !== "terminated");
|
||||
if (nonTerminated.length > 0) {
|
||||
setAllPaused(nonTerminated.every((a) => a.status === "paused"));
|
||||
}
|
||||
}
|
||||
}, [agents]);
|
||||
|
||||
async function handleTogglePauseAll() {
|
||||
if (!agents || !selectedCompanyId || isPauseToggling) return;
|
||||
setIsPauseToggling(true);
|
||||
try {
|
||||
if (allPaused) {
|
||||
// Resume all paused agents
|
||||
const paused = agents.filter((a) => a.status === "paused");
|
||||
await Promise.all(paused.map((a) => agentsApi.resume(a.id, selectedCompanyId)));
|
||||
} else {
|
||||
// Pause all active/running agents
|
||||
const active = agents.filter((a) => a.status === "active" || a.status === "running");
|
||||
await Promise.all(active.map((a) => agentsApi.pause(a.id, selectedCompanyId)));
|
||||
}
|
||||
setAllPaused(!allPaused);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId) });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.dashboard(selectedCompanyId) });
|
||||
} finally {
|
||||
setIsPauseToggling(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Dashboard" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
|
@ -206,6 +240,33 @@ export function Dashboard() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{agents && agents.length > 0 && (
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
onClick={handleTogglePauseAll}
|
||||
disabled={isPauseToggling}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
allPaused
|
||||
? "bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
: "bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
{allPaused ? (
|
||||
<>
|
||||
<Play className="h-4 w-4" />
|
||||
{isPauseToggling ? "Resuming..." : "Resume All"}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="h-4 w-4" />
|
||||
{isPauseToggling ? "Pausing..." : "Pause All"}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActiveAgentsPanel companyId={selectedCompanyId!} />
|
||||
|
||||
{data && (
|
||||
|
|
@ -287,10 +348,10 @@ export function Dashboard() {
|
|||
<ChartCard title="Run Activity" subtitle="Last 14 days">
|
||||
<RunActivityChart runs={runs ?? []} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Priority" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Priority" subtitle="Last 14 days">
|
||||
<PriorityChart issues={issues ?? []} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Issues by Status" subtitle="Last 14 days">
|
||||
<ChartCard title="Tasks by Status" subtitle="Last 14 days">
|
||||
<IssueStatusChart issues={issues ?? []} />
|
||||
</ChartCard>
|
||||
<ChartCard title="Success Rate" subtitle="Last 14 days">
|
||||
|
|
|
|||
|
|
@ -769,7 +769,7 @@ export function DesignGuide() {
|
|||
<SubSection title="Metric Cards">
|
||||
<div className="grid md:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<MetricCard icon={Bot} value={12} label="Active Agents" description="+3 this week" />
|
||||
<MetricCard icon={CircleDot} value={48} label="Open Issues" />
|
||||
<MetricCard icon={CircleDot} value={48} label="Open Tasks" />
|
||||
<MetricCard icon={DollarSign} value="$1,234" label="Monthly Cost" description="Under budget" />
|
||||
<MetricCard icon={Zap} value="99.9%" label="Uptime" />
|
||||
</div>
|
||||
|
|
@ -1139,9 +1139,9 @@ export function DesignGuide() {
|
|||
</Section>
|
||||
|
||||
{/* ============================================================ */}
|
||||
{/* GROUPED LIST (Issues pattern) */}
|
||||
{/* GROUPED LIST (Tasks pattern) */}
|
||||
{/* ============================================================ */}
|
||||
<Section title="Grouped List (Issues pattern)">
|
||||
<Section title="Grouped List (Tasks pattern)">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-muted/50 rounded-t-md">
|
||||
<StatusIcon status="in_progress" />
|
||||
|
|
@ -1310,7 +1310,7 @@ export function DesignGuide() {
|
|||
<div className="border border-border rounded-md divide-y divide-border text-sm">
|
||||
{[
|
||||
["Cmd+K / Ctrl+K", "Open Command Palette"],
|
||||
["C", "New Issue (outside inputs)"],
|
||||
["C", "New Task (outside inputs)"],
|
||||
["[", "Toggle Sidebar"],
|
||||
["]", "Toggle Properties Panel"],
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function ExecutionWorkspaceDetail() {
|
|||
<DetailRow label="Project">
|
||||
{workspace.projectId ? <Link to={`/projects/${workspace.projectId}`} className="hover:underline">{workspace.projectId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Source issue">
|
||||
<DetailRow label="Source task">
|
||||
{workspace.sourceIssueId ? <Link to={`/issues/${workspace.sourceIssueId}`} className="hover:underline">{workspace.sourceIssueId}</Link> : "None"}
|
||||
</DetailRow>
|
||||
<DetailRow label="Branch">{workspace.branchName ?? "None"}</DetailRow>
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ function FailedRunCard({
|
|||
</Link>
|
||||
) : (
|
||||
<span className="block text-sm text-muted-foreground">
|
||||
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked issue"}
|
||||
{run.errorCode ? `Error code: ${run.errorCode}` : "No linked task"}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
|
@ -596,7 +596,7 @@ export function Inbox() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="everything">All categories</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent issues</SelectItem>
|
||||
<SelectItem value="issues_i_touched">My recent tasks</SelectItem>
|
||||
<SelectItem value="join_requests">Join requests</SelectItem>
|
||||
<SelectItem value="approvals">Approvals</SelectItem>
|
||||
<SelectItem value="failed_runs">Failed runs</SelectItem>
|
||||
|
|
|
|||
|
|
@ -59,17 +59,17 @@ type CommentReassignment = {
|
|||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
"issue.created": "created the issue",
|
||||
"issue.updated": "updated the issue",
|
||||
"issue.checked_out": "checked out the issue",
|
||||
"issue.released": "released the issue",
|
||||
"issue.created": "created the task",
|
||||
"issue.updated": "updated the task",
|
||||
"issue.checked_out": "checked out the task",
|
||||
"issue.released": "released the task",
|
||||
"issue.comment_added": "added a comment",
|
||||
"issue.attachment_added": "added an attachment",
|
||||
"issue.attachment_removed": "removed an attachment",
|
||||
"issue.document_created": "created a document",
|
||||
"issue.document_updated": "updated a document",
|
||||
"issue.document_deleted": "deleted a document",
|
||||
"issue.deleted": "deleted the issue",
|
||||
"issue.deleted": "deleted the task",
|
||||
"agent.created": "created an agent",
|
||||
"agent.updated": "updated the agent",
|
||||
"agent.paused": "paused the agent",
|
||||
|
|
@ -160,8 +160,8 @@ function formatAction(action: string, details?: Record<string, unknown> | null):
|
|||
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
||||
parts.push(
|
||||
details.assigneeAgentId || details.assigneeUserId
|
||||
? "assigned the issue"
|
||||
: "unassigned the issue",
|
||||
? "assigned the task"
|
||||
: "unassigned the task",
|
||||
);
|
||||
}
|
||||
if (details.title !== undefined) parts.push("updated the title");
|
||||
|
|
@ -267,7 +267,7 @@ export function IssueDetail() {
|
|||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const sourceBreadcrumb = useMemo(
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Issues", href: "/issues" },
|
||||
() => readIssueDetailBreadcrumb(location.state) ?? { label: "Tasks", href: "/issues" },
|
||||
[location.state],
|
||||
);
|
||||
|
||||
|
|
@ -560,7 +560,7 @@ export function IssueDetail() {
|
|||
});
|
||||
|
||||
useEffect(() => {
|
||||
const titleLabel = issue?.title ?? issueId ?? "Issue";
|
||||
const titleLabel = issue?.title ?? issueId ?? "Task";
|
||||
setBreadcrumbs([
|
||||
sourceBreadcrumb,
|
||||
{ label: hasLiveRuns ? `🔵 ${titleLabel}` : titleLabel },
|
||||
|
|
@ -763,7 +763,7 @@ export function IssueDetail() {
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={copyIssueToClipboard}
|
||||
title="Copy issue as markdown"
|
||||
title="Copy task as markdown"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
|
@ -782,7 +782,7 @@ export function IssueDetail() {
|
|||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={copyIssueToClipboard}
|
||||
title="Copy issue as markdown"
|
||||
title="Copy task as markdown"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
|
@ -978,7 +978,7 @@ export function IssueDetail() {
|
|||
</TabsTrigger>
|
||||
<TabsTrigger value="subissues" className="gap-1.5">
|
||||
<ListTree className="h-3.5 w-3.5" />
|
||||
Sub-issues
|
||||
Sub-tasks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="activity" className="gap-1.5">
|
||||
<ActivityIcon className="h-3.5 w-3.5" />
|
||||
|
|
|
|||
|
|
@ -68,14 +68,14 @@ export function Issues() {
|
|||
const issueLinkState = useMemo(
|
||||
() =>
|
||||
createIssueDetailLocationState(
|
||||
"Issues",
|
||||
"Tasks",
|
||||
`${location.pathname}${location.search}${location.hash}`,
|
||||
),
|
||||
[location.pathname, location.search, location.hash],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Issues" }]);
|
||||
setBreadcrumbs([{ label: "Tasks" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
|
|
@ -93,7 +93,7 @@ export function Issues() {
|
|||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={CircleDot} message="Select a company to view issues." />;
|
||||
return <EmptyState icon={CircleDot} message="Select a company to view tasks." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ export function MyIssues() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "My Issues" }]);
|
||||
setBreadcrumbs([{ label: "My Tasks" }]);
|
||||
}, [setBreadcrumbs]);
|
||||
|
||||
const { data: issues, isLoading, error } = useQuery({
|
||||
|
|
@ -27,7 +27,7 @@ export function MyIssues() {
|
|||
});
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={ListTodo} message="Select a company to view your issues." />;
|
||||
return <EmptyState icon={ListTodo} message="Select a company to view your tasks." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
|
|
@ -44,7 +44,7 @@ export function MyIssues() {
|
|||
{error && <p className="text-sm text-destructive">{error.message}</p>}
|
||||
|
||||
{myIssues.length === 0 && (
|
||||
<EmptyState icon={ListTodo} message="No issues assigned to you." />
|
||||
<EmptyState icon={ListTodo} message="No tasks assigned to you." />
|
||||
)}
|
||||
|
||||
{myIssues.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ export function ProjectDetail() {
|
|||
<Tabs value={activeTab ?? "list"} onValueChange={(value) => handleTabChange(value as ProjectTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "list", label: "Issues" },
|
||||
{ value: "list", label: "Tasks" },
|
||||
{ value: "overview", label: "Overview" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ const surfaceOptions: Array<{
|
|||
},
|
||||
{
|
||||
id: "live",
|
||||
label: "Issue Widget",
|
||||
label: "Task Widget",
|
||||
eyebrow: "Live stream",
|
||||
description: "The issue-detail live run widget, optimized for following an active run without leaving the task page.",
|
||||
description: "The task-detail live run widget, optimized for following an active run without leaving the task page.",
|
||||
icon: RadioTower,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue