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:
scotttong 2026-03-19 16:45:21 -07:00
parent 05a2848b02
commit 2d8003d2f5
28 changed files with 1835 additions and 109 deletions

View 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

View file

@ -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 />} />

View file

@ -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>

View 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>
);
}

View 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>
);
}

View file

@ -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}

View 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>
);
}

View file

@ -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())}
/>
)}

View file

@ -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 },
{

View file

@ -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>

View file

@ -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",

View file

@ -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 />

View file

@ -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}

View file

@ -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) => (

View file

@ -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">

View file

@ -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

View 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
View 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;
}

View file

@ -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">

View file

@ -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">

View file

@ -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"],

View file

@ -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>

View file

@ -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>

View file

@ -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" />

View file

@ -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 (

View file

@ -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 && (

View file

@ -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" },

View file

@ -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,
},
{