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:
Forgotten
2026-02-19 13:03:08 -06:00
parent 0d73e1b407
commit 176d279403
31 changed files with 1271 additions and 214 deletions

View File

@@ -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,

View 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} />;
}

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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}`);
},
});

View File

@@ -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();

View File

@@ -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} />

View File

@@ -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",
};