UI: approval detail page, agent hiring UX, costs breakdown, sidebar badges, and dashboard improvements
Add ApprovalDetail page with comment thread, revision request/resubmit flow, and ApprovalPayload component for structured payload display. Extend AgentDetail with permissions management, config revision history, and duplicate action. Add agent hire dialog with permission-gated access. Rework Costs page with per-agent breakdown table and period filtering. Add sidebar badge counts for pending approvals and inbox items. Enhance Dashboard with live metrics and sparkline trends. Extend Agents list with pending_approval status and bulk actions. Update IssueDetail with approval linking. Various component improvements to MetricCard, InlineEditor, CommentThread, and StatusBadge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FolderOpen, Heart, ChevronDown } from "lucide-react";
|
||||
import { FolderOpen, Heart, ChevronDown, X } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
Field,
|
||||
@@ -122,28 +122,6 @@ function formatArgList(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
function parseEnvVars(text: string): Record<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq <= 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
const value = trimmed.slice(eq + 1);
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
||||
env[key] = value;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function formatEnvVars(value: unknown): string {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return "";
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, v]) => typeof v === "string")
|
||||
.map(([k, v]) => `${k}=${String(v)}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractPickedDirectoryPath(handle: unknown): string | null {
|
||||
if (typeof handle !== "object" || handle === null) return null;
|
||||
@@ -540,19 +518,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
minRows={3}
|
||||
/>
|
||||
) : (
|
||||
<DraftTextarea
|
||||
value={eff("adapterConfig", "env", formatEnvVars(config.env))}
|
||||
onCommit={(v) => {
|
||||
const parsed = parseEnvVars(v);
|
||||
mark(
|
||||
"adapterConfig",
|
||||
"env",
|
||||
Object.keys(parsed).length > 0 ? parsed : undefined,
|
||||
);
|
||||
}}
|
||||
immediate
|
||||
placeholder={"ANTHROPIC_API_KEY=...\nPAPERCLIP_API_URL=http://localhost:3100"}
|
||||
minRows={3}
|
||||
<EnvVarEditor
|
||||
value={(eff("adapterConfig", "env", config.env ?? {}) as Record<string, string>)}
|
||||
onChange={(env) => mark("adapterConfig", "env", env)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
@@ -727,6 +695,98 @@ function AdapterTypeDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function EnvVarEditor({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Record<string, string>;
|
||||
onChange: (env: Record<string, string> | undefined) => void;
|
||||
}) {
|
||||
type Row = { key: string; value: string };
|
||||
|
||||
function toRows(rec: Record<string, string> | null | undefined): Row[] {
|
||||
if (!rec || typeof rec !== "object") return [{ key: "", value: "" }];
|
||||
const entries = Object.entries(rec).map(([k, v]) => ({ key: k, value: String(v) }));
|
||||
return [...entries, { key: "", value: "" }];
|
||||
}
|
||||
|
||||
const [rows, setRows] = useState<Row[]>(() => toRows(value));
|
||||
const valueRef = useRef(value);
|
||||
|
||||
// Sync when value identity changes (overlay reset after save)
|
||||
useEffect(() => {
|
||||
if (value !== valueRef.current) {
|
||||
valueRef.current = value;
|
||||
setRows(toRows(value));
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function emit(nextRows: Row[]) {
|
||||
const rec: Record<string, string> = {};
|
||||
for (const row of nextRows) {
|
||||
const k = row.key.trim();
|
||||
if (k) rec[k] = row.value;
|
||||
}
|
||||
onChange(Object.keys(rec).length > 0 ? rec : undefined);
|
||||
}
|
||||
|
||||
function updateRow(i: number, field: "key" | "value", v: string) {
|
||||
const next = rows.map((r, idx) => (idx === i ? { ...r, [field]: v } : r));
|
||||
if (next[next.length - 1].key || next[next.length - 1].value) {
|
||||
next.push({ key: "", value: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
function removeRow(i: number) {
|
||||
const next = rows.filter((_, idx) => idx !== i);
|
||||
if (next.length === 0 || next[next.length - 1].key || next[next.length - 1].value) {
|
||||
next.push({ key: "", value: "" });
|
||||
}
|
||||
setRows(next);
|
||||
emit(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
{rows.map((row, i) => {
|
||||
const isTrailing = i === rows.length - 1 && !row.key && !row.value;
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
className={cn(inputClass, "flex-[2]")}
|
||||
placeholder="KEY"
|
||||
value={row.key}
|
||||
onChange={(e) => updateRow(i, "key", e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={cn(inputClass, "flex-[3]")}
|
||||
placeholder="value"
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(i, "value", e.target.value)}
|
||||
/>
|
||||
{!isTrailing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={() => removeRow(i)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-[26px] shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-[11px] text-muted-foreground/60">
|
||||
PAPERCLIP_* variables are injected automatically at runtime.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelDropdown({
|
||||
models,
|
||||
value,
|
||||
|
||||
74
ui/src/components/ApprovalPayload.tsx
Normal file
74
ui/src/components/ApprovalPayload.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { UserPlus, Lightbulb, ShieldCheck } from "lucide-react";
|
||||
|
||||
export const typeLabel: Record<string, string> = {
|
||||
hire_agent: "Hire Agent",
|
||||
approve_ceo_strategy: "CEO Strategy",
|
||||
};
|
||||
|
||||
export const typeIcon: Record<string, typeof UserPlus> = {
|
||||
hire_agent: UserPlus,
|
||||
approve_ceo_strategy: Lightbulb,
|
||||
};
|
||||
|
||||
export const defaultTypeIcon = ShieldCheck;
|
||||
|
||||
function PayloadField({ label, value }: { label: string; value: unknown }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">{label}</span>
|
||||
<span>{String(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HireAgentPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Name</span>
|
||||
<span className="font-medium">{String(payload.name ?? "—")}</span>
|
||||
</div>
|
||||
<PayloadField label="Role" value={payload.role} />
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!payload.capabilities && (
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs pt-0.5">Capabilities</span>
|
||||
<span className="text-muted-foreground">{String(payload.capabilities)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!payload.adapterType && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0 text-xs">Adapter</span>
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
{String(payload.adapterType)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CeoStrategyPayload({ payload }: { payload: Record<string, unknown> }) {
|
||||
const plan = payload.plan ?? payload.description ?? payload.strategy ?? payload.text;
|
||||
return (
|
||||
<div className="mt-3 space-y-1.5 text-sm">
|
||||
<PayloadField label="Title" value={payload.title} />
|
||||
{!!plan && (
|
||||
<div className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-sm text-muted-foreground whitespace-pre-wrap font-mono text-xs max-h-48 overflow-y-auto">
|
||||
{String(plan)}
|
||||
</div>
|
||||
)}
|
||||
{!plan && (
|
||||
<pre className="mt-2 rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground overflow-x-auto max-h-48">
|
||||
{JSON.stringify(payload, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalPayloadRenderer({ type, payload }: { type: string; payload: Record<string, unknown> }) {
|
||||
if (type === "hire_agent") return <HireAgentPayload payload={payload} />;
|
||||
return <CeoStrategyPayload payload={payload} />;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import Markdown from "react-markdown";
|
||||
import type { IssueComment, Agent } from "@paperclip/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -72,7 +73,9 @@ export function CommentThread({ comments, onAdd, issueStatus, agentMap }: Commen
|
||||
{formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{comment.body}</p>
|
||||
<div className="text-sm prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
|
||||
<Markdown>{comment.body}</Markdown>
|
||||
</div>
|
||||
{comment.runId && comment.runAgentId && (
|
||||
<div className="mt-2 pt-2 border-t border-border/60">
|
||||
<Link
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
interface InlineEditorProps {
|
||||
@@ -118,7 +119,13 @@ export function InlineEditor({
|
||||
)}
|
||||
onClick={() => setEditing(true)}
|
||||
>
|
||||
{value || placeholder}
|
||||
{value && multiline ? (
|
||||
<div className="prose prose-sm prose-invert max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 prose-pre:my-2 prose-headings:my-2 prose-headings:text-sm">
|
||||
<Markdown>{value}</Markdown>
|
||||
</div>
|
||||
) : (
|
||||
value || placeholder
|
||||
)}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface MetricCardProps {
|
||||
icon: LucideIcon;
|
||||
value: string | number;
|
||||
label: string;
|
||||
description?: string;
|
||||
description?: ReactNode;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function MetricCard({ icon: Icon, value, label, description }: MetricCardProps) {
|
||||
export function MetricCard({ icon: Icon, value, label, description, onClick }: MetricCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted p-2">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-2xl font-bold${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm text-muted-foreground${onClick ? " cursor-pointer" : ""}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
{description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{description}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-muted p-2 rounded-md h-fit shrink-0">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-2xl font-bold">{value}</p>
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-2">{description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -76,12 +76,13 @@ export function NewAgentDialog() {
|
||||
|
||||
const createAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) =>
|
||||
agentsApi.create(selectedCompanyId!, data),
|
||||
onSuccess: (agent) => {
|
||||
agentsApi.hire(selectedCompanyId!, data),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||
reset();
|
||||
closeNewAgent();
|
||||
navigate(`/agents/${agent.id}`);
|
||||
navigate(`/agents/${result.agent.id}`);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ export function NewIssueDialog() {
|
||||
issuesApi.create(selectedCompanyId!, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(selectedCompanyId!) });
|
||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||
clearDraft();
|
||||
reset();
|
||||
closeNewIssue();
|
||||
|
||||
@@ -15,15 +15,25 @@ import {
|
||||
BookOpen,
|
||||
Paperclip,
|
||||
} from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CompanySwitcher } from "./CompanySwitcher";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarNavItem } from "./SidebarNavItem";
|
||||
import { useDialog } from "../context/DialogContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
export function Sidebar() {
|
||||
const { openNewIssue } = useDialog();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { data: sidebarBadges } = useQuery({
|
||||
queryKey: queryKeys.sidebarBadges(selectedCompanyId!),
|
||||
queryFn: () => sidebarBadgesApi.get(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
function openSearch() {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true }));
|
||||
@@ -63,7 +73,12 @@ export function Sidebar() {
|
||||
<ScrollArea className="flex-1">
|
||||
<nav className="flex flex-col gap-4 px-3 py-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarNavItem to="/inbox" label="Inbox" icon={Inbox} />
|
||||
<SidebarNavItem
|
||||
to="/inbox"
|
||||
label="Inbox"
|
||||
icon={Inbox}
|
||||
badge={sidebarBadges?.inbox}
|
||||
/>
|
||||
<SidebarNavItem to="/my-issues" label="My Issues" icon={ListTodo} />
|
||||
</div>
|
||||
|
||||
@@ -76,7 +91,12 @@ export function Sidebar() {
|
||||
<SidebarSection label="Company">
|
||||
<SidebarNavItem to="/dashboard" label="Dashboard" icon={LayoutDashboard} />
|
||||
<SidebarNavItem to="/agents" label="Agents" icon={Bot} />
|
||||
<SidebarNavItem to="/approvals" label="Approvals" icon={ShieldCheck} />
|
||||
<SidebarNavItem
|
||||
to="/approvals"
|
||||
label="Approvals"
|
||||
icon={ShieldCheck}
|
||||
badge={sidebarBadges?.approvals}
|
||||
/>
|
||||
<SidebarNavItem to="/costs" label="Costs" icon={DollarSign} />
|
||||
<SidebarNavItem to="/activity" label="Activity" icon={History} />
|
||||
<SidebarNavItem to="/companies" label="Companies" icon={Building2} />
|
||||
|
||||
@@ -12,14 +12,17 @@ const statusColors: Record<string, string> = {
|
||||
failed: "bg-red-900/50 text-red-300",
|
||||
succeeded: "bg-green-900/50 text-green-300",
|
||||
error: "bg-red-900/50 text-red-300",
|
||||
pending_approval: "bg-amber-900/50 text-amber-300",
|
||||
backlog: "bg-neutral-800 text-neutral-400",
|
||||
todo: "bg-blue-900/50 text-blue-300",
|
||||
in_progress: "bg-indigo-900/50 text-indigo-300",
|
||||
in_review: "bg-violet-900/50 text-violet-300",
|
||||
blocked: "bg-amber-900/50 text-amber-300",
|
||||
done: "bg-green-900/50 text-green-300",
|
||||
terminated: "bg-red-900/50 text-red-300",
|
||||
cancelled: "bg-neutral-800 text-neutral-500",
|
||||
pending: "bg-yellow-900/50 text-yellow-300",
|
||||
revision_requested: "bg-amber-900/50 text-amber-300",
|
||||
approved: "bg-green-900/50 text-green-300",
|
||||
rejected: "bg-red-900/50 text-red-300",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user