diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c1e1e86..673c8e89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) lucide-react: specifier: ^0.574.0 version: 0.574.0(react@19.2.4) @@ -1839,6 +1842,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -4333,6 +4342,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 diff --git a/ui/package.json b/ui/package.json index 992d3717..3db92cba 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-slot": "^1.2.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "lucide-react": "^0.574.0", "radix-ui": "^1.4.3", "react": "^19.0.0", diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index a10ed3a4..1f28465d 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -8,3 +8,4 @@ export { approvalsApi } from "./approvals"; export { costsApi } from "./costs"; export { activityApi } from "./activity"; export { dashboardApi } from "./dashboard"; +export { heartbeatsApi } from "./heartbeats"; diff --git a/ui/src/components/BreadcrumbBar.tsx b/ui/src/components/BreadcrumbBar.tsx new file mode 100644 index 00000000..57238eee --- /dev/null +++ b/ui/src/components/BreadcrumbBar.tsx @@ -0,0 +1,43 @@ +import { Link } from "react-router-dom"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { Fragment } from "react"; + +export function BreadcrumbBar() { + const { breadcrumbs } = useBreadcrumbs(); + + if (breadcrumbs.length === 0) return null; + + return ( +
+ + + {breadcrumbs.map((crumb, i) => { + const isLast = i === breadcrumbs.length - 1; + return ( + + {i > 0 && } + + {isLast || !crumb.href ? ( + {crumb.label} + ) : ( + + {crumb.label} + + )} + + + ); + })} + + +
+ ); +} diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx new file mode 100644 index 00000000..da967004 --- /dev/null +++ b/ui/src/components/CommandPalette.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { useCompany } from "../context/CompanyContext"; +import { issuesApi } from "../api/issues"; +import { agentsApi } from "../api/agents"; +import { projectsApi } from "../api/projects"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { CircleDot, Bot, Hexagon, Target, LayoutDashboard, Inbox } from "lucide-react"; +import type { Issue, Agent, Project } from "@paperclip/shared"; + +export function CommandPalette() { + const [open, setOpen] = useState(false); + const [issues, setIssues] = useState([]); + const [agents, setAgents] = useState([]); + const [projects, setProjects] = useState([]); + const navigate = useNavigate(); + const { selectedCompanyId } = useCompany(); + + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen(true); + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + const loadData = useCallback(async () => { + if (!selectedCompanyId) return; + const [i, a, p] = await Promise.all([ + issuesApi.list(selectedCompanyId).catch(() => []), + agentsApi.list(selectedCompanyId).catch(() => []), + projectsApi.list(selectedCompanyId).catch(() => []), + ]); + setIssues(i); + setAgents(a); + setProjects(p); + }, [selectedCompanyId]); + + useEffect(() => { + if (open) { + void loadData(); + } + }, [open, loadData]); + + function go(path: string) { + setOpen(false); + navigate(path); + } + + return ( + + + + No results found. + + + go("/")}> + + Dashboard + + go("/inbox")}> + + Inbox + + go("/tasks")}> + + Issues + + go("/projects")}> + + Projects + + go("/goals")}> + + Goals + + go("/agents")}> + + Agents + + + + {issues.length > 0 && ( + + {issues.slice(0, 10).map((issue) => ( + go(`/issues/${issue.id}`)}> + + + {issue.id.slice(0, 8)} + + {issue.title} + + ))} + + )} + + {agents.length > 0 && ( + + {agents.slice(0, 10).map((agent) => ( + go(`/agents/${agent.id}`)}> + + {agent.name} + + ))} + + )} + + {projects.length > 0 && ( + + {projects.slice(0, 10).map((project) => ( + go(`/projects/${project.id}`)}> + + {project.name} + + ))} + + )} + + + ); +} diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx new file mode 100644 index 00000000..89fdcdbf --- /dev/null +++ b/ui/src/components/CommentThread.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import type { IssueComment } from "@paperclip/shared"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { formatDate } from "../lib/utils"; + +interface CommentThreadProps { + comments: IssueComment[]; + onAdd: (body: string) => Promise; +} + +export function CommentThread({ comments, onAdd }: CommentThreadProps) { + const [body, setBody] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + const trimmed = body.trim(); + if (!trimmed) return; + + setSubmitting(true); + try { + await onAdd(trimmed); + setBody(""); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Comments ({comments.length})

+ + {comments.length === 0 && ( +

No comments yet.

+ )} + +
+ {comments.map((comment) => ( +
+
+ + {comment.authorAgentId ? "Agent" : "Human"} + + + {formatDate(comment.createdAt)} + +
+

{comment.body}

+
+ ))} +
+ +
+