Polish UI: enhance dialogs, command palette, and page layouts

Expand NewIssueDialog with richer form fields. Add NewProjectDialog.
Enhance CommandPalette with more actions and search. Improve
CompanySwitcher, EmptyState, and IssueProperties. Flesh out Activity,
Companies, Dashboard, and Inbox pages with real content and layouts.
Refine sidebar, routing, and dialog context. CSS tweaks for dark theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-17 10:53:20 -06:00
parent 102f61c96d
commit d912670f72
22 changed files with 1301 additions and 254 deletions

View File

@@ -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 (
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route index element={<Navigate to="/dashboard" replace />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="companies" element={<Companies />} />
<Route path="org" element={<Org />} />
<Route path="agents" element={<Agents />} />
<Route path="agents/:agentId" element={<AgentDetail />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetail />} />
<Route path="tasks" element={<Issues />} />
<Route path="issues" element={<Issues />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />

View File

@@ -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<Project[]>([]);
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 (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Search issues, agents, projects..." />
@@ -64,7 +84,7 @@ export function CommandPalette() {
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup heading="Pages">
<CommandItem onSelect={() => go("/")}>
<CommandItem onSelect={() => go("/dashboard")}>
<LayoutDashboard className="mr-2 h-4 w-4" />
Dashboard
</CommandItem>
@@ -72,7 +92,7 @@ export function CommandPalette() {
<Inbox className="mr-2 h-4 w-4" />
Inbox
</CommandItem>
<CommandItem onSelect={() => go("/tasks")}>
<CommandItem onSelect={() => go("/issues")}>
<CircleDot className="mr-2 h-4 w-4" />
Issues
</CommandItem>
@@ -88,9 +108,46 @@ export function CommandPalette() {
<Bot className="mr-2 h-4 w-4" />
Agents
</CommandItem>
<CommandItem onSelect={() => go("/costs")}>
<DollarSign className="mr-2 h-4 w-4" />
Costs
</CommandItem>
<CommandItem onSelect={() => go("/activity")}>
<History className="mr-2 h-4 w-4" />
Activity
</CommandItem>
<CommandItem onSelect={() => go("/org")}>
<GitBranch className="mr-2 h-4 w-4" />
Org Chart
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Actions">
<CommandItem
onSelect={() => {
setOpen(false);
openNewIssue();
}}
>
<SquarePen className="mr-2 h-4 w-4" />
Create new issue
<span className="ml-auto text-xs text-muted-foreground">C</span>
</CommandItem>
<CommandItem onSelect={() => go("/agents")}>
<Plus className="mr-2 h-4 w-4" />
Create new agent
</CommandItem>
<CommandItem onSelect={() => go("/projects")}>
<Plus className="mr-2 h-4 w-4" />
Create new project
</CommandItem>
</CommandGroup>
{issues.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Issues">
{issues.slice(0, 10).map((issue) => (
<CommandItem key={issue.id} onSelect={() => go(`/issues/${issue.id}`)}>
@@ -98,24 +155,36 @@ export function CommandPalette() {
<span className="text-muted-foreground mr-2 font-mono text-xs">
{issue.id.slice(0, 8)}
</span>
{issue.title}
<span className="flex-1 truncate">{issue.title}</span>
{issue.assigneeAgentId && (
<span className="text-xs text-muted-foreground ml-2">
{agentName(issue.assigneeAgentId)}
</span>
)}
</CommandItem>
))}
</CommandGroup>
</>
)}
{agents.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Agents">
{agents.slice(0, 10).map((agent) => (
<CommandItem key={agent.id} onSelect={() => go(`/agents/${agent.id}`)}>
<Bot className="mr-2 h-4 w-4" />
{agent.name}
<span className="text-xs text-muted-foreground ml-2">{agent.role}</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
{projects.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Projects">
{projects.slice(0, 10).map((project) => (
<CommandItem key={project.id} onSelect={() => go(`/projects/${project.id}`)}>
@@ -124,6 +193,7 @@ export function CommandPalette() {
</CommandItem>
))}
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="w-full justify-between px-3 py-2 h-auto text-left"
className="w-full justify-between px-2 py-1.5 h-auto text-left"
>
<div className="flex flex-col min-w-0">
<span className="text-xs text-muted-foreground">Company</span>
<div className="flex items-center gap-2 min-w-0">
{selectedCompany && (
<span className={`h-2 w-2 rounded-full shrink-0 ${statusDotColor(selectedCompany.status)}`} />
)}
<span className="text-sm font-medium truncate">
{selectedCompany?.name ?? "Select company"}
</span>
</div>
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
<ChevronsUpDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[220px]">
@@ -38,6 +55,7 @@ export function CompanySwitcher() {
onClick={() => setSelectedCompanyId(company.id)}
className={company.id === selectedCompany?.id ? "bg-accent" : ""}
>
<span className={`h-2 w-2 rounded-full shrink-0 mr-2 ${statusDotColor(company.status)}`} />
<span className="truncate">{company.name}</span>
</DropdownMenuItem>
))}
@@ -45,11 +63,9 @@ export function CompanySwitcher() {
<DropdownMenuItem disabled>No companies</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href="/companies" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
<DropdownMenuItem onClick={() => navigate("/companies")}>
<Plus className="h-4 w-4 mr-2" />
Manage Companies
</a>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -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 (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Icon className="h-12 w-12 text-muted-foreground/50 mb-4" />
<div className="rounded-xl bg-muted/50 p-4 mb-4">
<Icon className="h-10 w-10 text-muted-foreground/50" />
</div>
<p className="text-sm text-muted-foreground mb-4">{message}</p>
{action && onAction && (
<Button variant="outline" size="sm" onClick={onAction}>
<Button onClick={onAction}>
<Plus className="h-4 w-4 mr-1.5" />
{action}
</Button>
)}

View File

@@ -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 (
<div className="space-y-4">
<div className="space-y-1">
@@ -46,30 +58,37 @@ export function IssueProperties({ issue, onUpdate }: IssuePropertiesProps) {
<span className="text-sm">{priorityLabel(issue.priority)}</span>
</PropertyRow>
{issue.assigneeAgentId && (
<PropertyRow label="Assignee">
<span className="text-sm font-mono">{issue.assigneeAgentId.slice(0, 8)}</span>
<span className="text-sm">
{issue.assigneeAgentId ? agentName(issue.assigneeAgentId) : "Unassigned"}
</span>
</PropertyRow>
)}
{issue.projectId && (
<PropertyRow label="Project">
<span className="text-sm font-mono">{issue.projectId.slice(0, 8)}</span>
<span className="text-sm text-muted-foreground">
{issue.projectId ? issue.projectId.slice(0, 8) : "None"}
</span>
</PropertyRow>
)}
</div>
<Separator />
<div className="space-y-1">
<PropertyRow label="ID">
<span className="text-sm font-mono">{issue.id.slice(0, 8)}</span>
{issue.startedAt && (
<PropertyRow label="Started">
<span className="text-sm">{formatDate(issue.startedAt)}</span>
</PropertyRow>
)}
{issue.completedAt && (
<PropertyRow label="Completed">
<span className="text-sm">{formatDate(issue.completedAt)}</span>
</PropertyRow>
)}
<PropertyRow label="Created">
<span className="text-sm">{formatDate(issue.createdAt)}</span>
</PropertyRow>
<PropertyRow label="Updated">
<span className="text-sm">{formatDate(issue.updatedAt)}</span>
<span className="text-sm">{timeAgo(issue.updatedAt)}</span>
</PropertyRow>
</div>
</div>

View File

@@ -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 (
<div className="flex h-screen bg-background text-foreground">
<div className="flex h-screen bg-background text-foreground overflow-hidden">
<div
className={cn(
"transition-all duration-200 ease-in-out shrink-0 overflow-hidden",
"transition-all duration-200 ease-in-out shrink-0 h-full overflow-hidden",
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
</div>
<div className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 h-full">
<BreadcrumbBar />
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 min-h-0">
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
@@ -47,6 +48,7 @@ export function Layout() {
</div>
<CommandPalette />
<NewIssueDialog />
<NewProjectDialog />
</div>
);
}

View File

@@ -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 (
<Dialog
open={newIssueOpen}
@@ -71,71 +139,232 @@ export function NewIssueDialog({ onCreated }: NewIssueDialogProps) {
}
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>New Issue</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="issue-title">Title</Label>
<Input
id="issue-title"
<DialogContent
showCloseButton={false}
className={cn(
"p-0 gap-0",
expanded ? "sm:max-w-2xl" : "sm:max-w-lg"
)}
onKeyDown={handleKeyDown}
>
{/* Header bar */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.name.slice(0, 3).toUpperCase()}
</span>
)}
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New issue</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => { reset(); closeNewIssue(); }}
>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
</div>
{/* Title */}
<div className="px-4 pt-3">
<input
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Issue title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="issue-desc">Description</Label>
<Textarea
id="issue-desc"
placeholder="Add a description..."
{/* Description */}
<div className="px-4 pb-2">
<textarea
className={cn(
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
expanded ? "min-h-[200px]" : "min-h-[60px]"
)}
placeholder="Add description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="flex gap-4">
<div className="space-y-2 flex-1">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="backlog">Backlog</SelectItem>
<SelectItem value="todo">Todo</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
<SelectItem value="in_review">In Review</SelectItem>
<SelectItem value="done">Done</SelectItem>
</SelectContent>
</Select>
{/* Property chips bar */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Status chip */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<CircleDot className={cn("h-3 w-3", currentStatus.color)} />
{currentStatus.label}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{statuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
<CircleDot className={cn("h-3 w-3", s.color)} />
{s.label}
</button>
))}
</PopoverContent>
</Popover>
{/* Priority chip */}
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
{currentPriority ? (
<>
<currentPriority.icon className={cn("h-3 w-3", currentPriority.color)} />
{currentPriority.label}
</>
) : (
<>
<Minus className="h-3 w-3 text-muted-foreground" />
Priority
</>
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{priorities.map((p) => (
<button
key={p.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
p.value === priority && "bg-accent"
)}
onClick={() => { setPriority(p.value); setPriorityOpen(false); }}
>
<p.icon className={cn("h-3 w-3", p.color)} />
{p.label}
</button>
))}
</PopoverContent>
</Popover>
{/* Assignee chip */}
<Popover open={assigneeOpen} onOpenChange={setAssigneeOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<User className="h-3 w-3 text-muted-foreground" />
{currentAssignee ? currentAssignee.name : "Assignee"}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!assigneeId && "bg-accent"
)}
onClick={() => { setAssigneeId(""); setAssigneeOpen(false); }}
>
No assignee
</button>
{(agents ?? []).map((a) => (
<button
key={a.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
a.id === assigneeId && "bg-accent"
)}
onClick={() => { setAssigneeId(a.id); setAssigneeOpen(false); }}
>
{a.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Project chip */}
<Popover open={projectOpen} onOpenChange={setProjectOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<Hexagon className="h-3 w-3 text-muted-foreground" />
{currentProject ? currentProject.name : "Project"}
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!projectId && "bg-accent"
)}
onClick={() => { setProjectId(""); setProjectOpen(false); }}
>
No project
</button>
{(projects ?? []).map((p) => (
<button
key={p.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
p.id === projectId && "bg-accent"
)}
onClick={() => { setProjectId(p.id); setProjectOpen(false); }}
>
{p.name}
</button>
))}
</PopoverContent>
</Popover>
{/* Labels chip (placeholder) */}
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
<Tag className="h-3 w-3" />
Labels
</button>
{/* More (dates) */}
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center justify-center rounded-md border border-border p-1 text-xs hover:bg-accent/50 transition-colors text-muted-foreground">
<MoreHorizontal className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="start">
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
<Calendar className="h-3 w-3" />
Start date
</button>
<button className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground">
<Calendar className="h-3 w-3" />
Due date
</button>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2 flex-1">
<Label>Priority</Label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={closeNewIssue}>
Cancel
{/* Footer */}
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
<Button
size="sm"
disabled={!title.trim() || submitting}
onClick={handleSubmit}
>
{submitting ? "Creating..." : "Create issue"}
</Button>
<Button type="submit" disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
);

View File

@@ -0,0 +1,251 @@
import { useState, useCallback } from "react";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { projectsApi } from "../api/projects";
import { goalsApi } from "../api/goals";
import { useApi } from "../hooks/useApi";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Maximize2,
Minimize2,
Target,
Calendar,
} from "lucide-react";
import { cn } from "../lib/utils";
import { StatusBadge } from "./StatusBadge";
import type { Goal } from "@paperclip/shared";
const projectStatuses = [
{ value: "backlog", label: "Backlog" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
interface NewProjectDialogProps {
onCreated?: () => void;
}
export function NewProjectDialog({ onCreated }: NewProjectDialogProps) {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("planned");
const [goalId, setGoalId] = useState("");
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [statusOpen, setStatusOpen] = useState(false);
const [goalOpen, setGoalOpen] = useState(false);
const goalsFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([] as Goal[]);
return goalsApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: goals } = useApi(goalsFetcher);
function reset() {
setName("");
setDescription("");
setStatus("planned");
setGoalId("");
setTargetDate("");
setExpanded(false);
}
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
setSubmitting(true);
try {
await projectsApi.create(selectedCompanyId, {
name: name.trim(),
description: description.trim() || undefined,
status,
...(goalId ? { goalId } : {}),
...(targetDate ? { targetDate } : {}),
});
reset();
closeNewProject();
onCreated?.();
} finally {
setSubmitting(false);
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
const currentGoal = (goals ?? []).find((g) => g.id === goalId);
return (
<Dialog
open={newProjectOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeNewProject();
}
}}
>
<DialogContent
showCloseButton={false}
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.name.slice(0, 3).toUpperCase()}
</span>
)}
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New project</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => { reset(); closeNewProject(); }}
>
<span className="text-lg leading-none">&times;</span>
</Button>
</div>
</div>
{/* Name */}
<div className="px-4 pt-3">
<input
className="w-full text-base font-medium bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
{/* Description */}
<div className="px-4 pb-2">
<textarea
className={cn(
"w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40 resize-none",
expanded ? "min-h-[160px]" : "min-h-[48px]"
)}
placeholder="Add description..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{/* Property chips */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<StatusBadge status={status} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{projectStatuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
{s.label}
</button>
))}
</PopoverContent>
</Popover>
{/* Goal */}
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<Target className="h-3 w-3 text-muted-foreground" />
{currentGoal ? currentGoal.title : "Goal"}
</button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="start">
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
!goalId && "bg-accent"
)}
onClick={() => { setGoalId(""); setGoalOpen(false); }}
>
No goal
</button>
{(goals ?? []).map((g) => (
<button
key={g.id}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
g.id === goalId && "bg-accent"
)}
onClick={() => { setGoalId(g.id); setGoalOpen(false); }}
>
{g.title}
</button>
))}
</PopoverContent>
</Popover>
{/* Target date */}
<div className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
<Calendar className="h-3 w-3 text-muted-foreground" />
<input
type="date"
className="bg-transparent outline-none text-xs w-24"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
placeholder="Target date"
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end px-4 py-2.5 border-t border-border">
<Button
size="sm"
disabled={!name.trim() || submitting}
onClick={handleSubmit}
>
{submitting ? "Creating..." : "Create project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -12,6 +12,7 @@ import {
SquarePen,
Building2,
ListTodo,
LayoutList,
} from "lucide-react";
import { CompanySwitcher } from "./CompanySwitcher";
import { SidebarSection } from "./SidebarSection";
@@ -29,16 +30,15 @@ export function Sidebar() {
}
return (
<aside className="w-60 border-r border-border bg-card flex flex-col shrink-0">
<div className="p-3">
<aside className="w-60 h-full border-r border-border bg-card flex flex-col">
<div className="flex items-center gap-1 p-3">
<div className="flex-1 min-w-0">
<CompanySwitcher />
</div>
<div className="flex items-center gap-1 px-3 pb-2">
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
className="text-muted-foreground shrink-0"
onClick={openSearch}
>
<Search className="h-4 w-4" />
@@ -46,7 +46,7 @@ export function Sidebar() {
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground"
className="text-muted-foreground shrink-0"
onClick={() => openNewIssue()}
>
<SquarePen className="h-4 w-4" />
@@ -63,13 +63,13 @@ export function Sidebar() {
</div>
<SidebarSection label="Work">
<SidebarNavItem to="/tasks" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/issues" label="Issues" icon={CircleDot} />
<SidebarNavItem to="/projects" label="Projects" icon={Hexagon} />
<SidebarNavItem to="/goals" label="Goals" icon={Target} />
</SidebarSection>
<SidebarSection label="Company">
<SidebarNavItem to="/" label="Dashboard" icon={LayoutDashboard} end />
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
<SidebarNavItem to="/org" label="Org Chart" icon={GitBranch} />
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />

View File

@@ -37,7 +37,7 @@ function DialogOverlay({
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 duration-100",
className
)}
{...props}
@@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-[0.97] data-[state=open]:zoom-in-[0.97] data-[state=closed]:slide-out-to-top-[1%] data-[state=open]:slide-in-from-top-[1%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-150 ease-[cubic-bezier(0.16,1,0.3,1)] outline-none sm:max-w-lg",
className
)}
{...props}

View File

@@ -11,6 +11,9 @@ interface DialogContextValue {
newIssueDefaults: NewIssueDefaults;
openNewIssue: (defaults?: NewIssueDefaults) => void;
closeNewIssue: () => void;
newProjectOpen: boolean;
openNewProject: () => void;
closeNewProject: () => void;
}
const DialogContext = createContext<DialogContextValue | null>(null);
@@ -18,6 +21,7 @@ const DialogContext = createContext<DialogContextValue | null>(null);
export function DialogProvider({ children }: { children: ReactNode }) {
const [newIssueOpen, setNewIssueOpen] = useState(false);
const [newIssueDefaults, setNewIssueDefaults] = useState<NewIssueDefaults>({});
const [newProjectOpen, setNewProjectOpen] = useState(false);
const openNewIssue = useCallback((defaults: NewIssueDefaults = {}) => {
setNewIssueDefaults(defaults);
@@ -29,8 +33,26 @@ export function DialogProvider({ children }: { children: ReactNode }) {
setNewIssueDefaults({});
}, []);
const openNewProject = useCallback(() => {
setNewProjectOpen(true);
}, []);
const closeNewProject = useCallback(() => {
setNewProjectOpen(false);
}, []);
return (
<DialogContext.Provider value={{ newIssueOpen, newIssueDefaults, openNewIssue, closeNewIssue }}>
<DialogContext.Provider
value={{
newIssueOpen,
newIssueDefaults,
openNewIssue,
closeNewIssue,
newProjectOpen,
openNewProject,
closeNewProject,
}}
>
{children}
</DialogContext.Provider>
);

View File

@@ -120,3 +120,8 @@
@apply bg-background text-foreground;
}
}
/* Expandable dialog transition for max-width changes */
[data-slot="dialog-content"] {
transition: max-width 200ms cubic-bezier(0.16, 1, 0.3, 1);
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { activityApi } from "../api/activity";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -6,11 +7,74 @@ import { useApi } from "../hooks/useApi";
import { EmptyState } from "../components/EmptyState";
import { timeAgo } from "../lib/timeAgo";
import { Badge } from "@/components/ui/badge";
import { History } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { History, Bot, User, Settings } from "lucide-react";
function formatAction(action: string, entityType: string, entityId: string): string {
const shortId = entityId.slice(0, 8);
const actionMap: Record<string, string> = {
"company.created": "Company created",
"agent.created": `Agent created`,
"agent.updated": `Agent updated`,
"agent.paused": `Agent paused`,
"agent.resumed": `Agent resumed`,
"agent.terminated": `Agent terminated`,
"agent.key_created": `API key created for agent`,
"issue.created": `Issue created`,
"issue.updated": `Issue updated`,
"issue.checked_out": `Issue checked out`,
"issue.released": `Issue released`,
"issue.commented": `Comment added to issue`,
"heartbeat.invoked": `Heartbeat invoked`,
"heartbeat.completed": `Heartbeat completed`,
"heartbeat.failed": `Heartbeat failed`,
"approval.created": `Approval requested`,
"approval.approved": `Approval granted`,
"approval.rejected": `Approval rejected`,
"project.created": `Project created`,
"project.updated": `Project updated`,
"goal.created": `Goal created`,
"goal.updated": `Goal updated`,
"cost.recorded": `Cost recorded`,
};
return actionMap[action] ?? `${action.replace(/[._]/g, " ")}`;
}
function actorIcon(entityType: string) {
if (entityType === "agent") return <Bot className="h-4 w-4 text-muted-foreground" />;
if (entityType === "company" || entityType === "approval")
return <User className="h-4 w-4 text-muted-foreground" />;
return <Settings className="h-4 w-4 text-muted-foreground" />;
}
function entityLink(entityType: string, entityId: string): string | null {
switch (entityType) {
case "issue":
return `/issues/${entityId}`;
case "agent":
return `/agents/${entityId}`;
case "project":
return `/projects/${entityId}`;
case "goal":
return `/goals/${entityId}`;
case "approval":
return `/approvals/${entityId}`;
default:
return null;
}
}
export function Activity() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [filter, setFilter] = useState("all");
useEffect(() => {
setBreadcrumbs([{ label: "Activity" }]);
@@ -27,26 +91,61 @@ export function Activity() {
return <EmptyState icon={History} message="Select a company to view activity." />;
}
const filtered =
data && filter !== "all"
? data.filter((e) => e.entityType === filter)
: data;
const entityTypes = data
? [...new Set(data.map((e) => e.entityType))].sort()
: [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Activity</h2>
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-[140px] h-8 text-xs">
<SelectValue placeholder="Filter by type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
{entityTypes.map((type) => (
<SelectItem key={type} value={type}>
{type.charAt(0).toUpperCase() + type.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && data.length === 0 && (
{filtered && filtered.length === 0 && (
<EmptyState icon={History} message="No activity yet." />
)}
{data && data.length > 0 && (
{filtered && filtered.length > 0 && (
<div className="border border-border rounded-md divide-y divide-border">
{data.map((event) => (
<div key={event.id} className="px-4 py-3 flex items-center justify-between gap-4">
{filtered.map((event) => {
const link = entityLink(event.entityType, event.entityId);
return (
<div
key={event.id}
className={`px-4 py-3 flex items-center justify-between gap-4 ${
link ? "cursor-pointer hover:bg-accent/50 transition-colors" : ""
}`}
onClick={link ? () => navigate(link) : undefined}
>
<div className="flex items-center gap-3 min-w-0">
<Badge variant="secondary" className="shrink-0">
{actorIcon(event.entityType)}
<span className="text-sm">
{formatAction(event.action, event.entityType, event.entityId)}
</span>
<Badge variant="secondary" className="shrink-0 text-[10px]">
{event.entityType}
</Badge>
<span className="text-sm font-medium">{event.action}</span>
<span className="text-xs text-muted-foreground font-mono truncate">
{event.entityId.slice(0, 8)}
</span>
@@ -55,7 +154,8 @@ export function Activity() {
{timeAgo(event.createdAt)}
</span>
</div>
))}
);
})}
</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useAgents } from "../hooks/useAgents";
import { useCompany } from "../context/CompanyContext";
@@ -21,17 +21,6 @@ export function Agents() {
setBreadcrumbs([{ label: "Agents" }]);
}, [setBreadcrumbs]);
async function invoke(e: React.MouseEvent, agentId: string) {
e.stopPropagation();
setActionError(null);
try {
await agentsApi.invoke(agentId);
reload();
} catch (err) {
setActionError(err instanceof Error ? err.message : "Failed to invoke agent");
}
}
if (!selectedCompanyId) {
return <EmptyState icon={Bot} message="Select a company to view agents." />;
}
@@ -45,7 +34,10 @@ export function Agents() {
{actionError && <p className="text-sm text-destructive">{actionError}</p>}
{agents && agents.length === 0 && (
<EmptyState icon={Bot} message="No agents yet." />
<EmptyState
icon={Bot}
message="No agents yet. Agents are created via the API or templates."
/>
)}
{agents && agents.length > 0 && (

View File

@@ -1,9 +1,12 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { formatCents } from "../lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Pencil, Check, X } from "lucide-react";
export function Companies() {
const {
@@ -15,12 +18,22 @@ export function Companies() {
error,
reloadCompanies,
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [budget, setBudget] = useState("0");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editName, setEditName] = useState("");
const [editSaving, setEditSaving] = useState(false);
useEffect(() => {
setBreadcrumbs([{ label: "Companies" }]);
}, [setBreadcrumbs]);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
@@ -44,16 +57,38 @@ export function Companies() {
}
}
function startEdit(companyId: string, currentName: string) {
setEditingId(companyId);
setEditName(currentName);
}
async function saveEdit() {
if (!editingId || !editName.trim()) return;
setEditSaving(true);
try {
await companiesApi.update(editingId, { name: editName.trim() });
await reloadCompanies();
setEditingId(null);
} finally {
setEditSaving(false);
}
}
function cancelEdit() {
setEditingId(null);
setEditName("");
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold">Companies</h2>
<p className="text-muted-foreground">Create and select the company you are operating.</p>
<h2 className="text-lg font-semibold">Companies</h2>
<p className="text-sm text-muted-foreground">Create and manage your companies.</p>
</div>
<Card>
<CardContent className="p-4 space-y-3">
<h3 className="font-semibold">Create Company</h3>
<h3 className="text-sm font-semibold">Create Company</h3>
<form onSubmit={onSubmit} className="space-y-3">
<div className="grid md:grid-cols-3 gap-3">
<Input
@@ -73,31 +108,72 @@ export function Companies() {
/>
</div>
{submitError && <p className="text-sm text-destructive">{submitError}</p>}
<Button type="submit" disabled={submitting}>
<Button type="submit" size="sm" disabled={submitting}>
{submitting ? "Creating..." : "Create Company"}
</Button>
</form>
</CardContent>
</Card>
{loading && <p className="text-muted-foreground">Loading companies...</p>}
{error && <p className="text-destructive">{error.message}</p>}
{loading && <p className="text-sm text-muted-foreground">Loading companies...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
<div className="grid gap-3">
{companies.map((company) => {
const selected = company.id === selectedCompanyId;
const isEditing = editingId === company.id;
return (
<button
key={company.id}
onClick={() => setSelectedCompanyId(company.id)}
className={`text-left bg-card border rounded-lg p-4 ${
selected ? "border-primary ring-1 ring-primary" : "border-border"
className={`text-left bg-card border rounded-lg p-4 transition-colors ${
selected ? "border-primary ring-1 ring-primary" : "border-border hover:border-muted-foreground/30"
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="h-7 text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit();
if (e.key === "Escape") cancelEdit();
}}
/>
<Button
variant="ghost"
size="icon-xs"
onClick={saveEdit}
disabled={editSaving}
>
<Check className="h-3.5 w-3.5 text-green-500" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={cancelEdit}>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</div>
) : (
<div className="flex items-center gap-2">
<h3 className="font-semibold">{company.name}</h3>
{company.description && (
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
startEdit(company.id, company.name);
}}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
)}
{company.description && !isEditing && (
<p className="text-sm text-muted-foreground mt-1">{company.description}</p>
)}
</div>

View File

@@ -1,18 +1,62 @@
import { useCallback, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { dashboardApi } from "../api/dashboard";
import { activityApi } from "../api/activity";
import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext";
import { useAgents } from "../hooks/useAgents";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useApi } from "../hooks/useApi";
import { MetricCard } from "../components/MetricCard";
import { EmptyState } from "../components/EmptyState";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { timeAgo } from "../lib/timeAgo";
import { formatCents } from "../lib/utils";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard } from "lucide-react";
import { Bot, CircleDot, DollarSign, ShieldCheck, LayoutDashboard, Clock } from "lucide-react";
import type { Issue } from "@paperclip/shared";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
function formatAction(action: string): string {
const actionMap: Record<string, string> = {
"company.created": "Company created",
"agent.created": "Agent created",
"agent.updated": "Agent updated",
"agent.key_created": "API key created",
"issue.created": "Issue created",
"issue.updated": "Issue updated",
"issue.checked_out": "Issue checked out",
"issue.released": "Issue released",
"issue.commented": "Comment added",
"heartbeat.invoked": "Heartbeat invoked",
"heartbeat.completed": "Heartbeat completed",
"approval.created": "Approval requested",
"approval.approved": "Approval granted",
"approval.rejected": "Approval rejected",
"project.created": "Project created",
"goal.created": "Goal created",
"cost.recorded": "Cost recorded",
};
return actionMap[action] ?? action.replace(/[._]/g, " ");
}
function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now();
return issues
.filter(
(i) =>
["in_progress", "todo"].includes(i.status) &&
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS
)
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime());
}
export function Dashboard() {
const { selectedCompanyId, selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const { data: agents } = useAgents(selectedCompanyId);
useEffect(() => {
setBreadcrumbs([{ label: "Dashboard" }]);
@@ -28,8 +72,21 @@ export function Dashboard() {
return activityApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const issuesFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data, loading, error } = useApi(dashFetcher);
const { data: activity } = useApi(activityFetcher);
const { data: issues } = useApi(issuesFetcher);
const staleIssues = issues ? getStaleIssues(issues) : [];
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
if (!selectedCompanyId) {
return (
@@ -78,6 +135,8 @@ export function Dashboard() {
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
{/* Recent Activity */}
{activity && activity.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
@@ -86,13 +145,15 @@ export function Dashboard() {
<div className="border border-border rounded-md divide-y divide-border">
{activity.slice(0, 10).map((event) => (
<div key={event.id} className="px-4 py-2 flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="font-medium">{event.action}</span>
<span className="text-muted-foreground">
{event.entityType}
<div className="flex items-center gap-2 min-w-0">
<span className="font-medium truncate">
{formatAction(event.action)}
</span>
<span className="text-xs text-muted-foreground font-mono shrink-0">
{event.entityId.slice(0, 8)}
</span>
</div>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground shrink-0 ml-2">
{timeAgo(event.createdAt)}
</span>
</div>
@@ -100,6 +161,42 @@ export function Dashboard() {
</div>
</div>
)}
{/* Stale Tasks */}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Stale Tasks
</h3>
{staleIssues.length === 0 ? (
<div className="border border-border rounded-md p-4">
<p className="text-sm text-muted-foreground">No stale tasks. All work is up to date.</p>
</div>
) : (
<div className="border border-border rounded-md divide-y divide-border">
{staleIssues.slice(0, 10).map((issue) => (
<div
key={issue.id}
className="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/issues/${issue.id}`)}
>
<Clock className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="truncate flex-1">{issue.title}</span>
{issue.assigneeAgentId && (
<span className="text-xs text-muted-foreground shrink-0">
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
</span>
)}
<span className="text-xs text-muted-foreground shrink-0">
{timeAgo(issue.updatedAt)}
</span>
</div>
))}
</div>
)}
</div>
</div>
</>
)}
</div>

View File

@@ -36,7 +36,12 @@ export function Goals() {
{error && <p className="text-sm text-destructive">{error.message}</p>}
{goals && goals.length === 0 && (
<EmptyState icon={Target} message="No goals yet." />
<EmptyState
icon={Target}
message="No goals yet."
action="Add Goal"
onAction={() => {/* TODO: goal creation */}}
/>
)}
{goals && goals.length > 0 && (

View File

@@ -1,20 +1,50 @@
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { approvalsApi } from "../api/approvals";
import { dashboardApi } from "../api/dashboard";
import { issuesApi } from "../api/issues";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useAgents } from "../hooks/useAgents";
import { useApi } from "../hooks/useApi";
import { StatusBadge } from "../components/StatusBadge";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { EmptyState } from "../components/EmptyState";
import { timeAgo } from "../lib/timeAgo";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Inbox as InboxIcon } from "lucide-react";
import {
Inbox as InboxIcon,
Shield,
AlertTriangle,
Clock,
ExternalLink,
} from "lucide-react";
import type { Issue } from "@paperclip/shared";
const STALE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24 hours
function getStaleIssues(issues: Issue[]): Issue[] {
const now = Date.now();
return issues
.filter(
(i) =>
["in_progress", "todo"].includes(i.status) &&
now - new Date(i.updatedAt).getTime() > STALE_THRESHOLD_MS
)
.sort(
(a, b) =>
new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
);
}
export function Inbox() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const { data: agents } = useAgents(selectedCompanyId);
useEffect(() => {
setBreadcrumbs([{ label: "Inbox" }]);
@@ -30,8 +60,22 @@ export function Inbox() {
return dashboardApi.summary(selectedCompanyId);
}, [selectedCompanyId]);
const issuesFetcher = useCallback(() => {
if (!selectedCompanyId) return Promise.resolve([]);
return issuesApi.list(selectedCompanyId);
}, [selectedCompanyId]);
const { data: approvals, loading, error, reload } = useApi(approvalsFetcher);
const { data: dashboard } = useApi(dashboardFetcher);
const { data: issues } = useApi(issuesFetcher);
const staleIssues = issues ? getStaleIssues(issues) : [];
const agentName = (id: string | null) => {
if (!id || !agents) return null;
const agent = agents.find((a) => a.id === id);
return agent?.name ?? null;
};
async function approve(id: string) {
setActionError(null);
@@ -57,7 +101,13 @@ export function Inbox() {
return <EmptyState icon={InboxIcon} message="Select a company to view inbox." />;
}
const hasContent = (approvals && approvals.length > 0) || (dashboard && (dashboard.staleTasks > 0));
const hasApprovals = approvals && approvals.length > 0;
const hasAlerts =
dashboard &&
(dashboard.agents.error > 0 ||
dashboard.costs.monthUtilizationPercent >= 80);
const hasStale = staleIssues.length > 0;
const hasContent = hasApprovals || hasAlerts || hasStale;
return (
<div className="space-y-6">
@@ -71,28 +121,37 @@ export function Inbox() {
<EmptyState icon={InboxIcon} message="You're all caught up!" />
)}
{approvals && approvals.length > 0 && (
{/* Pending Approvals */}
{hasApprovals && (
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Pending Approvals ({approvals.length})
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">
Approvals
</h3>
<button
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
onClick={() => navigate("/approvals")}
>
See all approvals <ExternalLink className="inline h-3 w-3 ml-0.5" />
</button>
</div>
<div className="border border-border rounded-md divide-y divide-border">
{approvals.map((approval) => (
{approvals!.map((approval) => (
<div key={approval.id} className="p-4 space-y-2">
<div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium">{approval.type.replace(/_/g, " ")}</span>
<span className="text-xs text-muted-foreground ml-2">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-yellow-500 shrink-0" />
<span className="text-sm font-medium">
{approval.type.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{timeAgo(approval.createdAt)}
</span>
</div>
<StatusBadge status={approval.status} />
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="border-green-700 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20"
className="border-green-700 text-green-500 hover:bg-green-900/20"
onClick={() => approve(approval.id)}
>
Approve
@@ -104,6 +163,14 @@ export function Inbox() {
>
Reject
</Button>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground ml-auto"
onClick={() => navigate(`/approvals/${approval.id}`)}
>
View details
</Button>
</div>
</div>
))}
@@ -111,18 +178,79 @@ export function Inbox() {
</div>
)}
{dashboard && dashboard.staleTasks > 0 && (
{/* Alerts */}
{hasAlerts && (
<>
<Separator />
{hasApprovals && <Separator />}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Alerts
</h3>
<div className="border border-border rounded-md divide-y divide-border">
{dashboard!.agents.error > 0 && (
<div
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate("/agents")}
>
<AlertTriangle className="h-4 w-4 text-red-400 shrink-0" />
<span className="text-sm">
<span className="font-medium">{dashboard!.agents.error}</span>{" "}
{dashboard!.agents.error === 1 ? "agent has" : "agents have"} errors
</span>
</div>
)}
{dashboard!.costs.monthUtilizationPercent >= 80 && (
<div
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate("/costs")}
>
<AlertTriangle className="h-4 w-4 text-yellow-400 shrink-0" />
<span className="text-sm">
Budget at{" "}
<span className="font-medium">
{dashboard!.costs.monthUtilizationPercent}%
</span>{" "}
utilization this month
</span>
</div>
)}
</div>
</div>
</>
)}
{/* Stale Work */}
{hasStale && (
<>
{(hasApprovals || hasAlerts) && <Separator />}
<div>
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
Stale Work
</h3>
<div className="border border-border rounded-md p-4">
<p className="text-sm">
<span className="font-medium">{dashboard.staleTasks}</span> tasks have gone stale
and may need attention.
</p>
<div className="border border-border rounded-md divide-y divide-border">
{staleIssues.map((issue) => (
<div
key={issue.id}
className="px-4 py-3 flex items-center gap-3 cursor-pointer hover:bg-accent/50 transition-colors"
onClick={() => navigate(`/issues/${issue.id}`)}
>
<Clock className="h-4 w-4 text-muted-foreground shrink-0" />
<PriorityIcon priority={issue.priority} />
<StatusIcon status={issue.status} />
<span className="text-xs font-mono text-muted-foreground">
{issue.id.slice(0, 8)}
</span>
<span className="text-sm truncate flex-1">{issue.title}</span>
{issue.assigneeAgentId && (
<span className="text-xs text-muted-foreground">
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
</span>
)}
<span className="text-xs text-muted-foreground shrink-0">
updated {timeAgo(issue.updatedAt)}
</span>
</div>
))}
</div>
</div>
</>

View File

@@ -32,7 +32,7 @@ export function IssueDetail() {
useEffect(() => {
setBreadcrumbs([
{ label: "Issues", href: "/tasks" },
{ label: "Issues", href: "/issues" },
{ label: issue?.title ?? issueId ?? "Issue" },
]);
}, [setBreadcrumbs, issue, issueId]);

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { issuesApi } from "../api/issues";
import { useApi } from "../hooks/useApi";
import { useAgents } from "../hooks/useAgents";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
@@ -43,6 +44,7 @@ export function Issues() {
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
const [tab, setTab] = useState<TabFilter>("all");
const { data: agents } = useAgents(selectedCompanyId);
useEffect(() => {
setBreadcrumbs([{ label: "Issues" }]);
@@ -55,6 +57,11 @@ export function Issues() {
const { data: issues, loading, error, reload } = useApi(fetcher);
const agentName = (id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
};
async function handleStatusChange(issue: Issue, status: string) {
await issuesApi.update(issue.id, { status });
reload();
@@ -135,9 +142,16 @@ export function Issues() {
</>
}
trailing={
<div className="flex items-center gap-3">
{issue.assigneeAgentId && (
<span className="text-xs text-muted-foreground">
{agentName(issue.assigneeAgentId) ?? issue.assigneeAgentId.slice(0, 8)}
</span>
)}
<span className="text-xs text-muted-foreground">
{formatDate(issue.createdAt)}
</span>
</div>
}
/>
))}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { agentsApi, type OrgNode } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
@@ -8,7 +8,6 @@ import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { ChevronRight, GitBranch } from "lucide-react";
import { cn } from "../lib/utils";
import { useState } from "react";
function OrgTree({
nodes,
@@ -113,7 +112,10 @@ export function Org() {
{error && <p className="text-sm text-destructive">{error.message}</p>}
{data && data.length === 0 && (
<EmptyState icon={GitBranch} message="No agents in the organization." />
<EmptyState
icon={GitBranch}
message="No agents in the organization. Create agents to build your org chart."
/>
)}
{data && data.length > 0 && (

View File

@@ -3,15 +3,18 @@ import { useNavigate } from "react-router-dom";
import { projectsApi } from "../api/projects";
import { useApi } from "../hooks/useApi";
import { useCompany } from "../context/CompanyContext";
import { useDialog } from "../context/DialogContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge";
import { EmptyState } from "../components/EmptyState";
import { formatDate } from "../lib/utils";
import { Hexagon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Hexagon, Plus } from "lucide-react";
export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialog();
const { setBreadcrumbs } = useBreadcrumbs();
const navigate = useNavigate();
@@ -32,13 +35,24 @@ export function Projects() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Projects</h2>
<Button size="sm" onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
</Button>
</div>
{loading && <p className="text-sm text-muted-foreground">Loading...</p>}
{error && <p className="text-sm text-destructive">{error.message}</p>}
{projects && projects.length === 0 && (
<EmptyState icon={Hexagon} message="No projects yet." />
<EmptyState
icon={Hexagon}
message="No projects yet."
action="Add Project"
onAction={openNewProject}
/>
)}
{projects && projects.length > 0 && (