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:
@@ -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,35 +91,71 @@ 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">
|
||||
<h2 className="text-lg font-semibold">Activity</h2>
|
||||
<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">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{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)}
|
||||
{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">
|
||||
{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-xs text-muted-foreground font-mono truncate">
|
||||
{event.entityId.slice(0, 8)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
<h3 className="font-semibold">{company.name}</h3>
|
||||
{company.description && (
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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,28 +135,68 @@ export function Dashboard() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{activity && activity.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide mb-3">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<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="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">
|
||||
Recent Activity
|
||||
</h3>
|
||||
<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 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 shrink-0 ml-2">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{timeAgo(event.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</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>
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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})
|
||||
</h3>
|
||||
<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">
|
||||
{timeAgo(approval.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<StatusBadge status={approval.status} />
|
||||
<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>
|
||||
<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>
|
||||
</>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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={
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(issue.createdAt)}
|
||||
</span>
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
<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 && (
|
||||
|
||||
Reference in New Issue
Block a user