diff --git a/ui/src/App.tsx b/ui/src/App.tsx index faa0598e..3e644766 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -4,12 +4,18 @@ import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; import { Org } from "./pages/Org"; import { Agents } from "./pages/Agents"; +import { AgentDetail } from "./pages/AgentDetail"; import { Projects } from "./pages/Projects"; +import { ProjectDetail } from "./pages/ProjectDetail"; import { Issues } from "./pages/Issues"; +import { IssueDetail } from "./pages/IssueDetail"; import { Goals } from "./pages/Goals"; +import { GoalDetail } from "./pages/GoalDetail"; import { Approvals } from "./pages/Approvals"; import { Costs } from "./pages/Costs"; import { Activity } from "./pages/Activity"; +import { Inbox } from "./pages/Inbox"; +import { MyIssues } from "./pages/MyIssues"; export function App() { return ( @@ -19,12 +25,18 @@ export function App() { } /> } /> } /> + } /> } /> + } /> } /> + } /> } /> + } /> } /> } /> } /> + } /> + } /> ); diff --git a/ui/src/api/heartbeats.ts b/ui/src/api/heartbeats.ts new file mode 100644 index 00000000..19d0a70e --- /dev/null +++ b/ui/src/api/heartbeats.ts @@ -0,0 +1,9 @@ +import type { HeartbeatRun } from "@paperclip/shared"; +import { api } from "./client"; + +export const heartbeatsApi = { + list: (companyId: string, agentId?: string) => { + const params = agentId ? `?agentId=${agentId}` : ""; + return api.get(`/companies/${companyId}/heartbeat-runs${params}`); + }, +}; diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx new file mode 100644 index 00000000..592f5291 --- /dev/null +++ b/ui/src/components/AgentProperties.tsx @@ -0,0 +1,79 @@ +import type { Agent } from "@paperclip/shared"; +import { StatusBadge } from "./StatusBadge"; +import { formatCents, formatDate } from "../lib/utils"; +import { Separator } from "@/components/ui/separator"; + +interface AgentPropertiesProps { + agent: Agent; +} + +function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function AgentProperties({ agent }: AgentPropertiesProps) { + return ( +
+
+ + + + + {agent.role} + + {agent.title && ( + + {agent.title} + + )} + + {agent.adapterType} + + + {agent.contextMode} + +
+ + + +
+ + + {formatCents(agent.spentMonthlyCents)} / {formatCents(agent.budgetMonthlyCents)} + + + + + {agent.budgetMonthlyCents > 0 + ? Math.round((agent.spentMonthlyCents / agent.budgetMonthlyCents) * 100) + : 0} + % + + +
+ + + +
+ {agent.lastHeartbeatAt && ( + + {formatDate(agent.lastHeartbeatAt)} + + )} + {agent.reportsTo && ( + + {agent.reportsTo.slice(0, 8)} + + )} + + {formatDate(agent.createdAt)} + +
+
+ ); +} diff --git a/ui/src/components/GoalProperties.tsx b/ui/src/components/GoalProperties.tsx new file mode 100644 index 00000000..ca485882 --- /dev/null +++ b/ui/src/components/GoalProperties.tsx @@ -0,0 +1,53 @@ +import type { Goal } from "@paperclip/shared"; +import { StatusBadge } from "./StatusBadge"; +import { formatDate } from "../lib/utils"; +import { Separator } from "@/components/ui/separator"; + +interface GoalPropertiesProps { + goal: Goal; +} + +function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +export function GoalProperties({ goal }: GoalPropertiesProps) { + return ( +
+
+ + + + + {goal.level} + + {goal.ownerAgentId && ( + + {goal.ownerAgentId.slice(0, 8)} + + )} + {goal.parentId && ( + + {goal.parentId.slice(0, 8)} + + )} +
+ + + +
+ + {formatDate(goal.createdAt)} + + + {formatDate(goal.updatedAt)} + +
+
+ ); +} diff --git a/ui/src/components/GoalTree.tsx b/ui/src/components/GoalTree.tsx new file mode 100644 index 00000000..0fe931ff --- /dev/null +++ b/ui/src/components/GoalTree.tsx @@ -0,0 +1,91 @@ +import type { Goal } from "@paperclip/shared"; +import { StatusBadge } from "./StatusBadge"; +import { ChevronRight } from "lucide-react"; +import { cn } from "../lib/utils"; +import { useState } from "react"; + +interface GoalTreeProps { + goals: Goal[]; + onSelect?: (goal: Goal) => void; +} + +interface GoalNodeProps { + goal: Goal; + children: Goal[]; + allGoals: Goal[]; + depth: number; + onSelect?: (goal: Goal) => void; +} + +function GoalNode({ goal, children, allGoals, depth, onSelect }: GoalNodeProps) { + const [expanded, setExpanded] = useState(true); + const hasChildren = children.length > 0; + + return ( +
+
onSelect?.(goal)} + > + {hasChildren ? ( + + ) : ( + + )} + {goal.level} + {goal.title} + +
+ {hasChildren && expanded && ( +
+ {children.map((child) => ( + g.parentId === child.id)} + allGoals={allGoals} + depth={depth + 1} + onSelect={onSelect} + /> + ))} +
+ )} +
+ ); +} + +export function GoalTree({ goals, onSelect }: GoalTreeProps) { + const roots = goals.filter((g) => !g.parentId); + + if (goals.length === 0) { + return

No goals.

; + } + + return ( +
+ {roots.map((goal) => ( + g.parentId === goal.id)} + allGoals={goals} + depth={0} + onSelect={onSelect} + /> + ))} +
+ ); +} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx new file mode 100644 index 00000000..f847d284 --- /dev/null +++ b/ui/src/components/IssueProperties.tsx @@ -0,0 +1,77 @@ +import type { Issue } from "@paperclip/shared"; +import { StatusIcon } from "./StatusIcon"; +import { PriorityIcon } from "./PriorityIcon"; +import { formatDate } from "../lib/utils"; +import { Separator } from "@/components/ui/separator"; + +interface IssuePropertiesProps { + issue: Issue; + onUpdate: (data: Record) => void; +} + +function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} +
{children}
+
+ ); +} + +function statusLabel(status: string): string { + return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function priorityLabel(priority: string): string { + return priority.charAt(0).toUpperCase() + priority.slice(1); +} + +export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { + return ( +
+
+ + onUpdate({ status })} + /> + {statusLabel(issue.status)} + + + + onUpdate({ priority })} + /> + {priorityLabel(issue.priority)} + + + {issue.assigneeAgentId && ( + + {issue.assigneeAgentId.slice(0, 8)} + + )} + + {issue.projectId && ( + + {issue.projectId.slice(0, 8)} + + )} +
+ + + +
+ + {issue.id.slice(0, 8)} + + + {formatDate(issue.createdAt)} + + + {formatDate(issue.updatedAt)} + +
+
+ ); +} diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx new file mode 100644 index 00000000..21da228c --- /dev/null +++ b/ui/src/components/NewIssueDialog.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useDialog } from "../context/DialogContext"; +import { useCompany } from "../context/CompanyContext"; +import { issuesApi } from "../api/issues"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +interface NewIssueDialogProps { + onCreated?: () => void; +} + +export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { + const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); + const { selectedCompanyId } = useCompany(); + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState(newIssueDefaults.status ?? "todo"); + const [priority, setPriority] = useState(newIssueDefaults.priority ?? "medium"); + const [submitting, setSubmitting] = useState(false); + + function reset() { + setTitle(""); + setDescription(""); + setStatus("todo"); + setPriority("medium"); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!selectedCompanyId || !title.trim()) return; + + setSubmitting(true); + try { + await issuesApi.create(selectedCompanyId, { + title: title.trim(), + description: description.trim() || undefined, + status, + priority, + }); + reset(); + closeNewIssue(); + onCreated?.(); + } finally { + setSubmitting(false); + } + } + + return ( + { + if (!open) { + reset(); + closeNewIssue(); + } + }} + > + + + New Issue + +
+
+ + setTitle(e.target.value)} + autoFocus + /> +
+
+ +