From 2d8003d2f5f922f17f88f79c9fc73328342865e3 Mon Sep 17 00:00:00 2001 From: scotttong Date: Thu, 19 Mar 2026 16:45:21 -0700 Subject: [PATCH] experiment: 3-panel CEO chat, artifacts, front door, and UX overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/public/paperclip-thinking.svg | 17 + ui/src/App.tsx | 6 + .../openclaw-gateway/config-fields.tsx | 2 +- ui/src/components/ArtifactsPanel.tsx | 339 +++++++++ ui/src/components/CEOChatPanel.tsx | 664 ++++++++++++++++++ ui/src/components/CommandPalette.tsx | 8 +- ui/src/components/FrontDoor.tsx | 61 ++ ui/src/components/IssuesList.tsx | 8 +- ui/src/components/MobileBottomNav.tsx | 2 +- ui/src/components/NewIssueDialog.tsx | 6 +- ui/src/components/OnboardingWizard.tsx | 237 +++++-- ui/src/components/Sidebar.tsx | 31 +- ui/src/components/SidebarNavItem.tsx | 10 +- ui/src/components/ToastViewport.tsx | 2 +- ui/src/pages/AgentDetail.tsx | 6 +- ui/src/pages/ApprovalDetail.tsx | 4 +- ui/src/pages/Artifacts.tsx | 62 ++ ui/src/pages/Chat.tsx | 352 ++++++++++ ui/src/pages/Companies.tsx | 2 +- ui/src/pages/Dashboard.tsx | 69 +- ui/src/pages/DesignGuide.tsx | 8 +- ui/src/pages/ExecutionWorkspaceDetail.tsx | 2 +- ui/src/pages/Inbox.tsx | 4 +- ui/src/pages/IssueDetail.tsx | 24 +- ui/src/pages/Issues.tsx | 6 +- ui/src/pages/MyIssues.tsx | 6 +- ui/src/pages/ProjectDetail.tsx | 2 +- ui/src/pages/RunTranscriptUxLab.tsx | 4 +- 28 files changed, 1835 insertions(+), 109 deletions(-) create mode 100644 ui/public/paperclip-thinking.svg create mode 100644 ui/src/components/ArtifactsPanel.tsx create mode 100644 ui/src/components/CEOChatPanel.tsx create mode 100644 ui/src/components/FrontDoor.tsx create mode 100644 ui/src/pages/Artifacts.tsx create mode 100644 ui/src/pages/Chat.tsx diff --git a/ui/public/paperclip-thinking.svg b/ui/public/paperclip-thinking.svg new file mode 100644 index 00000000..5fb88a0c --- /dev/null +++ b/ui/public/paperclip-thinking.svg @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/ui/src/App.tsx b/ui/src/App.tsx index ff1daecf..e97d3d68 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -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() { <> } /> } /> + } /> + } /> } /> } /> } /> @@ -317,6 +321,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/ui/src/adapters/openclaw-gateway/config-fields.tsx b/ui/src/adapters/openclaw-gateway/config-fields.tsx index 19780d94..ead29ab6 100644 --- a/ui/src/adapters/openclaw-gateway/config-fields.tsx +++ b/ui/src/adapters/openclaw-gateway/config-fields.tsx @@ -157,7 +157,7 @@ export function OpenClawGatewayConfigFields({ className={inputClass} > - + diff --git a/ui/src/components/ArtifactsPanel.tsx b/ui/src/components/ArtifactsPanel.tsx new file mode 100644 index 00000000..ef634f0c --- /dev/null +++ b/ui/src/components/ArtifactsPanel.tsx @@ -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("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 ( + + ); + } + + return ( +
+
+ +

Artifacts

+
+ + {/* Filter chips */} +
+ {FILTERS.map((f) => ( + + ))} +
+ + {/* Work products list */} +
+ {isLoading ? ( +
+ + Loading... +
+ ) : filtered.length === 0 ? ( +
+ +

+ {workProducts?.length === 0 + ? "Your team's deliverables and plans will appear here as they're produced." + : "No artifacts match this filter."} +

+
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} +
+
+ ); +} + +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 ( +
+
+ +

{title}

+ +
+
+ {isLoading ? ( +
+ + Loading document... +
+ ) : error ? ( +

Document not available yet.

+ ) : doc?.body ? ( +
+ {doc.body} +
+ ) : ( +

Document is empty.

+ )} +
+ + {/* Sticky action footer */} + {needsAction && ( +
+

This document needs your review.

+
+ + +
+
+ )} + {isApproved && ( +
+
+ +

+ Approved — hire tasks created +

+
+
+ )} + {isRejected && ( +
+
+ +

+ Changes requested — CEO is revising +

+
+
+ )} +
+ ); +} diff --git a/ui/src/components/CEOChatPanel.tsx b/ui/src/components/CEOChatPanel.tsx new file mode 100644 index 00000000..bb604e81 --- /dev/null +++ b/ui/src/components/CEOChatPanel.tsx @@ -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 ( + + ); +} + +/** + * 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(null); + const [ignoreBeforeCommentId, setIgnoreBeforeCommentId] = useState(null); + const [usePaperclipIndicator, setUsePaperclipIndicator] = useState(true); + const [drawerOpen, setDrawerOpen] = useState(false); + const [drawerSearch, setDrawerSearch] = useState(""); + // Welcome typing animation — phases: typing → message + const [welcomePhase, setWelcomePhase] = useState<"typing" | "message">("typing"); + // Optimistic typing indicator — shows immediately after user sends + const [optimisticTyping, setOptimisticTyping] = useState(false); + const scrollRef = useRef(null); + const inputRef = useRef(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 ( +
+ + Loading conversation... +
+ ); + } + + const filteredConversations = (conversations ?? []).filter((c) => + !drawerSearch || c.title.toLowerCase().includes(drawerSearch.toLowerCase()), + ); + + return ( +
+ {/* Chat header */} +
+ + {agentName} +
+ + {/* Chat history drawer — slides over chat */} + {drawerOpen && ( +
+
+ + Conversations + {onNewConversation && ( + + )} +
+
+
+ + setDrawerSearch(e.target.value)} + autoFocus + /> +
+
+
+ {filteredConversations.length === 0 ? ( +
+ {conversations?.length === 0 ? "No conversations yet" : "No matches"} +
+ ) : ( + filteredConversations.map((conv) => ( + + )) + )} +
+
+ )} + + {/* Progress step indicator */} + {showStatus && progressStep && ( +
+ + {progressStep} +
+ )} + + {/* Messages */} +
+ {/* CEO Welcome — typing indicator then message */} + {comments?.length === 0 && welcomePhase === "typing" && ( +
+ {usePaperclipIndicator ? ( + + ) : ( + + + + + )} + {agentName} is composing a message... +
+ )} + {comments?.length === 0 && welcomePhase === "message" && ( +
+
+ + {agentName} + +
+

+ Hello! I'm {agentName}{companyName ? <>, your CEO at {companyName} : ", your CEO"}. +

+ {companyGoal && ( +

+ Our mission: {companyGoal} +

+ )} +

+ I'd love to understand your vision and priorities before we start building the team. What's most important to you right now? +

+
+ )} + + {comments?.map((comment) => { + const isAgent = Boolean(comment.authorAgentId); + const isPlan = detectedPlanCommentId === comment.id; + return ( +
+
+
+ + {isAgent ? agentName : "You"} + + {isPlan && ( + + + Hiring plan detected + + )} +
+
+ + {isAgent + ? comment.body.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + : comment.body} + +
+
+ + {/* Inline plan link — opens in artifacts pane */} + {isPlan && ( + + )} +
+ ); + })} + + {/* Status indicator — click to toggle between paperclip SVG and blue dot */} + {showStatus && ( + + )} + {/* Optimistic typing indicator — shows immediately after user sends */} + {optimisticTyping && !showStatus && ( +
+ {usePaperclipIndicator ? ( + + ) : ( + + + + + )} + {agentName} is typing... +
+ )} +
+ + {/* Suggestion chips */} +
+ {suggestionChips.map((chip) => ( + + ))} +
+ + {/* Input area */} +
+ setInput(e.target.value)} + onKeyDown={handleKeyDown} + autoFocus + /> + +
+
+ ); +} + diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index 9d84be52..73c59c7c 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -106,7 +106,7 @@ export function CommandPalette() { if (v && isMobile) setSidebarOpen(false); }}> @@ -121,7 +121,7 @@ export function CommandPalette() { }} > - Create new issue + Create new task C go("/issues")}> - Issues + Tasks go("/projects")}> @@ -179,7 +179,7 @@ export function CommandPalette() { {visibleIssues.length > 0 && ( <> - + {visibleIssues.slice(0, 10).map((issue) => ( void; +} + +export function FrontDoor({ onChoose }: FrontDoorProps) { + return ( +
+
+

+ Welcome to Paperclip +

+

+ How would you like to get started? +

+
+ +
+ + + +
+
+ ); +} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 442f8ae4..b21d39d0 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -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" /> @@ -586,8 +586,8 @@ export function IssuesList({ {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && ( openNewIssue(newIssueDefaults())} /> )} diff --git a/ui/src/components/MobileBottomNav.tsx b/ui/src/components/MobileBottomNav.tsx index daa17318..e05bc4b9 100644 --- a/ui/src/components/MobileBottomNav.tsx +++ b/ui/src/components/MobileBottomNav.tsx @@ -42,7 +42,7 @@ export function MobileBottomNav({ visible }: MobileBottomNavProps) { const items = useMemo( () => [ { 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 }, { diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index ad8efb67..5d43944d 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -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() {