diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 3e644766..52d68289 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { Routes, Route } from "react-router-dom"; +import { Routes, Route, Navigate } from "react-router-dom"; import { Layout } from "./components/Layout"; import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; @@ -21,14 +21,15 @@ export function App() { return ( }> - } /> + } /> + } /> } /> } /> } /> } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/components/CommandPalette.tsx b/ui/src/components/CommandPalette.tsx index da967004..a26da594 100644 --- a/ui/src/components/CommandPalette.tsx +++ b/ui/src/components/CommandPalette.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useCompany } from "../context/CompanyContext"; +import { useDialog } from "../context/DialogContext"; import { issuesApi } from "../api/issues"; import { agentsApi } from "../api/agents"; import { projectsApi } from "../api/projects"; @@ -11,8 +12,21 @@ import { CommandInput, CommandItem, CommandList, + CommandSeparator, } from "@/components/ui/command"; -import { CircleDot, Bot, Hexagon, Target, LayoutDashboard, Inbox } from "lucide-react"; +import { + CircleDot, + Bot, + Hexagon, + Target, + LayoutDashboard, + Inbox, + DollarSign, + History, + GitBranch, + SquarePen, + Plus, +} from "lucide-react"; import type { Issue, Agent, Project } from "@paperclip/shared"; export function CommandPalette() { @@ -22,6 +36,7 @@ export function CommandPalette() { const [projects, setProjects] = useState([]); const navigate = useNavigate(); const { selectedCompanyId } = useCompany(); + const { openNewIssue } = useDialog(); useEffect(() => { function handleKeyDown(e: KeyboardEvent) { @@ -57,6 +72,11 @@ export function CommandPalette() { navigate(path); } + const agentName = (id: string | null) => { + if (!id) return null; + return agents.find((a) => a.id === id)?.name ?? null; + }; + return ( @@ -64,7 +84,7 @@ export function CommandPalette() { No results found. - go("/")}> + go("/dashboard")}> Dashboard @@ -72,7 +92,7 @@ export function CommandPalette() { Inbox - go("/tasks")}> + go("/issues")}> Issues @@ -88,42 +108,92 @@ export function CommandPalette() { Agents + go("/costs")}> + + Costs + + go("/activity")}> + + Activity + + go("/org")}> + + Org Chart + + + + + + + { + setOpen(false); + openNewIssue(); + }} + > + + Create new issue + C + + go("/agents")}> + + Create new agent + + go("/projects")}> + + Create new project + {issues.length > 0 && ( - - {issues.slice(0, 10).map((issue) => ( - go(`/issues/${issue.id}`)}> - - - {issue.id.slice(0, 8)} - - {issue.title} - - ))} - + <> + + + {issues.slice(0, 10).map((issue) => ( + go(`/issues/${issue.id}`)}> + + + {issue.id.slice(0, 8)} + + {issue.title} + {issue.assigneeAgentId && ( + + {agentName(issue.assigneeAgentId)} + + )} + + ))} + + )} {agents.length > 0 && ( - - {agents.slice(0, 10).map((agent) => ( - go(`/agents/${agent.id}`)}> - - {agent.name} - - ))} - + <> + + + {agents.slice(0, 10).map((agent) => ( + go(`/agents/${agent.id}`)}> + + {agent.name} + {agent.role} + + ))} + + )} {projects.length > 0 && ( - - {projects.slice(0, 10).map((project) => ( - go(`/projects/${project.id}`)}> - - {project.name} - - ))} - + <> + + + {projects.slice(0, 10).map((project) => ( + go(`/projects/${project.id}`)}> + + {project.name} + + ))} + + )} diff --git a/ui/src/components/CompanySwitcher.tsx b/ui/src/components/CompanySwitcher.tsx index 2e25c23a..77e01904 100644 --- a/ui/src/components/CompanySwitcher.tsx +++ b/ui/src/components/CompanySwitcher.tsx @@ -1,4 +1,5 @@ import { ChevronsUpDown, Plus } from "lucide-react"; +import { useNavigate } from "react-router-dom"; import { useCompany } from "../context/CompanyContext"; import { DropdownMenu, @@ -10,23 +11,39 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; +function statusDotColor(status?: string): string { + switch (status) { + case "active": + return "bg-green-400"; + case "paused": + return "bg-yellow-400"; + case "archived": + return "bg-neutral-400"; + default: + return "bg-green-400"; + } +} + export function CompanySwitcher() { const { companies, selectedCompany, setSelectedCompanyId } = useCompany(); + const navigate = useNavigate(); return ( @@ -38,6 +55,7 @@ export function CompanySwitcher() { onClick={() => setSelectedCompanyId(company.id)} className={company.id === selectedCompany?.id ? "bg-accent" : ""} > + {company.name} ))} @@ -45,11 +63,9 @@ export function CompanySwitcher() { No companies )} - - - - Manage Companies - + navigate("/companies")}> + + Manage Companies diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index b795da5f..8b6091f2 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -1,3 +1,4 @@ +import { Plus } from "lucide-react"; import type { LucideIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -11,10 +12,13 @@ interface EmptyStateProps { export function EmptyState({ icon: Icon, message, action, onAction }: EmptyStateProps) { return (
- +
+ +

{message}

{action && onAction && ( - )} diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index f847d284..69f37ec9 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -1,7 +1,10 @@ import type { Issue } from "@paperclip/shared"; +import { useCompany } from "../context/CompanyContext"; +import { useAgents } from "../hooks/useAgents"; import { StatusIcon } from "./StatusIcon"; import { PriorityIcon } from "./PriorityIcon"; import { formatDate } from "../lib/utils"; +import { timeAgo } from "../lib/timeAgo"; import { Separator } from "@/components/ui/separator"; interface IssuePropertiesProps { @@ -27,6 +30,15 @@ function priorityLabel(priority: string): string { } export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { + const { selectedCompanyId } = useCompany(); + const { data: agents } = useAgents(selectedCompanyId); + + const agentName = (id: string | null) => { + if (!id || !agents) return null; + const agent = agents.find((a) => a.id === id); + return agent?.name ?? id.slice(0, 8); + }; + return (
@@ -46,30 +58,37 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) { {priorityLabel(issue.priority)} - {issue.assigneeAgentId && ( - - {issue.assigneeAgentId.slice(0, 8)} - - )} + + + {issue.assigneeAgentId ? agentName(issue.assigneeAgentId) : "Unassigned"} + + - {issue.projectId && ( - - {issue.projectId.slice(0, 8)} - - )} + + + {issue.projectId ? issue.projectId.slice(0, 8) : "None"} + +
- - {issue.id.slice(0, 8)} - + {issue.startedAt && ( + + {formatDate(issue.startedAt)} + + )} + {issue.completedAt && ( + + {formatDate(issue.completedAt)} + + )} {formatDate(issue.createdAt)} - {formatDate(issue.updatedAt)} + {timeAgo(issue.updatedAt)}
diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 40ff59f4..8176c6f0 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { BreadcrumbBar } from "./BreadcrumbBar"; import { PropertiesPanel } from "./PropertiesPanel"; import { CommandPalette } from "./CommandPalette"; import { NewIssueDialog } from "./NewIssueDialog"; +import { NewProjectDialog } from "./NewProjectDialog"; import { useDialog } from "../context/DialogContext"; import { usePanel } from "../context/PanelContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; @@ -27,18 +28,18 @@ export function Layout() { }); return ( -
+
-
+
-
+
@@ -47,6 +48,7 @@ export function Layout() {
+
); } diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index 21da228c..c7e8bbcf 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1,25 +1,51 @@ -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { useDialog } from "../context/DialogContext"; import { useCompany } from "../context/CompanyContext"; import { issuesApi } from "../api/issues"; +import { projectsApi } from "../api/projects"; +import { useAgents } from "../hooks/useAgents"; +import { useApi } from "../hooks/useApi"; 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"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Maximize2, + Minimize2, + MoreHorizontal, + CircleDot, + Minus, + ArrowUp, + ArrowDown, + AlertTriangle, + User, + Hexagon, + Tag, + Calendar, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import type { Project, Agent } from "@paperclip/shared"; + +const statuses = [ + { value: "backlog", label: "Backlog", color: "text-muted-foreground" }, + { value: "todo", label: "Todo", color: "text-blue-400" }, + { value: "in_progress", label: "In Progress", color: "text-yellow-400" }, + { value: "in_review", label: "In Review", color: "text-violet-400" }, + { value: "done", label: "Done", color: "text-green-400" }, +]; + +const priorities = [ + { value: "critical", label: "Critical", icon: AlertTriangle, color: "text-red-400" }, + { value: "high", label: "High", icon: ArrowUp, color: "text-orange-400" }, + { value: "medium", label: "Medium", icon: Minus, color: "text-yellow-400" }, + { value: "low", label: "Low", icon: ArrowDown, color: "text-blue-400" }, +]; interface NewIssueDialogProps { onCreated?: () => void; @@ -27,22 +53,50 @@ interface NewIssueDialogProps { export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog(); - const { selectedCompanyId } = useCompany(); + const { selectedCompanyId, selectedCompany } = useCompany(); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); - const [status, setStatus] = useState(newIssueDefaults.status ?? "todo"); - const [priority, setPriority] = useState(newIssueDefaults.priority ?? "medium"); + const [status, setStatus] = useState("todo"); + const [priority, setPriority] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + const [projectId, setProjectId] = useState(""); + const [expanded, setExpanded] = useState(false); const [submitting, setSubmitting] = useState(false); + // Popover states + const [statusOpen, setStatusOpen] = useState(false); + const [priorityOpen, setPriorityOpen] = useState(false); + const [assigneeOpen, setAssigneeOpen] = useState(false); + const [projectOpen, setProjectOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + + const { data: agents } = useAgents(selectedCompanyId); + + const projectsFetcher = useCallback(() => { + if (!selectedCompanyId) return Promise.resolve([] as Project[]); + return projectsApi.list(selectedCompanyId); + }, [selectedCompanyId]); + const { data: projects } = useApi(projectsFetcher); + + useEffect(() => { + if (newIssueOpen) { + setStatus(newIssueDefaults.status ?? "todo"); + setPriority(newIssueDefaults.priority ?? ""); + setProjectId(newIssueDefaults.projectId ?? ""); + } + }, [newIssueOpen, newIssueDefaults]); + function reset() { setTitle(""); setDescription(""); setStatus("todo"); - setPriority("medium"); + setPriority(""); + setAssigneeId(""); + setProjectId(""); + setExpanded(false); } - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + async function handleSubmit() { if (!selectedCompanyId || !title.trim()) return; setSubmitting(true); @@ -51,7 +105,9 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { title: title.trim(), description: description.trim() || undefined, status, - priority, + priority: priority || "medium", + ...(assigneeId ? { assigneeAgentId: assigneeId } : {}), + ...(projectId ? { projectId } : {}), }); reset(); closeNewIssue(); @@ -61,6 +117,18 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) { } } + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + } + + const currentStatus = statuses.find((s) => s.value === status) ?? statuses[1]!; + const currentPriority = priorities.find((p) => p.value === priority); + const currentAssignee = (agents ?? []).find((a) => a.id === assigneeId); + const currentProject = (projects ?? []).find((p) => p.id === projectId); + return ( - - - New Issue - -
-
- - setTitle(e.target.value)} - autoFocus - /> + + {/* Header bar */} +
+
+ {selectedCompany && ( + + {selectedCompany.name.slice(0, 3).toUpperCase()} + + )} + + New issue
-
- -